From 81c2a8b2b0fb221fff3d5d8ddcb96c673b9fbe68 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 20 Oct 2026 10:49:21 +0000 Subject: [PATCH 01/54] fix(types)!: allow assignment-only creative syncs Match AdCP 3.2 by making the canonical SyncCreativesRequest.creatives field optional while retaining the nonempty constraint when supplied. The inherited validator still requires assets or assignments, and older-version schema validation continues to require creatives. Guard the reference seller's upload loop so existing creative assignments can proceed without uploading assets. Cover public Python/JSON parsing, typed dispatch, schema compatibility, canonical assets, and the example's upstream attachment behavior. Fixes #1545 BREAKING CHANGE: SyncCreativesRequest.creatives is now list[CreativeAsset] | None. Handlers must guard it before iteration or use request.creatives or [] for assignment-only requests. --- examples/v3_reference_seller/src/platform.py | 2 +- .../tests/test_smoke_broadening.py | 32 +++++ src/adcp/types/canonical_creative.py | 3 +- tests/test_sync_creatives_assignment_only.py | 129 ++++++++++++++++++ .../canonical_override_field_types.py | 4 +- 5 files changed, 166 insertions(+), 4 deletions(-) create mode 100644 tests/test_sync_creatives_assignment_only.py diff --git a/examples/v3_reference_seller/src/platform.py b/examples/v3_reference_seller/src/platform.py index f8a41a749..5c98c2dba 100644 --- a/examples/v3_reference_seller/src/platform.py +++ b/examples/v3_reference_seller/src/platform.py @@ -1768,7 +1768,7 @@ async def sync_creatives( advertiser_id = ctx.account.metadata["advertiser_id"] results: list[SyncCreativeResult] = [] client = self._client(ctx) - for creative in req.creatives: + for creative in req.creatives or []: # Re-sync of a known creative_id is a no-op upload — the # upstream's idempotency table is keyed on body-fingerprint # and rejects same-key/different-body with 409. The spec diff --git a/examples/v3_reference_seller/tests/test_smoke_broadening.py b/examples/v3_reference_seller/tests/test_smoke_broadening.py index b8f44f5d1..9de0197b7 100644 --- a/examples/v3_reference_seller/tests/test_smoke_broadening.py +++ b/examples/v3_reference_seller/tests/test_smoke_broadening.py @@ -1416,6 +1416,38 @@ async def test_sync_creatives_uploads_each_creative_to_upstream( assert "spring-300x250" in body +@pytest.mark.asyncio +@respx.mock(base_url=_RESPX_BASE_URL) +async def test_sync_creatives_assigns_existing_creative_without_upload(respx_mock: Any) -> None: + from adcp.types import SyncCreativesRequest + + attach = respx_mock.post("/v1/orders/ord_1/lineitems/pkg_1/creative-attach").mock( + return_value=httpx.Response(200, json={}) + ) + platform = _platform_with_upstream() + ctx = _build_ctx() + scope = platform._account_scope(ctx) + platform._creative_id_map[(scope, "existing")] = "up_cr_1" + platform._buy_state[(scope, "ord_1")] = {"packages": {"pkg_1": {}}} + req = SyncCreativesRequest.model_validate( + { + "account": {"account_id": "signed-buyer-main"}, + "idempotency_key": "assignment-only-sync-0001", + "assignments": [{"creative_id": "existing", "package_id": "pkg_1"}], + } + ) + + response = await platform.sync_creatives(req, ctx) + + assert attach.call_count == 1 + assert json.loads(attach.calls.last.request.content) == {"creative_id": "up_cr_1"} + assert len(respx_mock.calls) == 1 + assert response.creatives == [] + assert platform._buy_state[(scope, "ord_1")]["packages"]["pkg_1"]["creative_assignments"] == [ + {"creative_id": "existing"} + ] + + @pytest.mark.asyncio @respx.mock(base_url=_RESPX_BASE_URL) async def test_get_media_buys_filters_by_advertiser_id(respx_mock: Any) -> None: diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 96014822d..3ff2e4008 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -1219,7 +1219,8 @@ class UpdateMediaBuyResponse3(_LegacyUpdateMediaBuyResponse3, CanonicalBoundaryM class SyncCreativesRequest(_LegacySyncCreativesRequest, CanonicalBoundaryModel): """Canonical creative sync request; creatives are canonical assets.""" - creatives: list[CreativeAsset] = Field(min_length=1) # type: ignore[assignment] + # Requiredness is version-specific: AdCP 3.2 permits assignment-only syncs. + creatives: list[CreativeAsset] | None = Field(default=None, min_length=1) # type: ignore[assignment] class ListCreativesRequest(_LegacyListCreativesRequest, CanonicalBoundaryModel): diff --git a/tests/test_sync_creatives_assignment_only.py b/tests/test_sync_creatives_assignment_only.py new file mode 100644 index 000000000..75f77fd7c --- /dev/null +++ b/tests/test_sync_creatives_assignment_only.py @@ -0,0 +1,129 @@ +"""AdCP 3.2 can traffic existing creatives without uploading assets (#1545).""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest +from pydantic import ValidationError + +from adcp.server.base import ADCPHandler, ToolContext +from adcp.server.mcp_tools import create_tool_caller +from adcp.types import CreativeAsset, SyncCreativesRequest +from adcp.validation import ValidationHookConfig, validate_request + +_BASE = { + "adcp_version": "3.2", + "idempotency_key": "assignment-only-sync-0001", + "account": {"account_id": "acct_1"}, +} +_ASSIGNMENTS = {"assignments": [{"creative_id": "cr_1", "package_id": "pkg_1"}]} +_MUTATIONS = [ + pytest.param(_ASSIGNMENTS, id="assignments"), + *[ + pytest.param( + { + "assignment_operations": [ + { + "operation": operation, + "creative_id": "cr_1", + "package_id": "pkg_1", + **({"replaces_creative_id": "cr_old"} if operation == "replace" else {}), + } + ] + }, + id=operation, + ) + for operation in ("assign", "unassign", "replace") + ], +] + + +@pytest.mark.parametrize("mutation", _MUTATIONS) +@pytest.mark.parametrize("json_input", [False, True], ids=["python", "json"]) +def test_public_model_accepts_assignment_only_sync( + mutation: dict[str, Any], json_input: bool +) -> None: + payload = {**_BASE, **mutation} + request = ( + SyncCreativesRequest.model_validate_json(json.dumps(payload)) + if json_input + else SyncCreativesRequest.model_validate(payload) + ) + + assert request.creatives is None + wire = request.model_dump(mode="json", exclude_unset=True) + assert wire == payload + assert validate_request("sync_creatives", wire, version="3.2").valid + + +def test_empty_creatives_are_rejected_even_with_assignments() -> None: + with pytest.raises(ValidationError) as caught: + SyncCreativesRequest.model_validate({**_BASE, **_ASSIGNMENTS, "creatives": []}) + assert caught.value.errors()[0]["loc"] == ("creatives",) + assert caught.value.errors()[0]["type"] == "too_short" + + +def test_sync_still_requires_assets_or_assignments() -> None: + with pytest.raises(ValidationError) as caught: + SyncCreativesRequest.model_validate(_BASE) + assert caught.value.errors()[0]["type"] == "anyOf" + + +def test_asset_sync_still_parses_canonical_creatives() -> None: + request = SyncCreativesRequest.model_validate( + { + **_BASE, + "creatives": [ + {"creative_id": "cr_1", "name": "Banner", "format_kind": "image", "assets": {}} + ], + } + ) + assert request.creatives is not None + assert isinstance(request.creatives[0], CreativeAsset) + assert request.creatives[0].format_kind == "image" + + +def test_public_schema_allows_omitting_creatives() -> None: + assert "creatives" not in SyncCreativesRequest.model_json_schema()["required"] + + +class _AssignmentHandler(ADCPHandler[Any]): + adcp_capabilities = {"media_buy": {"features": {"canonical_creatives": True}}} + + def __init__(self) -> None: + self.received: SyncCreativesRequest | None = None + + async def sync_creatives( + self, params: SyncCreativesRequest, context: ToolContext | None = None + ) -> dict[str, Any]: + self.received = params + return {"creatives": []} + + +@pytest.mark.parametrize("mutation", _MUTATIONS) +async def test_assignment_only_sync_reaches_typed_handler(mutation: dict[str, Any]) -> None: + handler = _AssignmentHandler() + caller = create_tool_caller( + handler, "sync_creatives", validation=ValidationHookConfig(requests="strict") + ) + + result = await caller({**_BASE, **mutation}) + + assert result["creatives"] == [] + assert isinstance(handler.received, SyncCreativesRequest) + assert handler.received.creatives is None + assert handler.received.model_dump(mode="json", exclude_unset=True) == {**_BASE, **mutation} + + +@pytest.mark.parametrize("version", ["3.0", "3.1"]) +def test_older_version_schemas_still_require_creatives(version: str) -> None: + payload = {**_BASE, **_ASSIGNMENTS} + payload.pop("adcp_version") + outcome = validate_request("sync_creatives", payload, version=version) + + assert not outcome.valid + assert any( + issue.pointer == "/creatives" and issue.keyword == "required" for issue in outcome.issues + ) diff --git a/tests/type_checks/canonical_override_field_types.py b/tests/type_checks/canonical_override_field_types.py index 4703ae264..09628c3bf 100644 --- a/tests/type_checks/canonical_override_field_types.py +++ b/tests/type_checks/canonical_override_field_types.py @@ -79,8 +79,8 @@ def create_media_buy_response1_packages(response: CreateMediaBuyResponse1) -> li def sync_creatives_request_creatives(request: SyncCreativesRequest) -> list[str]: - assert_type(request.creatives, list[CreativeAsset]) - return [asset.creative_id for asset in request.creatives] + assert_type(request.creatives, list[CreativeAsset] | None) + return [asset.creative_id for asset in request.creatives or []] def list_creatives_response_creatives(response: ListCreativesResponse) -> list[str]: From 31d7217e81533e6513cfe4ab28441abb0ed3db77 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 22 Oct 2026 05:47:29 -0400 Subject: [PATCH 02/54] feat(compat): negotiate and translate AdCP minor versions (#1559) * feat(compat): negotiate and translate AdCP minor versions * fix(compat): harden negotiated version boundaries and regressions * docs(compat): clarify preflight failure results * fix(client): retain callback evidence and discovery failures * fix(a2a): isolate capability discovery from business sessions --- README.md | 4 + docs/cross-version-conversion.md | 136 +++ examples/seller_agent.py | 45 + src/adcp/accounts.py | 103 ++- src/adcp/client.py | 477 +++++++++-- src/adcp/compat/governance.py | 72 ++ .../compat/negotiated_catalog_purchase.py | 60 +- src/adcp/compat/versions.py | 670 +++++++++++++++ src/adcp/property_lists.py | 5 + src/adcp/protocols/a2a.py | 37 +- src/adcp/protocols/base.py | 44 + src/adcp/protocols/mcp.py | 31 +- src/adcp/server/mcp_tools.py | 126 ++- src/adcp/signing/negotiated_profiles.py | 107 +++ tests/conformance/reporting/_feed_support.py | 5 +- .../test_reporting_production_feed_numbers.py | 4 +- .../test_reporting_production_scope.py | 3 +- .../test_reporting_status_capabilities.py | 8 +- tests/integration/test_a2a_context_id.py | 109 ++- tests/test_account_version_continuity.py | 141 ++++ tests/test_control_request_intent.py | 8 +- tests/test_cross_version_conversion.py | 791 ++++++++++++++++++ tests/test_decisioning_task_polling.py | 8 +- tests/test_dispatcher_version_routing.py | 9 +- tests/test_governance_version_conversion.py | 139 +++ tests/test_idempotency.py | 4 +- tests/test_idempotency_storyboard.py | 2 +- .../test_lifecycle_coordinator_integration.py | 2 + tests/test_negotiated_catalog_purchase.py | 56 ++ tests/test_negotiated_signing_profiles.py | 256 ++++++ tests/test_negotiation.py | 25 + tests/test_property_lists.py | 15 + tests/test_protocols.py | 27 +- tests/test_rc3_media_buy_runtime.py | 8 +- tests/test_response_enhancer.py | 2 +- tests/test_response_size_limit.py | 30 +- tests/test_schema_validation_client.py | 3 + tests/test_schema_validation_server.py | 16 + tests/test_seller_governance_compatibility.py | 89 ++ tests/test_task_options.py | 10 +- tests/test_update_rights_roundtrip.py | 4 +- tests/test_version_client_boundaries.py | 327 ++++++++ tests/test_version_interop.py | 14 +- tests/test_webhook_handling.py | 70 +- 44 files changed, 3930 insertions(+), 172 deletions(-) create mode 100644 docs/cross-version-conversion.md create mode 100644 src/adcp/compat/governance.py create mode 100644 src/adcp/compat/versions.py create mode 100644 src/adcp/signing/negotiated_profiles.py create mode 100644 tests/test_account_version_continuity.py create mode 100644 tests/test_cross_version_conversion.py create mode 100644 tests/test_governance_version_conversion.py create mode 100644 tests/test_negotiated_signing_profiles.py create mode 100644 tests/test_seller_governance_compatibility.py create mode 100644 tests/test_version_client_boundaries.py diff --git a/README.md b/README.md index a099c818d..b6a3a675c 100644 --- a/README.md +++ b/README.md @@ -368,6 +368,10 @@ adcp.get_adcp_sdk_version() # SDK package version, e.g. "8.0.0" adcp.get_adcp_spec_version() # AdCP spec this build targets, e.g. "3.2.1" ``` +Client calls and server handlers use current application models while the SDK +translates older wire versions. See [cross-version conversion](docs/cross-version-conversion.md) +for negotiation, dropped-field reports, and account-key reduction. + The SDK also accepts and advertises `"3.2-rc.7"` as an explicit compatibility alias for buyers shipped with `adcp==8.0.0rc3`. These requests use the stable 3.2 validators and responses retain the `"3.2-rc.7"` alias so strict rc3 diff --git a/docs/cross-version-conversion.md b/docs/cross-version-conversion.md new file mode 100644 index 000000000..798c8070e --- /dev/null +++ b/docs/cross-version-conversion.md @@ -0,0 +1,136 @@ +# Application and wire versions + +Application requests, responses, and server handlers use the latest AdCP models +packaged with the SDK. The client discovers a seller's `adcp.supported_versions` +and chooses the highest common release for each call. Discovery follows the +existing `capabilities_ttl` cache; refreshing capabilities changes subsequent +calls, without changing requests already in flight. An advertisement containing +only `major_versions: [3]` selects the 3.0 compatibility contract. +For A2A, discovery uses a separate conversation and never changes a retained +business context or task ID. Refreshing capabilities while approval is pending +therefore preserves the original task for resumption. + +`adcp_version` sets a negotiation ceiling. A request's explicit `adcp_version` +overrides the constructor ceiling for that call. `server_version` declares a +known peer version and bypasses automatic capability discovery. Application +models remain current with either option. Unsupported tasks fail before sending +a request; the SDK never retries a mutation using another protocol version. + +The SDK chains consecutive minor conversions: 3.2 ↔ 3.1 ↔ 3.0. The bundled +schemas identify newly introduced fields, including fields in nested arrays, +account-reference unions, and maps. Extension and context bags remain opaque. +Existing creative-format adapters supply deterministic legacy format routes; +formats that cannot be represented still require a configured resolver. +Historical prerelease pins retain their existing explicit compatibility gates; +minor converters do not infer compatibility between prereleases. + +Some valid historical responses lack information required by the current +contract. The SDK cannot infer an omitted media-buy `revision`, because it is +the seller's optimistic concurrency token. Such a response fails conversion +with an unsupported-feature diagnostic identifying the missing field; the SDK +never invents a token. The client returns a failed `TaskResult` with +`adcp_error.code="UNSUPPORTED_FEATURE"` and preserves the original wire response +in `metadata["adcp_conversion_wire_response"]`. This is a local representation +failure after the peer completed the operation; reconcile that response before +deciding whether any further action is needed, and never blindly retry a +mutation because its result could not be upgraded. Diagnostics identify the +field pointer, source and target releases, and reason. An absent `confirmed_at` +becomes `null` only when the current contract permits unconfirmed state. Active +buys and packages carrying committed metrics require the seller's actual +confirmation timestamp instead. +When converting a response down to a release that permits omission but rejects +`null`, the SDK omits that field and records the loss. Request-side explicit +null clears instead fail if the older contract cannot express the clear. +Unsupported request conversions, including a missing common version or a task +absent from the negotiated release, return a failed `TaskResult` with +`adcp_error.code="UNSUPPORTED_FEATURE"` before sending the business request. +Its error details set `wire_response_received=false`; any supplied idempotency +key is retained. Explicit `fetch_capabilities()` calls still raise `ADCPError` +when discovery fails, while automatic discovery retains the failed task result. +Historical product reporting metrics that have no verified newer equivalent, +including 3.0's `video_completions`, also fail with an exact field pointer. +The SDK does not manufacture a mapping to `completed_views`. + +Older governance registrations can name several agents or restrict an agent to +specific `categories`. AdCP 3.1+ instead assigns one agent the full account +lifecycle. The SDK rejects those older scoped or multi-agent registrations with +a structured version-conversion error before handler dispatch; it never drops +scope restrictions or merges governance authorities automatically. A legacy +single-agent registration without categories upgrades normally. + +Both MCP and A2A validate requests after converting to the wire version and +validate responses before upgrading them. Server dispatch validates an older +request against its original schema, upgrades it before typed handler parsing, +and converts the response before wire validation. `ToolContext.resolved_adcp_version` +continues to describe the buyer's wire contract for transport-aware integrations. +Response envelope defaults and the 3.0 media-buy lifecycle status are adapted +at the boundary. Governance decision and acknowledgement statuses map to +`verdict` and `outcome_state`, including verdicts on audit check entries. +Frozen reporting feeds and receipt ingress retain their exact +version restrictions because they preserve signed/durable wire data. + +Polling results use their `task_type` to convert the embedded `result`, including +results supplied on `list_tasks` entries. Loss pointers include `/result` or +`/tasks//result`. Unknown vendor task results remain opaque. Nested polling +wrappers remain opaque as well, bounding conversion to one wrapper level. + +## Conversion reports + +Client results expose conversion details in +`TaskResult.metadata["adcp_version_conversion"]`: + +```python +{ + "wire_version": "3.1", + "losses": [ + { + "pointer": "/accounts/0/brand/countries", + "source_version": "3.2", + "target_version": "3.1", + "reason": "field_unavailable", + } + ], +} +``` + +Every loss also produces a warning. Reports use JSON Pointers and contain no +discarded values. They remain available when a request fails validation or the +peer returns a failed task. Server reports use the same key in +`ToolContext.metadata`; response losses are appended after the handler returns +and are also logged. These diagnostics do not add unsupported fields to older +wire envelopes. + +## Account identity + +The upstream account-key decision in +[adcp#8373](https://github.com/adcontextprotocol/adcp/issues/8373) is still open. +Until that decision is settled, this SDK **reduces and shares** when a new buyer +contacts an older seller: it omits `brand.countries`, `operator_unit`, `currency`, +and `timezone` wherever the old account schema cannot express them, and reports +each omission. It preserves the published brand ID and the remaining key fields. +Buyer accounts differing only in omitted fields therefore address the same +seller account. Applications can use the reports to surface billing or account +collisions. An older buyer reaching a newer server keeps its original key; +the SDK does not invent missing identity components. + +The provisioning registry correlates reduced echoes only when they identify +one complete input key. It does not guess associations for ambiguous merged +batch results. + +For a successfully recorded reduced identity, the registry also retains the +natural key used on the wire. If the seller later advertises 3.2, subsequent +calls keep addressing that existing account by its reduced key and expose +`account_identity_migration_pending` in the conversion report. This avoids +silently creating another account or losing access to its buys and history. +Complete any identity migration explicitly before replacing that registry +binding. Durable `AccountStorage` adapters must persist the optional +`AccountRecord.wire_key` and `wire_version` fields to preserve this behavior +across client restarts. + +## Conformance + +`tests/test_cross_version_conversion.py` exercises both client transports, +server dispatch, account reduction, nested references, and chained conversions +against the bundled schemas. Upstream has not published the shared cross-version +cases requested by adcp#8373 yet; those cases must be added to the conformance +suite when they are available. diff --git a/examples/seller_agent.py b/examples/seller_agent.py index 1705085c8..a296becc7 100644 --- a/examples/seller_agent.py +++ b/examples/seller_agent.py @@ -17,6 +17,7 @@ import hashlib import json +import logging import os import uuid from copy import deepcopy @@ -69,6 +70,49 @@ "https://your-platform.example.com", } + +def _adapt_demo_legacy_governance(tool: str, params: dict[str, Any]) -> dict[str, Any]: + """Acknowledge the demo's existing account-wide mock registration policy. + + This example has never enforced governance categories. Real sellers must + retain the SDK's unsupported-feature rejection when old category scoping + cannot be represented by the current account-wide governance contract. + """ + from adcp._version import normalize_to_release_precision + from adcp.validation import validate_request + + if tool != "sync_governance" or params.get("adcp_major_version", 3) != 3: + return params + version = params.get("adcp_version") + if version is not None: + if not isinstance(version, str): + return params + try: + if normalize_to_release_precision(version) != "3.0": + return params + except ValueError: + return params + if not validate_request(tool, params, version="3.0").valid: + return params + entries = params.get("accounts") or [] + # Multiple old agents cannot be collapsed into the current single-agent + # binding. Keep those requests on the normal rejection path. + if any(len(entry.get("governance_agents") or []) != 1 for entry in entries): + return params + if not any("categories" in entry["governance_agents"][0] for entry in entries): + return params + adapted = deepcopy(params) + for entry in adapted["accounts"]: + entry["governance_agents"][0].pop("categories", None) + logging.getLogger(__name__).warning( + "DemoSeller uses account-wide mock governance registrations and does not apply " + "legacy category scope" + ) + return adapted + + +DEMO_PRE_VALIDATION_HOOKS = {"sync_governance": _adapt_demo_legacy_governance} + # Spec-valid values for ``Product.channels`` (the canonical # ``MediaChannelSchema`` enum from schemas/cache/enums/channels.json). # Storyboard fixtures occasionally seed legacy channel names ("video") @@ -2169,6 +2213,7 @@ async def seed_creative_format( DemoSeller(), name="demo-seller", port=PORT, + pre_validation_hooks=DEMO_PRE_VALIDATION_HOOKS, test_controller=DemoStore(), # Demo example: bypass the comply_test_controller sandbox-mode gate # so storyboard runs work without an Account.mode-aware AccountStore. diff --git a/src/adcp/accounts.py b/src/adcp/accounts.py index 0079cf16a..b27738a27 100644 --- a/src/adcp/accounts.py +++ b/src/adcp/accounts.py @@ -5,6 +5,7 @@ import asyncio import uuid from collections.abc import Mapping +from copy import deepcopy from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal, Protocol, cast @@ -53,6 +54,8 @@ class AccountRecord: account_id: str | None status: str | None + wire_key: dict[str, Any] | None = None + wire_version: str | None = None class AccountStorage(Protocol): @@ -60,6 +63,8 @@ class AccountStorage(Protocol): An adapter MUST be private to one authenticated buyer identity. Sharing it across credentials requires a separate namespace for each buyer. + Persist the optional ``AccountRecord.wire_key`` and ``wire_version`` + fields to retain account continuity across seller upgrades and restarts. """ async def load(self, seller: str, key: str) -> AccountRecord | None: ... @@ -125,7 +130,11 @@ def _sync_key( matched = key == echoed else: matched = all( - key.get(name) == value + ( + all(key.get("brand", {}).get(part) == item for part, item in value.items()) + if name == "brand" and isinstance(value, dict) + else key.get(name) == value + ) for name, value in echoed.items() if name in ref or name in {"brand", "operator"} ) @@ -158,6 +167,67 @@ async def forget(self, key: AccountReference | Mapping[str, Any]) -> None: """Invalidate a stale record, e.g. after a seller reports a missing account.""" await self._storage.delete(self._seller, account_key(key)) + async def preserve_wire_keys( + self, operation: str, params: dict[str, Any], *, version: str + ) -> tuple[dict[str, Any], list[dict[str, Any]]]: + """Keep previously reduced account addresses stable across seller upgrades. + + Full application keys remain the storage address. A seller upgrading its + schemas does not reconcile an existing account's identity, so subsequent + requests must retain the natural key used to provision it. Diagnostics + contain paths and versions only, never account identifiers. + """ + from adcp._version import ( + is_adcp_version_at_least, + normalize_to_release_precision, + resolve_adcp_version_alias, + ) + + result = deepcopy(params) + pending: list[dict[str, Any]] = [] + references: list[tuple[dict[str, Any], str]] = [] + if isinstance(result.get("account"), dict): + references.append((result["account"], "/account")) + if operation == "sync_accounts": + references.extend( + (entry, f"/accounts/{index}") + for index, entry in enumerate(result.get("accounts") or []) + if isinstance(entry, dict) + ) + for reference, pointer in references: + if reference.get("account_id"): + continue + try: + record = await self.get(reference) + except ValueError: + continue + if record is None or record.wire_key is None: + continue + # Only identity dimensions are replaced. Keep provisioning options + # and mutable display metadata supplied by this request. + for name in ("operator", "operator_unit", "currency", "timezone", "sandbox"): + if name in record.wire_key: + reference[name] = deepcopy(record.wire_key[name]) + else: + reference.pop(name, None) + brand = reference.get("brand", {}) + for name in ("domain", "brand_id", "countries"): + if name in record.wire_key["brand"]: + brand[name] = deepcopy(record.wire_key["brand"][name]) + else: + brand.pop(name, None) + reference["brand"] = brand + normalized = resolve_adcp_version_alias(normalize_to_release_precision(version)) + if is_adcp_version_at_least(normalized, "3.2"): + pending.append( + { + "pointer": pointer, + "provisioned_wire_version": record.wire_version, + "wire_version": version, + } + ) + return result, pending + async def ensure( self, key: AccountReference | Mapping[str, Any], @@ -287,10 +357,39 @@ async def observe( key.pop("timezone", None) if row.get("action") is None and operation == "sync_accounts": continue + previous = await self.get(key) + wire_key = previous.wire_key if previous is not None else None + wire_version = previous.wire_version if previous is not None else None + report = (getattr(result, "metadata", None) or {}).get("adcp_version_conversion", {}) + observed_version = report.get("wire_version") + if ( + operation == "sync_accounts" + and wire_key is None + and "account_id" not in key + and isinstance(observed_version, str) + and observed_version in {"3.0", "3.1"} + ): + from adcp.compat.versions import convert_payload + + reduced = convert_payload( + "sync_accounts", + {"accounts": [key]}, + direction="request", + source_version="3.2", + target_version=observed_version, + ).payload["accounts"][0] + if account_key(reduced) != account_key(key): + wire_key = account_key_payload(reduced) + wire_version = observed_version await self._storage.save( self._seller, account_key(key), - AccountRecord(account_id=row.get("account_id"), status=row.get("status")), + AccountRecord( + account_id=row.get("account_id"), + status=row.get("status"), + wire_key=wire_key, + wire_version=wire_version, + ), ) async def prepare(self, operation: str, request: BaseModel, policy: AccountPolicy) -> BaseModel: diff --git a/src/adcp/client.py b/src/adcp/client.py index 5030ccfed..9c1f610e4 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -13,6 +13,7 @@ import time import warnings from collections.abc import Awaitable, Callable, Coroutine, Iterator, Mapping +from copy import deepcopy from datetime import datetime, timezone from functools import wraps from typing import TYPE_CHECKING, Any, ParamSpec, TypedDict, TypeVar, cast @@ -31,7 +32,11 @@ reset_call_max_response_bytes, set_call_max_response_bytes, ) -from adcp._version import resolve_adcp_version +from adcp._version import ( + normalize_to_release_precision, + resolve_adcp_version, + resolve_adcp_version_alias, +) from adcp.accounts import ( ACCOUNT_POLICIES, AccountPolicy, @@ -54,9 +59,17 @@ ) from adcp.capabilities import TASK_FEATURE_MAP, FeatureResolver, looks_like_v3_capabilities from adcp.compat.legacy import LEGACY_ADAPTER_VERSIONS +from adcp.compat.versions import ( + CONVERSION_METADATA_KEY, + ConversionLoss, + ConversionResult, + convert_payload, + negotiate_version, +) from adcp.exceptions import ( ACCOUNT_ERROR_CODE_MAP, ADCPError, + ADCPFeatureUnsupportedError, ADCPResponseEncodingError, ADCPResponseTooLargeError, ADCPTimeoutError, @@ -92,6 +105,7 @@ from adcp.signing.autosign import ( current_operation as _signing_current_operation, ) +from adcp.signing.negotiated_profiles import NegotiatedSigningProfiles from adcp.signing.signer import sign_request from adcp.task_options import ( TaskOptions, @@ -464,6 +478,18 @@ async def _account_bookkeeping( ) +class _TaskPreparationError(ADCPError): + """Retain preflight failure details for the enclosing task result.""" + + def __init__(self, message: str, *, result: TaskResult[Any], **kwargs: Any) -> None: + super().__init__(message, **kwargs) + self.result = result + + +class _CapabilitiesFetchError(_TaskPreparationError): + """Explicit capability fetches retain their documented exception API.""" + + def _task_options_method( method: Callable[P, Coroutine[Any, Any, R]], ) -> Callable[P, Coroutine[Any, Any, R]]: @@ -513,19 +539,28 @@ async def invoke() -> R: if len(positional) > request_index else call_kwargs.get("request") ) - if isinstance(request, BaseModel): - if ( - implicit_account_policy - and task_name != "list_accounts" - and has_natural_reference(request) - ): - client._warn_implicit_account_policy() - prepared = await client.accounts.prepare(task_name, request, account_policy) - if len(positional) > request_index: - positional[request_index] = prepared - else: - call_kwargs["request"] = prepared - result = await method(*cast(Any, positional), **cast(Any, call_kwargs)) + try: + if isinstance(request, BaseModel): + if ( + implicit_account_policy + and task_name != "list_accounts" + and has_natural_reference(request) + ): + client._warn_implicit_account_policy() + prepared = await client.accounts.prepare(task_name, request, account_policy) + if len(positional) > request_index: + positional[request_index] = prepared + else: + call_kwargs["request"] = prepared + result = await method(*cast(Any, positional), **cast(Any, call_kwargs)) + except _TaskPreparationError as exc: + if workflow: + raise + # Discovery and conversion precede the business request. Keep the + # existing failed-result contract (including auth diagnostics) + # when that transport step fails. Explicit capability fetches + # still raise ADCPError as documented. + result = cast(R, exc.result) if ( isinstance(result, TaskResult) and client.product_property_policy is not None @@ -844,11 +879,11 @@ def __init__( advertises before pinning. Raises ``TypeError`` if passed with a non-A2A protocol. - adcp_version: AdCP protocol release this client speaks + adcp_version: Maximum AdCP protocol release to negotiate (release-precision string, e.g. ``"3.0"``, ``"3.1"``, - ``"3.1-beta"``). Stripe-style per-instance pin: the - value is sent as ``adcp_version`` on every outbound - request and selects creative dialect behavior. ``None`` + ``"3.1-beta"``). Each call selects the highest release + shared with the seller, at or below this ceiling. Application + requests and responses use the SDK's latest models. ``None`` (default) resolves to the SDK's compile-time pin (``ADCP_VERSION`` packaged with the wheel). Cross-major pins raise @@ -861,9 +896,9 @@ def __init__( of the negotiation contract. ``get_adcp_version()`` returns the normalized form. - Caller-supplied ``adcp_version`` on a per-call params - dict wins over the constructor pin: the enricher is - the default, not an override. + Caller-supplied ``adcp_version`` on a per-call params dict + supplies that call's ceiling. ``server_version`` explicitly + declares the peer's wire version and bypasses discovery. Migration from ``adcp_major_version`` (legacy integer wire field): generated request types still expose @@ -941,6 +976,8 @@ def __init__( # Capabilities cache self._capabilities: GetAdcpCapabilitiesResponse | None = None + self._capabilities_wire_version: str | None = None + self._task_wire_contexts: dict[tuple[str, str], ConversionResult] = {} self._feature_resolver: FeatureResolver | None = None self._capabilities_fetched_at: float | None = None self._idempotency_capability_verified: bool = False @@ -975,8 +1012,10 @@ def __init__( if strict_idempotency: self.adapter.idempotency_capability_check = self._ensure_idempotency_capability if signing is not None: + self._negotiated_signing_profiles = NegotiatedSigningProfiles() self.adapter.signing_request_hook = self._sign_outgoing_request self.adapter.signing_capability_check = self._prepare_signing_capabilities + self.adapter.signing_request_scope = self._negotiated_signing_profiles.bind # Apply schema validation modes (default: requests=warn, responses=strict # in dev/test, warn in production — see ``ValidationHookConfig`` docs). self.adapter.configure_validation(validation) @@ -997,6 +1036,8 @@ def _inject_adcp_version(params: dict[str, Any]) -> dict[str, Any]: return {"adcp_version": _pinned_version, **params} self.adapter.envelope_enricher = _inject_adcp_version + self.adapter.request_converter = self._prepare_versioned_task + self.adapter.response_converter = self._convert_incoming_response if context_id: # Empty string is treated as "not provided" — callers using @@ -1013,6 +1054,211 @@ def _inject_adcp_version(params: dict[str, Any]) -> dict[str, Any]: self.simple = SimpleAPI(self) + async def _prepare_versioned_task(self, tool: str, params: dict[str, Any]) -> ConversionResult: + """Convert version preflight failures to the public task-result contract.""" + try: + return await self._convert_outgoing_request(tool, params) + except ADCPFeatureUnsupportedError as exc: + message = str(exc) + details: dict[str, Any] = {"wire_response_received": False} + for name in ("pointer", "source_version", "target_version", "reason"): + if getattr(exc, name, None) is not None: + details[name] = getattr(exc, name) + result = TaskResult[Any]( + status=TaskStatus.FAILED, + success=False, + error=message, + adcp_error={"code": "UNSUPPORTED_FEATURE", "message": message, "details": details}, + idempotency_key=params.get("idempotency_key"), + metadata={ + CONVERSION_METADATA_KEY: { + "wire_version": details.get("target_version"), + "losses": [], + } + }, + ) + raise _TaskPreparationError( + message, + result=result, + agent_id=self.agent_config.id, + agent_uri=self.agent_config.agent_uri, + ) from exc + + async def _convert_outgoing_request( + self, tool: str, params: dict[str, Any] + ) -> ConversionResult: + """Negotiate independently for each call; discovery uses its own envelope.""" + if tool == "get_adcp_capabilities": + # An unversioned discovery request works before the peer's version + # set is known. Preserve explicit discovery pins for callers. + return ConversionResult(dict(params)) + maximum = params.get("adcp_version") or self._adcp_version + declared_peer = self._server_version or maximum + if not str(declared_peer).startswith("3."): + return ConversionResult(self.adapter._enrich_outgoing_params(params)) + if "-" in str(declared_peer) and declared_peer != "3.2-rc.7": + # Historical prerelease pins keep their exact contract and gates; + # minor-release translators must not guess prerelease semantics. + return ConversionResult(self.adapter._enrich_outgoing_params(params)) + if self._server_version is not None: + target = negotiate_version([self._server_version], maximum=maximum) + else: + caps = await self.fetch_capabilities() + declared = caps.model_dump(mode="json", exclude_none=True).get("adcp", {}) + supported = declared.get("supported_versions") + if supported is None: + # Major-only advertisements predate minor negotiation. + supported = ["3.0"] if 3 in declared.get("major_versions", []) else [] + target = negotiate_version(supported, maximum=maximum) + prepared = deepcopy(params) + prepared["adcp_version"] = target + try: + prepared, migrations = await self.accounts.preserve_wire_keys( + tool, prepared, version=target + ) + except Exception: + if self.account_policy != "off": + raise + logger.warning("Account continuity storage unavailable for %s", self.agent_config.id) + migrations = [] + continuity_losses: list[ConversionLoss] = [] + for migration in migrations: + pointer = migration["pointer"] + if pointer == "/account": + original, retained = params.get("account", {}), prepared.get("account", {}) + else: + index = int(pointer.split("/")[-1]) + original, retained = params["accounts"][index], prepared["accounts"][index] + dropped = [ + name + for name in ("operator_unit", "currency", "timezone") + if name in original and name not in retained + ] + if "countries" in original.get("brand", {}) and "countries" not in retained.get( + "brand", {} + ): + dropped.append("brand/countries") + for name in dropped: + loss = ConversionLoss( + f"{pointer}/{name}", + resolve_adcp_version(None), + migration["provisioned_wire_version"], + "account_identity_migration_pending", + ) + continuity_losses.append(loss) + logger.warning("Account continuity retained an older identity at %s", loss.pointer) + dialect = ( + self._creative_dialect(prepared, legacy_projection_available=True) + if tool in _LEGACY_CREATIVE_TASKS + else None + ) + if dialect is CreativeDialect.LEGACY: + for collection in ("packages", "new_packages"): + for index, package in enumerate(prepared.get(collection) or []): + if isinstance(package, dict): + self._downgrade_package(package, field=f"{collection}[{index}]") + for index, creative in enumerate(prepared.get("creatives") or []): + if creative.get("format_kind") or creative.get("format_option_ref"): + self._downgrade_creative(creative, field=f"creatives[{index}]") + converted = convert_payload( + tool, + prepared, + direction="request", + source_version=resolve_adcp_version(None), + target_version=target, + ) + return ConversionResult( + converted.payload, + converted.losses + tuple(continuity_losses), + dialect.value if dialect is not None else None, + identity_migrations=tuple(migrations), + ) + + def _convert_incoming_response( + self, tool: str, result: TaskResult[Any], request: ConversionResult + ) -> TaskResult[Any]: + """Upgrade validated wire data and retain request losses on failures too.""" + version = self.adapter._request_version(request.payload) + losses = request.metadata() + if result.success and isinstance(result.data, dict): + version = version or result.data.get("adcp_version") + if ( + isinstance(version, str) + and version.startswith("3.") + and "-" not in resolve_adcp_version_alias(normalize_to_release_precision(version)) + ): + direction = result.data.get("status") + try: + converted = convert_payload( + tool, + result.data, + direction=( + direction + if direction in {"submitted", "working", "input-required"} + else "sync" + ), + source_version=version, + target_version=resolve_adcp_version(None), + request=request.payload, + ) + result = result.model_copy(update={"data": converted.payload}) + losses.extend(converted.metadata()) + except ADCPFeatureUnsupportedError as exc: + message = str(exc) + metadata = dict(result.metadata or {}) + metadata["adcp_conversion_wire_response"] = result.data + details = { + "source_version": version, + "target_version": resolve_adcp_version(None), + "wire_response_received": True, + } + for name in ("pointer", "reason"): + if getattr(exc, name, None) is not None: + details[name] = getattr(exc, name) + result = result.model_copy( + update={ + "success": False, + "status": TaskStatus.FAILED, + "data": None, + "error": message, + "metadata": metadata, + "adcp_error": { + "code": "UNSUPPORTED_FEATURE", + "message": message, + "details": details, + }, + } + ) + if version is not None or losses: + metadata = dict(result.metadata or {}) + metadata[CONVERSION_METADATA_KEY] = {"wire_version": version, "losses": losses} + if request.creative_dialect is not None: + metadata[CONVERSION_METADATA_KEY]["creative_dialect"] = request.creative_dialect + if request.identity_migrations: + metadata[CONVERSION_METADATA_KEY]["account_identity_migration_pending"] = list( + request.identity_migrations + ) + for migration in request.identity_migrations: + logger.warning("Account identity migration pending at %s", migration["pointer"]) + result = result.model_copy(update={"metadata": metadata}) + if result.success and isinstance(version, str): + body = result.data if isinstance(result.data, dict) else {} + task_id = body.get("task_id") or (result.metadata or {}).get("task_id") + if isinstance(task_id, str) and ( + body.get("status") in {"submitted", "working", "input-required"} + or result.status + in {TaskStatus.SUBMITTED, TaskStatus.WORKING, TaskStatus.NEEDS_INPUT} + ): + context: dict[str, Any] = {"adcp_version": version} + if request.payload.get("account") is not None: + context["account"] = {} + self._task_wire_contexts[(task_id, tool)] = ConversionResult( + context, creative_dialect=request.creative_dialect + ) + if len(self._task_wire_contexts) > 1024: + self._task_wire_contexts.pop(next(iter(self._task_wire_contexts))) + return result + def get_adcp_version(self) -> str: """Return the AdCP protocol release this client is pinned to. @@ -1495,7 +1741,11 @@ async def _sign_outgoing_request(self, request: httpx.Request) -> None: cover_digest = True body = request.content - signing_profile_version = self._signing_profile_version + signing_profile_version = ( + self.signing.signing_profile_version + or self._negotiated_signing_profiles.resolve(request.content) + or self._signing_profile_version + ) if signing_profile_version is None: # pragma: no cover - constructor invariant raise RuntimeError("request signing profile was not initialized") signed = sign_request( @@ -1711,6 +1961,8 @@ async def refresh_capabilities(self) -> GetAdcpCapabilitiesResponse: result = await self.get_adcp_capabilities(GetAdcpCapabilitiesRequest()) if result.success and result.data is not None: self._capabilities = result.data + conversion = (result.metadata or {}).get(CONVERSION_METADATA_KEY, {}) + self._capabilities_wire_version = conversion.get("wire_version") self._feature_resolver = FeatureResolver(result.data) self._capabilities_fetched_at = time.monotonic() return self._capabilities @@ -1746,18 +1998,20 @@ async def refresh_capabilities(self) -> GetAdcpCapabilitiesResponse: bool(result.error), raw_data is not None, ) - raise ADCPError( + raise _CapabilitiesFetchError( f"v3 capabilities response from agent {self.agent_config.id!r} " f"failed schema validation: {result.error or result.message}. " f"The response is structurally v3-shaped (carries `adcp`, " f"`supported_protocols`, or a v3 protocol block) — fix the " f"agent's wire shape rather than downgrading to v2.", + result=result, agent_id=self.agent_config.id, agent_uri=self.agent_config.agent_uri, ) - raise ADCPError( + raise _CapabilitiesFetchError( f"Failed to fetch capabilities: {result.error or result.message}", + result=result, agent_id=self.agent_config.id, agent_uri=self.agent_config.agent_uri, ) @@ -1855,16 +2109,29 @@ def _remember_canonical_product_routes(self, products: list[Any]) -> None: self._canonical_legacy_routes[global_key] = None def _creative_dialect( - self, request: Any = None, *, legacy_projection_available: bool = False + self, + request: Any = None, + *, + legacy_projection_available: bool = False, + result: TaskResult[Any] | None = None, ) -> CreativeDialect: """Resolve this call's wire dialect from version, capability, and request evidence.""" + if result is not None: + report = (result.metadata or {}).get(CONVERSION_METADATA_KEY, {}) + if report.get("creative_dialect") in {"canonical", "legacy"}: + return CreativeDialect(report["creative_dialect"]) request_version = None if isinstance(request, dict): request_version = request.get("adcp_version") elif request is not None: request_version = getattr(request, "adcp_version", None) version = request_version or self._server_version or self._adcp_version + if self._server_version is None and self._capabilities is not None: + declared = self._capabilities.model_dump(mode="json", exclude_none=True).get("adcp", {}) + supported = declared.get("supported_versions") + if supported: + version = negotiate_version(supported, maximum=version) if not str(version).startswith("3."): return CreativeDialect.LEGACY return resolve_creative_dialect( @@ -1960,11 +2227,14 @@ def _downgrade_package(self, package: dict[str, Any], *, field: str) -> None: package.pop("params", None) for index, creative in enumerate(package.get("creatives") or []): - self._downgrade_creative( - creative, - field=f"{field}.creatives[{index}]", - product_id=product_id, - ) + if isinstance(creative, dict) and ( + creative.get("format_kind") or creative.get("format_option_ref") + ): + self._downgrade_creative( + creative, + field=f"{field}.creatives[{index}]", + product_id=product_id, + ) def _downgrade_creative( self, @@ -2003,7 +2273,7 @@ def _prepare_creative_params( # create/update_media_buy would drop every targeting null-clear and # the MediaBuy-level frequency_cap removal. params = preserve_explicit_nulls( - request, params, task_name=task_name, version=self._adcp_version + request, params, task_name=task_name, version=resolve_adcp_version(None) ) if strip_legacy_creative_identity(params) != params: raise ValueError( @@ -2240,7 +2510,7 @@ async def _execute_typed_task( # from becoming a request no seller can accept. Kept on the shared # client path so MCP and A2A preserve the same intent. params = preserve_explicit_nulls( - request, params, task_name=task_type, version=self._adcp_version + request, params, task_name=task_type, version=resolve_adcp_version(None) ) self._emit_activity( Activity( @@ -2801,7 +3071,7 @@ async def sync_creatives( Returns: TaskResult containing SyncCreativesResponse """ - dialect = self._creative_dialect(request, legacy_projection_available=True) + self._creative_dialect(request, legacy_projection_available=True) operation_id = self._task_operation_id() params = self._prepare_creative_params(request, task_name="sync_creatives") @@ -2828,7 +3098,10 @@ async def sync_creatives( ) ) - if dialect is CreativeDialect.LEGACY: + if ( + self._creative_dialect(request, legacy_projection_available=True, result=raw_result) + is CreativeDialect.LEGACY + ): return cast( TaskResult[SyncCreativesResponse], self._canonicalize_lifecycle_result( @@ -2856,7 +3129,7 @@ async def list_creatives( Returns: TaskResult containing ListCreativesResponse """ - dialect = self._creative_dialect(request, legacy_projection_available=True) + self._creative_dialect(request, legacy_projection_available=True) operation_id = self._task_operation_id() params = request.model_dump(mode="json", exclude_none=True) @@ -2883,7 +3156,10 @@ async def list_creatives( ) ) - if dialect is CreativeDialect.CANONICAL: + if ( + self._creative_dialect(request, legacy_projection_available=True, result=raw_result) + is CreativeDialect.CANONICAL + ): return self.adapter._parse_response(raw_result, ListCreativesResponse) return cast( TaskResult[ListCreativesResponse], @@ -2913,7 +3189,7 @@ async def get_media_buy_delivery( Returns: TaskResult containing GetMediaBuyDeliveryResponse """ - dialect = self._creative_dialect(request, legacy_projection_available=True) + self._creative_dialect(request, legacy_projection_available=True) operation_id = self._task_operation_id() params = request.model_dump(mode="json", exclude_none=True) @@ -2940,7 +3216,10 @@ async def get_media_buy_delivery( ) ) - if dialect is CreativeDialect.LEGACY: + if ( + self._creative_dialect(request, legacy_projection_available=True, result=raw_result) + is CreativeDialect.LEGACY + ): return cast( TaskResult[GetMediaBuyDeliveryResponse], self._canonicalize_lifecycle_result( @@ -2968,7 +3247,7 @@ async def get_media_buys( Returns: TaskResult containing GetMediaBuysResponse """ - dialect = self._creative_dialect(request, legacy_projection_available=True) + self._creative_dialect(request, legacy_projection_available=True) operation_id = self._task_operation_id() params = request.model_dump(mode="json", exclude_none=True) if params.get("include_webhook_activity") is False: @@ -2999,7 +3278,10 @@ async def get_media_buys( ) ) - if dialect is CreativeDialect.LEGACY: + if ( + self._creative_dialect(request, legacy_projection_available=True, result=raw_result) + is CreativeDialect.LEGACY + ): return cast( TaskResult[GetMediaBuysResponse], self._canonicalize_lifecycle_result( @@ -3185,7 +3467,7 @@ async def create_media_buy( >>> if result.success: ... media_buy_id = result.data.media_buy_id """ - dialect = self._creative_dialect(request, legacy_projection_available=True) + self._creative_dialect(request, legacy_projection_available=True) operation_id = self._task_operation_id() params = self._prepare_creative_params(request, task_name="create_media_buy") @@ -3212,7 +3494,10 @@ async def create_media_buy( ) ) - if dialect is CreativeDialect.LEGACY: + if ( + self._creative_dialect(request, legacy_projection_available=True, result=raw_result) + is CreativeDialect.LEGACY + ): return cast( TaskResult[CreateMediaBuyResponse], self._canonicalize_lifecycle_result( @@ -3263,7 +3548,7 @@ async def update_media_buy( >>> if result.success: ... updated_packages = result.data.packages """ - dialect = self._creative_dialect(request, legacy_projection_available=True) + self._creative_dialect(request, legacy_projection_available=True) operation_id = self._task_operation_id() params = self._prepare_creative_params(request, task_name="update_media_buy") @@ -3290,7 +3575,10 @@ async def update_media_buy( ) ) - if dialect is CreativeDialect.LEGACY: + if ( + self._creative_dialect(request, legacy_projection_available=True, result=raw_result) + is CreativeDialect.LEGACY + ): return cast( TaskResult[UpdateMediaBuyResponse], self._canonicalize_lifecycle_result( @@ -3871,7 +4159,7 @@ async def get_creative_delivery( ) if ( - self._creative_dialect(request, legacy_projection_available=True) + self._creative_dialect(request, legacy_projection_available=True, result=raw_result) is CreativeDialect.CANONICAL ): return self.adapter._parse_response(raw_result, GetCreativeDeliveryResponse) @@ -5954,6 +6242,7 @@ def _parse_webhook_result( context_id: str | None, *, preserve_legacy_identity: bool = False, + wire_version: str | None = None, ) -> TaskResult[AdcpAsyncResponseData]: """ Parse webhook data into typed TaskResult based on task_type. @@ -5976,6 +6265,84 @@ def _parse_webhook_result( protocol-agnostic parameters rather than protocol-specific objects. """ from adcp.utils.response_parser import parse_json_or_text + from adcp.validation.client_hooks import validate_incoming_response + from adcp.validation.schema_validator import format_issues + + conversion_metadata: dict[str, Any] = { + "task_id": task_id, + "operation_id": operation_id, + "timestamp": timestamp, + "message": message, + "context_id": context_id, + } + callback_dialect: CreativeDialect | None = None + wire_result = result + if task_type == "refine_proposals" and isinstance(wire_result, Mapping): + # Verification needs the authenticated original even when strict + # validation or conversion rejects its application representation. + conversion_metadata[WIRE_RESPONSE_METADATA_KEY] = wire_result + if ( + status == GeneratedTaskStatus.completed + and isinstance(result, dict) + and not preserve_legacy_identity + ): + saved_context = self._task_wire_contexts.pop((task_id, task_type), None) + version = ( + wire_version + or result.get("adcp_version") + or (self.adapter._request_version(saved_context.payload) if saved_context else None) + or self._server_version + or self._adcp_version + ) + if isinstance(version, str) and version.startswith("3."): + # Authentication happens before this parser. Validate the + # authenticated wire mapping before applying application defaults. + outcome = validate_incoming_response( + task_type, result, self.adapter.response_validation_mode, version=version + ) + if not outcome.valid and self.adapter.response_validation_mode == "strict": + summary = format_issues(outcome.issues) + return TaskResult( + status=TaskStatus.FAILED, + success=False, + error=f"Schema validation failed for {task_type}: {summary}", + metadata={ + **conversion_metadata, + CONVERSION_METADATA_KEY: {"wire_version": version, "losses": []}, + }, + ) + context = saved_context or ConversionResult( + {"adcp_version": version, "account": {}} + ) + converted = self._convert_incoming_response( + task_type, + TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data=result, + metadata=conversion_metadata, + ), + context, + ) + if not converted.success: + return cast(TaskResult[AdcpAsyncResponseData], converted) + result = converted.data + conversion_metadata = converted.metadata or {} + dialect = conversion_metadata.get(CONVERSION_METADATA_KEY, {}).get( + "creative_dialect" + ) + if dialect in {"canonical", "legacy"}: + callback_dialect = CreativeDialect(dialect) + elif task_type in _LEGACY_CREATIVE_TASKS: + try: + callback_dialect = resolve_creative_dialect( + version, + request=wire_result, + capabilities=self._capabilities, + legacy_projection_available=True, + ) + except CreativeDialectError: + callback_dialect = None # Map task types to their response types (using string literals, not enum) # Note: Some response types are Union types (e.g., ActivateSignalResponse = Success | Error) @@ -6083,6 +6450,7 @@ def _parse_webhook_result( success=True, message=message, metadata={ + **conversion_metadata, "task_id": task_id, "operation_id": operation_id, "timestamp": timestamp, @@ -6105,7 +6473,8 @@ def _parse_webhook_result( if ( not preserve_legacy_identity and lifecycle_types is not None - and self._callback_creative_dialect(result) is CreativeDialect.LEGACY + and (callback_dialect or self._callback_creative_dialect(result)) + is CreativeDialect.LEGACY ): projected = self._canonicalize_lifecycle_result( TaskResult[Any]( @@ -6113,6 +6482,7 @@ def _parse_webhook_result( data=result, success=True, message=message, + metadata=conversion_metadata, ), legacy_type=lifecycle_types[0], canonical_type=lifecycle_types[1], @@ -6121,7 +6491,8 @@ def _parse_webhook_result( if ( not preserve_legacy_identity and task_type == "list_creatives" - and self._callback_creative_dialect(result) is CreativeDialect.LEGACY + and (callback_dialect or self._callback_creative_dialect(result)) + is CreativeDialect.LEGACY ): projected = self._canonicalize_format_read_result( TaskResult[Any]( @@ -6129,6 +6500,7 @@ def _parse_webhook_result( data=result, success=True, message=message, + metadata=conversion_metadata, ), legacy_type=LegacyListCreativesResponse, canonical_type=ListCreativesResponse, @@ -6139,7 +6511,8 @@ def _parse_webhook_result( if ( not preserve_legacy_identity and task_type == "get_creative_delivery" - and self._callback_creative_dialect(result) is CreativeDialect.LEGACY + and (callback_dialect or self._callback_creative_dialect(result)) + is CreativeDialect.LEGACY ): projected = self._canonicalize_format_read_result( TaskResult[Any]( @@ -6147,6 +6520,7 @@ def _parse_webhook_result( data=result, success=True, message=message, + metadata=conversion_metadata, ), legacy_type=LegacyGetCreativeDeliveryResponse, canonical_type=GetCreativeDeliveryResponse, @@ -6159,13 +6533,14 @@ def _parse_webhook_result( try: parsed_result: Any = parse_json_or_text(result, response_type) metadata: dict[str, Any] = { + **conversion_metadata, "task_id": task_id, "operation_id": operation_id, "timestamp": timestamp, "message": message, } if task_type == "refine_proposals" and isinstance(result, Mapping): - metadata[WIRE_RESPONSE_METADATA_KEY] = result + metadata[WIRE_RESPONSE_METADATA_KEY] = wire_result return TaskResult[AdcpAsyncResponseData]( status=TaskStatus.COMPLETED, data=parsed_result, @@ -6197,6 +6572,7 @@ def _parse_webhook_result( error_message = first_error.message fallback_metadata: dict[str, Any] = { + **conversion_metadata, "task_id": task_id, "operation_id": operation_id, "timestamp": timestamp, @@ -6204,7 +6580,7 @@ def _parse_webhook_result( "context_id": context_id, } if task_type == "refine_proposals" and isinstance(result, Mapping): - fallback_metadata[WIRE_RESPONSE_METADATA_KEY] = result + fallback_metadata[WIRE_RESPONSE_METADATA_KEY] = wire_result return TaskResult[AdcpAsyncResponseData]( status=task_status, data=result, @@ -6329,6 +6705,7 @@ async def _handle_mcp_webhook( message=webhook.message, context_id=webhook.context_id, preserve_legacy_identity=preserve_legacy_identity, + wire_version=payload.get("adcp_version"), ) async def _handle_a2a_webhook( diff --git a/src/adcp/compat/governance.py b/src/adcp/compat/governance.py new file mode 100644 index 000000000..9fdc5eaed --- /dev/null +++ b/src/adcp/compat/governance.py @@ -0,0 +1,72 @@ +"""Lossless governance vocabulary changes at the AdCP 3.0/3.1 boundary.""" + +from __future__ import annotations + +from typing import Any + +from adcp.exceptions import ADCPFeatureUnsupportedError + +_VERDICTS = frozenset({"approved", "denied", "conditions"}) +_OUTCOME_STATES = frozenset({"accepted", "findings"}) + + +def _upgrade_field( + payload: dict[str, Any], field: str, values: frozenset[str], pointer: str +) -> None: + value = payload.get("status") + if not isinstance(value, str) or value not in values: + return + if field in payload and payload[field] != value: + raise ADCPFeatureUnsupportedError( + [f"Conflicting legacy and current governance fields at {pointer}/{field}"] + ) + payload[field] = payload.pop("status") + + +def convert_governance_fields( + tool: str, + payload: dict[str, Any], + source_version: str, + target_version: str, + direction: str, +) -> None: + """Mutate one adjacent response hop before generic envelope projection. + + AdCP 3.1 reserves top-level ``status`` for task execution. Governance + decisions moved to ``verdict`` and outcome acknowledgements moved to + ``outcome_state``. Audit check entries use the same verdict vocabulary; + plan and governed-action lifecycle statuses keep their original meaning. + """ + if direction == "request" or {source_version, target_version} != {"3.0", "3.1"}: + return + upgrading = source_version == "3.0" + if tool in {"check_governance", "report_plan_outcome"}: + field = "verdict" if tool == "check_governance" else "outcome_state" + if upgrading: + _upgrade_field( + payload, field, _VERDICTS if tool == "check_governance" else _OUTCOME_STATES, "" + ) + elif field in payload: + payload["status"] = payload.pop(field) + elif tool == "get_plan_audit_logs": + plans = payload.get("plans") + if not isinstance(plans, list): + return + for plan_index, plan in enumerate(plans): + if not isinstance(plan, dict): + continue + entries = plan.get("entries") + if not isinstance(entries, list): + continue + for entry_index, entry in enumerate(entries): + if not isinstance(entry, dict) or entry.get("type") != "check": + continue + if upgrading: + _upgrade_field( + entry, + "verdict", + _VERDICTS, + f"/plans/{plan_index}/entries/{entry_index}", + ) + elif "verdict" in entry: + entry["status"] = entry.pop("verdict") diff --git a/src/adcp/compat/negotiated_catalog_purchase.py b/src/adcp/compat/negotiated_catalog_purchase.py index 9df1d25e5..5b4a3d95c 100644 --- a/src/adcp/compat/negotiated_catalog_purchase.py +++ b/src/adcp/compat/negotiated_catalog_purchase.py @@ -21,13 +21,14 @@ from pydantic import BaseModel, RootModel from typing_extensions import NotRequired, Required, TypedDict -from adcp._version import normalize_to_release_precision +from adcp._version import normalize_to_release_precision, resolve_adcp_version_alias from adcp.compat.purchase_continuation import ( CompatibilityPurchaseOperation, LegacyPurchaseCoordinator, _token_hash, canonical_account_identity, ) +from adcp.compat.versions import CONVERSION_METADATA_KEY, convert_payload, negotiate_version from adcp.types import ( BuyProductsRequest, GetAdcpCapabilitiesResponse, @@ -267,7 +268,25 @@ def __init__( self.client = client self.capabilities = capabilities - self.negotiated_version = _negotiate_version(capabilities, client.get_adcp_version()) + # Capability models describe the current application shape after the + # client upgrades them. Only provenance for this exact cached response + # can identify the discovery contract the seller actually served. + wire_version = ( + getattr(client, "_capabilities_wire_version", None) + if capabilities is getattr(client, "capabilities", None) + else None + ) + maximum = client.get_adcp_version() + configured_peer = ( + client.get_server_version() + if wire_version is not None and hasattr(client, "get_server_version") + else None + ) + if configured_peer is not None and _compare_release(configured_peer, maximum) < 0: + maximum = configured_peer + self.negotiated_version = _negotiate_version( + capabilities, maximum, wire_version=wire_version + ) self._tools = frozenset(str(tool) for tool in advertised_tools) media_buy = getattr(capabilities, "media_buy", None) self._tools = self._tools | frozenset( @@ -968,10 +987,31 @@ def _completed_payload( f"{operation} did not return a completed successful response: " f"{result.error or result.message or result.status.value}", ) - return cast( + payload = cast( JsonObject, result.data.model_dump(mode="json", exclude_none=True, exclude_unset=True), ) + conversion = (result.metadata or {}).get(CONVERSION_METADATA_KEY, {}) + wire_version = conversion.get("wire_version") + if isinstance(wire_version, str): + # The compatibility continuation stores exact-version seller + # evidence. Reconstruct that contract from the client's upgraded + # application payload after checking the actual boundary version. + self._validate_served_version(operation, {"adcp_version": wire_version}, lifecycle) + application_version = payload.get("adcp_version") + if isinstance(application_version, str) and application_version != wire_version: + payload = convert_payload( + ( + "get_products" + if lifecycle is MediaBuyLifecycle.ESTABLISHED + else "list_products" + ), + payload, + direction="sync", + source_version=application_version, + target_version=wire_version, + ).payload + return payload def _validate_served_version( self, operation: str, payload: JsonObject, lifecycle: MediaBuyLifecycle @@ -988,8 +1028,8 @@ def _validate_served_version( "served_version_invalid", "The seller returned an invalid response adcp_version.", ) - normalized = normalize_to_release_precision(served) - if normalized != self.negotiated_version: + normalized = resolve_adcp_version_alias(normalize_to_release_precision(served)) + if normalized != resolve_adcp_version_alias(self.negotiated_version): raise self._unsupported( operation, lifecycle, @@ -1077,7 +1117,9 @@ def _unsupported( NegotiatedCatalogBuyer = MediaBuyLifecycleCoordinator -def _negotiate_version(capabilities: Any, client_version: str) -> str: +def _negotiate_version( + capabilities: Any, client_version: str, *, wire_version: str | None = None +) -> str: client = normalize_to_release_precision(client_version) client_parsed = _parse_release(client) if client_parsed is None: @@ -1089,6 +1131,12 @@ def _negotiate_version(capabilities: Any, client_version: str) -> str: for value in raw_advertised if _parse_release(str(_root_value(value))) is not None ] + if wire_version is not None: + # Discovery may use a newer contract than subsequent calls allow. + # Current clients already validated and upgraded that response, so + # negotiate from the advertised releases within the call ceiling. + # A major-only old declaration has only its observed wire contract. + return negotiate_version(advertised or [wire_version], maximum=client) served = getattr(capabilities, "adcp_version", None) if isinstance(served, str) and served: normalized = normalize_to_release_precision(served) diff --git a/src/adcp/compat/versions.py b/src/adcp/compat/versions.py new file mode 100644 index 000000000..72f7f82c7 --- /dev/null +++ b/src/adcp/compat/versions.py @@ -0,0 +1,670 @@ +"""Translation between consecutive AdCP minor releases. + +Application payloads use the packaged release. Only protocol boundaries use +older schemas. Unknown extension data is opaque; known newer fields are +projected using both schemas, with a value-free record for every loss. +""" + +from __future__ import annotations + +import logging +import re +from copy import deepcopy +from dataclasses import asdict, dataclass +from functools import lru_cache +from typing import Any + +from adcp._version import ( + get_supported_adcp_versions, + normalize_to_release_precision, + resolve_adcp_version_alias, +) +from adcp.exceptions import ADCPFeatureUnsupportedError +from adcp.validation.schema_loader import Direction, get_portable_schema + +logger = logging.getLogger(__name__) +CONVERSION_METADATA_KEY = "adcp_version_conversion" +_OPAQUE_FIELDS = frozenset({"context", "ext"}) + + +@dataclass(frozen=True) +class ConversionLoss: + """One discarded field/item; values are deliberately never retained.""" + + pointer: str + source_version: str + target_version: str + reason: str = "field_unavailable" + + +@dataclass(frozen=True) +class ConversionResult: + payload: dict[str, Any] + losses: tuple[ConversionLoss, ...] = () + creative_dialect: str | None = None + identity_migrations: tuple[dict[str, Any], ...] = () + + def metadata(self) -> list[dict[str, Any]]: + return [asdict(loss) for loss in self.losses] + + +class ADCPVersionConversionError(ADCPFeatureUnsupportedError): + """A payload cannot be represented without inventing state or changing a clear.""" + + def __init__( + self, *, pointer: str, source_version: str, target_version: str, reason: str + ) -> None: + self.pointer = pointer + self.source_version = source_version + self.target_version = target_version + self.reason = reason + super().__init__( + [f"Cannot convert AdCP {source_version} to {target_version} at {pointer}: {reason}"] + ) + + +def _release(version: str) -> str: + return resolve_adcp_version_alias(normalize_to_release_precision(version)) + + +def negotiate_version(supported: list[str], *, maximum: str) -> str: + """Select the highest common release, bounded by this call's pin. + + Prereleases match exactly (except explicitly supported aliases). Never + infer compatibility with an unadvertised release or another major. + """ + from adcp._version import is_adcp_version_at_least + + maximum = _release(maximum) + available = {_release(version) for version in get_supported_adcp_versions()} + common = [ + version + for version in supported + if isinstance(version, str) + and _release(version) in available + and is_adcp_version_at_least(maximum, _release(version)) + ] + if not common: + raise ADCPFeatureUnsupportedError(["No common AdCP version with this seller"]) + selected = common[0] + for candidate in common[1:]: + candidate_release, selected_release = _release(candidate), _release(selected) + if ( + candidate_release != selected_release + and is_adcp_version_at_least(candidate_release, selected_release) + ) or (candidate_release == selected_release and candidate == candidate_release): + selected = candidate + # Retain a seller's advertised alias for old sellers with exact matching. + return selected + + +@lru_cache(maxsize=512) +def _schema(tool: str, direction: Direction, version: str) -> dict[str, Any]: + if version == "3.0" and direction == "submitted" and tool == "create_media_buy": + # The main 3.0 response includes the submitted wire envelope. Its + # separate async schema describes webhook data without that envelope. + direction = "sync" + schema = get_portable_schema(tool, direction, version=version) + if schema is None: + raise ADCPFeatureUnsupportedError([f"{tool} is unavailable in AdCP {version}"]) + if version == "3.0" and direction in {"submitted", "working", "input-required"}: + # Older async schemas describe data beneath the transport envelope. + # Preserve that envelope when projecting a flat tool response. + schema["properties"] = { + **schema.get("properties", {}), + "status": {"type": "string", "const": direction}, + "task_id": {"type": "string"}, + } + return schema + + +def _pointer(path: str, key: str | int) -> str: + return path + "/" + str(key).replace("~", "~0").replace("/", "~1") + + +def _resolve(schema: Any, root: dict[str, Any]) -> dict[str, Any]: + if not isinstance(schema, dict): + return {} + ref = schema.get("$ref") + if isinstance(ref, str) and ref.startswith("#/"): + resolved: Any = root + for segment in ref[2:].split("/"): + resolved = resolved[segment.replace("~1", "/").replace("~0", "~")] + return {**resolved, **{key: value for key, value in schema.items() if key != "$ref"}} + return schema + + +def _score(schema: dict[str, Any], value: Any, root: dict[str, Any]) -> int: + schema = _resolve(schema, root) + if "const" in schema: + return 100 if value == schema["const"] else -1000 + if "enum" in schema: + return 20 if value in schema["enum"] else -100 + if isinstance(value, dict): + props = schema.get("properties", {}) + score = len(props.keys() & value.keys()) * 2 + # A malformed discriminator must not redirect a structurally complete + # success into an errors-only arm and discard its financial artifact. + score -= len(set(schema.get("required", [])) - value.keys()) * 10_000 + for key in props.keys() & value.keys(): + child = _resolve(props[key], root) + if "const" in child or "enum" in child: + score += _score(child, value[key], root) + return score + expected = schema.get("type") + if expected is not None: + types = expected if isinstance(expected, list) else [expected] + actual = ( + "null" + if value is None + else ( + "array" + if isinstance(value, list) + else ( + "boolean" + if isinstance(value, bool) + else ( + "string" + if isinstance(value, str) + else "number" if isinstance(value, (int, float)) else "object" + ) + ) + ) + ) + return 5 if actual in types or actual == "number" and "integer" in types else -1000 + return 0 + + +def _view(schema: Any, value: Any, root: dict[str, Any]) -> dict[str, Any]: + """Collect properties across allOf and the matching union arm.""" + schema = _resolve(schema, root) + result = dict(schema) + properties = dict(schema.get("properties", {})) + required = set(schema.get("required", [])) + branches = list(schema.get("allOf", [])) + choices = schema.get("oneOf", schema.get("anyOf", [])) + if choices: + branches.append(max(choices, key=lambda arm: _score(_resolve(arm, root), value, root))) + for branch in branches: + child = _view(branch, value, root) + properties.update(child.get("properties", {})) + required.update(child.get("required", [])) + for key in ("type", "items", "additionalProperties", "patternProperties", "enum", "const"): + if key in child: + result[key] = child[key] + result["properties"] = properties + result["required"] = required + return result + + +def _property(schema: dict[str, Any], key: str) -> Any: + if key in schema["properties"]: + return schema["properties"][key] + for pattern, child in schema.get("patternProperties", {}).items(): + if re.search(pattern, key): + return child + additional = schema.get("additionalProperties") + return additional if isinstance(additional, dict) else None + + +def _project( + value: Any, + source: Any, + target: Any, + source_root: dict[str, Any], + target_root: dict[str, Any], + path: str, + source_version: str, + target_version: str, + losses: list[ConversionLoss], + *, + drop_unavailable_nulls: bool = False, +) -> Any: + source = _view(source, value, source_root) + target = _view(target, value, target_root) + if isinstance(value, dict): + if target_version == "3.0" and source.get("title") in { + "Postal Area", + "Postal Area Support", + }: + from adcp.types.projections import project_geo_postal_areas + + if source["title"] == "Postal Area Support": + for country, systems in value.items(): + if isinstance(systems, list): + for index, system in enumerate(systems): + if not project_geo_postal_areas({country: [system]}, target_version): + losses.append( + ConversionLoss( + _pointer(_pointer(path, country), index), + source_version, + target_version, + "postal_system_unavailable", + ) + ) + return project_geo_postal_areas(value, target_version) + if "country" in value: + aliases = project_geo_postal_areas( + {value["country"]: [value.get("system")]}, target_version + ) + if not aliases: + raise ADCPFeatureUnsupportedError( + [f"Postal area at {path} has no AdCP 3.0 representation"] + ) + value = {key: child for key, child in value.items() if key != "country"} + value["system"] = next(iter(aliases)) + source = _view(source, value, source_root) + projected = {} + for key, child in value.items(): + child_path = _pointer(path, key) + if key in _OPAQUE_FIELDS: + projected[key] = child + continue + source_child = _property(source, key) + target_child = _property(target, key) + if source_child is not None and target_child is None: + if ( + drop_unavailable_nulls + and not path + and key == "message" + and target.get("additionalProperties", True) is not False + ): + # Human-readable MCP summaries predate the protocol + # envelope; older open response objects can retain them. + projected[key] = child + continue + if ( + not drop_unavailable_nulls + and child is None + and _allows_null(_view(source_child, child, source_root)) + ): + raise ADCPVersionConversionError( + pointer=child_path, + source_version=source_version, + target_version=target_version, + reason="the older contract cannot express an explicit null clear", + ) + # Schema maps (e.g. asset IDs) are application-owned keys. + additional = target.get("additionalProperties") + if isinstance(additional, dict) or target.get("patternProperties"): + target_child = additional or {} + else: + losses.append(ConversionLoss(child_path, source_version, target_version)) + continue + if source_child is None or target_child is None: + projected[key] = child + continue + source_view = _view(source_child, child, source_root) + target_view = _view(target_child, child, target_root) + if ( + (not drop_unavailable_nulls or key in target["required"]) + and child is None + and _allows_null(source_view) + and _rejects_null(target_view) + ): + raise ADCPVersionConversionError( + pointer=child_path, + source_version=source_version, + target_version=target_version, + reason=( + "the older contract cannot express an explicit null clear" + if not drop_unavailable_nulls + else "the older contract requires a value unavailable in this response" + ), + ) + if ( + drop_unavailable_nulls + and child is None + and key not in target["required"] + and _allows_null(source_view) + and _rejects_null(target_view) + ): + losses.append( + ConversionLoss(child_path, source_version, target_version, "null_unavailable") + ) + continue + # An optional enum member introduced by the newer release cannot + # be sent to the old peer. Required values remain for validation + # to reject rather than manufacturing a different meaning. + if ( + not isinstance(child, (dict, list)) + and "enum" in source_view + and child in source_view["enum"] + and "enum" in target_view + and child not in target_view["enum"] + and key not in target["required"] + ): + losses.append( + ConversionLoss(child_path, source_version, target_version, "enum_unavailable") + ) + continue + projected[key] = _project( + child, + source_child, + target_child, + source_root, + target_root, + child_path, + source_version, + target_version, + losses, + drop_unavailable_nulls=drop_unavailable_nulls, + ) + return projected + if isinstance(value, list): + projected_items = [] + for index, child in enumerate(value): + source_item = _view(source.get("items", {}), child, source_root) + target_item = _view(target.get("items", {}), child, target_root) + if ( + not isinstance(child, (dict, list)) + and "enum" in source_item + and child in source_item["enum"] + and "enum" in target_item + and child not in target_item["enum"] + ): + losses.append( + ConversionLoss( + _pointer(path, index), source_version, target_version, "enum_unavailable" + ) + ) + continue + projected_items.append( + _project( + child, + source.get("items", {}), + target.get("items", {}), + source_root, + target_root, + _pointer(path, index), + source_version, + target_version, + losses, + drop_unavailable_nulls=drop_unavailable_nulls, + ) + ) + return projected_items + return value + + +def _allows_null(schema: dict[str, Any]) -> bool: + types = schema.get("type", []) + return ( + "const" in schema + and schema["const"] is None + or "enum" in schema + and None in schema["enum"] + or types == "null" + or isinstance(types, list) + and "null" in types + ) + + +def _rejects_null(schema: dict[str, Any]) -> bool: + types = schema.get("type") + return ( + "const" in schema + and schema["const"] is not None + or "enum" in schema + and None not in schema["enum"] + or types is not None + and types != "null" + and (not isinstance(types, list) or "null" not in types) + ) + + +def _upgrade_media_buy_metadata(tool: str, payload: dict[str, Any]) -> None: + """Represent absent confirmation honestly; concurrency tokens cannot be inferred.""" + entries: list[tuple[dict[str, Any], str]] = [] + if tool in {"create_media_buy", "update_media_buy"}: + if "media_buy_id" in payload and "errors" not in payload and "task_id" not in payload: + entries.append((payload, "")) + elif tool == "get_media_buys": + entries.extend( + (row, _pointer("/media_buys", index)) + for index, row in enumerate(payload.get("media_buys", [])) + if isinstance(row, dict) + ) + for entry, path in entries: + if "revision" not in entry: + raise ADCPVersionConversionError( + pointer=_pointer(path, "revision"), + source_version="3.0", + target_version="3.1", + reason="the seller omitted the required optimistic concurrency token", + ) + if tool == "update_media_buy" or "confirmed_at" in entry: + continue + if entry.get("media_buy_status", entry.get("status")) == "active" or any( + isinstance(package, dict) and "committed_metrics" in package + for package in entry.get("packages", []) + ): + raise ADCPVersionConversionError( + pointer=_pointer(path, "confirmed_at"), + source_version="3.0", + target_version="3.1", + reason="the seller omitted the confirmation timestamp for committed delivery", + ) + entry["confirmed_at"] = None + + +def _upgrade_governance_registration(payload: dict[str, Any]) -> None: + """A category-scoped authority cannot become the full lifecycle owner.""" + for account_index, account in enumerate(payload.get("accounts", [])): + if not isinstance(account, dict): + continue + agents = account.get("governance_agents", []) + if not isinstance(agents, list): + continue + path = _pointer(_pointer("/accounts", account_index), "governance_agents") + if len(agents) > 1: + raise ADCPVersionConversionError( + pointer=path, + source_version="3.0", + target_version="3.1", + reason="multiple governance authorities cannot become one lifecycle owner", + ) + for agent_index, agent in enumerate(agents): + if isinstance(agent, dict) and "categories" in agent: + raise ADCPVersionConversionError( + pointer=_pointer(_pointer(path, agent_index), "categories"), + source_version="3.0", + target_version="3.1", + reason="category-scoped governance cannot become full lifecycle authority", + ) + + +def _upgrade_product_reporting_metrics(payload: dict[str, Any]) -> None: + """A removed historical metric has no certified equivalent in the newer vocabulary.""" + products = payload.get("products", []) + if not isinstance(products, list): + return + for product_index, product in enumerate(products): + if not isinstance(product, dict): + continue + capabilities = product.get("reporting_capabilities") + if not isinstance(capabilities, dict): + continue + metrics = capabilities.get("available_metrics", []) + if not isinstance(metrics, list): + continue + for metric_index, metric in enumerate(metrics): + if metric == "video_completions": + raise ADCPVersionConversionError( + pointer=f"/products/{product_index}/reporting_capabilities/available_metrics/{metric_index}", + source_version="3.0", + target_version="3.1", + reason="the historical reporting metric has no verified newer equivalent", + ) + + +def _convert_embedded_results( + tool: str, + payload: dict[str, Any], + source_version: str, + target_version: str, + losses: list[ConversionLoss], +) -> None: + """Use task identity for opaque polling artifacts, bounded to one wrapper level.""" + if tool == "get_task_status": + entries = [(payload, "")] + elif tool == "list_tasks": + entries = [ + (row, _pointer("/tasks", index)) + for index, row in enumerate(payload.get("tasks", [])) + if isinstance(row, dict) + ] + else: + return + for entry, path in entries: + task_type = entry.get("task_type") + result = entry.get("result") + if ( + not isinstance(task_type, str) + or task_type in {"get_task_status", "list_tasks"} + or not isinstance(result, dict) + ): + continue + status = result.get("status") + direction: Direction = ( + status if status in {"submitted", "working", "input-required"} else "sync" + ) + try: + _schema(task_type, direction, source_version) + except ADCPFeatureUnsupportedError: + # Vendor task names have no SDK-owned contract to project. + continue + prefix = _pointer(path, "result") + try: + converted = convert_payload( + task_type, + result, + direction=direction, + source_version=source_version, + target_version=target_version, + ) + except ADCPVersionConversionError as exc: + raise ADCPVersionConversionError( + pointer=prefix + exc.pointer, + source_version=exc.source_version, + target_version=exc.target_version, + reason=exc.reason, + ) from exc + entry["result"] = converted.payload + losses.extend( + ConversionLoss( + prefix + loss.pointer, loss.source_version, loss.target_version, loss.reason + ) + for loss in converted.losses + ) + + +def convert_payload( + tool: str, + payload: dict[str, Any], + *, + direction: Direction, + source_version: str, + target_version: str, + request: dict[str, Any] | None = None, +) -> ConversionResult: + """Chain adjacent minor conversions without modifying the caller's data. + + The 3.1/3.0 lifecycle envelope is translated semantically. Additive + changes use the bundled schemas, including nested arrays and unions. + New tasks without an older equivalent fail before transport. + """ + source_version, target_version = _release(source_version), _release(target_version) + result = deepcopy(payload) + losses: list[ConversionLoss] = [] + if source_version == target_version: + if direction != "request" and "adcp_version" in result: + result["adcp_version"] = target_version + return ConversionResult(result) + source_parts = source_version.split(".") + target_parts = target_version.split(".") + if source_parts[0] != target_parts[0] or "-" in source_version or "-" in target_version: + raise ADCPFeatureUnsupportedError( + [f"No AdCP converter from {source_version} to {target_version}"] + ) + minor, end = int(source_parts[1]), int(target_parts[1]) + step = 1 if end > minor else -1 + while minor != end: + source = f"{source_parts[0]}.{minor}" + target = f"{source_parts[0]}.{minor + step}" + source_schema = _schema(tool, direction, source) + target_schema = _schema(tool, direction, target) + from adcp.compat.governance import convert_governance_fields + + convert_governance_fields(tool, result, source, target, direction) + if ( + tool == "sync_governance" + and direction == "request" + and (source, target) == ("3.0", "3.1") + ): + _upgrade_governance_registration(result) + if tool == "get_products" and direction != "request" and (source, target) == ("3.0", "3.1"): + _upgrade_product_reporting_metrics(result) + if direction != "request" and {source, target} == {"3.0", "3.1"}: + from adcp.validation._response_envelope import uses_task_status_envelope + + if step > 0: + _upgrade_media_buy_metadata(tool, result) + if ( + tool in {"create_media_buy", "update_media_buy"} + and "media_buy_id" in result + and "errors" not in result + and "task_id" not in result + ): + if step > 0: + if result.get("status") not in {None, "completed"}: + result["media_buy_status"] = result["status"] + result["status"] = "completed" + elif step < 0: + if "media_buy_status" in result: + result["status"] = result.pop("media_buy_status") + elif result.get("status") == "completed": + result.pop("status") + losses.append(ConversionLoss("/status", source, target)) + elif step > 0 and uses_task_status_envelope(tool): + result.setdefault("status", "completed") + if step > 0 and tool in {"get_products", "get_signals"}: + # Old responses have no cache declaration. An account-scoped + # result must never be upgraded into a globally cacheable one. + scope = ( + "account" if request is None or request.get("account") is not None else "public" + ) + result.setdefault("cache_scope", scope) + if step < 0: + result = _project( + result, + source_schema, + target_schema, + source_schema, + target_schema, + "", + source, + target, + losses, + drop_unavailable_nulls=direction != "request", + ) + minor += step + # The version envelope describes the boundary's contract, including + # upgrades before application code. 3.0 has only the major envelope. + if target_version == "3.0": + result.pop("adcp_version", None) + if direction == "request": + result["adcp_major_version"] = 3 + elif direction != "request" or "adcp_version" in payload: + result["adcp_version"] = target_version + if direction != "request": + _convert_embedded_results(tool, result, source_version, target_version, losses) + for loss in losses: + logger.warning( + "AdCP conversion %s -> %s dropped %s (%s)", + loss.source_version, + loss.target_version, + loss.pointer, + loss.reason, + ) + return ConversionResult(result, tuple(losses)) diff --git a/src/adcp/property_lists.py b/src/adcp/property_lists.py index 7fccc1b6a..b35b8a0d3 100644 --- a/src/adcp/property_lists.py +++ b/src/adcp/property_lists.py @@ -92,6 +92,7 @@ def _discard(self, key: tuple[str, str, str]) -> None: def _create_client(self, agent_url: str, auth_token: str | None) -> Any: import httpx2 + from adcp._version import resolve_adcp_version from adcp.client import ADCPClient from adcp.signing import build_async_ip_pinned_transport2 from adcp.types.core import AgentConfig, Protocol @@ -117,6 +118,10 @@ def pinned_factory(**kwargs: Any) -> Any: ), httpx_client_factory=pinned_factory, account_policy="off", + # Shared-list credentials authorize this task alone. Keep the + # reference's existing SDK contract without a discovery request + # that the issuer must reject for the scoped bearer credential. + server_version=resolve_adcp_version(None), ) async def resolve(self, ref: Any) -> ResolvedPropertyList: diff --git a/src/adcp/protocols/a2a.py b/src/adcp/protocols/a2a.py index f4047d8b6..b07d87b49 100644 --- a/src/adcp/protocols/a2a.py +++ b/src/adcp/protocols/a2a.py @@ -430,6 +430,13 @@ async def close(self) -> None: async def _call_a2a_tool( self, tool_name: str, params: dict[str, Any], use_explicit_skill: bool = True + ) -> TaskResult[Any]: + request = await self._prepare_outgoing_request(tool_name, params) + result = await self._call_a2a_tool_wire(tool_name, request.payload, use_explicit_skill) + return self._finish_incoming_result(tool_name, result, request) + + async def _call_a2a_tool_wire( + self, tool_name: str, params: dict[str, Any], use_explicit_skill: bool = True ) -> TaskResult[Any]: """ Call a tool using A2A protocol via official a2a-sdk client. @@ -450,7 +457,6 @@ async def _call_a2a_tool( tool_name, params, client_token=self.idempotency_client_token ) # Apply per-instance envelope enrichment (e.g. adcp_version pin). - params = self._enrich_outgoing_params(params) assert_reporting_request_supported(tool_name, params) # Pre-send schema validation. Matches the MCP adapter: strict mode @@ -461,11 +467,7 @@ async def _call_a2a_tool( tool_name, params, self.request_validation_mode, - version=( - params.get("adcp_version") - if isinstance(params.get("adcp_version"), str) - else None - ), + version=self._request_version(params), ) except SchemaValidationError as exc: return TaskResult[Any]( @@ -492,12 +494,13 @@ async def _call_a2a_tool( # intent from text. parts = [_make_text_part(self._format_tool_request(tool_name, params))] + discovery = tool_name == "get_adcp_capabilities" message = pb.Message( message_id=message_id, role=pb.Role.ROLE_USER, parts=parts, - context_id=self._context_id or "", - task_id=self._active_task_id or "", + context_id="" if discovery else self._context_id or "", + task_id="" if discovery else self._active_task_id or "", ) request = pb.SendMessageRequest(message=message) @@ -526,7 +529,12 @@ async def _call_a2a_tool( mutating=_idempotency.is_mutating(tool_name), idempotency_key=idempotency_key, ) - stream_event = await self._send_and_aggregate(a2a_client, request) + # Fingerprint the protobuf's actual JSON values. Struct numbers + # normalize to floats, so the original Python dict may differ. + invocation = _part_data_dict(parts[0]) if use_explicit_skill else None + signing_params = invocation["parameters"] if invocation is not None else params + with self._outgoing_signing_scope(tool_name, signing_params): + stream_event = await self._send_and_aggregate(a2a_client, request) payload_kind = stream_event.WhichOneof("payload") if payload_kind == "task": @@ -573,8 +581,13 @@ async def _call_a2a_tool( # All raise-sites have passed; commit next-turn state so # the adapter reflects the response the caller is about # to receive. - self._context_id = next_context_id - self._active_task_id = next_active_task_id + # Discovery is independent of business conversations, including + # an approval task retained across capability TTL refreshes. + # Guard writes rather than restoring old values after await: + # concurrent business calls may have advanced the session. + if not discovery: + self._context_id = next_context_id + self._active_task_id = next_active_task_id # Post-receive schema validation. Only runs when the task # carries data (terminal completion); async interim states # with ``data=None`` skip naturally. Strict mode flips the @@ -584,7 +597,7 @@ async def _call_a2a_tool( # call in the same conversation should still target the # right session. if task_result.success and task_result.data is not None: - response_version = ( + response_version = self._request_version(params) or ( task_result.data.get("adcp_version") if isinstance(task_result.data, dict) and isinstance(task_result.data.get("adcp_version"), str) diff --git a/src/adcp/protocols/base.py b/src/adcp/protocols/base.py index 9800364ec..038ecbac4 100644 --- a/src/adcp/protocols/base.py +++ b/src/adcp/protocols/base.py @@ -4,10 +4,12 @@ from abc import ABC, abstractmethod from collections.abc import Awaitable, Callable +from contextlib import AbstractContextManager, nullcontext from typing import TYPE_CHECKING, Any, TypeVar from pydantic import BaseModel +from adcp.compat.versions import ConversionResult from adcp.types.core import AgentConfig, TaskResult, TaskStatus from adcp.utils.response_parser import parse_json_or_text, parse_mcp_content from adcp.validation.client_hooks import ValidationHookConfig, ValidationMode @@ -56,6 +58,9 @@ def __init__(self, agent_config: AgentConfig): # so the event hook never needs to fetch capabilities recursively on # the same MCP session. self.signing_capability_check: Callable[[], Awaitable[None]] | None = None + self.signing_request_scope: ( + Callable[[str, dict[str, Any], str | None], AbstractContextManager[None]] | None + ) = None # Schema validation modes — resolved by the owning ADCPClient via # ``configure_validation``. Class defaults match the TS port: warn # on requests (don't block partial payloads in error-path tests), @@ -80,6 +85,41 @@ def __init__(self, agent_config: AgentConfig): # types) — flipping any of them to ``extra="forbid"`` would break # this assumption silently. self.envelope_enricher: Callable[[dict[str, Any]], dict[str, Any]] | None = None + self.request_converter: ( + Callable[[str, dict[str, Any]], Awaitable[ConversionResult]] | None + ) = None + self.response_converter: ( + Callable[[str, TaskResult[Any], ConversionResult], TaskResult[Any]] | None + ) = None + + async def _prepare_outgoing_request( + self, tool: str, params: dict[str, Any] + ) -> ConversionResult: + if self.request_converter is not None: + return await self.request_converter(tool, params) + return ConversionResult(self._enrich_outgoing_params(params)) + + def _finish_incoming_result( + self, tool: str, result: TaskResult[Any], request: ConversionResult + ) -> TaskResult[Any]: + if self.response_converter is not None: + return self.response_converter(tool, result, request) + return result + + def _outgoing_signing_scope( + self, tool: str, params: dict[str, Any] + ) -> AbstractContextManager[None]: + """Bind exact post-idempotency parameters for a transport writer hook.""" + if self.signing_request_scope is None: + return nullcontext() + return self.signing_request_scope(tool, params, self._request_version(params)) + + @staticmethod + def _request_version(params: dict[str, Any]) -> str | None: + version = params.get("adcp_version") + if isinstance(version, str): + return version + return "3.0" if params.get("adcp_major_version") == 3 else None def _enrich_outgoing_params(self, params: Any) -> Any: """Apply ``envelope_enricher`` to an outbound params dict. @@ -131,6 +171,7 @@ def _parse_response( message=raw_result.message, success=raw_result.success, # Preserve original success state error=raw_result.error, # Only use error if one was set + adcp_error=raw_result.adcp_error, metadata=raw_result.metadata, debug_info=raw_result.debug_info, idempotency_key=raw_result.idempotency_key, @@ -151,6 +192,7 @@ def _parse_response( message=raw_result.message, # Preserve human-readable message from protocol success=raw_result.success, error=raw_result.error, + adcp_error=raw_result.adcp_error, metadata=raw_result.metadata, debug_info=raw_result.debug_info, idempotency_key=raw_result.idempotency_key, @@ -163,8 +205,10 @@ def _parse_response( return TaskResult[T]( status=TaskStatus.FAILED, error=f"Failed to parse response: {e}", + adcp_error=raw_result.adcp_error, message=raw_result.message, success=False, + metadata=raw_result.metadata, debug_info=raw_result.debug_info, idempotency_key=raw_result.idempotency_key, replayed=raw_result.replayed, diff --git a/src/adcp/protocols/mcp.py b/src/adcp/protocols/mcp.py index 42ca6c4b6..36a9c9635 100644 --- a/src/adcp/protocols/mcp.py +++ b/src/adcp/protocols/mcp.py @@ -1157,6 +1157,11 @@ def _serialize_mcp_content(self, content: list[Any]) -> list[dict[str, Any]]: return result async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskResult[Any]: + request = await self._prepare_outgoing_request(tool_name, params) + result = await self._call_mcp_tool_wire(tool_name, request.payload) + return self._finish_incoming_result(tool_name, result, request) + + async def _call_mcp_tool_wire(self, tool_name: str, params: dict[str, Any]) -> TaskResult[Any]: """Call a tool using MCP protocol.""" start_time = time.time() if self.agent_config.debug else None debug_info = None @@ -1169,7 +1174,6 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe # Apply per-instance envelope enrichment (e.g. adcp_version pin). # Runs after idempotency injection so the enriched dict is the # one that's validated and sent. - params = self._enrich_outgoing_params(params) assert_reporting_request_supported(tool_name, params) try: @@ -1181,11 +1185,7 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe tool_name, params, self.request_validation_mode, - version=( - params.get("adcp_version") - if isinstance(params.get("adcp_version"), str) - else None - ), + version=self._request_version(params), ) except SchemaValidationError as exc: return TaskResult[Any]( @@ -1231,14 +1231,15 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe idempotency_key=idempotency_key, ) trace_meta = mcp_trace_meta() - if trace_meta is None: - result = await session.call_tool(tool_name, params) - else: - result = await session.call_tool( - tool_name, - params, - meta=cast("RequestParamsMeta", trace_meta), - ) + with self._outgoing_signing_scope(tool_name, params): + if trace_meta is None: + result = await session.call_tool(tool_name, params) + else: + result = await session.call_tool( + tool_name, + params, + meta=cast("RequestParamsMeta", trace_meta), + ) except (Exception, asyncio.CancelledError) as exc: # CancelledError is listed because that is how a limiter abort # reaches this await: it raises inside MCP's writer task, which @@ -1367,7 +1368,7 @@ async def _call_mcp_tool(self, tool_name: str, params: dict[str, Any]) -> TaskRe # the data unchanged; off short-circuits without invoking the # validator. Never raises — mirrors the existing contract where # response-side failures surface as TaskStatus.FAILED. - response_version = ( + response_version = self._request_version(params) or ( data_to_return.get("adcp_version") if isinstance(data_to_return, dict) and isinstance(data_to_return.get("adcp_version"), str) diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index 6b6794017..28c6ecc63 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -3193,7 +3193,13 @@ def create_tool_caller( ) from adcp.compat.legacy import LEGACY_ADAPTER_VERSIONS, get_legacy_adapter from adcp.compat.legacy.errors import legacy_adapter_task_error - from adcp.exceptions import ADCPTaskError + from adcp.compat.versions import ( + CONVERSION_METADATA_KEY, + ADCPVersionConversionError, + ConversionResult, + convert_payload, + ) + from adcp.exceptions import ADCPFeatureUnsupportedError, ADCPTaskError from adcp.server.helpers import inject_context from adcp.server.version_policy import project_supported_versions, resolve_supported_versions from adcp.types import Error @@ -3203,6 +3209,7 @@ def create_tool_caller( detect_wire_version, ) from adcp.validation.schema_errors import build_adcp_validation_error_payload + from adcp.validation.schema_loader import Direction from adcp.validation.schema_validator import ( format_issues, validate_request, @@ -3258,6 +3265,38 @@ def unsupported_version_error(exc: UnsupportedVersionError) -> ADCPTaskError: ], ) + def convert_version_payload( + payload: dict[str, Any], *, direction: Direction, source_version: str, target_version: str + ) -> ConversionResult: + try: + return convert_payload( + method_name, + payload, + direction=direction, + source_version=source_version, + target_version=target_version, + ) + except ADCPFeatureUnsupportedError as exc: + details = None + if isinstance(exc, ADCPVersionConversionError): + details = { + "pointer": exc.pointer, + "source_version": exc.source_version, + "target_version": exc.target_version, + "reason": exc.reason, + } + raise ADCPTaskError( + operation=method_name, + errors=[ + Error( + code="VERSION_UNSUPPORTED", + message=str(exc), + field=exc.pointer if isinstance(exc, ADCPVersionConversionError) else None, + details=details, + ) + ], + ) from exc + async def call_tool(params: dict[str, Any], context: ToolContext | None = None) -> Any: ctx = context if context is not None else ToolContext() @@ -3381,6 +3420,22 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) wire_release_was_negotiated = isinstance(params, dict) and isinstance( params.get("adcp_version"), str ) + if ( + method_name == "get_adcp_capabilities" + and not wire_release_was_negotiated + # Legacy-only mounts keep the structured unsupported-version + # path below; minor negotiation only knows native 3.x releases. + and any(version not in LEGACY_ADAPTER_VERSIONS for version in accepted_versions) + ): + from adcp._version import resolve_adcp_version + from adcp.compat.versions import negotiate_version + + # Discovery must expose supported_versions before a buyer can + # negotiate. Projecting an unversioned probe through 3.0 would + # remove that field and strand modern buyers on the legacy floor. + wire_version = negotiate_version( + list(accepted_versions), maximum=resolve_adcp_version(None) + ) if wire_version is None: wire_version = default_unnegotiated_adcp_version if selected_versions is not None: @@ -3513,6 +3568,49 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) post_adapter_validator_version = wire_version response_validator_version = None if legacy_adapter is not None else wire_version + from adcp._version import resolve_adcp_version + + application_version = resolve_adcp_version(None) + convert_minor_version = ( + legacy_adapter is None + and wire_version is not None + and wire_version != application_version + and wire_version.startswith("3.") + and "-" not in wire_version + and method_name != "sync_reporting_receipts" + and not frozen_reporting_feed + ) + if convert_minor_version and wire_version is not None: + # Validate the original wire contract before converting. Handler + # validation below then checks the upgraded application shape. + if request_mode is not None and request_mode != "off": + wire_outcome = validate_request(method_name, params, version=wire_version) + if not wire_outcome.valid: + if request_mode == "strict": + raise ADCPTaskError( + operation=method_name, + errors=[ + Error( + **build_adcp_validation_error_payload( + method_name, "request", wire_outcome.issues + ) + ) + ], + ) + logger.warning( + "Schema validation warning (wire request) for %s: %s", + method_name, + format_issues(wire_outcome.issues), + ) + params = convert_version_payload( + params, + direction="request", + source_version=wire_version, + target_version=application_version, + ).payload + post_adapter_validator_version = None + ctx.metadata[CONVERSION_METADATA_KEY] = {"wire_version": wire_version, "losses": []} + creative_boundary_tools = { "create_media_buy", "get_creative_delivery", @@ -3743,7 +3841,9 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) method_name, result, raw_params, - adcp_version=response_validator_version, + adcp_version=( + application_version if convert_minor_version else response_validator_version + ), ) inject_context(raw_params, result) # Run the seller's response enhancer AFTER ``inject_context`` @@ -3762,6 +3862,28 @@ async def call_tool(params: dict[str, Any], context: ToolContext | None = None) if method_name == "get_adcp_capabilities" and "adcp_error" not in result: result = project_supported_versions(result, selected_versions) + if ( + convert_minor_version + and wire_version is not None + and isinstance(result, dict) + and "adcp_error" not in result + ): + response_direction = result.get("status") + converted = convert_version_payload( + result, + direction=( + response_direction + if response_direction in {"submitted", "working", "input-required"} + else "sync" + ), + source_version=application_version, + target_version=wire_version, + ) + result = converted.payload + ctx.metadata[CONVERSION_METADATA_KEY]["losses"].extend(converted.metadata()) + if bridged_response_version is not None: + result["adcp_version"] = bridged_response_version + if response_mode is not None and response_mode != "off" and isinstance(result, dict): # Skip validation when the handler returned the AdCP L3 # error envelope (``{"adcp_error": {...}}``). That envelope diff --git a/src/adcp/signing/negotiated_profiles.py b/src/adcp/signing/negotiated_profiles.py new file mode 100644 index 000000000..7cdbefccf --- /dev/null +++ b/src/adcp/signing/negotiated_profiles.py @@ -0,0 +1,107 @@ +"""Bind negotiated signing profiles to exact, actively dispatched requests. + +MCP sends from a long-lived writer task, so caller ContextVars cannot carry a +per-call profile to its HTTP hook. This per-client registry crosses that task +boundary without trusting arbitrary version fields from an HTTP body. +""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Iterator +from contextlib import contextmanager +from typing import Any + +from adcp.signing._strict_json import StrictJsonError, parse_strict_json +from adcp.signing.autosign import SigningProfileVersion, signing_profile_for_adcp_version + + +def _fingerprint(tool: str, params: dict[str, Any]) -> bytes: + encoded = json.dumps([tool, params], sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(encoded.encode()).digest() + + +def _invocation(body: bytes) -> tuple[str, dict[str, Any]] | None: + try: + payload = parse_strict_json(body) + except StrictJsonError: + return None + if not isinstance(payload, dict): + return None + params = payload.get("params") + if payload.get("method") == "tools/call" and isinstance(params, dict): + tool, arguments = params.get("name"), params.get("arguments") + if isinstance(tool, str) and isinstance(arguments, dict): + return tool, arguments + return None + # A2A v1 REST uses a root message; its JSON-RPC compatibility transport + # wraps the same message in params. DataParts keep the same data object. + message = payload.get("message") + if message is None and isinstance(params, dict): + message = params.get("message") + if not isinstance(message, dict): + return None + parts = message.get("parts") + if not isinstance(parts, list): + return None + invocations = [] + for part in parts: + text = part.get("text") if isinstance(part, dict) else None + if isinstance(text, str) and text.startswith("Execute tool: "): + header, separator, arguments_text = text.partition("\nParameters: ") + if separator: + try: + arguments = parse_strict_json(arguments_text.encode("utf-8")) + except StrictJsonError: + continue + if isinstance(arguments, dict): + invocations.append((header.removeprefix("Execute tool: "), arguments)) + continue + data = part.get("data") if isinstance(part, dict) else None + if not isinstance(data, dict): + continue + tool, arguments = data.get("skill"), data.get("parameters") + if isinstance(tool, str) and isinstance(arguments, dict): + invocations.append((tool, arguments)) + return invocations[0] if len(invocations) == 1 else None + + +class NegotiatedSigningProfiles: + """An owning client's currently dispatched parameter fingerprints. + + Bind only parameters emitted by that client's negotiated converter, after + idempotency injection. ``resolve`` never derives a profile from a body's + version: the body must match a registered invocation exactly. Registrations + are reference counted so equal concurrent calls cannot erase each other. + """ + + def __init__(self) -> None: + self._profiles: dict[bytes, tuple[SigningProfileVersion, int]] = {} + + @contextmanager + def bind(self, tool: str, params: dict[str, Any], version: str | None) -> Iterator[None]: + if version is None or not version.startswith("3."): + yield + return + profile = signing_profile_for_adcp_version(version) + key = _fingerprint(tool, params) + existing, count = self._profiles.get(key, (profile, 0)) + if existing != profile: + raise RuntimeError("Conflicting signing profiles for an identical outbound request") + self._profiles[key] = (profile, count + 1) + try: + yield + finally: + _, remaining = self._profiles[key] + if remaining == 1: + del self._profiles[key] + else: + self._profiles[key] = (profile, remaining - 1) + + def resolve(self, body: bytes) -> SigningProfileVersion | None: + invocation = _invocation(body) + if invocation is None: + return None + entry = self._profiles.get(_fingerprint(*invocation)) + return entry[0] if entry is not None else None diff --git a/tests/conformance/reporting/_feed_support.py b/tests/conformance/reporting/_feed_support.py index 8a047effd..26cb3a6a4 100644 --- a/tests/conformance/reporting/_feed_support.py +++ b/tests/conformance/reporting/_feed_support.py @@ -200,7 +200,7 @@ async def capture(request): observed.append((protocol, method, params)) async with self.client(public_url="http://localhost") as transport: - # Keep both public SDK transport stacks, negotiation and encoders. + # Keep both public SDK transport stacks and encoders. # The factory/HTTP pool injection only replaces the network socket. def mcp_http(**kwargs): return httpx2.AsyncClient( @@ -226,6 +226,9 @@ def mcp_http(**kwargs): ADCPClient( config, adcp_version=self.version, + # Exercise the known frozen mount contract. + # Production discovery is checked separately. + server_version=self.version, force_a2a_version=a2a_version if protocol == "a2a" else None, httpx_client_factory=mcp_http if protocol == "mcp" else None, ) diff --git a/tests/conformance/reporting/test_reporting_production_feed_numbers.py b/tests/conformance/reporting/test_reporting_production_feed_numbers.py index 6b720d34c..946381adb 100644 --- a/tests/conformance/reporting/test_reporting_production_feed_numbers.py +++ b/tests/conformance/reporting/test_reporting_production_feed_numbers.py @@ -198,8 +198,8 @@ async def test_typed_numbers_cross_protocol_and_client_rounding_are_distinct(bac async with onboarding_server(backend, tmp_path, scenario="feed") as (uri, ready): for version in ("0.3", "1.0"): async with ( - public_client(uri, "acct_a") as mcp, - public_client(uri, "acct_a", "a2a-" + version) as a2a, + public_client(uri, "acct_a", server_version="3.2") as mcp, + public_client(uri, "acct_a", "a2a-" + version, server_version="3.2") as a2a, ): for start, finish in ((mcp, a2a), (a2a, mcp)): raw = { diff --git a/tests/conformance/reporting/test_reporting_production_scope.py b/tests/conformance/reporting/test_reporting_production_scope.py index 1d9f938f9..8107f3365 100644 --- a/tests/conformance/reporting/test_reporting_production_scope.py +++ b/tests/conformance/reporting/test_reporting_production_scope.py @@ -77,7 +77,7 @@ async def onboarding_server(backend, root, *, scenario="scope", count=0, notific await asyncio.to_thread(child.wait) -def public_client(uri, account, route="mcp"): +def public_client(uri, account, route="mcp", *, server_version=None): return ADCPClient( AgentConfig( id="scope-" + account, @@ -88,6 +88,7 @@ def public_client(uri, account, route="mcp"): auth_type="bearer", ), adcp_version="3.2.1", + server_version=server_version, force_a2a_version=route.removeprefix("a2a-") if route != "mcp" else None, ) diff --git a/tests/conformance/reporting/test_reporting_status_capabilities.py b/tests/conformance/reporting/test_reporting_status_capabilities.py index 6a2f92f4d..1bffe0d75 100644 --- a/tests/conformance/reporting/test_reporting_status_capabilities.py +++ b/tests/conformance/reporting/test_reporting_status_capabilities.py @@ -242,11 +242,16 @@ async def test_authoritative_task_is_callable_through_mcp_and_a2a_with_authentic assert "sync_reporting_receipts" not in {t["name"] for t in get_tools_for_handler(handler)} executor = ADCPAgentExecutor(handler, validation=None) for caller in ( - create_tool_caller(handler, "get_reporting_status"), + create_tool_caller( + handler, + "get_reporting_status", + default_unnegotiated_adcp_version=handler.get_adcp_version(), + ), executor._tool_callers["get_reporting_status"], ): buyer = await caller( { + "adcp_version": "3.2", "account": {"account_id": "acct_a"}, "view": "summary", "context": {"consumer_id": "auditor"}, @@ -255,6 +260,7 @@ async def test_authoritative_task_is_callable_through_mcp_and_a2a_with_authentic ) auditor = await caller( { + "adcp_version": "3.2", "account": {"account_id": "acct_a"}, "view": "summary", "context": {"consumer_id": "buyer"}, diff --git a/tests/integration/test_a2a_context_id.py b/tests/integration/test_a2a_context_id.py index e71401c03..83551b926 100644 --- a/tests/integration/test_a2a_context_id.py +++ b/tests/integration/test_a2a_context_id.py @@ -26,6 +26,7 @@ from __future__ import annotations import asyncio +import json import socket import sys from collections.abc import AsyncIterator @@ -49,6 +50,7 @@ from adcp import ADCPClient from adcp.server import ADCPHandler from adcp.server.a2a_server import create_a2a_server +from adcp.server.responses import capabilities_response from adcp.types import AgentConfig, Protocol # Starlette/uvicorn A2A integration requires Python 3.11+. @@ -66,7 +68,7 @@ class _EchoHandler(ADCPHandler): adcp_capabilities = {"media_buy": {"features": {"canonical_creatives": True}}} async def get_adcp_capabilities(self, params: Any, context: Any = None) -> dict[str, Any]: - return {"adcp": {"major_versions": [3]}, "supported_protocols": ["media_buy"]} + return capabilities_response(["media_buy"], idempotency={"supported": False}) async def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: return {"products": []} @@ -100,9 +102,9 @@ class _Observer: def __init__(self) -> None: self.calls: list[dict[str, str | None]] = [] + self.discovery_calls: list[dict[str, str | None]] = [] def parser(self, context: RequestContext) -> tuple[str | None, dict[str, Any]]: - self.calls.append({"context_id": context.context_id, "task_id": context.task_id}) # Reimplement the default skill-DataPart parse inline so we # don't reach into executor internals. msg = context.message @@ -115,10 +117,40 @@ def parser(self, context: RequestContext) -> tuple[str | None, dict[str, Any]]: skill = data.get("skill") params = data.get("parameters") or {} if skill and isinstance(params, dict): + call = {"context_id": context.context_id, "task_id": context.task_id} + if skill == "get_adcp_capabilities": + self.discovery_calls.append(call) + else: + self.calls.append(call) return str(skill), params return None, {} +async def _capture_wire_messages(client: ADCPClient, observations: list[dict[str, Any]]) -> None: + """Observe before the server SDK fills missing Message IDs in place.""" + + async def capture(request): + if request.method != "POST": + return + payload = json.loads(request.content) + message = payload.get("params", payload).get("message") + if not isinstance(message, dict): + return + for part in message.get("parts", []): + data = part.get("data", {}) + if data.get("skill"): + observations.append( + { + "skill": data["skill"], + "context_id": message.get("contextId") or None, + "task_id": message.get("taskId") or None, + } + ) + + http_client = await client.adapter._get_httpx_client() + http_client.event_hooks.setdefault("request", []).append(capture) + + def _pick_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) @@ -162,6 +194,7 @@ async def test_two_calls_share_server_assigned_context_id(): echoing it, turn 2 would get a different server-minted id — the assertion that calls[0] == calls[1] catches that regression.""" observer = _Observer() + wire_messages: list[dict[str, Any]] = [] async with _running_server(_EchoHandler(), observer) as base_url: config = AgentConfig( id="ctx-test-agent", @@ -170,6 +203,7 @@ async def test_two_calls_share_server_assigned_context_id(): auth_token="test", ) async with ADCPClient(config) as client: + await _capture_wire_messages(client, wire_messages) assert client.context_id is None r1 = await client.adapter.get_products({"brief": "x"}) assert r1.success, r1.error @@ -181,6 +215,14 @@ async def test_two_calls_share_server_assigned_context_id(): assert r2.success, r2.error assert len(observer.calls) == 2 + assert len(observer.discovery_calls) == 1 + assert wire_messages[0] == { + "skill": "get_adcp_capabilities", + "context_id": None, + "task_id": None, + } + assert wire_messages[1]["skill"] == "get_products" + assert wire_messages[1]["context_id"] is None # The server saw the same context_id on both turns — this proves # session continuity end-to-end. The server is authoritative, so # this value is what any handler keyed on context_id would scope to. @@ -252,6 +294,29 @@ def __init__(self) -> None: self._served = 0 async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + # Discovery completes independently of the approval workflow, so it + # neither consumes a HITL turn nor changes the business observations. + if context.message is not None and any( + (data := _part_data_dict(part)) is not None + and data.get("skill") == "get_adcp_capabilities" + for part in context.message.parts + ): + capabilities = Value() + ParseDict( + capabilities_response(["media_buy"], idempotency={"supported": False}), + capabilities, + ) + await event_queue.enqueue_event( + pb.Task( + id=context.task_id or str(uuid4()), + context_id=context.context_id or str(uuid4()), + status=pb.TaskStatus(state=pb.TaskState.TASK_STATE_COMPLETED), + artifacts=[ + pb.Artifact(artifact_id=str(uuid4()), parts=[pb.Part(data=capabilities)]) + ], + ) + ) + return self.observations.append( { "context_id": context.context_id, @@ -337,12 +402,18 @@ def _make_hitl_app(executor: _HitlExecutor, port: int) -> Any: default_input_modes=["application/json"], default_output_modes=["application/json"], skills=[ + pb.AgentSkill( + id="get_adcp_capabilities", + name="get_adcp_capabilities", + description="get_adcp_capabilities", + tags=["adcp"], + ), pb.AgentSkill( id="create_media_buy", name="create_media_buy", description="create_media_buy", tags=["adcp"], - ) + ), ], ) handler = DefaultRequestHandler( @@ -454,3 +525,35 @@ async def test_resume_across_simulated_restart_lands_on_same_session(): # Both server-observed calls share the same context_id. assert observer.calls[0]["context_id"] == observer.calls[1]["context_id"] assert observer.calls[1]["context_id"] == persisted_context_id + + +@pytest.mark.asyncio +async def test_capability_refresh_preserves_pending_approval_and_resume_ids(): + """Explicit and automatic discovery must leave the pending approval intact.""" + executor = _HitlExecutor() + wire_messages: list[dict[str, Any]] = [] + async with _running_raw_server(executor) as base_url: + config = AgentConfig( + id="hitl-refresh-agent", + agent_uri=base_url, + protocol=Protocol.A2A, + auth_token="test", + ) + async with ADCPClient(config) as client: + await _capture_wire_messages(client, wire_messages) + await client.adapter.create_media_buy({"budget": 1000}) + retained = (client.context_id, client.active_task_id) + assert all(retained) + await client.refresh_capabilities() + assert (client.context_id, client.active_task_id) == retained + # Expire the cache so resume itself also triggers discovery. + client.capabilities_ttl = 0 + resumed = await client.adapter.create_media_buy({"approval": "yes"}) + assert resumed.success, resumed.error + + assert len(executor.observations) == 2 + assert executor.observations[1]["message_context_id"] == retained[0] + assert executor.observations[1]["message_task_id"] == retained[1] + discovery = [call for call in wire_messages if call["skill"] == "get_adcp_capabilities"] + assert len(discovery) == 3 + assert all(call["context_id"] is None and call["task_id"] is None for call in discovery) diff --git a/tests/test_account_version_continuity.py b/tests/test_account_version_continuity.py new file mode 100644 index 000000000..5cb668506 --- /dev/null +++ b/tests/test_account_version_continuity.py @@ -0,0 +1,141 @@ +"""Seller upgrades must not silently change an existing account's address.""" + +from copy import deepcopy +from types import SimpleNamespace + +import pytest + +from adcp.account_identity import account_key_payload +from adcp.accounts import AccountRecord, AccountRegistry, InMemoryAccountStorage +from adcp.types.core import TaskResult, TaskStatus + +KEY = { + "brand": {"domain": "brand.example", "countries": ["US"]}, + "operator": "agency.example", + "operator_unit": {"id": "seat-one"}, + "currency": "USD", + "timezone": "UTC", +} +REDUCED = { + "brand": {"domain": "brand.example"}, + "operator": "agency.example", + "sandbox": False, +} + + +def registry(storage=None): + client = SimpleNamespace( + agent_config=SimpleNamespace(agent_uri="https://seller.example/mcp"), + capabilities=None, + ) + return AccountRegistry(client, storage) + + +async def provision(accounts, *, key=KEY, version="3.1", row_key=REDUCED): + result = TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data={ + "accounts": [ + { + **deepcopy(row_key), + "account_id": "seller-account", + "status": "active", + "action": "created", + } + ] + }, + metadata={"adcp_version_conversion": {"wire_version": version, "losses": []}}, + ) + await accounts.observe( + "sync_accounts", + {"accounts": [{**key, "billing": "operator"}]}, + result, + fetch_capabilities=False, + ) + + +@pytest.mark.parametrize("version", ["3.0", "3.1"]) +async def test_provisioned_reduced_key_survives_seller_upgrade_and_restart(version): + storage = InMemoryAccountStorage() + accounts = registry(storage) + await provision(accounts, version=version) + record = await accounts.get(KEY) + assert record.wire_key == REDUCED + assert record.wire_version == version + # A durable adapter retains the wire address under the complete buyer key. + restarted = registry(storage) + original = {"account": deepcopy(KEY), "brief": "video"} + wire, pending = await restarted.preserve_wire_keys("get_products", original, version="3.2") + assert original["account"] == KEY + assert wire["account"] == REDUCED + assert wire["account"].get("account_id") is None + assert pending == [ + {"pointer": "/account", "provisioned_wire_version": version, "wire_version": "3.2"} + ] + + +async def test_reduced_identity_survives_resync_without_losing_mutable_options(): + accounts = registry() + await provision(accounts) + entry = {**deepcopy(KEY), "billing": "brand", "payment_terms": "net_30"} + entry["brand"]["name"] = "New display name" + original = {"accounts": [entry]} + wire, pending = await accounts.preserve_wire_keys("sync_accounts", original, version="3.2") + assert account_key_payload(wire["accounts"][0]) == REDUCED + assert wire["accounts"][0]["brand"]["name"] == "New display name" + assert wire["accounts"][0]["billing"] == "brand" + assert wire["accounts"][0]["payment_terms"] == "net_30" + assert original["accounts"][0]["currency"] == "USD" + assert pending[0]["pointer"] == "/accounts/0" + await provision(accounts, version="3.2") + assert (await accounts.get(KEY)).wire_version == "3.1" + assert (await accounts.get(KEY)).wire_key == REDUCED + + +async def test_reduce_and_share_retains_each_full_key_without_new_refusal(): + accounts = registry() + second = {**KEY, "currency": "EUR", "operator_unit": {"id": "seat-two"}} + await provision(accounts) + await provision(accounts, key=second) + assert (await accounts.get(KEY)).wire_key == REDUCED + assert (await accounts.get(second)).wire_key == REDUCED + for key in (KEY, second): + wire, pending = await accounts.preserve_wire_keys( + "get_products", {"account": key}, version="3.2" + ) + assert wire["account"] == REDUCED + assert len(pending) == 1 + + +async def test_old_contract_keeps_address_without_premature_migration_diagnostic(): + accounts = registry() + await provision(accounts) + wire, pending = await accounts.preserve_wire_keys( + "get_products", {"account": KEY}, version="3.1" + ) + assert wire["account"] == REDUCED + assert pending == [] + + +async def test_latest_provisioning_and_explicit_ids_need_no_identity_pin(): + accounts = registry() + await provision(accounts, version="3.2", row_key=KEY) + assert await accounts.get(KEY) == AccountRecord("seller-account", "active") + for ref in (KEY, {"account_id": "external"}): + wire, pending = await accounts.preserve_wire_keys( + "get_products", {"account": ref}, version="3.2" + ) + assert wire["account"] == ref + assert pending == [] + + +async def test_forgetting_account_removes_the_recorded_wire_address(): + accounts = registry() + await provision(accounts) + await accounts.forget(KEY) + wire, pending = await accounts.preserve_wire_keys( + "get_products", {"account": KEY}, version="3.2" + ) + assert wire["account"] == KEY + assert pending == [] diff --git a/tests/test_control_request_intent.py b/tests/test_control_request_intent.py index d9959603f..4e41d3aae 100644 --- a/tests/test_control_request_intent.py +++ b/tests/test_control_request_intent.py @@ -13,6 +13,7 @@ from adcp import ADCPClient from adcp.server import ADCPHandler, create_mcp_server from adcp.server.base import ToolContext +from adcp.server.responses import capabilities_response from adcp.types import ControlMediaBuyRequest from adcp.validation import ValidationHookConfig, validate_request @@ -51,11 +52,16 @@ def construct(value: bool) -> Any: class _ControlSeller(ADCPHandler): - advertised_tools = {"control_media_buy"} + advertised_tools = {"get_adcp_capabilities", "control_media_buy"} def __init__(self) -> None: self.received: list[ControlMediaBuyRequest] = [] + async def get_adcp_capabilities(self, params, context=None): + response = capabilities_response(["media_buy"]) + response["adcp"]["idempotency"] = {"supported": False} + return response + async def control_media_buy( self, params: ControlMediaBuyRequest, context: ToolContext | None = None ) -> dict[str, Any]: diff --git a/tests/test_cross_version_conversion.py b/tests/test_cross_version_conversion.py new file mode 100644 index 000000000..ac53526b9 --- /dev/null +++ b/tests/test_cross_version_conversion.py @@ -0,0 +1,791 @@ +"""Application shapes stay current while client/server wire versions vary.""" + +import asyncio +from copy import deepcopy +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from adcp import ADCPClient +from adcp.canonical_formats import CreativeDialect +from adcp.compat.versions import ( + CONVERSION_METADATA_KEY, + ADCPVersionConversionError, + convert_payload, + negotiate_version, +) +from adcp.exceptions import ADCPFeatureUnsupportedError +from adcp.server import ADCPHandler, ToolContext +from adcp.server.mcp_tools import create_tool_caller +from adcp.server.responses import capabilities_response +from adcp.types import GetAdcpCapabilitiesResponse, GetProductsRequest, SyncAccountsRequest +from adcp.types.core import AgentConfig, Protocol, TaskResult, TaskStatus +from adcp.validation import ValidationHookConfig, validate_request, validate_response + + +def test_highest_common_version_and_call_ceiling() -> None: + assert negotiate_version(["3.0", "3.1"], maximum="3.2") == "3.1" + assert negotiate_version(["3.2", "3.0", "3.1"], maximum="3.1") == "3.1" + assert negotiate_version(["3.2-rc.7"], maximum="3.2") == "3.2-rc.7" + assert negotiate_version(["3.2"], maximum="3.2-rc.7") == "3.2" + assert negotiate_version(["3.2", "3.2-rc.7"], maximum="3.2") == "3.2" + with pytest.raises(ADCPFeatureUnsupportedError): + negotiate_version(["2.5", "3.3"], maximum="3.2") + + +@pytest.mark.parametrize("target", ["3.1", "3.0"]) +def test_account_keys_reduce_with_complete_loss_records(target: str) -> None: + request = { + "idempotency_key": "cross-version-key-1234", + "accounts": [ + { + "brand": {"domain": "example.com", "brand_id": "example", "countries": ["US"]}, + "operator": "agency.example", + "operator_unit": {"id": "seat_1", "name": "One"}, + "currency": "USD", + "timezone": "America/New_York", + "billing": "operator", + } + ], + "context": {"countries": ["US"], "operator_unit": "opaque"}, + "ext": {"vendor": {"countries": ["US"]}}, + } + original = deepcopy(request) + converted = convert_payload( + "sync_accounts", + request, + direction="request", + source_version="3.2", + target_version=target, + ) + assert request == original + assert converted.payload["accounts"] == [ + { + "brand": {"domain": "example.com", "brand_id": "example"}, + "operator": "agency.example", + "billing": "operator", + } + ] + assert converted.payload["context"] == original["context"] + assert converted.payload["ext"] == original["ext"] + assert {loss.pointer for loss in converted.losses} >= { + "/accounts/0/brand/countries", + "/accounts/0/operator_unit", + "/accounts/0/currency", + "/accounts/0/timezone", + } + assert all("value" not in record for record in converted.metadata()) + assert validate_request("sync_accounts", converted.payload, version=target).valid + + +def test_nested_account_reference_union_and_extensions() -> None: + request = { + "buying_mode": "brief", + "brief": "video", + "account": { + "brand": {"domain": "example.com", "countries": ["US"]}, + "operator": "agency.example", + "operator_unit": {"id": "seat_1"}, + "currency": "USD", + "timezone": "UTC", + }, + "vendor_unknown": {"currency": "do not edit"}, + } + result = convert_payload( + "get_products", + request, + direction="request", + source_version="3.2", + target_version="3.1", + ) + assert result.payload["account"] == { + "brand": {"domain": "example.com"}, + "operator": "agency.example", + } + assert result.payload["vendor_unknown"] == request["vendor_unknown"] + assert len(result.losses) == 4 + + +def test_new_task_fails_before_an_unrepresentable_old_call() -> None: + with pytest.raises(ADCPFeatureUnsupportedError, match="list_products"): + convert_payload( + "list_products", {}, direction="request", source_version="3.2", target_version="3.0" + ) + + +def test_response_upgrade_and_downgrade_chain() -> None: + old = {"products": [], "context": {"account": "opaque"}} + upgraded = convert_payload( + "get_products", + old, + direction="sync", + source_version="3.0", + target_version="3.2", + request={}, + ) + assert upgraded.payload["status"] == "completed" + assert upgraded.payload["cache_scope"] == "public" + assert upgraded.payload["adcp_version"] == "3.2" + reduced = convert_payload( + "get_products", + upgraded.payload, + direction="sync", + source_version="3.2", + target_version="3.0", + ) + assert reduced.payload == old + assert {loss.pointer for loss in reduced.losses} >= {"/status", "/cache_scope"} + + +@pytest.mark.parametrize("tool", ["create_media_buy", "update_media_buy"]) +def test_old_media_buy_success_without_lifecycle_status_gets_task_status(tool: str) -> None: + old = { + "media_buy_id": "mb_1", + "packages": [], + "revision": 1, + "confirmed_at": "2026-10-22T00:00:00Z", + } + assert validate_response(tool, old, version="3.0").valid + + upgraded = convert_payload( + tool, old, direction="sync", source_version="3.0", target_version="3.2" + ) + + assert upgraded.payload["status"] == "completed" + assert "media_buy_status" not in upgraded.payload + assert validate_response(tool, upgraded.payload, version="3.2").valid + + +def test_response_alias_is_normalized_without_rewriting_the_request_alias() -> None: + payload = {"adcp_version": "3.2-rc.7"} + response = convert_payload( + "get_products", payload, direction="sync", source_version="3.2-rc.7", target_version="3.2" + ) + request = convert_payload( + "get_products", + payload, + direction="request", + source_version="3.2", + target_version="3.2-rc.7", + ) + assert response.payload["adcp_version"] == "3.2" + assert request.payload["adcp_version"] == "3.2-rc.7" + + +def test_unknown_confirmation_is_omitted_when_old_response_cannot_express_null() -> None: + payload = { + "status": "completed", + "media_buy_id": "mb_1", + "packages": [], + "revision": 1, + "confirmed_at": None, + } + assert validate_response("create_media_buy", payload, version="3.2").valid + response = convert_payload( + "create_media_buy", payload, direction="sync", source_version="3.2", target_version="3.0" + ) + assert "confirmed_at" not in response.payload + assert any( + loss.pointer == "/confirmed_at" and loss.reason == "null_unavailable" + for loss in response.losses + ) + assert validate_response("create_media_buy", response.payload, version="3.0").valid + + +@pytest.mark.parametrize( + "payload,expected_keys", + [ + ( + {"status": "submitted", "task_id": "task_1", "message": "Awaiting approval"}, + {"status", "task_id", "message"}, + ), + ( + {"status": "failed", "errors": [{"code": "INVALID_REQUEST", "message": "Invalid"}]}, + {"errors"}, + ), + ( + { + "status": "completed", + "media_buy_id": "mb_1", + "packages": [], + "revision": 1, + "confirmed_at": "2026-10-22T00:00:00Z", + }, + {"media_buy_id", "packages", "revision", "confirmed_at"}, + ), + ], +) +def test_media_buy_union_projection_keeps_the_structural_response_arm(payload, expected_keys): + assert validate_response("create_media_buy", payload, version="3.2").valid + response = convert_payload( + "create_media_buy", payload, direction="sync", source_version="3.2", target_version="3.0" + ) + assert expected_keys <= response.payload.keys() + assert validate_response("create_media_buy", response.payload, version="3.0").valid + + +@pytest.mark.parametrize("tool", ["create_media_buy", "update_media_buy"]) +def test_explicit_async_schema_projection_preserves_submitted_envelopes(tool: str) -> None: + payload = {"status": "submitted", "task_id": "task_1", "message": "Awaiting approval"} + assert validate_response(tool, payload, version="3.2").valid + response = convert_payload( + tool, payload, direction="submitted", source_version="3.2", target_version="3.0" + ) + assert response.payload == payload + assert validate_response(tool, response.payload, version="3.0").valid + + +def test_human_summary_message_survives_an_older_open_response_contract() -> None: + response = convert_payload( + "get_products", + {"status": "completed", "products": [], "cache_scope": "public", "message": "No products"}, + direction="sync", + source_version="3.2", + target_version="3.0", + ) + assert response.payload["message"] == "No products" + assert "cache_scope" not in response.payload + assert all(loss.pointer != "/message" for loss in response.losses) + assert validate_response("get_products", response.payload, version="3.0").valid + + +@pytest.mark.parametrize("target", ["3.1", "3.2"]) +@pytest.mark.parametrize("metrics", [["video_completions"], ["impressions", "video_completions"]]) +def test_removed_reporting_metrics_fail_without_inventing_a_new_metric(target, metrics) -> None: + payload = { + "products": [ + { + "product_id": "product_1", + "name": "Video", + "description": "Video product", + "publisher_properties": [ + {"publisher_domain": "publisher.example", "selection_type": "all"} + ], + "format_ids": [], + "delivery_type": "non_guaranteed", + "pricing_options": [ + {"pricing_model": "cpm", "pricing_option_id": "price_1", "currency": "USD"} + ], + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 0, + "timezone": "UTC", + "supports_webhooks": False, + "available_metrics": metrics, + "date_range_support": "date_range", + }, + } + ] + } + original = deepcopy(payload) + assert validate_response("get_products", payload, version="3.0").valid + with pytest.raises(ADCPVersionConversionError) as error: + convert_payload( + "get_products", payload, direction="sync", source_version="3.0", target_version=target + ) + assert payload == original + assert error.value.pointer == ( + f"/products/0/reporting_capabilities/available_metrics/{len(metrics) - 1}" + ) + assert error.value.source_version == "3.0" + assert error.value.target_version == "3.1" + + +@pytest.mark.parametrize("scope", ["categories", "multiple_agents", "account"]) +def test_legacy_governance_does_not_silently_broaden_authority(scope: str) -> None: + agent = { + "url": "https://governance.example/mcp/", + "authentication": {"schemes": ["Bearer"], "credentials": "secret" * 8}, + } + agents = [agent] + if scope == "categories": + agent["categories"] = ["budget_authority"] + elif scope == "multiple_agents": + agents.append(deepcopy(agent)) + payload = { + "idempotency_key": "cross-version-governance-1234", + "accounts": [{"account": {"account_id": "account_1"}, "governance_agents": agents}], + } + assert validate_request("sync_governance", payload, version="3.0").valid + if scope == "account": + response = convert_payload( + "sync_governance", + payload, + direction="request", + source_version="3.0", + target_version="3.2", + ) + assert validate_request("sync_governance", response.payload, version="3.2").valid + else: + with pytest.raises(ADCPVersionConversionError) as error: + convert_payload( + "sync_governance", + payload, + direction="request", + source_version="3.0", + target_version="3.2", + ) + prefix = "/accounts/0/governance_agents" + assert error.value.pointer == ( + prefix + "/0/categories" if scope == "categories" else prefix + ) + assert "secret" not in str(error.value) + + +@pytest.mark.parametrize("tool", ["create_media_buy", "get_media_buys"]) +def test_missing_legacy_confirmation_uses_null_only_for_uncommitted_state(tool: str) -> None: + entry = {"media_buy_id": "mb_1", "packages": [], "revision": 1} + payload = ( + entry + if tool == "create_media_buy" + else { + "media_buys": [ + {**entry, "status": "pending_start", "total_budget": 100, "currency": "USD"} + ] + } + ) + assert validate_response(tool, payload, version="3.0").valid + response = convert_payload( + tool, payload, direction="sync", source_version="3.0", target_version="3.2" + ) + converted = ( + response.payload if tool == "create_media_buy" else response.payload["media_buys"][0] + ) + assert converted["confirmed_at"] is None + assert validate_response(tool, response.payload, version="3.2").valid + + +@pytest.mark.parametrize("missing", ["revision", "confirmed_at"]) +def test_nonempty_old_media_buy_reads_never_invent_financial_state(missing: str) -> None: + entry = { + "media_buy_id": "mb_1", + "status": "active", + "total_budget": 100, + "currency": "USD", + "packages": [], + "revision": 7, + "confirmed_at": "2026-10-22T00:00:00Z", + } + entry.pop(missing) + payload = {"media_buys": [entry]} + assert validate_response("get_media_buys", payload, version="3.0").valid + with pytest.raises(ADCPVersionConversionError) as error: + convert_payload( + "get_media_buys", payload, direction="sync", source_version="3.0", target_version="3.2" + ) + assert error.value.pointer == f"/media_buys/0/{missing}" + + +@pytest.mark.parametrize("clear", ["geo_countries", "daily_budget_cap"]) +def test_downgrade_rejects_an_explicit_clear_the_old_contract_cannot_express(clear: str) -> None: + payload = { + "idempotency_key": "cross-version-key-1234", + "account": {"account_id": "account_1"}, + "media_buy_id": "mb_1", + } + if clear == "geo_countries": + payload["packages"] = [ + {"package_id": "package_1", "targeting_overlay": {"geo_countries": None}} + ] + else: + payload["daily_budget_cap"] = None + assert validate_request("update_media_buy", payload, version="3.2").valid + with pytest.raises(ADCPVersionConversionError, match="explicit null clear") as error: + convert_payload( + "update_media_buy", + payload, + direction="request", + source_version="3.2", + target_version="3.1", + ) + assert error.value.pointer == ( + "/packages/0/targeting_overlay/geo_countries" + if clear == "geo_countries" + else "/daily_budget_cap" + ) + + +@pytest.mark.parametrize("tool", ["get_task_status", "list_tasks"]) +def test_polling_results_project_the_task_contract_and_prefix_loss_paths(tool: str) -> None: + row = { + "task_id": "task_1", + "task_type": "get_products", + "protocol": "media-buy", + "domain": "media-buy", + "status": "completed", + "created_at": "2026-10-22T00:00:00Z", + "updated_at": "2026-10-22T00:00:00Z", + "result": { + "status": "completed", + "products": [], + "cache_scope": "public", + "targeting_resolution": {"brief_targeting": {"geo_countries": ["US"]}}, + }, + } + payload = ( + row + if tool == "get_task_status" + else { + "status": "completed", + "query_summary": {}, + "tasks": [row], + "pagination": {"has_more": False}, + } + ) + assert validate_response(tool, payload, version="3.2").valid + downgraded = convert_payload( + tool, payload, direction="sync", source_version="3.2", target_version="3.1" + ) + converted_row = ( + downgraded.payload if tool == "get_task_status" else downgraded.payload["tasks"][0] + ) + assert "targeting_resolution" not in converted_row["result"] + prefix = "/result" if tool == "get_task_status" else "/tasks/0/result" + assert any(loss.pointer == prefix + "/targeting_resolution" for loss in downgraded.losses) + assert validate_response(tool, downgraded.payload, version="3.1").valid + upgraded = convert_payload( + tool, downgraded.payload, direction="sync", source_version="3.1", target_version="3.2" + ) + converted_row = upgraded.payload if tool == "get_task_status" else upgraded.payload["tasks"][0] + assert converted_row["result"]["adcp_version"] == "3.2" + assert validate_response(tool, upgraded.payload, version="3.2").valid + + +@pytest.mark.parametrize("task_type", ["vendor_task", "get_task_status", "list_tasks"]) +def test_unknown_and_recursive_poll_artifacts_remain_opaque(task_type: str) -> None: + payload = {"task_type": task_type, "result": {"vendor": {"currency": "opaque"}}} + response = convert_payload( + "get_task_status", payload, direction="sync", source_version="3.2", target_version="3.1" + ) + assert response.payload["result"] == payload["result"] + + +async def test_legacy_only_discovery_returns_a_structured_version_error() -> None: + from adcp.exceptions import ADCPTaskError + + class Seller(ADCPHandler): + async def get_adcp_capabilities(self, params, context=None): + pytest.fail("unsupported discovery must not reach the handler") + + caller = create_tool_caller(Seller(), "get_adcp_capabilities", supported_versions=("2.5",)) + with pytest.raises(ADCPTaskError) as error: + await caller({}) + + assert error.value.errors[0].code == "VERSION_UNSUPPORTED" + + +@pytest.mark.parametrize("request_payload", [None, {"account": {"account_id": "private"}}]) +def test_old_account_responses_never_become_publicly_cacheable(request_payload) -> None: + result = convert_payload( + "get_products", + {"products": []}, + direction="sync", + source_version="3.0", + target_version="3.2", + request=request_payload, + ) + assert result.payload["cache_scope"] == "account" + + +@pytest.mark.parametrize("protocol", [Protocol.MCP, Protocol.A2A]) +async def test_client_converts_at_both_transport_boundaries(protocol: Protocol) -> None: + client = ADCPClient( + AgentConfig( + id="seller", + agent_uri="https://seller.example/mcp/", + protocol=protocol, + ) + ) + declaration = capabilities_response( + ["media_buy"], + adcp_version="3.1", + supported_versions=["3.0", "3.1"], + ) + declaration["adcp"]["idempotency"] = {"supported": False} + caps = GetAdcpCapabilitiesResponse.model_validate(declaration) + client.fetch_capabilities = AsyncMock(return_value=caps) + wire_response = TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data={ + "products": [], + "status": "completed", + "cache_scope": "public", + "adcp_version": "3.1", + }, + ) + wire = AsyncMock(return_value=wire_response) + setattr( + client.adapter, + "_call_mcp_tool_wire" if protocol == Protocol.MCP else "_call_a2a_tool_wire", + wire, + ) + result = await client.get_products( + GetProductsRequest.model_validate( + { + "buying_mode": "brief", + "brief": "video", + "brand": {"domain": "example.com", "countries": ["US"]}, + } + ) + ) + params = wire.call_args.args[1] + assert params["adcp_version"] == "3.1" + assert params["brand"] == {"domain": "example.com"} + assert result.success, result.error + assert result.data.adcp_version == "3.2" + report = result.metadata[CONVERSION_METADATA_KEY] + assert report["wire_version"] == "3.1" + assert report["losses"][0]["pointer"] == "/brand/countries" + + +@pytest.mark.parametrize("version", ["3.0", "3.1"]) +async def test_server_handlers_receive_latest_and_respond_to_old_wire(version: str) -> None: + contexts = [] + + class Seller(ADCPHandler): + adcp_capabilities = {"media_buy": {"features": {"canonical_creatives": True}}} + + async def get_products(self, params: dict[str, Any], context: ToolContext): + assert params.get("adcp_version", "3.2") == "3.2" + assert context.resolved_adcp_version == version + contexts.append(context) + return {"products": [], "status": "completed", "cache_scope": "public"} + + caller = create_tool_caller( + Seller(), + "get_products", + validation=ValidationHookConfig(requests="strict", responses="strict"), + ) + wire = {"buying_mode": "brief", "brief": "video"} + if version == "3.0": + wire["adcp_major_version"] = 3 + else: + wire["adcp_version"] = version + response = await caller(wire) + assert validate_response("get_products", response, version=version).valid + if version == "3.0": + assert "status" not in response + assert "cache_scope" not in response + assert contexts[0].metadata[CONVERSION_METADATA_KEY]["losses"] + else: + assert response["adcp_version"] == "3.1" + + +async def test_old_wire_validated_before_handler_upgrade() -> None: + class Seller(ADCPHandler): + async def get_products(self, params, context=None): + pytest.fail("invalid old request must not reach the handler") + + caller = create_tool_caller( + Seller(), + "get_products", + validation=ValidationHookConfig(requests="strict"), + ) + from adcp.exceptions import ADCPTaskError + + with pytest.raises(ADCPTaskError, match="buying_mode"): + await caller({"adcp_version": "3.1", "buying_mode": 123}) + + +async def test_server_rejects_new_task_on_old_contract_with_structured_error() -> None: + from adcp.exceptions import ADCPTaskError + + caller = create_tool_caller(ADCPHandler(), "list_products") + with pytest.raises(ADCPTaskError) as exc: + await caller({"adcp_version": "3.0"}) + assert exc.value.errors[0].code == "VERSION_UNSUPPORTED" + + +@pytest.mark.parametrize("selected", [None, ("3.0", "3.1")]) +async def test_unversioned_server_discovery_retains_supported_versions(selected) -> None: + class Seller(ADCPHandler): + async def get_adcp_capabilities(self, params, context=None): + declaration = capabilities_response(["media_buy"]) + declaration["adcp"]["idempotency"] = {"supported": False} + return declaration + + context = ToolContext() + caller = create_tool_caller( + Seller(), + "get_adcp_capabilities", + supported_versions=selected, + validation=ValidationHookConfig(requests="strict", responses="strict"), + ) + response = await caller({}, context) + assert response["adcp"]["supported_versions"] == ( + list(selected) if selected else ["3.0", "3.1", "3.2", "3.2-rc.7"] + ) + assert context.resolved_adcp_version == ("3.1" if selected else "3.2") + + +async def test_mcp_discovery_wire_validation_and_concurrent_call_versions() -> None: + """Exercise real adapter validation, discovery caching, and per-call state.""" + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example/mcp/", protocol=Protocol.MCP), + validation=ValidationHookConfig(requests="strict", responses="strict"), + ) + declaration = capabilities_response( + ["media_buy"], + adcp_version="3.1", + supported_versions=["3.0", "3.1"], + ) + declaration["adcp"]["idempotency"] = {"supported": False} + sent = [] + + async def call_tool(name, params): + result = MagicMock() + result.isError = False + result.content = [] + if name == "get_adcp_capabilities": + assert "adcp_version" not in params + result.structuredContent = declaration + else: + sent.append(deepcopy(params)) + version = client.adapter._request_version(params) + assert validate_request(name, params, version=version).valid + await asyncio.sleep(0) + # Deliberately different wire envelopes for concurrent calls. + result.structuredContent = {"products": []} + if version == "3.1": + result.structuredContent.update(status="completed", cache_scope="public") + return result + + session = MagicMock() + session.call_tool = AsyncMock(side_effect=call_tool) + client.adapter._get_session = AsyncMock(return_value=session) + # Complete discovery once, then use the real TTL cache for both calls. + await client.fetch_capabilities() + results = await asyncio.gather( + *[ + client.get_products( + GetProductsRequest.model_validate( + { + "buying_mode": "brief", + "brief": "video", + "adcp_version": version, + "brand": {"domain": "example.com", "countries": ["US"]}, + } + ) + ) + for version in ("3.2", "3.0") + ] + ) + assert all(result.success for result in results), [result.error for result in results] + assert {client.adapter._request_version(params) for params in sent} == {"3.0", "3.1"} + assert all("countries" not in params["brand"] for params in sent) + assert [result.metadata[CONVERSION_METADATA_KEY]["wire_version"] for result in results] == [ + "3.1", + "3.0", + ] + assert all(result.data.adcp_version == "3.2" for result in results) + assert session.call_tool.call_count == 3 + + +async def test_server_response_losses_are_visible_to_application_context() -> None: + class Seller(ADCPHandler): + async def sync_accounts(self, params, context=None): + return { + "status": "completed", + "accounts": [ + { + "brand": {"domain": "example.com", "countries": ["US"]}, + "operator": "agency.example", + "operator_unit": {"id": "one"}, + "currency": "USD", + "timezone": "UTC", + "action": "created", + "status": "active", + "billing": "operator", + "account_scope": "operator_brand", + } + ], + } + + context = ToolContext() + response = await create_tool_caller(Seller(), "sync_accounts")( + { + "adcp_version": "3.1", + "accounts": [], + }, + context, + ) + assert response["accounts"][0]["brand"] == {"domain": "example.com"} + report = context.metadata[CONVERSION_METADATA_KEY] + assert {loss["pointer"] for loss in report["losses"]} >= { + "/accounts/0/brand/countries", + "/accounts/0/operator_unit", + "/accounts/0/currency", + "/accounts/0/timezone", + } + + +async def test_reduced_echo_registers_the_complete_original_account_key() -> None: + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example/mcp/", protocol=Protocol.MCP), + server_version="3.1", + account_policy="auto", + ) + key = { + "brand": {"domain": "example.com", "countries": ["US"]}, + "operator": "agency.example", + "operator_unit": {"id": "one"}, + "currency": "USD", + "timezone": "UTC", + } + client.adapter._call_mcp_tool_wire = AsyncMock( + return_value=TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data={ + "status": "completed", + "accounts": [ + { + "brand": {"domain": "example.com"}, + "operator": "agency.example", + "account_id": "older-seller-account", + "status": "active", + "action": "created", + } + ], + }, + ) + ) + result = await client.sync_accounts( + SyncAccountsRequest.model_validate( + { + "idempotency_key": "cross-version-key-1234", + "accounts": [{**key, "billing": "operator"}], + } + ) + ) + assert result.success, result.error + record = await client.accounts.get(key) + assert record is not None + assert record.account_id == "older-seller-account" + + +async def test_in_flight_creative_dialect_survives_capability_refresh() -> None: + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example/mcp/", protocol=Protocol.MCP), + server_version="3.1", + ) + request = {"buying_mode": "brief", "brief": "video"} + prepared = await client._convert_outgoing_request("get_products", request) + assert prepared.creative_dialect == "legacy" + declaration = capabilities_response(["media_buy"]) + declaration["adcp"]["idempotency"] = {"supported": False} + declaration["media_buy"] = {"features": {"canonical_creatives": True}} + client._capabilities = GetAdcpCapabilitiesResponse.model_validate(declaration) + result = client._convert_incoming_response( + "get_products", + TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data={"products": [], "status": "completed", "cache_scope": "public"}, + ), + prepared, + ) + assert client._creative_dialect(request) is CreativeDialect.CANONICAL + assert client._creative_dialect(request, result=result) is CreativeDialect.LEGACY diff --git a/tests/test_decisioning_task_polling.py b/tests/test_decisioning_task_polling.py index fffdf08e2..65a88938f 100644 --- a/tests/test_decisioning_task_polling.py +++ b/tests/test_decisioning_task_polling.py @@ -242,7 +242,7 @@ async def test_scoped_listing_filters_cursor_and_not_found(registry: Any) -> Non async def missing_error(task_id: str) -> dict[str, Any]: with pytest.raises(AdcpError) as caught: - await caller({"task_id": task_id}) + await caller({"adcp_version": "3.2", "task_id": task_id}) return caught.value.to_wire() missing = await missing_error("absent") @@ -328,10 +328,10 @@ async def test_minimal_third_party_registry_boots_and_expiry_is_hidden() -> None errors = [] for reference in (task, "absent"): with pytest.raises(AdcpError) as caught: - await caller({"task_id": reference}) + await caller({"adcp_version": "3.2", "task_id": reference}) errors.append(caught.value.to_wire()) assert errors[0] == errors[1] - assert (await create_tool_caller(handler, "list_tasks")({}))["error"][ + assert (await create_tool_caller(handler, "list_tasks")({"adcp_version": "3.2"}))["error"][ "code" ] == "NOT_SUPPORTED" finally: @@ -358,7 +358,7 @@ def resolve(self, ref: dict[str, Any] | None, *, auth_info: Any = None) -> Accou reg.list = listing for tool, params in (("get_task_status", {"task_id": "probe"}), ("list_tasks", {})): with pytest.raises(AdcpError, match="PERMISSION_DENIED"): - await create_tool_caller(handler, tool)(params) + await create_tool_caller(handler, tool)({"adcp_version": "3.2", **params}) read.assert_not_awaited() listing.assert_not_awaited() finally: diff --git a/tests/test_dispatcher_version_routing.py b/tests/test_dispatcher_version_routing.py index 75e2eddb7..6c97de857 100644 --- a/tests/test_dispatcher_version_routing.py +++ b/tests/test_dispatcher_version_routing.py @@ -55,7 +55,8 @@ async def test_no_version_field_validator_uses_legacy_30_compat() -> None: ) await caller({"buying_mode": "brief", "brief": "Q4"}) - assert mock_validate.call_count == 1 + assert mock_validate.call_count == 2 + assert mock_validate.call_args_list[0].kwargs["version"] == "3.0" _, kwargs = mock_validate.call_args assert kwargs.get("version") is None assert handler.contexts[0].resolved_adcp_version == "3.0" @@ -97,7 +98,8 @@ async def test_explicit_adcp_version_threads_through_to_validator() -> None: }, ) - assert mock_validate.call_count == 1 + assert mock_validate.call_count == 2 + assert mock_validate.call_args_list[0].kwargs["version"] == "3.0" _, kwargs = mock_validate.call_args assert kwargs.get("version") is None @@ -162,7 +164,8 @@ async def test_adcp_major_version_int_threads_through_to_validator() -> None: }, ) - assert mock_validate.call_count == 1 + assert mock_validate.call_count == 2 + assert mock_validate.call_args_list[0].kwargs["version"] == "3.0" _, kwargs = mock_validate.call_args assert kwargs.get("version") is None diff --git a/tests/test_governance_version_conversion.py b/tests/test_governance_version_conversion.py new file mode 100644 index 000000000..0760392ec --- /dev/null +++ b/tests/test_governance_version_conversion.py @@ -0,0 +1,139 @@ +"""Governance response semantics survive both sides of the 3.0/3.1 hop.""" + +from copy import deepcopy + +import pytest + +from adcp.compat.governance import convert_governance_fields +from adcp.compat.versions import convert_payload +from adcp.exceptions import ADCPFeatureUnsupportedError +from adcp.validation import validate_response + + +@pytest.mark.parametrize("verdict", ["approved", "denied", "conditions"]) +def test_governance_verdict_round_trips_without_replacing_the_task_status(verdict): + old = { + "check_id": "check-one", + "plan_id": "plan-one", + "status": verdict, + "explanation": "Governance evaluation completed", + } + if verdict in {"approved", "conditions"}: + old["expires_at"] = "2027-01-01T00:00:00Z" + if verdict == "denied": + old["findings"] = [ + {"category_id": "budget", "severity": "critical", "explanation": "Over budget"} + ] + elif verdict == "conditions": + old["conditions"] = [{"field": "budget", "reason": "Reduce budget"}] + original = deepcopy(old) + assert validate_response("check_governance", old, version="3.0").valid + + upgraded = convert_payload( + "check_governance", old, direction="sync", source_version="3.0", target_version="3.2" + ) + + assert old == original + assert upgraded.payload["verdict"] == verdict + assert upgraded.payload["status"] == "completed" + assert validate_response("check_governance", upgraded.payload, version="3.2").valid + downgraded = convert_payload( + "check_governance", + upgraded.payload, + direction="sync", + source_version="3.2", + target_version="3.0", + ) + assert downgraded.payload == old + assert validate_response("check_governance", downgraded.payload, version="3.0").valid + + +@pytest.mark.parametrize("state", ["accepted", "findings"]) +def test_outcome_acknowledgement_round_trips_to_its_original_state(state): + old = {"outcome_id": "outcome-one", "status": state} + assert validate_response("report_plan_outcome", old, version="3.0").valid + + upgraded = convert_payload( + "report_plan_outcome", old, direction="sync", source_version="3.0", target_version="3.2" + ) + + assert upgraded.payload["outcome_state"] == state + assert upgraded.payload["status"] == "completed" + assert validate_response("report_plan_outcome", upgraded.payload, version="3.2").valid + downgraded = convert_payload( + "report_plan_outcome", + upgraded.payload, + direction="sync", + source_version="3.2", + target_version="3.0", + ) + assert downgraded.payload == old + assert validate_response("report_plan_outcome", downgraded.payload, version="3.0").valid + + +def test_audit_check_verdict_round_trips_without_changing_other_lifecycle_states(): + old = { + "plans": [ + { + "plan_id": "plan-one", + "plan_version": 1, + "status": "active", + "budget": {}, + "governed_actions": [], + "summary": {}, + "entries": [ + { + "id": "check-one", + "type": "check", + "timestamp": "2026-10-22T00:00:00Z", + "status": "approved", + }, + { + "id": "outcome-one", + "type": "outcome", + "timestamp": "2026-10-22T00:00:00Z", + "outcome_status": "accepted", + }, + ], + } + ] + } + assert validate_response("get_plan_audit_logs", old, version="3.0").valid + + upgraded = convert_payload( + "get_plan_audit_logs", old, direction="sync", source_version="3.0", target_version="3.2" + ) + + plan = upgraded.payload["plans"][0] + assert plan["status"] == "active" + assert plan["entries"][0]["verdict"] == "approved" + assert "status" not in plan["entries"][0] + assert plan["entries"][1] == old["plans"][0]["entries"][1] + assert validate_response("get_plan_audit_logs", upgraded.payload, version="3.2").valid + downgraded = convert_payload( + "get_plan_audit_logs", + upgraded.payload, + direction="sync", + source_version="3.2", + target_version="3.0", + ) + assert downgraded.payload == old + assert validate_response("get_plan_audit_logs", downgraded.payload, version="3.0").valid + + +@pytest.mark.parametrize("direction", ["request", "sync"]) +def test_other_version_hops_and_request_extension_values_remain_opaque(direction): + payload = {"status": "approved", "ext": {"status": "approved"}} + original = deepcopy(payload) + convert_governance_fields("check_governance", payload, "3.1", "3.2", direction) + assert payload == original + if direction == "request": + convert_governance_fields("check_governance", payload, "3.0", "3.1", direction) + assert payload == original + + +def test_conflicting_governance_fields_are_rejected_without_overwriting_a_decision(): + payload = {"status": "approved", "verdict": "denied"} + with pytest.raises(ADCPFeatureUnsupportedError, match="/verdict"): + convert_governance_fields("check_governance", payload, "3.0", "3.1", "sync") + assert payload == {"status": "approved", "verdict": "denied"} diff --git a/tests/test_idempotency.py b/tests/test_idempotency.py index 10f67b091..c45d49a8a 100644 --- a/tests/test_idempotency.py +++ b/tests/test_idempotency.py @@ -731,7 +731,7 @@ async def test_gather_siblings_do_not_share_pinned_key(self) -> None: TaskStatus as A2ATaskStatus, ) - client = ADCPClient(_cfg()) + client = ADCPClient(_cfg(), server_version="3.2") adapter: A2AAdapter = client.adapter # type: ignore[assignment] task = Task( id="t1", @@ -790,7 +790,7 @@ async def test_caller_set_pydantic_key_reaches_adapter(self) -> None: TaskStatus as A2ATaskStatus, ) - client = ADCPClient(_cfg()) + client = ADCPClient(_cfg(), server_version="3.2") pinned = str(uuid.uuid4()) req = ReportUsageRequest.model_validate( { diff --git a/tests/test_idempotency_storyboard.py b/tests/test_idempotency_storyboard.py index 029d90fe9..f9bb6ef89 100644 --- a/tests/test_idempotency_storyboard.py +++ b/tests/test_idempotency_storyboard.py @@ -238,7 +238,7 @@ class TestStoryboardPhase6VerifyDedupActuallyHeld: @pytest.mark.asyncio async def test_pinned_key_across_retry_yields_one_resource(self) -> None: - client = ADCPClient(_cfg()) + client = ADCPClient(_cfg(), server_version="3.2") adapter = client.adapter seller_cache: dict[str, dict[str, Any]] = {} created_ids: set[str] = set() diff --git a/tests/test_lifecycle_coordinator_integration.py b/tests/test_lifecycle_coordinator_integration.py index 754bc391e..1d11ea7c2 100644 --- a/tests/test_lifecycle_coordinator_integration.py +++ b/tests/test_lifecycle_coordinator_integration.py @@ -329,6 +329,8 @@ async def execute_legacy_create(execution: LegacyPurchaseExecution) -> Any: assert lifecycle.negotiated_version == seller_version assert listing.compatibility.lifecycle is MediaBuyLifecycle.ESTABLISHED + assert listing.raw.data.adcp_version == "3.2" + assert listing.raw.metadata["adcp_version_conversion"]["wire_version"] == seller_version assert purchase.success routed = [name for name, _ in handler.calls] assert "get_products" in routed diff --git a/tests/test_negotiated_catalog_purchase.py b/tests/test_negotiated_catalog_purchase.py index 62765b307..4b53fad53 100644 --- a/tests/test_negotiated_catalog_purchase.py +++ b/tests/test_negotiated_catalog_purchase.py @@ -184,6 +184,62 @@ def test_negotiation_rejects_served_version_newer_than_client_pin() -> None: _negotiate_version(_caps(["3.2"], served="3.2"), "3.2-rc.1") +@pytest.mark.parametrize("wire_version", ["3.0", "3.1"]) +def test_coordinator_uses_wire_provenance_for_the_upgraded_capability_cache(wire_version): + client = _Client("3.2", {}) + capabilities = _caps([wire_version], served="3.2") + client.capabilities = capabilities + client._capabilities_wire_version = wire_version + + coordinator = MediaBuyLifecycleCoordinator(client, capabilities, []) + + assert coordinator.negotiated_version == wire_version + assert capabilities.adcp_version == "3.2" + + +def test_coordinator_does_not_apply_cached_wire_provenance_to_supplied_capabilities(): + client = _Client("3.2", {}) + client.capabilities = _caps(["3.0"], served="3.2") + client._capabilities_wire_version = "3.0" + + coordinator = MediaBuyLifecycleCoordinator(client, _caps(["3.1"], served="3.1"), []) + + assert coordinator.negotiated_version == "3.1" + + +def test_cached_discovery_can_serve_newer_than_the_coordinator_ceiling(): + client = _Client("3.1", {}) + client.capabilities = _caps(["3.0", "3.1", "3.2"], served="3.2") + client._capabilities_wire_version = "3.2" + + coordinator = MediaBuyLifecycleCoordinator(client, client.capabilities, []) + + assert coordinator.negotiated_version == "3.1" + + +def test_cached_discovery_respects_the_configured_peer_contract(): + client = _Client("3.2", {}) + client.capabilities = _caps(["3.0", "3.1", "3.2"], served="3.2") + client._capabilities_wire_version = "3.2" + client.get_server_version = lambda: "3.1" + + coordinator = MediaBuyLifecycleCoordinator(client, client.capabilities, []) + + assert coordinator.negotiated_version == "3.1" + + +def test_cached_discovery_alias_matches_an_upgraded_application_response(): + client = _Client("3.2", {}) + client.capabilities = _caps(["3.2-rc.7"], served="3.2") + client._capabilities_wire_version = "3.2-rc.7" + coordinator = MediaBuyLifecycleCoordinator(client, client.capabilities, []) + + assert coordinator.negotiated_version == "3.2-rc.7" + coordinator._validate_served_version( + "list_products", {"adcp_version": "3.2"}, MediaBuyLifecycle.COMPACT + ) + + @pytest.mark.parametrize( ("versions", "served", "message"), [ diff --git a/tests/test_negotiated_signing_profiles.py b/tests/test_negotiated_signing_profiles.py new file mode 100644 index 000000000..33b4c476d --- /dev/null +++ b/tests/test_negotiated_signing_profiles.py @@ -0,0 +1,256 @@ +"""Negotiated request profiles reach HTTP writers without trusting body pins.""" + +import asyncio +import json +from copy import deepcopy +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest +from a2a import types as pb +from cryptography.hazmat.primitives.asymmetric import ed25519 +from google.protobuf.json_format import MessageToDict + +from adcp import ADCPClient +from adcp.protocols.a2a import A2AAdapter, _make_data_part +from adcp.signing import SigningConfig +from adcp.signing.negotiated_profiles import NegotiatedSigningProfiles +from adcp.types import GetAdcpCapabilitiesResponse, GetProductsRequest, GetProductsResponse +from adcp.types.core import AgentConfig, TaskResult, TaskStatus + + +def mcp_body(params, tool="get_products"): + return json.dumps( + { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": tool, + "arguments": params, + }, + } + ).encode() + + +def make_client(*, override=None, covers="forbidden"): + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example/mcp", protocol="mcp"), + signing=SigningConfig( + private_key=ed25519.Ed25519PrivateKey.generate(), + key_id="buyer", + signing_profile_version=override, + ), + ) + client._capabilities = GetAdcpCapabilitiesResponse.model_validate( + { + "adcp": { + "major_versions": [3], + "supported_versions": ["3.0", "3.1"], + "idempotency": {"supported": False}, + }, + "supported_protocols": ["media_buy"], + "request_signing": { + "supported": True, + "supported_for": ["get_products"], + "covers_content_digest": covers, + }, + } + ) + client.adapter.signing_capability_check = AsyncMock() + client.fetch_capabilities = AsyncMock(return_value=client._capabilities) + client.adapter.request_validation_mode = "off" + client.adapter.response_validation_mode = "off" + return client + + +async def test_mcp_writer_uses_negotiated_31_profile(): + client = make_client() + observed = [] + queue = asyncio.Queue() + + # Start the writer before entering the dispatch scope, as MCP does. Its + # ContextVars never inherit the caller's operation/profile changes. + async def writer(): + request, completed = await queue.get() + try: + await client._sign_outgoing_request(request) + completed.set_result(request) + except Exception as exc: + completed.set_exception(exc) + + writer_task = asyncio.create_task(writer()) + + async def call_tool(tool, params): + request = httpx.Request("POST", "https://seller.example/mcp", content=mcp_body(params)) + done = asyncio.get_running_loop().create_future() + await queue.put((request, done)) + observed.append(await done) + return MagicMock( + isError=False, + structuredContent={"products": [], "status": "completed", "cache_scope": "public"}, + content=[], + ) + + session = MagicMock(call_tool=AsyncMock(side_effect=call_tool)) + client.adapter._get_session = AsyncMock(return_value=session) + try: + result = await client.get_products(GetProductsRequest(buying_mode="wholesale")) + assert result.success, result.error + assert session.call_tool.call_args.args[1]["adcp_version"] == "3.1" + signed = observed[0] + assert "signature" in signed.headers + assert not signed.headers["signature"].split(":")[1].endswith("=") + assert "content-digest" not in signed.headers + assert "content-digest" not in signed.headers["signature-input"] + assert client._negotiated_signing_profiles.resolve(signed.content) is None + finally: + if not writer_task.done(): + writer_task.cancel() + await asyncio.gather(writer_task, return_exceptions=True) + + +async def test_explicit_profile_override_wins_over_31_negotiation(): + client = make_client(override="3.2", covers="required") + registry = client._negotiated_signing_profiles + params = {"adcp_version": "3.1"} + request = httpx.Request("POST", "https://seller.example/mcp", content=mcp_body(params)) + with registry.bind("get_products", params, "3.1"): + await client._sign_outgoing_request(request) + assert request.headers["signature"].split(":")[1].endswith("==") + assert "content-digest" in request.headers + + +async def test_concurrent_bindings_survive_sibling_completion_and_cancellation(): + registry = NegotiatedSigningProfiles() + params = {"adcp_version": "3.1", "idempotency_key": "same-outbound-key"} + body = mcp_body(params) + entered = asyncio.Event() + released = asyncio.Event() + + async def sibling(): + with registry.bind("get_products", params, "3.1"): + entered.set() + await released.wait() + + task = asyncio.create_task(sibling()) + await entered.wait() + with registry.bind("get_products", params, "3.1"): + assert registry.resolve(body) == "3.1" + assert registry.resolve(body) == "3.1" + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.gather(task) + assert registry.resolve(body) is None + + +def test_unregistered_and_modified_parameters_cannot_select_a_profile(): + registry = NegotiatedSigningProfiles() + params = {"adcp_version": "3.1", "idempotency_key": "registered-key"} + assert registry.resolve(mcp_body(params)) is None + with registry.bind("get_products", params, "3.1"): + assert registry.resolve(mcp_body(params)) == "3.1" + changed = {**params, "adcp_version": "3.0"} + assert registry.resolve(mcp_body(changed)) is None + assert registry.resolve(mcp_body(params, tool="sync_accounts")) is None + assert NegotiatedSigningProfiles().resolve(mcp_body(params)) is None + assert registry.resolve(mcp_body(params)) is None + + +def test_duplicate_version_keys_cannot_match_a_registered_invocation(): + registry = NegotiatedSigningProfiles() + params = {"adcp_version": "3.1"} + ambiguous = mcp_body(params).replace( + b'"adcp_version": "3.1"', b'"adcp_version": "3.2", "adcp_version": "3.1"' + ) + with registry.bind("get_products", params, "3.1"): + assert registry.resolve(ambiguous) is None + + +@pytest.mark.parametrize("jsonrpc", [False, True]) +def test_a2a_fingerprint_matches_actual_protobuf_parameters(jsonrpc): + registry = NegotiatedSigningProfiles() + params = {"adcp_version": "3.1", "budget": 100, "ext": {"nested": [5, 3.25]}} + message = MessageToDict( + pb.Message( + parts=[ + _make_data_part( + { + "skill": "get_products", + "parameters": params, + } + ) + ] + ) + ) + normalized = message["parts"][0]["data"]["parameters"] + payload = {"message": message} + if jsonrpc: + payload = {"method": "message/send", "params": payload} + body = json.dumps(payload).encode() + with registry.bind("get_products", normalized, "3.1"): + assert registry.resolve(body) == "3.1" + assert registry.resolve(body) is None + + +def test_natural_language_a2a_invocation_uses_registered_parameters(): + registry = NegotiatedSigningProfiles() + params = {"adcp_version": "3.1", "brief": "video"} + text = f"Execute tool: get_products\nParameters: {json.dumps(params, indent=2)}" + body = json.dumps({"message": {"parts": [{"text": text}]}}).encode() + with registry.bind("get_products", params, "3.1"): + assert registry.resolve(body) == "3.1" + + +async def test_a2a_adapter_binds_after_key_injection_and_cleans_up_failed_send(): + adapter = A2AAdapter( + AgentConfig(id="seller", agent_uri="https://seller.example/a2a", protocol="a2a") + ) + adapter.request_validation_mode = "off" + adapter.response_validation_mode = "off" + registry = NegotiatedSigningProfiles() + adapter.signing_request_scope = registry.bind + adapter._get_a2a_client = AsyncMock(return_value=object()) + sent = [] + + async def send(client, request): + body = json.dumps(MessageToDict(request)).encode() + actual = MessageToDict(request)["message"]["parts"][0]["data"]["parameters"] + assert actual["idempotency_key"] + assert registry.resolve(body) == "3.1" + sent.append(body) + raise RuntimeError("simulated send failure") + + adapter._send_and_aggregate = AsyncMock(side_effect=send) + params = {"adcp_version": "3.1", "accounts": [], "ext": {"count": 123}} + result = await adapter._call_a2a_tool_wire("sync_accounts", params) + assert not result.success + assert "idempotency_key" not in params + assert len(sent) == 1 + assert registry.resolve(sent[0]) is None + + +def test_failed_typed_parse_preserves_conversion_metadata(): + client = make_client() + report = {"adcp_version_conversion": {"wire_version": "3.0", "losses": [{"pointer": "/x"}]}} + error = {"code": "UNSUPPORTED_FEATURE", "message": "No old representation"} + raw = TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data={"products": 42}, + metadata=report, + adcp_error=error, + ) + parsed = client.adapter._parse_response(raw, GetProductsResponse) + assert not parsed.success + assert parsed.metadata == deepcopy(report) + assert parsed.adcp_error == error + + +def test_unsuccessful_typed_result_preserves_structured_conversion_error(): + client = make_client() + error = {"code": "UNSUPPORTED_FEATURE", "message": "No old representation"} + raw = TaskResult(status=TaskStatus.FAILED, success=False, adcp_error=error) + parsed = client.adapter._parse_response(raw, GetProductsResponse) + assert not parsed.success + assert parsed.adcp_error == error diff --git a/tests/test_negotiation.py b/tests/test_negotiation.py index ecaf2fed0..566fa5983 100644 --- a/tests/test_negotiation.py +++ b/tests/test_negotiation.py @@ -953,3 +953,28 @@ def test_webhook_parser_retains_refinement_wire_payload() -> None: ) assert result.metadata[WIRE_RESPONSE_METADATA_KEY] is wire + + +def test_invalid_refinement_webhook_preserves_evidence_without_bypassing_validation() -> None: + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example", protocol=Protocol.A2A) + ) + wire = {"status": "completed", "results": "invalid", "products": []} + original = deepcopy(wire) + + result = client._parse_webhook_result( + task_id="task-1", + task_type="refine_proposals", + operation_id="op-1", + status=GeneratedTaskStatus.completed, + result=wire, + timestamp="2026-08-24T00:00:00Z", + message=None, + context_id=None, + ) + + assert not result.success + assert result.status == TaskStatus.FAILED + assert "Schema validation failed" in result.error + assert result.metadata[WIRE_RESPONSE_METADATA_KEY] is wire + assert wire == original diff --git a/tests/test_property_lists.py b/tests/test_property_lists.py index 1309d6747..d02d4cefb 100644 --- a/tests/test_property_lists.py +++ b/tests/test_property_lists.py @@ -63,6 +63,21 @@ def create(*args): return client +@pytest.mark.asyncio +async def test_scoped_list_client_uses_known_contract_without_capability_discovery(monkeypatch): + client = MCPPropertyListFetcher()._create_client(REF["agent_url"], REF["auth_token"]) + discovery = AsyncMock(side_effect=AssertionError("shared token cannot discover capabilities")) + monkeypatch.setattr(client, "fetch_capabilities", discovery) + + prepared = await client._convert_outgoing_request( + "get_property_list", {"list_id": REF["list_id"], "resolve": True} + ) + + assert prepared.payload["adcp_version"] == client.get_adcp_version() + assert prepared.payload["list_id"] == REF["list_id"] + discovery.assert_not_awaited() + + @pytest.mark.asyncio async def test_pages_types_expiry_and_auth_isolated_cache(monkeypatch): fetcher = MCPPropertyListFetcher() diff --git a/tests/test_protocols.py b/tests/test_protocols.py index b755b036b..7d578dbfb 100644 --- a/tests/test_protocols.py +++ b/tests/test_protocols.py @@ -2185,12 +2185,18 @@ class TestFromMcpClientFactory: def _make_mock_session(self) -> AsyncMock: """Return a minimal mock ClientSession.""" + from adcp.server.responses import capabilities_response + session = AsyncMock() - mock_result = MagicMock() - mock_result.isError = False - mock_result.structuredContent = {"products": [], "cache_scope": "public"} - mock_result.content = [] - session.call_tool.return_value = mock_result + + async def call_tool(name, params): + data = {"products": [], "cache_scope": "public", "status": "completed"} + if name == "get_adcp_capabilities": + data = capabilities_response(["media_buy"]) + data["adcp"]["idempotency"] = {"supported": False} + return MagicMock(isError=False, structuredContent=data, content=[]) + + session.call_tool.side_effect = call_tool return session def test_factory_injects_session(self): @@ -2231,11 +2237,16 @@ async def test_tool_call_routes_through_injected_session(self): session = self._make_mock_session() client = ADCPClient.from_mcp_client(session, agent_id="test-seller") - result = await client.adapter._call_mcp_tool("get_products", {"brief": "test"}) + result = await client.adapter._call_mcp_tool( + "get_products", {"buying_mode": "brief", "brief": "test"} + ) - session.call_tool.assert_called_once() + assert [call.args[0] for call in session.call_tool.call_args_list] == [ + "get_adcp_capabilities", + "get_products", + ] assert result.success is True - assert result.data == {"products": [], "cache_scope": "public"} + assert result.data == {"products": [], "cache_scope": "public", "status": "completed"} @pytest.mark.asyncio async def test_close_is_noop_for_injected_session(self): diff --git a/tests/test_rc3_media_buy_runtime.py b/tests/test_rc3_media_buy_runtime.py index a6f7aae51..1a71bfd63 100644 --- a/tests/test_rc3_media_buy_runtime.py +++ b/tests/test_rc3_media_buy_runtime.py @@ -377,12 +377,18 @@ async def test_a_clear_survives_the_real_client_and_typed_seller() -> None: from adcp import ADCPClient from adcp.server import ADCPHandler, create_mcp_server from adcp.server.base import ToolContext + from adcp.server.responses import capabilities_response from adcp.validation import ValidationHookConfig received: list[dict[str, Any]] = [] class _Seller(ADCPHandler[Any]): - advertised_tools = {"update_media_buy"} + advertised_tools = {"get_adcp_capabilities", "update_media_buy"} + + async def get_adcp_capabilities(self, params, context=None): + response = capabilities_response(["media_buy"]) + response["adcp"]["idempotency"] = {"supported": False} + return response async def update_media_buy( self, params: dict[str, Any], context: ToolContext diff --git a/tests/test_response_enhancer.py b/tests/test_response_enhancer.py index e40c2a9e2..068cb0d78 100644 --- a/tests/test_response_enhancer.py +++ b/tests/test_response_enhancer.py @@ -212,7 +212,7 @@ async def test_enhancer_mutation_reaches_each_response_class( self, method: str, params: dict[str, Any], assertion_key: str ) -> None: caller = create_tool_caller(_Handler(), method, response_enhancer=_stamp) - result = await caller(params) + result = await caller({"adcp_version": "3.2", **params}) assert result["enhanced"] is True assert assertion_key in result # original payload preserved diff --git a/tests/test_response_size_limit.py b/tests/test_response_size_limit.py index 876364988..d544e4bc5 100644 --- a/tests/test_response_size_limit.py +++ b/tests/test_response_size_limit.py @@ -32,6 +32,7 @@ from adcp.exceptions import ADCPResponseEncodingError, ADCPResponseTooLargeError from adcp.protocols.a2a import A2AAdapter from adcp.protocols.mcp import MCPAdapter +from adcp.server.responses import capabilities_response from adcp.types import GetProductsRequest from adcp.types.core import AgentConfig, Protocol @@ -400,6 +401,12 @@ def test_task_options_default_inherits_the_agent_budget(): # --------------------------------------------------------------------------- +def _capabilities_payload(): + data = capabilities_response(["media_buy"]) + data["adcp"]["idempotency"] = {"supported": False} + return data + + class _MCPHandler(BaseHTTPRequestHandler): """Smallest streamable-HTTP MCP server that can answer one tool call.""" @@ -419,7 +426,13 @@ def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler override self.end_headers() return method = message.get("method") - padding = "x" * self.tool_payload_size if method == self.padded_method else "" + discovery = ( + method == "tools/call" + and message.get("params", {}).get("name") == "get_adcp_capabilities" + ) + padding = ( + "x" * self.tool_payload_size if method == self.padded_method and not discovery else "" + ) if method == "initialize": result = { "protocolVersion": message["params"]["protocolVersion"], @@ -427,7 +440,9 @@ def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler override "serverInfo": {"name": "oversize-agent", "version": padding or "1"}, } elif method == "tools/call": - if self.text_fallback: + if discovery: + result = {"content": [], "structuredContent": _capabilities_payload()} + elif self.text_fallback: result = { "content": [ { @@ -443,13 +458,16 @@ def do_POST(self): # noqa: N802 - BaseHTTPRequestHandler override } else: result = { - "tools": [{"name": padding or "get_products", "inputSchema": {"type": "object"}}] + "tools": [ + {"name": padding or "get_products", "inputSchema": {"type": "object"}}, + {"name": "get_adcp_capabilities", "inputSchema": {"type": "object"}}, + ] } body = json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": result}).encode() self.send_response(200) self.send_header("content-type", "application/json") self.send_header("mcp-session-id", "session-1") - if self.content_encoding and method == self.padded_method: + if self.content_encoding and method == self.padded_method and not discovery: self.send_header("content-encoding", self.content_encoding) self.send_header("content-length", str(len(body))) self.end_headers() @@ -886,6 +904,10 @@ async def initialize(self): return None async def call_tool(self, name, params, **kwargs): + if name == "get_adcp_capabilities": + return SimpleNamespace( + isError=False, content=[], structuredContent=_capabilities_payload() + ) return SimpleNamespace(isError=False, content=[{"type": "text", "text": text}]) async def list_tools(self): diff --git a/tests/test_schema_validation_client.py b/tests/test_schema_validation_client.py index 0d1f030f3..499ac8249 100644 --- a/tests/test_schema_validation_client.py +++ b/tests/test_schema_validation_client.py @@ -52,6 +52,7 @@ class TestMCPAdapterHooks: async def test_strict_request_blocks_call_before_transport(self) -> None: client = ADCPClient( _agent_config(), + server_version="3.2", validation=ValidationHookConfig(requests="strict"), ) session = MagicMock() @@ -70,6 +71,7 @@ async def test_strict_request_blocks_call_before_transport(self) -> None: async def test_strict_response_fails_task_on_drift(self) -> None: client = ADCPClient( _agent_config(), + server_version="3.2", validation=ValidationHookConfig(requests="off", responses="strict"), ) @@ -99,6 +101,7 @@ async def test_strict_response_fails_task_on_drift(self) -> None: async def test_off_mode_skips_validator(self) -> None: client = ADCPClient( _agent_config(), + server_version="3.2", validation=ValidationHookConfig(requests="off", responses="off"), ) diff --git a/tests/test_schema_validation_server.py b/tests/test_schema_validation_server.py index aaa0b23fc..6a206b51b 100644 --- a/tests/test_schema_validation_server.py +++ b/tests/test_schema_validation_server.py @@ -101,6 +101,7 @@ async def update_media_buy(self, params: dict[str, Any], ctx: ToolContext) -> di VALID_GET_PRODUCTS = { + "adcp_version": "3.2", "brief": "test campaign", "promoted_offering": "shoes", "buying_mode": "brief", @@ -396,6 +397,21 @@ async def test_invalid_legacy_media_buy_status_is_not_rewritten(self) -> None: ) assert result["status"] == "draft" assert "media_buy_status" not in result + assert result["media_buy_id"] == "mb_1" + assert result["packages"] == [] + assert result["revision"] == 1 + assert result["confirmed_at"] == "2026-05-23T10:00:00Z" + + @pytest.mark.asyncio + async def test_invalid_legacy_media_buy_status_still_fails_strict_validation(self) -> None: + caller = create_tool_caller( + _InvalidLegacyCreateMediaBuyStatusHandler(), + "create_media_buy", + validation=ValidationHookConfig(responses="strict"), + ) + with pytest.raises(ADCPTaskError) as error: + await caller({"adcp_major_version": 3}) + assert error.value.errors[0].code == "VALIDATION_ERROR" @pytest.mark.asyncio async def test_task_id_response_without_status_is_not_marked_completed(self) -> None: diff --git a/tests/test_seller_governance_compatibility.py b/tests/test_seller_governance_compatibility.py new file mode 100644 index 000000000..e17187ad6 --- /dev/null +++ b/tests/test_seller_governance_compatibility.py @@ -0,0 +1,89 @@ +"""The demo's mock policy must not weaken real governance scope conversion.""" + +from __future__ import annotations + +import sys +from copy import deepcopy +from pathlib import Path + +import pytest + +_EXAMPLES = str(Path(__file__).parent.parent / "examples") +if _EXAMPLES not in sys.path: + sys.path.insert(0, _EXAMPLES) + +import seller_agent as seller # noqa: E402 + +from adcp.exceptions import ADCPTaskError # noqa: E402 +from adcp.server.mcp_tools import create_tool_caller # noqa: E402 +from adcp.validation import ValidationHookConfig, validate_response # noqa: E402 + + +def legacy_request(): + return { + "adcp_major_version": 3, + "idempotency_key": "governance-storyboard-regression", + "accounts": [ + { + "account": {"account_id": "account-one"}, + "governance_agents": [ + { + "url": "https://governance.example/mcp", + "authentication": {"schemes": ["Bearer"], "credentials": "x" * 40}, + "categories": ["budget_authority", "brand_policy"], + } + ], + } + ], + } + + +def caller(*, demo_policy): + return create_tool_caller( + seller.DemoSeller(), + "sync_governance", + validation=ValidationHookConfig(requests="strict", responses="strict"), + pre_validation_hook=( + seller.DEMO_PRE_VALIDATION_HOOKS["sync_governance"] if demo_policy else None + ), + ) + + +async def test_demo_explicitly_acknowledges_its_existing_accountwide_mock_policy(caplog): + request = legacy_request() + original = deepcopy(request) + response = await caller(demo_policy=True)(request) + assert request == original + assert validate_response("sync_governance", response, version="3.0").valid + assert response["accounts"][0]["governance_agents"] == [ + {"url": "https://governance.example/mcp"} + ] + assert "account-wide mock governance" in caplog.text + assert "does not apply legacy category scope" in caplog.text + assert "x" * 40 not in caplog.text + assert "budget_authority" not in caplog.text + + +async def test_default_sdk_refuses_to_broaden_legacy_category_scope(): + with pytest.raises(ADCPTaskError) as caught: + await caller(demo_policy=False)(legacy_request()) + assert caught.value.errors[0].code == "VERSION_UNSUPPORTED" + + +async def test_demo_does_not_accept_categories_on_the_current_contract(): + request = legacy_request() + request.pop("adcp_major_version") + request["adcp_version"] = "3.2" + with pytest.raises(ADCPTaskError) as caught: + await caller(demo_policy=True)(request) + assert caught.value.errors[0].code == "VALIDATION_ERROR" + + +async def test_demo_does_not_collapse_multiple_legacy_governance_agents(): + request = legacy_request() + agents = request["accounts"][0]["governance_agents"] + agents.append({**deepcopy(agents[0]), "url": "https://other-governance.example/mcp"}) + original = deepcopy(request) + with pytest.raises(ADCPTaskError): + await caller(demo_policy=True)(request) + assert request == original diff --git a/tests/test_task_options.py b/tests/test_task_options.py index 8c7198ddb..790074f3c 100644 --- a/tests/test_task_options.py +++ b/tests/test_task_options.py @@ -6,6 +6,7 @@ import inspect import time import warnings +from types import SimpleNamespace from typing import Any, get_type_hints from unittest.mock import patch @@ -212,16 +213,23 @@ async def dispatched(params: dict[str, Any]) -> TaskResult[Any]: @pytest.mark.asyncio async def test_real_mcp_dispatch_reports_the_exact_pinned_wire_key() -> None: + from adcp.server.responses import capabilities_response + class BlockingSession: sent: dict[str, Any] | None = None - async def call_tool(self, _name: str, params: dict[str, Any]) -> Any: + async def call_tool(self, name: str, params: dict[str, Any]) -> Any: + if name == "get_adcp_capabilities": + caps = capabilities_response(["creative"]) + caps["adcp"]["idempotency"] = {"supported": False} + return SimpleNamespace(isError=False, structuredContent=caps, content=[]) self.sent = params await asyncio.sleep(1) session = BlockingSession() client = ADCPClient.from_mcp_client(session, agent_id="in-process") # type: ignore[arg-type] client.adapter.request_validation_mode = "off" + await client.fetch_capabilities() key = "0123456789abcdef-pinned-wire-key" with warnings.catch_warnings(), client.use_idempotency_key(key): diff --git a/tests/test_update_rights_roundtrip.py b/tests/test_update_rights_roundtrip.py index 4c82f286c..f19412fb9 100644 --- a/tests/test_update_rights_roundtrip.py +++ b/tests/test_update_rights_roundtrip.py @@ -102,7 +102,7 @@ class TestUpdateRightsA2A: @pytest.mark.asyncio async def test_partial_update_reaches_wire(self) -> None: """Only the mutated fields travel to the adapter; rights_id is required.""" - client = ADCPClient(_cfg()) + client = ADCPClient(_cfg(), server_version="3.2") req = UpdateRightsRequest.model_validate( { "idempotency_key": str(uuid.uuid4()), @@ -141,7 +141,7 @@ async def test_partial_update_reaches_wire(self) -> None: async def test_response_parses_as_union_type(self) -> None: """UpdateRightsResponse is UpdateRightsResponse1 | UpdateRightsResponse2 — the client's _parse_response must dispatch through the Union.""" - client = ADCPClient(_cfg()) + client = ADCPClient(_cfg(), server_version="3.2") req = UpdateRightsRequest.model_validate( { "idempotency_key": str(uuid.uuid4()), diff --git a/tests/test_version_client_boundaries.py b/tests/test_version_client_boundaries.py new file mode 100644 index 000000000..7a4605878 --- /dev/null +++ b/tests/test_version_client_boundaries.py @@ -0,0 +1,327 @@ +"""Regression coverage for application intent crossing negotiated boundaries.""" + +from copy import deepcopy +from unittest.mock import AsyncMock + +import pytest + +from adcp import ADCPClient +from adcp.compat.versions import CONVERSION_METADATA_KEY +from adcp.exceptions import ADCPError +from adcp.server.responses import capabilities_response +from adcp.types import ( + CreateMediaBuyRequest, + GetAdcpCapabilitiesResponse, + GetProductsRequest, + ListProductsRequest, + SyncAccountsRequest, + UpdateMediaBuyRequest, +) +from adcp.types.core import AgentConfig, Protocol, TaskResult, TaskStatus +from adcp.types.domains.enums.task_status import TaskStatus as GeneratedTaskStatus + + +def _client(**kwargs): + return ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example/mcp/", protocol=Protocol.MCP), + account_policy=kwargs.pop("account_policy", "off"), + **kwargs, + ) + + +def _capabilities(version): + declaration = capabilities_response( + ["media_buy"], adcp_version=version, supported_versions=["3.0", version] + ) + declaration["adcp"]["idempotency"] = {"supported": False} + if version == "3.1": + declaration["media_buy"] = {"features": {"canonical_creatives": False}} + return GetAdcpCapabilitiesResponse.model_validate(declaration) + + +def _completed(data): + return TaskResult(status=TaskStatus.COMPLETED, success=True, data=data) + + +def _discovery(**kwargs): + return GetProductsRequest.model_validate({"buying_mode": "brief", "brief": "video", **kwargs}) + + +@pytest.mark.parametrize("account_policy", ["off", "auto", "strict"]) +async def test_automatic_discovery_transport_failure_retains_failed_result(account_policy): + client = _client(account_policy=account_policy) + failure = TaskResult( + status=TaskStatus.FAILED, + success=False, + error="HTTP 403: token cannot discover this seller", + adcp_error={"code": "UNAUTHORIZED", "message": "Discovery denied"}, + metadata={"http_status": 403}, + ) + wire = AsyncMock(return_value=failure) + client.adapter._call_mcp_tool_wire = wire + + result = await client.get_products( + _discovery(account={"brand": {"domain": "brand.example"}, "operator": "buyer.example"}) + ) + + assert not result.success + assert result.error == failure.error + assert result.adcp_error == failure.adcp_error + assert result.metadata["http_status"] == 403 + assert [call.args[0] for call in wire.call_args_list] == ["get_adcp_capabilities"] + with pytest.raises(ADCPError, match="Failed to fetch capabilities"): + await client.fetch_capabilities() + + +async def test_invalid_v3_discovery_is_a_failed_task_and_explicit_fetch_still_raises(): + client = _client() + wire = AsyncMock( + return_value=_completed( + { + "adcp": {"major_versions": [3]}, + "supported_protocols": ["media_buy"], + "media_buy": "invalid capability block", + } + ) + ) + client.adapter._call_mcp_tool_wire = wire + + result = await client.get_products(_discovery()) + + assert not result.success + assert "Failed to parse response" in result.error + assert all(call.args[0] == "get_adcp_capabilities" for call in wire.call_args_list) + with pytest.raises(ADCPError, match="v3 capabilities response"): + await client.fetch_capabilities() + + +@pytest.mark.parametrize("protocol", [Protocol.MCP, Protocol.A2A]) +@pytest.mark.parametrize("case", ["no_common_version", "new_task", "null_clear"]) +async def test_version_preflight_failure_is_a_failed_result_without_dispatch(protocol, case): + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example", protocol=protocol), + server_version="3.1", + adcp_version="3.0" if case == "no_common_version" else "3.2", + account_policy="off", + ) + wire = AsyncMock() + if protocol is Protocol.MCP: + client.adapter._call_mcp_tool_wire = wire + else: + client.adapter._call_a2a_tool_wire = wire + + if case == "no_common_version": + result = await client.get_products(_discovery()) + elif case == "new_task": + result = await client.list_products(ListProductsRequest()) + else: + result = await client.update_media_buy( + UpdateMediaBuyRequest.model_validate( + { + "account": {"account_id": "account-one"}, + "media_buy_id": "buy-one", + "idempotency_key": "unsupported-null-clear-1551", + "frequency_cap": None, + } + ) + ) + + assert not result.success + assert result.status == TaskStatus.FAILED + assert result.data is None + assert result.adcp_error["code"] == "UNSUPPORTED_FEATURE" + assert result.adcp_error["details"]["wire_response_received"] is False + if case == "null_clear": + assert result.adcp_error["details"]["pointer"] == "/frequency_cap" + assert result.idempotency_key == "unsupported-null-clear-1551" + wire.assert_not_awaited() + + +async def test_per_call_newer_ceiling_preserves_explicit_null_clear(): + client = _client(adcp_version="3.1") + client.fetch_capabilities = AsyncMock(return_value=_capabilities("3.2")) + wire = AsyncMock(return_value=TaskResult(status=TaskStatus.FAILED, success=False)) + client.adapter._call_mcp_tool_wire = wire + + await client.update_media_buy( + UpdateMediaBuyRequest.model_validate( + { + "adcp_version": "3.2", + "idempotency_key": "per-call-null-clear-1551", + "account": {"account_id": "account-one"}, + "media_buy_id": "buy-one", + "frequency_cap": None, + } + ) + ) + + params = wire.call_args.args[1] + assert params["adcp_version"] == "3.2" + assert "frequency_cap" in params + assert params["frequency_cap"] is None + + +async def test_historical_prerelease_success_keeps_its_existing_response_path(): + client = _client(server_version="3.2.0-beta.5") + client.adapter._call_mcp_tool_wire = AsyncMock( + return_value=_completed({"status": "completed", "products": [], "cache_scope": "public"}) + ) + + result = await client.get_products(_discovery()) + + assert result.success, result.error + assert result.data.products == [] + params = client.adapter._call_mcp_tool_wire.call_args.args[1] + assert result.metadata[CONVERSION_METADATA_KEY]["wire_version"] == params["adcp_version"] + + +async def test_cold_legacy_discovery_converts_inline_creative_without_package_selector(): + legacy_ref = {"agent_url": "https://formats.example/mcp", "id": "display_300x250_image"} + client = _client(canonical_format_legacy_resolver=lambda _: [legacy_ref]) + + async def respond(tool, params): + if tool == "get_adcp_capabilities": + return _completed(_capabilities("3.1").model_dump(mode="json", exclude_none=True)) + return TaskResult(status=TaskStatus.FAILED, success=False) + + wire = AsyncMock(side_effect=respond) + client.adapter._call_mcp_tool_wire = wire + assert client.capabilities is None + + await client.create_media_buy( + CreateMediaBuyRequest.model_validate( + { + "idempotency_key": "cold-creative-conversion-1551", + "account": {"account_id": "account-one"}, + "brand": {"domain": "brand.example"}, + "start_time": "2026-11-01T00:00:00Z", + "end_time": "2026-11-30T00:00:00Z", + "packages": [ + { + "product_id": "product-one", + "pricing_option_id": "price-one", + "budget": 1000, + "creatives": [ + { + "creative_id": "creative-one", + "name": "Inline image", + "format_kind": "image", + "assets": {"headline": {"asset_type": "text", "content": "Hello"}}, + } + ], + } + ], + } + ) + ) + + params = wire.call_args.args[1] + package = params["packages"][0] + assert params["adcp_version"] == "3.1" + assert "format_ids" not in package + assert package["creatives"][0]["format_id"] == legacy_ref + assert "format_kind" not in package["creatives"][0] + + +async def test_response_parse_failure_keeps_request_loss_metadata(): + client = _client(server_version="3.1") + client.adapter._call_mcp_tool_wire = AsyncMock( + return_value=_completed( + {"products": "invalid", "status": "completed", "cache_scope": "public"} + ) + ) + + result = await client.get_products( + _discovery(brand={"domain": "brand.example", "countries": ["US"]}) + ) + + assert not result.success + assert "parse" in result.error.lower() + report = result.metadata[CONVERSION_METADATA_KEY] + assert report["wire_version"] == "3.1" + assert any(loss["pointer"] == "/brand/countries" for loss in report["losses"]) + + +async def test_per_call_ceiling_cannot_exceed_configured_peer_version(): + client = _client(server_version="3.1") + client.adapter._call_mcp_tool_wire = AsyncMock( + return_value=_completed({"products": [], "status": "completed", "cache_scope": "public"}) + ) + + result = await client.get_products(_discovery(adcp_version="3.2")) + + assert result.success, result.error + assert client.adapter._call_mcp_tool_wire.call_args.args[1]["adcp_version"] == "3.1" + assert result.metadata[CONVERSION_METADATA_KEY]["wire_version"] == "3.1" + + +async def test_seller_upgrade_retains_provisioned_account_address_at_client_boundary(): + key = { + "brand": {"domain": "brand.example", "countries": ["US"]}, + "operator": "agency.example", + "operator_unit": {"id": "seat-one"}, + "currency": "USD", + "timezone": "UTC", + } + reduced = {"brand": {"domain": "brand.example"}, "operator": "agency.example"} + client = _client() + client.fetch_capabilities = AsyncMock(return_value=_capabilities("3.1")) + client.adapter._call_mcp_tool_wire = AsyncMock( + return_value=_completed( + { + "status": "completed", + "accounts": [ + { + **deepcopy(reduced), + "account_id": "seller-account", + "action": "created", + "status": "active", + } + ], + } + ) + ) + provisioned = await client.sync_accounts( + SyncAccountsRequest.model_validate( + { + "idempotency_key": "account-upgrade-1551", + "accounts": [{**key, "billing": "operator"}], + } + ) + ) + assert provisioned.success, provisioned.error + assert (await client.accounts.get(key)).wire_version == "3.1" + client.fetch_capabilities = AsyncMock(return_value=_capabilities("3.2")) + client.adapter._call_mcp_tool_wire = AsyncMock( + return_value=_completed({"products": [], "status": "completed", "cache_scope": "account"}) + ) + + result = await client.get_products(_discovery(account=key)) + + assert result.success, result.error + params = client.adapter._call_mcp_tool_wire.call_args.args[1] + assert params["adcp_version"] == "3.2" + assert params["account"] == {**reduced, "sandbox": False} + assert result.metadata[CONVERSION_METADATA_KEY]["account_identity_migration_pending"] == [ + {"pointer": "/account", "provisioned_wire_version": "3.1", "wire_version": "3.2"} + ] + + +def test_known_legacy_webhook_upgrades_product_envelope_without_public_cache_assumption(): + client = _client(server_version="3.0", webhook_secret="webhook-secret-for-test-1551") + + result = client._parse_webhook_result( + task_id="task-one", + task_type="get_products", + operation_id="operation-one", + status=GeneratedTaskStatus.completed, + result={"products": []}, + timestamp="2026-10-22T00:00:00Z", + message=None, + context_id=None, + ) + + assert result.success, result.error + assert result.data.status == "completed" + assert result.data.cache_scope == "account" + assert result.data.adcp_version == "3.2" diff --git a/tests/test_version_interop.py b/tests/test_version_interop.py index 93941c686..02569fe66 100644 --- a/tests/test_version_interop.py +++ b/tests/test_version_interop.py @@ -199,17 +199,9 @@ async def handle_get_media_buy_artifacts(self, request, context=None): class TestVersionNegotiationDesign: """Tests documenting the SDK's version negotiation design. - Version negotiation is INTENTIONALLY delegated to consumers. The SDK provides: - - All tool methods (V2 and V3) - - get_adcp_capabilities() for capability discovery - - TaskResult wrapping for transparent error handling - - The SDK does NOT provide: - - Automatic version detection/fallback - - Feature compatibility mapping - - Retry logic for missing tools - - Consumers implement their own negotiation strategy using these primitives. + The SDK negotiates minor releases within AdCP 3 and translates at the + client/server boundary. Pre-3 compatibility remains an explicit legacy + surface. Missing tools do not trigger retries of mutating operations. """ def test_task_result_wraps_errors_transparently(self): diff --git a/tests/test_webhook_handling.py b/tests/test_webhook_handling.py index 473da1b92..9dc845f97 100644 --- a/tests/test_webhook_handling.py +++ b/tests/test_webhook_handling.py @@ -37,6 +37,18 @@ ) +def _completed_media_buy(media_buy_id: str, buyer_ref: str) -> dict: + """A valid current-version artifact for transport and authentication tests.""" + return { + "status": "completed", + "media_buy_id": media_buy_id, + "buyer_ref": buyer_ref, + "packages": [], + "revision": 1, + "confirmed_at": "2025-01-15T10:00:00Z", + } + + class TestMCPWebhooks: """Test MCP webhook handling (HTTP POST with dict payload).""" @@ -82,7 +94,7 @@ async def test_mcp_webhook_completed_success(self): "task_type": "create_media_buy", "status": "completed", "timestamp": "2025-01-15T10:00:00Z", - "result": {"media_buy_id": "mb_123", "buyer_ref": "ref_123", "packages": []}, + "result": _completed_media_buy("mb_123", "ref_123"), "message": "Media buy created successfully", } @@ -212,7 +224,7 @@ async def test_mcp_webhook_signature_verification_valid(self): "task_type": "create_media_buy", "status": "completed", "timestamp": "2025-01-15T10:00:00Z", - "result": {"media_buy_id": "mb_333", "buyer_ref": "ref_333", "packages": []}, + "result": _completed_media_buy("mb_333", "ref_333"), } # Generate valid signature using {timestamp}.{payload} format @@ -249,7 +261,7 @@ async def test_mcp_webhook_signature_verification_with_raw_body(self): "task_type": "create_media_buy", "status": "completed", "timestamp": "2025-01-15T10:00:00Z", - "result": {"media_buy_id": "mb_333b", "buyer_ref": "ref_333b", "packages": []}, + "result": _completed_media_buy("mb_333b", "ref_333b"), } import hashlib @@ -276,7 +288,8 @@ async def test_mcp_webhook_signature_verification_with_raw_body(self): assert result.status == TaskStatus.COMPLETED @pytest.mark.asyncio - async def test_mcp_webhook_processes_the_authenticated_raw_body(self): + @pytest.mark.parametrize("valid_result", [True, False], ids=["valid", "schema_failure"]) + async def test_mcp_webhook_processes_the_authenticated_raw_body(self, valid_result): """A separately parsed payload cannot replace the signed content.""" import hashlib import hmac @@ -288,19 +301,17 @@ async def test_mcp_webhook_processes_the_authenticated_raw_body(self): "task_type": "create_media_buy", "status": "completed", "timestamp": "2025-01-15T10:00:00Z", - "result": {"media_buy_id": "mb_wrong", "buyer_ref": "ref_wrong", "packages": []}, + "result": _completed_media_buy("mb_wrong", "ref_wrong"), } authenticated_payload = { **supplied_payload, "idempotency_key": "whk_authenticated_payload", "operation_id": "op_payload", "task_id": "task_authenticated", - "result": { - "media_buy_id": "mb_authenticated", - "buyer_ref": "ref_authenticated", - "packages": [], - }, + "result": _completed_media_buy("mb_authenticated", "ref_authenticated"), } + if not valid_result: + authenticated_payload["result"].pop("revision") raw_body = json.dumps(authenticated_payload, separators=(",", ":")) header_timestamp = str(int(time.time())) signature = hmac.new( @@ -320,6 +331,9 @@ async def test_mcp_webhook_processes_the_authenticated_raw_body(self): ) assert result.metadata["task_id"] == "task_authenticated" + assert result.success is valid_result + if valid_result: + assert result.data.media_buy_id == "mb_authenticated" @pytest.mark.asyncio async def test_mcp_webhook_uses_authenticated_routing_fields(self): @@ -430,7 +444,7 @@ async def test_mcp_webhook_timestamp_valid(self): "task_type": "create_media_buy", "status": "completed", "timestamp": "2025-01-15T10:00:00Z", - "result": {"media_buy_id": "mb_ts1", "buyer_ref": "ref_ts1", "packages": []}, + "result": _completed_media_buy("mb_ts1", "ref_ts1"), } header_timestamp = str(int(time.time())) @@ -571,7 +585,7 @@ async def test_mcp_webhook_explicit_unauthenticated_escape(self): "task_type": "create_media_buy", "timestamp": "2024-01-01T00:00:00Z", "status": "completed", - "result": {"media_buy_id": "mb_escape", "buyer_ref": "ref_escape", "packages": []}, + "result": _completed_media_buy("mb_escape", "ref_escape"), } result = await client.handle_webhook(payload, "create_media_buy", "op-escape") @@ -607,7 +621,7 @@ def setup_method(self): @pytest.mark.asyncio async def test_a2a_webhook_completed_success(self): """Test A2A Task with completed status and valid AdCP payload.""" - media_buy_data = {"media_buy_id": "mb_123", "buyer_ref": "ref_123", "packages": []} + media_buy_data = _completed_media_buy("mb_123", "ref_123") task = Task( id="task_123", @@ -947,17 +961,7 @@ async def test_a2a_webhook_signature_not_required(self): artifacts=[ Artifact( artifact_id="test_artifact", - parts=[ - Part( - root=DataPart( - data={ - "media_buy_id": "mb_666", - "buyer_ref": "ref_666", - "packages": [], - } - ) - ) - ], + parts=[Part(root=DataPart(data=_completed_media_buy("mb_666", "ref_666")))], ) ], ) @@ -1004,7 +1008,7 @@ async def test_type_detection_mcp_dict(self): "task_type": "create_media_buy", "status": "completed", "timestamp": "2025-01-15T10:00:00Z", - "result": {"media_buy_id": "mb_mcp", "buyer_ref": "ref_mcp", "packages": []}, + "result": _completed_media_buy("mb_mcp", "ref_mcp"), } result = await self.mcp_client.handle_webhook( @@ -1026,17 +1030,7 @@ async def test_type_detection_a2a_task(self): artifacts=[ Artifact( artifact_id="test_artifact", - parts=[ - Part( - root=DataPart( - data={ - "media_buy_id": "mb_a2a", - "buyer_ref": "ref_a2a", - "packages": [], - } - ) - ) - ], + parts=[Part(root=DataPart(data=_completed_media_buy("mb_a2a", "ref_a2a")))], ) ], ) @@ -1075,7 +1069,7 @@ async def test_type_detection_a2a_taskstatusupdateevent(self): @pytest.mark.asyncio async def test_consistent_result_format(self): """Verify MCP and A2A return identical TaskResult structure.""" - media_buy_data = {"media_buy_id": "mb_test", "buyer_ref": "ref_test", "packages": []} + media_buy_data = _completed_media_buy("mb_test", "ref_test") # MCP webhook mcp_payload = { @@ -1577,7 +1571,7 @@ async def test_verify_fails_closed_when_raw_body_missing(self): "task_type": "create_media_buy", "status": "completed", "timestamp": "2025-01-15T10:00:00Z", - "result": {"media_buy_id": "mb_1", "buyer_ref": "ref_1", "packages": []}, + "result": _completed_media_buy("mb_1", "ref_1"), } # Compute what would have been a valid signature under the old # re-serialize-from-payload fallback. Under the fail-closed rule, From d0737127b94b58355221c3ab9b1e7353e66ac2d6 Mon Sep 17 00:00:00 2001 From: Constantine Mirin Date: Thu, 22 Oct 2026 14:21:17 +0200 Subject: [PATCH 03/54] fix(canonical-formats): compare every parameter a format reference declares (#1518) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `core/format-id.json` declares four parameters beyond the `(agent_url, id)` identity — `width`, `height`, `duration_ms` and `pixel_ratio`. Three helpers in `compat_helpers.py` each named three of them in a hand-written tuple, so none of them looked at `pixel_ratio`: - `format_is_supported` accepted a creative that claimed no pixel density for a product requiring 2x, and accepted one claiming a conflicting density; - `formats_are_equivalent` called two references with conflicting densities equal; - `_upgrade_legacy_format_id` discarded the `pixel_ratio` it was handed. Derive the set from the model's fields instead of listing it, so a parameter added to the schema is compared without anyone remembering to name it. A fourth literal would only reset the clock: an independent implementation of the same comparison hit this bug from its own hand-written list of the same three names. `pixel_ratio` keeps one deliberate per-field rule, because the schema gives it one: omitting it selects "the backward-compatible 1x variant", so an absent density reads as 1x rather than as unspecified. A product fixed at 2x therefore refuses a silent request while one fixed at 1x still serves it — without that, closing the gap would introduce the mirror-image false rejection. The new parametrized test reads the parameter list from the bundled schema rather than from the model, so it fails when a helper stops comparing something the spec declares. Closes #1517 --- src/adcp/canonical_formats/compat_helpers.py | 50 ++++++++-- tests/test_canonical_formats_compatibility.py | 96 +++++++++++++++++++ 2 files changed, 138 insertions(+), 8 deletions(-) diff --git a/src/adcp/canonical_formats/compat_helpers.py b/src/adcp/canonical_formats/compat_helpers.py index c0fdb70d0..a9a563043 100644 --- a/src/adcp/canonical_formats/compat_helpers.py +++ b/src/adcp/canonical_formats/compat_helpers.py @@ -28,6 +28,27 @@ r"^display_(?P[1-9][0-9]*)x(?P[1-9][0-9]*)(?:_image)?$" ) +#: The parameters a format reference carries beyond its ``(agent_url, id)`` +#: identity, derived from the model instead of listed. +#: +#: A hand-written list here is the defect that dropped ``pixel_ratio``: three +#: of the four parameters were copied by name, so a creative that claimed no +#: pixel density compared equal to a product format that requires 2x, two +#: references declaring *conflicting* densities compared equal, and the +#: legacy-size upgrade discarded the density it was handed. Deriving the set +#: means a parameter added to ``core/format-id.json`` — and so to the +#: generated model — is compared without being named here. +_PARAMETER_FIELDS: tuple[str, ...] = tuple(sorted(set(FormatId.model_fields) - {"agent_url", "id"})) + +#: Parameters the schema gives a value to when they are absent. +#: +#: ``core/format-id.json`` describes omitting ``pixel_ratio`` as selecting +#: "the backward-compatible 1x variant", which is a value, not silence. An +#: omitted width or duration means "template, unparameterized"; an omitted +#: density means 1x. So a product fixed at 2x refuses a request that says +#: nothing, while one fixed at 1x still serves it. +_IMPLIED_WHEN_ABSENT: dict[str, float] = {"pixel_ratio": 1.0} + def _coerce_format_id( value: FormatIdInput, @@ -74,11 +95,14 @@ def _upgrade_legacy_format_id( return FormatId.model_validate( { + # Every parameter the reference carried, then the two the legacy + # size ID itself states. Reparameterizing the ID must not drop a + # parameter the caller supplied alongside it. + **{field: getattr(fid, field) for field in _PARAMETER_FIELDS}, "agent_url": str(fid.agent_url), "id": "display_image", "width": int(match.group("width")), "height": int(match.group("height")), - "duration_ms": fid.duration_ms, } ) @@ -110,8 +134,9 @@ def formats_are_equivalent( Both inputs are first passed through :func:`upgrade_legacy_format_id`. Declared parameters must not conflict, but an omitted parameter on either side is treated as unspecified rather than a mismatch. Use - :func:`format_is_supported` for product/capability gating where a - supported fixed size or duration requires the request to state that value. + :func:`format_is_supported` for product/capability gating, where a + supported fixed size, duration or pixel density requires the request to + state that value. """ left = _upgrade_legacy_format_id(a, default_agent_url=default_agent_url, param="a") right = _upgrade_legacy_format_id(b, default_agent_url=default_agent_url, param="b") @@ -120,7 +145,7 @@ def formats_are_equivalent( if left.id != right.id: return False - for field in ("width", "height", "duration_ms"): + for field in _PARAMETER_FIELDS: left_value = getattr(left, field) right_value = getattr(right, field) if left_value is not None and right_value is not None and left_value != right_value: @@ -139,8 +164,14 @@ def format_is_supported( This is intentionally stricter than :func:`formats_are_equivalent`. A broad supported format such as ``display_image`` accepts a specific request such as ``display_image`` 300x250, but a fixed supported product - format requires the request to provide and match every fixed parameter - (``width``, ``height``, and ``duration_ms``). + format requires the request to provide and match every parameter the + supported side fixes — every parameter ``core/format-id.json`` declares, + not a chosen few. + + ``pixel_ratio`` is the one parameter whose absence carries a value rather + than meaning "unspecified": the schema describes omitting it as selecting + the backward-compatible 1x variant. A product fixed at 2x therefore + refuses a request that omits the field, and one fixed at 1x accepts it. """ req = _upgrade_legacy_format_id( requested, default_agent_url=default_agent_url, param="requested" @@ -151,11 +182,14 @@ def format_is_supported( if not formats_are_equivalent(req, sup, default_agent_url=default_agent_url): return False - for field in ("width", "height", "duration_ms"): + for field in _PARAMETER_FIELDS: supported_value = getattr(sup, field) if supported_value is None: continue - if getattr(req, field) != supported_value: + requested_value = getattr(req, field) + if requested_value is None: + requested_value = _IMPLIED_WHEN_ABSENT.get(field) + if requested_value != supported_value: return False return True diff --git a/tests/test_canonical_formats_compatibility.py b/tests/test_canonical_formats_compatibility.py index ffd1ee144..29ba34857 100644 --- a/tests/test_canonical_formats_compatibility.py +++ b/tests/test_canonical_formats_compatibility.py @@ -16,6 +16,7 @@ ) from adcp.types import FormatCard from adcp.types.legacy import LegacyFormatId as FormatId +from adcp.validation.schema_loader import get_named_schema_document def test_upgrade_legacy_display_size_to_parameterized_canonical_format_id() -> None: @@ -186,3 +187,98 @@ def test_projection_accepts_a_reference_taken_off_a_model_field() -> None: assert projected.diagnostic is None assert projected.declaration is not None + + +def _schema_parameters() -> frozenset[str]: + """The parameters ``core/format-id.json`` declares beyond the identity pair. + + Read from the bundled schema rather than from the generated model, so the + guard below fails when a helper stops comparing a parameter the *spec* + declares — not merely when it disagrees with the model it was written + against. + """ + document = get_named_schema_document("core/format-id.json") + assert document is not None, "core/format-id.json is absent from the bundled schema tree" + return frozenset(document["properties"]) - frozenset(document["required"]) + + +#: One pair of conflicting, dependency-legal values per declared parameter. +#: ``pixel_ratio`` needs ``width``/``height`` present, which ``_BASE`` supplies. +_CONFLICTING_VALUES: dict[str, tuple[object, object]] = { + "width": (300, 728), + "height": (250, 90), + "duration_ms": (15000, 30000), + "pixel_ratio": (2, 3), +} + +_BASE = { + "agent_url": CANONICAL_CREATIVE_AGENT_URL, + "id": "display_image", + "width": 300, + "height": 250, +} + + +def test_the_conflict_table_covers_every_parameter_the_schema_declares() -> None: + """Keeps the parametrized guard below honest as the schema grows. + + A new parameter in ``core/format-id.json`` fails here until it is given a + conflicting pair, which then puts it under the comparison guard. + """ + assert set(_CONFLICTING_VALUES) == set(_schema_parameters()) + + +@pytest.mark.parametrize("parameter", sorted(_CONFLICTING_VALUES)) +def test_the_helpers_refuse_a_conflict_in_every_declared_parameter(parameter: str) -> None: + """Two references that disagree about any declared parameter differ. + + The comparison walks the parameters the model declares, so a parameter + added to the schema is compared without being named here. + """ + supported_value, requested_value = _CONFLICTING_VALUES[parameter] + supported = {**_BASE, parameter: supported_value} + requested = {**_BASE, parameter: requested_value} + + assert not formats_are_equivalent(requested, supported) + assert not format_is_supported(requested, supported) + + +def test_format_is_supported_refuses_a_request_that_omits_a_required_pixel_ratio() -> None: + """A 2x product is not served by a creative that never claims a density. + + ``core/format-id.json`` makes the omitted form the 1x variant ("Omit for + the backward-compatible 1x variant"), so silence here is a 1x asset being + delivered into a 2x slot, not an unspecified parameter. + """ + supported = {**_BASE, "pixel_ratio": 2} + + assert formats_are_equivalent(_BASE, supported) + assert not format_is_supported(_BASE, supported) + + +def test_format_is_supported_accepts_the_omitted_form_of_a_1x_product() -> None: + """The mirror of the case above, and the reason it is not a plain equality. + + A seller that spells ``pixel_ratio: 1`` publishes the same render as one + that omits it, so a request omitting the field must still be served. + """ + assert format_is_supported(_BASE, {**_BASE, "pixel_ratio": 1}) + + +def test_upgrade_legacy_format_id_keeps_every_parameter_it_was_given() -> None: + """The legacy-size upgrade reparameterizes the ID; it drops no parameter.""" + legacy = FormatId( + agent_url=CANONICAL_CREATIVE_AGENT_URL, + id="display_300x250", + width=300, + height=250, + duration_ms=15000, + pixel_ratio=2, + ) + + upgraded = upgrade_legacy_format_id(legacy) + + assert upgraded.id == "display_image" + assert (upgraded.width, upgraded.height) == (300, 250) + assert upgraded.duration_ms == 15000 + assert upgraded.pixel_ratio == 2 From 69a0f6b9525b400b17b25c1b18294a4650c56afb Mon Sep 17 00:00:00 2001 From: Constantine Mirin Date: Thu, 22 Oct 2026 14:21:28 +0200 Subject: [PATCH 04/54] fix(types): inherit the generated constraints on the canonical creatives field (#1548) * fix(types): inherit the generated constraints on the canonical creatives field The canonical SyncCreativesRequest redeclares `creatives` to narrow the element type to CreativeAsset, and assigned a fresh Field() to do it. A fresh FieldInfo replaces the inherited one wholesale, so the generated field's max_length=100 and the bundle's description were lost: measured against the pinned 3.2 schema, the model published a node with neither maxItems nor description, and accepted 101 creatives where the schema declares maxItems: 100. That drop predates #1546 -- the line read `Field(min_length=1)` before it, with the same two pieces missing. `_inherit` in the same module exists for this case; its docstring warns that a bare Field() "would silently drop min_length and friends". The redeclaration now spells only the annotation it narrows and inherits everything else. What this does NOT do: it does not refuse an explicit JSON null for `creatives`. The generated parent renders the optional property as `list[Creative] | None`, so a null is admitted before any canonical redeclaration is consulted -- deleting the redeclaration outright leaves it accepted. That half is a generator-level rule, filed separately as #1547. Two tests: one grades the published node against the bundled node on type, description, minItems and maxItems; the other asserts the ceiling is enforced rather than advertised, on both the model and the JSON-Schema arm. * fix(types): inherit the generated creatives constraints on PackageUpdate too PackageUpdate redeclared creatives with only min_length=1, dropping the generated maxItems=100 and description, so it accepted 101 creatives that validate_request rejects. Use _inherit like SyncCreativesRequest, and restore the note on why SyncCreativesRequest.creatives is optional. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Brian O'Kelley Co-authored-by: Claude Opus 5.5 (1M context) --- src/adcp/types/canonical_creative.py | 7 +- tests/test_sync_creatives_assignment_only.py | 79 +++++++++++++++++++- 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 3ff2e4008..6c7809df3 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -1102,7 +1102,7 @@ def _validate_format_params(self) -> PackageRequest: class PackageUpdate(_LegacyPackageUpdate, CanonicalBoundaryModel): """Canonical package update; creatives are canonical assets.""" - creatives: list[CreativeAsset] | None = Field(default=None, min_length=1) # type: ignore[assignment] + creatives: list[CreativeAsset] | None = _inherit(_LegacyPackageUpdate, "creatives") # type: ignore[assignment] class Package(_LegacyPackage, CanonicalBoundaryModel): @@ -1219,8 +1219,9 @@ class UpdateMediaBuyResponse3(_LegacyUpdateMediaBuyResponse3, CanonicalBoundaryM class SyncCreativesRequest(_LegacySyncCreativesRequest, CanonicalBoundaryModel): """Canonical creative sync request; creatives are canonical assets.""" - # Requiredness is version-specific: AdCP 3.2 permits assignment-only syncs. - creatives: list[CreativeAsset] | None = Field(default=None, min_length=1) # type: ignore[assignment] + # Requiredness is version-specific: AdCP 3.2 permits assignment-only syncs, + # so optionality, like the item bounds, comes from the generated parent. + creatives: list[CreativeAsset] | None = _inherit(_LegacySyncCreativesRequest, "creatives") # type: ignore[assignment] class ListCreativesRequest(_LegacyListCreativesRequest, CanonicalBoundaryModel): diff --git a/tests/test_sync_creatives_assignment_only.py b/tests/test_sync_creatives_assignment_only.py index 75f77fd7c..97baaeac5 100644 --- a/tests/test_sync_creatives_assignment_only.py +++ b/tests/test_sync_creatives_assignment_only.py @@ -10,8 +10,9 @@ from adcp.server.base import ADCPHandler, ToolContext from adcp.server.mcp_tools import create_tool_caller -from adcp.types import CreativeAsset, SyncCreativesRequest -from adcp.validation import ValidationHookConfig, validate_request +from adcp.types import CreativeAsset, PackageUpdate, SyncCreativesRequest +from adcp.types.domains.media_buy.package_update import PackageUpdate as _LegacyPackageUpdate +from adcp.validation import ValidationHookConfig, get_schema, validate_request _BASE = { "adcp_version": "3.2", @@ -89,6 +90,80 @@ def test_public_schema_allows_omitting_creatives() -> None: assert "creatives" not in SyncCreativesRequest.model_json_schema()["required"] +def _bundled_creatives_node() -> dict[str, Any]: + bundle = get_schema("sync_creatives", "request", version="3.2") + assert bundle is not None + node: dict[str, Any] = bundle["properties"]["creatives"] + return node + + +def _published_creatives_node() -> dict[str, Any]: + """The ``creatives`` node as the public model advertises it, flattened. + + An optional field spells itself ``T | None``, which publishes + ``anyOf: [T, {"type": "null"}]`` and leaves the description beside the + union rather than inside the arm. Folding the sole non-null arm into its + siblings is what lets the constraints be read at one level, and keeps this + helper correct for a declaration that publishes no null arm at all. + """ + node = SyncCreativesRequest.model_json_schema()["properties"]["creatives"] + arms = [arm for arm in node.get("anyOf", [node]) if arm != {"type": "null"}] + assert len(arms) == 1, arms + return {**{key: value for key, value in node.items() if key != "anyOf"}, **arms[0]} + + +def test_public_schema_keeps_the_bundled_constraints_on_creatives() -> None: + """Omitting ``creatives`` must not cost the array its own schema. + + ``required`` alone does not grade this: a redeclaration that replaces the + generated ``FieldInfo`` wholesale drops ``maxItems`` and the description + while leaving ``required`` exactly as this file's sibling test wants it. + """ + published = _published_creatives_node() + bundled = _bundled_creatives_node() + graded = ("type", "description", "minItems", "maxItems") + + assert {key: published.get(key) for key in graded} == {key: bundled[key] for key in graded} + + +def test_sync_refuses_more_creatives_than_the_bundle_permits() -> None: + """The ceiling is enforced, not merely advertised.""" + ceiling = _bundled_creatives_node()["maxItems"] + payload = { + **_BASE, + "creatives": [ + {"creative_id": f"cr_{index}", "name": "Banner", "format_kind": "image", "assets": {}} + for index in range(ceiling + 1) + ], + } + + with pytest.raises(ValidationError) as caught: + SyncCreativesRequest.model_validate(payload) + assert caught.value.errors()[0]["loc"] == ("creatives",) + assert caught.value.errors()[0]["type"] == "too_long" + outcome = validate_request("sync_creatives", payload, version="3.2") + assert any( + issue.pointer == "/creatives" and issue.keyword == "maxItems" for issue in outcome.issues + ) + + +def test_package_update_keeps_the_generated_creatives_constraints() -> None: + """``PackageUpdate`` narrows ``creatives`` the same way and must not lose its ceiling.""" + generated = _LegacyPackageUpdate.model_fields["creatives"] + canonical = PackageUpdate.model_fields["creatives"] + assert canonical.metadata == generated.metadata + assert canonical.description == generated.description + + ceiling = next(m.max_length for m in generated.metadata if hasattr(m, "max_length")) + creative = {"creative_id": "cr", "name": "Banner", "format_kind": "image", "assets": {}} + with pytest.raises(ValidationError) as caught: + PackageUpdate.model_validate( + {"package_id": "pkg_1", "creatives": [creative] * (ceiling + 1)} + ) + assert caught.value.errors()[0]["loc"] == ("creatives",) + assert caught.value.errors()[0]["type"] == "too_long" + + class _AssignmentHandler(ADCPHandler[Any]): adcp_capabilities = {"media_buy": {"features": {"canonical_creatives": True}}} From 30a72ee2a409bccb53fe961816584bdea2518f70 Mon Sep 17 00:00:00 2001 From: Constantine Mirin Date: Thu, 22 Oct 2026 14:21:33 +0200 Subject: [PATCH 05/54] fix(types): generate the open asset unions, and derive the vocabulary they route by (#1501) * fix(types): generate the open asset unions, and derive the vocabulary they route by Closes #1486. `Format.assets` routed 14 of the 21 `asset_type` values the pinned schema declares, and the repeatable group's `assets` routed 12 of its 21. The missing ones did not fail: they matched the `_unknown` Tag and validated as `UnknownFormatAsset` / `UnknownGroupAsset`, so a consumer got an untyped object with the asset's fields in `__pydantic_extra__` where the schema promises a typed one -- while the generated class for every missing value sat in `domains/core/format.py`, unused. Format.assets missing card daast_tracker display_tag pixel_tracker published_post vast_tracker zip RepeatableAssetGroup.assets missing those, plus brief catalog The cause was a hand-written vocabulary beside a generated union. `aliases.py` declared both unions, both fallback classes, and two frozensets of 14 and 12 `asset_type` values; `_forward_compat._apply_forward_compat()` then rewrote `Format.assets` and the group's `assets` over the generated annotation at import time. The frozensets are what went stale, and they decide first: the callable `Discriminator` reads the frozenset and returns `_unknown` BEFORE pydantic reaches the arms, so adding arms would have changed routing not at all. `_forward_compat`'s own docstring used `pixel_tracker` as its EXAMPLE of a "novel asset_type"; by then the example was an instance of the defect. So the union is now emitted where its arms are declared. `_OPEN_ASSET_UNIONS` in `scripts/post_generate_fixes.py` writes, per slot, the fallback class, the `asset_type` vocabulary, the callable discriminator and the tagged union into `domains/core/format.py`, and repoints the carrying field. The generator knows each arm's `asset_type` literal because it emitted that literal, so nothing is declared and nothing can go stale: a schema that adds an asset type adds its class AND its tag in the same run. `_forward_compat`'s asset patch is deleted rather than rewired -- with the generated annotation correct there is nothing left to patch -- and `aliases.py` re-exports the four names instead of defining them. The arms keep their genuine asymmetry. Each union is derived from its own base class, because a group slot carries no nested `repeatable_group`. The group container is derived too, by its `item_type` discriminator rather than by name, for the reason `restore_format_asset_numbered_aliases` already derives it: the generator renumbers that class (`Assets94` once, `Assets29` now). `_unknown` is still an arm, so completeness costs none of the openness #744 bought: a value the schema does not declare still reaches the fallback. Six derived refusals, each fire-probed on a mutated copy of the generated module rather than on the schema, so none of them can pass vacuously: * an arm the derivation cannot see while the field still carries it, and an arm the field carries that the base does not own -- the membership check requires the field's current element union to resolve to exactly the arm set the alias was built from, and names the symmetric difference; * a duplicate `asset_type` across two arms (a `Tag` must be unique); * a carrying field that is renamed, or that stops being a list; * no class declaring `item_type='repeatable_group'`. Four tests in `tests/test_forward_compat_assets.py` asserted that `pixel_tracker` parses as `UnknownFormatAsset`. The obligation is right and the value was wrong -- they agreed with the stale frozenset rather than with the schema -- so the novel value is now derived from the generated classes: a hardcoded "novel" type can become declared by a schema refresh without the test noticing, which is how those four came to be wrong. A fifth restated the stale 14-value list as its own assertion and now reads the derivation. Added `TestDeclaredAssetTypesAreNotDegraded`, parametrized over the generated classes on both slots, asserting at the PARSE level that each declared type reaches its own class with no untyped extras -- plus one case comparing that population against the two arms of the pinned schema, read at their own depths and selected by `item_type` const, so a generator that stopped emitting a class would shrink the population loudly rather than grade fewer rows in silence. `adcp.types.UnknownFormatAsset` and `UnknownGroupAsset` now resolve to the classes in `domains.core.format` rather than the ones in `aliases`. The public name tracks the class the unions actually route to, as it did before, so `isinstance` against the public name is unaffected; `tests/fixtures/ public_api_snapshot.json` records the repoint and the gained arms. * fix(types): refuse a non-string asset_type instead of crashing the asset discriminator A list or dict asset_type raised TypeError (unhashable) from the frozenset membership check, which escapes a caller's except ValidationError. Route non-strings to the fallback arm so validation refuses them. Fixed in the post-generation template and the regenerated format module. Also add an adopter type-check fixture so FormatAssetUnion stays usable as a type expression under mypy and pyright. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Brian O'Kelley Co-authored-by: Claude Opus 5.5 (1M context) --- LADON.md | 11 + scripts/post_generate_fixes.py | 366 +++++++++++++++++++++++ scripts/post_generation_manifest.json | 1 + src/adcp/types/__init__.py | 4 +- src/adcp/types/_eager.py | 7 +- src/adcp/types/_forward_compat.py | 31 +- src/adcp/types/_generated.py | 10 +- src/adcp/types/aliases.py | 170 ++--------- src/adcp/types/domains/core/__init__.py | 10 +- src/adcp/types/domains/core/format.py | 225 ++++++++++++-- tests/fixtures/public_api_snapshot.json | 16 +- tests/test_forward_compat_assets.py | 228 ++++++++++++-- tests/type_checks/format_asset_unions.py | 27 ++ 13 files changed, 877 insertions(+), 229 deletions(-) create mode 100644 tests/type_checks/format_asset_unions.py diff --git a/LADON.md b/LADON.md index 653d8a781..ac2439b59 100644 --- a/LADON.md +++ b/LADON.md @@ -32,6 +32,17 @@ without an open-union escape hatch, or changing the discriminator key on a model already on the wire is a `high` finding — adopter deserialization breaks the moment upstream adds a variant. +The two asset unions are the exception to the file list: `FormatAssetUnion` and +`GroupFormatAssetUnion`, their fallback arms and the `asset_type` vocabulary +they route by are EMITTED into `src/adcp/types/domains/core/format.py` by +`_OPEN_ASSET_UNIONS` in `scripts/post_generate_fixes.py`, and `aliases.py` +re-exports them. Review a change to them there. A hand-written vocabulary beside +a generated union is itself the `high` finding: the callable discriminator reads +the vocabulary before pydantic reaches the arms, so a stale entry degrades a +declared asset type to the fallback while its generated class sits unused — and +counting arms does not detect it. Grade it with a parse-level check +(`tests/test_forward_compat_assets.py::TestDeclaredAssetTypesAreNotDegraded`). + ### Mandatory: type-system import layering Only `_generated.py`, `_eager.py`, `aliases.py`, `capabilities.py`, and diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 801c10d18..bb525cb40 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -7602,6 +7602,368 @@ def insert_task_message_markers() -> None: print(f" marked {len(wanted)} task-message classes across {len(by_module)} modules") +# --------------------------------------------------------------------------- +# #742: open asset unions, emitted where the arms are declared. +# +# ``core/format.py`` generates a CLOSED discriminated union per asset slot, and +# the spec requires a consumer to keep an unrecognised ``asset_type`` rather +# than fail the whole ``list_creative_formats`` response. So each slot gains one +# fallback arm, and the field carries the open union instead. +# +# This used to be a runtime patch: ``src/adcp/types/aliases.py`` declared the +# fallback class, the union and a frozenset of known ``asset_type`` values by +# hand, and ``_forward_compat._apply_forward_compat()`` rewrote +# ``Format.assets`` and the repeatable group's ``assets`` over the generated +# annotation at import time. The declaration is what broke: the two frozensets +# held 14 and 12 members beside a generated union of 21 and 21, and the callable +# discriminator reads the frozenset BEFORE pydantic reaches the arms, so seven +# individual asset types the pinned schema declares (``zip``, ``display_tag``, +# ``published_post``, ``card``, ``pixel_tracker``, ``vast_tracker``, +# ``daast_tracker``) and nine group types parsed as the unknown arm with their +# typed fields in ``__pydantic_extra__``, while generated classes for every one +# of them sat unused. +# +# Emitting here removes the declaration rather than deriving it: the generator +# knows each arm's ``asset_type`` literal because it emitted that literal, so a +# schema that adds an asset type adds its class AND its tag in the same run. + + +class OpenAssetUnion(NamedTuple): + """One asset slot's generated union, plus the fallback arm the spec needs.""" + + #: The union's name in the generated module. + alias: str + #: The base class whose direct subclasses are the arms. + base: str + #: The fallback arm's class name. + fallback: str + #: The callable discriminator's function name. + discriminator: str + #: The class carrying the field whose list element type becomes the alias. + #: ``None`` stands for the repeatable-group class, derived rather than named + #: because the generator renumbers it (see + #: :func:`restore_format_asset_numbered_aliases`). + carrier: str | None + #: The field on that class. + carried_field: str + #: Whether the repeatable-group class is itself an arm of this union. + carries_group_arm: bool + doc: str + + +#: The open asset unions, in emission order. Each is written into +#: ``core/format.py`` ahead of the class that carries it, so the alias exists +#: before pydantic builds that class. +_OPEN_ASSET_UNIONS: tuple[OpenAssetUnion, ...] = ( + # The group slots come first: the repeatable group carries them, and the + # individual union below has that same group class as one of its arms. + OpenAssetUnion( + alias="GroupFormatAssetUnion", + base="BaseGroupAsset", + fallback="UnknownGroupAsset", + discriminator="_group_asset_discriminator", + carrier=None, + carried_field="assets", + carries_group_arm=False, + doc=( + "Open discriminated union for the slots inside a repeatable asset group.\n\n" + "The generated union closes over the group asset classes; this adds the\n" + "fallback arm an unrecognised ``asset_type`` needs." + ), + ), + OpenAssetUnion( + alias="FormatAssetUnion", + base="BaseIndividualAsset", + fallback="UnknownFormatAsset", + discriminator="_format_asset_discriminator", + carrier="Format", + carried_field="assets", + carries_group_arm=True, + doc=( + "Open discriminated union for ``Format.assets``.\n\n" + "The generated union closes over the individual asset classes plus the\n" + "repeatable group; this adds the fallback arm an unrecognised\n" + "``asset_type`` needs." + ), + ), +) + + +def _repeatable_group_class(tree: ast.Module) -> str: + """The generated class whose ``item_type`` discriminator is the group. + + Derived for the same reason :func:`restore_format_asset_numbered_aliases` + derives it: the generator renumbers this class (``Assets94`` once, now + ``Assets29``), so naming it here would go stale on a schema refresh. + """ + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + for statement in node.body: + if not isinstance(statement, ast.AnnAssign): + continue + if not isinstance(statement.target, ast.Name) or statement.target.id != "item_type": + continue + default_is_group = ( + isinstance(statement.value, ast.Constant) + and statement.value.value == "repeatable_group" + ) + annotation_is_group = ( + _extract_single_literal_value(statement.annotation) == "repeatable_group" + ) + if default_is_group or annotation_is_group: + return node.name + raise RuntimeError( + "core/format.py: no class declares item_type='repeatable_group', so the " + "asset unions have no group arm and no group container" + ) + + +def _asset_union_arms(tree: ast.Module, base: str) -> list[tuple[str, str]]: + """``(class name, asset_type literal)`` for every direct subclass of ``base``. + + Read off the generated source in declaration order. A ``Tag`` must be unique + within a union, so a duplicate literal is refused rather than silently + dropping whichever arm came second. + """ + arms: list[tuple[str, str]] = [] + seen: dict[str, str] = {} + for node in tree.body: + if not isinstance(node, ast.ClassDef): + continue + if base not in {ast.unparse(parent) for parent in node.bases}: + continue + for statement in node.body: + if not isinstance(statement, ast.AnnAssign): + continue + if not isinstance(statement.target, ast.Name) or statement.target.id != "asset_type": + continue + literal = _extract_single_literal_value(statement.annotation) + if not isinstance(literal, str): + continue + if literal in seen: + raise RuntimeError( + f"core/format.py: asset_type {literal!r} is declared by both " + f"{seen[literal]} and {node.name}; a discriminated union cannot " + "tag two arms the same" + ) + seen[literal] = node.name + arms.append((node.name, literal)) + if not arms: + raise RuntimeError(f"core/format.py: no subclass of {base} declares an asset_type") + return arms + + +def _screaming_snake(class_name: str) -> str: + """``BaseIndividualAsset`` -> ``BASE_INDIVIDUAL_ASSET``.""" + return re.sub(r"(? str: + """The fallback class, its vocabulary, its discriminator and the union.""" + known = "".join(f" {literal!r},\n" for _name, literal in arms) + tagged = [f"Annotated[{name}, Tag({literal!r})]" for name, literal in arms] + if union.carries_group_arm: + tagged.append(f"Annotated[{group_class}, Tag('repeatable_group')]") + tagged.append(f"Annotated[{union.fallback}, Tag('_unknown')]") + # The first arm carries no leading ``|``, matching the generated layout, and + # the last carries the comma that closes the Annotated argument list. + body = f" {tagged[0]}\n" + "".join(f" | {arm}\n" for arm in tagged[1:-1]) + body += f" | {tagged[-1]},\n" + read_item_type = group_route = "" + if union.carries_group_arm: + read_item_type = ( + " item_type = (\n" + " value.get('item_type', 'individual')\n" + " if isinstance(value, dict)\n" + " else getattr(value, 'item_type', 'individual')\n" + " )\n" + ) + group_route = " if item_type == 'repeatable_group':\n return 'repeatable_group'\n" + vocabulary = f"_KNOWN_{_screaming_snake(union.base)}_TYPES" + return f'''class {union.fallback}({union.base}): + """Fallback arm for an ``asset_type`` outside this pin's vocabulary. + + A response carrying an asset type defined after this SDK's pin parses as + this class instead of failing, which would zero out every format in the + response. The structural fields are still validated; the type-specific ones + are kept in ``__pydantic_extra__``. Read them via + ``asset.__pydantic_extra__ or {{}}``. + + ``extra='allow'`` is deliberate and not inherited from the strict-validation + policy on ``AdCPBaseModel``: dropping or refusing the unknown fields defeats + the whole purpose of the arm. Read path only -- a request keeps its closed + ``Literal`` arms. + """ + + model_config = ConfigDict(extra='allow') + asset_type: str + + +#: The ``asset_type`` values this pin has a class for. Derived from the arms +#: above, so a schema that adds a type adds its class AND its tag together. +{vocabulary}: frozenset[str] = frozenset( + {{ +{known} }} +) + + +def {union.discriminator}(value: Any) -> str: + """Route a wire asset to its ``Tag``, or to the fallback.""" +{read_item_type}{group_route} asset_type = ( + value.get('asset_type', '') + if isinstance(value, dict) + else getattr(value, 'asset_type', '') + ) + if isinstance(asset_type, str) and asset_type in {vocabulary}: + return asset_type + return '_unknown' + + +{union.alias} = Annotated[ +{body} Discriminator({union.discriminator}), +] +"""{union.doc} +"""''' + + +def _module_union_aliases(tree: ast.Module) -> dict[str, ast.AST]: + """Top-level ``Name = `` bindings, so a union alias can be followed.""" + return { + node.targets[0].id: node.value + for node in tree.body + if isinstance(node, ast.Assign) + and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + } + + +def _union_class_names(node: ast.AST, aliases: dict[str, ast.AST]) -> set[str]: + """Every class name a possibly-aliased union annotation resolves to. + + ``Format.assets`` spells its union inline; the repeatable group reaches one + through a generated ``Assets30 = Annotated[A | B, Field(discriminator=...)]`` + alias, so the alias is followed rather than counted as an arm. + """ + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): + return _union_class_names(node.left, aliases) | _union_class_names(node.right, aliases) + if isinstance(node, ast.Subscript) and _subscript_base_name(node) == "Annotated": + inner = _first_subscript_arg(node) + return _union_class_names(inner, aliases) if inner is not None else set() + if isinstance(node, ast.Name): + if node.id in aliases: + return _union_class_names(aliases[node.id], aliases) + return {node.id} + return {ast.unparse(node)} + + +def _carry_open_asset_union( + content: str, union: OpenAssetUnion, class_name: str, expected: set[str] +) -> str: + """Point the carrying field at the alias, after checking what it carries now. + + The membership check is the guard, and it is derived rather than declared: + the field's current element union must resolve to exactly the arm set the + alias was just built from. A schema refresh that adds an asset type changes + both sides together and passes; one that reshapes the field, or that adds an + arm the base does not own, fails here rather than leaving the field closed. + """ + field_name = union.carried_field + block = _find_class_field_block(content, class_name, field_name) + if block is None: + raise RuntimeError( + f"core/format.py: {class_name}.{field_name} is not declared, so " + f"{union.alias} has nothing to open" + ) + start, end = block + annotation = content[start:end] + element = re.search(r"list\[(.*?)\]\s*(?:\|\s*None)?,", annotation, re.DOTALL) + if element is None: + raise RuntimeError( + f"core/format.py: {class_name}.{field_name} is not a list annotation " + f"({annotation.strip()[:80]!r}); {union.alias} assumes it is" + ) + aliases = _module_union_aliases(ast.parse(content)) + # Parenthesized: a generated union spans lines, and the continuation lines + # are indented, which bare ``ast.parse`` reads as an IndentationError. + carried = _union_class_names( + ast.parse(f"({element.group(1).strip()})", mode="eval").body, aliases + ) + if carried != expected: + raise RuntimeError( + f"core/format.py: {class_name}.{field_name} and {union.alias} disagree, so " + "the field would not be opened over the classes the union covers. " + f"Carried but not an arm of the union: {sorted(carried - expected)}. " + f"An arm of the union but not carried: {sorted(expected - carried)}. " + f"({len(carried)} carried, {len(expected)} derived.)" + ) + opened = annotation[: element.start(1)] + union.alias + annotation[element.end(1) :] + return content[:start] + opened + content[end:] + + +def _ensure_module_import(content: str, statement: str) -> str: + """Write ``statement`` ahead of the module's first class, once. + + The generated modules carry several import blocks, written by codegen and by + the passes above, so there is no single block to extend. Ahead of the first + class is after all of them and before any use. + """ + if statement in content: + return content + source, replaced = re.subn( + r"^class ", f"{statement}\n\n\nclass ", content, count=1, flags=re.MULTILINE + ) + if replaced != 1: + raise RuntimeError(f"no class to place {statement!r} ahead of") + return source + + +def emit_open_asset_unions() -> None: + """Write each :data:`_OPEN_ASSET_UNIONS` definition into ``core/format.py``. + + Raises when the module, a base, or an anchor class is missing: an asset union + that silently failed to open would turn every unrecognised asset type into a + whole-response validation failure, which is the thing it exists to prevent. + """ + print("Emitting the open asset unions into core/format.py...") + + path = OUTPUT_DIR / "core" / "format.py" + if not path.exists(): + raise RuntimeError("core/format.py does not exist; the asset unions have no home") + content = path.read_text() + for union in _OPEN_ASSET_UNIONS: + tree = ast.parse(content) + group_class = _repeatable_group_class(tree) + arms = _asset_union_arms(tree, union.base) + # The carrying class is also where the alias is emitted: the name has to + # exist before pydantic builds that class. + class_name = union.carrier or group_class + anchor = re.compile(rf"^class {re.escape(class_name)}\b", re.MULTILINE) + match = anchor.search(content) + if match is None: + raise RuntimeError( + f"core/format.py: no 'class {class_name}' to emit {union.alias} ahead of" + ) + content = ( + content[: match.start()] + + _render_open_asset_union(union, arms, group_class) + + "\n\n\n" + + content[match.start() :] + ) + expected = {name for name, _literal in arms} + if union.carries_group_arm: + expected.add(group_class) + content = _carry_open_asset_union(content, union, class_name, expected) + print( + f" ✓ {union.alias}: {len(arms)} arm(s) plus the fallback, carried by " + f"{class_name}.{union.carried_field}" + ) + content = _ensure_module_import(content, "from pydantic import Discriminator, Tag") + path.write_text(content) + + def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( @@ -7701,6 +8063,10 @@ def main(argv: list[str] | None = None) -> int: point_integer_fields_at_the_schema_integer_type, remove_imports_shadowed_by_a_local_class, remove_unused_pydantic_field_imports, + # After the import-cleanup passes, which would otherwise read the + # import this one adds as unused -- nothing references the name at the + # moment they run. + emit_open_asset_unions, # After every pass that can add, rename or remove a class: the # ``__all__`` it writes has to name the final set. declare_root_schema_module_exports, diff --git a/scripts/post_generation_manifest.json b/scripts/post_generation_manifest.json index 458da9d5e..008136d54 100644 --- a/scripts/post_generation_manifest.json +++ b/scripts/post_generation_manifest.json @@ -73,6 +73,7 @@ "point_integer_fields_at_the_schema_integer_type": true, "remove_imports_shadowed_by_a_local_class": true, "remove_unused_pydantic_field_imports": true, + "emit_open_asset_unions": true, "declare_root_schema_module_exports": true, "strip_extra_blank_lines_at_eof": true } diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 71232446f..98b67e3f6 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -1164,7 +1164,7 @@ def __getattr__(name: str) -> object: generated layer) resolve through the import system rather than ``_eager``. That matters for correctness, not just speed: ``aliases`` imports ``_generated`` from this package, and routing that through ``_eager`` would - re-enter the eager body (``_forward_compat`` → ``aliases``) and deadlock on + re-enter the eager body (which imports ``aliases`` itself) and deadlock on a circular import. Passing submodules straight to the import system avoids it. Also keeps the removed ``GeoPostalArea`` name importable while emitting a @@ -1184,7 +1184,7 @@ def __getattr__(name: str) -> object: # Submodule passthrough — the curated partial surfaces, ``aliases``, and # the internal generated layer are real submodules; import them directly # so they never round-trip through the eager body (which would re-enter - # on the ``_forward_compat`` → ``aliases`` cycle). ``_PARTIAL_MODULES`` is + # on the ``_eager`` → ``aliases`` cycle). ``_PARTIAL_MODULES`` is # the fast path for the curated set; ``find_spec`` covers the rest. if name in _PARTIAL_MODULES or ( not name.startswith("__") and importlib.util.find_spec(f"adcp.types.{name}") is not None diff --git a/src/adcp/types/_eager.py b/src/adcp/types/_eager.py index fdd27d206..9e854ca10 100644 --- a/src/adcp/types/_eager.py +++ b/src/adcp/types/_eager.py @@ -2,7 +2,7 @@ This module binds every public name exported by :mod:`adcp.types` and runs the import-time Pydantic patching (``_ergonomic`` coercion + -``_forward_compat`` open-union ``Format.assets``). It is imported on demand +``_forward_compat``'s readback and capability patches). It is imported on demand by :mod:`adcp.types`'s lazy ``__getattr__`` the first time any type symbol is accessed, so ``import adcp.types`` itself stays cheap. @@ -18,7 +18,10 @@ # Run the import-time patchers as a side effect, before binding the generated # types below (must come first — see the original "must be imported before # other types" note): ``_ergonomic`` applies BeforeValidator coercion and -# ``_forward_compat`` opens ``Format.assets`` / ``RepeatableAssetGroup.assets``. +# ``_forward_compat`` threads the readback nodes and the capability clones. The +# asset slots are no longer among them — ``Format.assets`` and the repeatable +# group's ``assets`` carry their open union by generation, so no import order +# decides whether a novel asset_type parses. # Imported via ``import_module`` — a call, not a name binding — so they read as # the intentional side-effect imports they are rather than tripping # unused-import analysers. (The removed ``format_category`` submodule has a real diff --git a/src/adcp/types/_forward_compat.py b/src/adcp/types/_forward_compat.py index 47bd1f8c3..f9d4837b7 100644 --- a/src/adcp/types/_forward_compat.py +++ b/src/adcp/types/_forward_compat.py @@ -1,9 +1,15 @@ """Forward-compatibility and generated-model composition patches. -Patches Format.assets and RepeatableAssetGroup.assets at import -time so responses containing novel asset_type values (e.g., 'pixel_tracker') -parse as UnknownFormatAsset / UnknownGroupAsset instead of raising a cascade -of ValidationErrors that zero out the entire list_creative_formats response. +The two asset slots are declared where their arms are. ``Format.assets`` and the +repeatable group's ``assets`` carry their open union BY GENERATION: +``_OPEN_ASSET_UNIONS`` in scripts/post_generate_fixes.py emits the fallback +class, the derived ``asset_type`` vocabulary, the callable discriminator and the +tagged union into core/format.py, beside the classes they route to. This module +used to rewrite both annotations at import time instead, over a vocabulary +written down by hand in aliases.py — and a hand-written vocabulary beside a +generated union is what fell 7 and 9 arms behind the schema, degrading declared +asset types to the fallback. A patch reaches the class it names; a generated +annotation reaches every class that nests it, and needs nothing kept in sync. This module is intentionally NOT auto-generated. It lives outside the generated tree and is preserved across codegen runs (generate_types.py only @@ -17,10 +23,9 @@ while accepting beta.14 TargetingOverlay instances as a compatibility bridge. Import order: comes after _ergonomic in types/__init__.py (alphabetical by -underscore-preserved sort). Importing this module directly triggers import of -adcp.types.aliases as a side effect (via ``from adcp.types.aliases import …``) -so there is no circular-import risk and no strict ordering requirement against -aliases in __init__.py. +underscore-preserved sort). This module no longer imports adcp.types.aliases at +all — the asset patch was its only reason to — so it has no ordering +requirement against aliases and takes no part in a cycle through it. Import layering: this module imports directly from the generated tree (like aliases.py and _ergonomic.py) because it must patch the generated classes @@ -53,7 +58,6 @@ from pydantic_core import CoreSchema, InitErrorDetails, PydanticCustomError, core_schema from pydantic_core.core_schema import ErrorType -from adcp.types.aliases import FormatAssetUnion, GroupFormatAssetUnion, RepeatableAssetGroup from adcp.types.base import AdCPBaseModel from adcp.types.canonical_creative import CreateMediaBuyRequest as _PublicCreateMediaBuyRequest from adcp.types.canonical_creative import PackageRequest as PublicPackageRequest @@ -77,7 +81,6 @@ from adcp.types.domains.core.canonical_product import PublisherDomain from adcp.types.domains.core.creative_manifest import CreativeManifest from adcp.types.domains.core.creative_variant import CreativeVariant -from adcp.types.domains.core.format import Format from adcp.types.domains.core.mcp_webhook_payload import McpWebhookPayload from adcp.types.domains.core.media_buy_features import MediaBuyFeatures from adcp.types.domains.core.reporting_webhook import ( @@ -429,7 +432,7 @@ def _patch_targeting_overlay(model: type[BaseModel]) -> None: def _apply_forward_compat() -> None: - """Apply open-union, capability, and public-model compatibility patches.""" + """Apply readback, capability, and public-model compatibility patches.""" for model in ( PackageRequest, PackageUpdate, @@ -506,12 +509,6 @@ def _apply_forward_compat() -> None: # completed task callbacks retain typed manifests. McpWebhookPayload.model_rebuild(force=True) - _patch_model_field(Format, "assets", list[FormatAssetUnion] | None) - Format.model_rebuild(force=True) - - _patch_model_field(RepeatableAssetGroup, "assets", list[GroupFormatAssetUnion]) - cast(type[BaseModel], RepeatableAssetGroup).model_rebuild(force=True) - # The 3.1 canonical-creatives capability was published after the bundled # generated model. Preserve it across code generation until the schema # bundle catches up; negotiation must not silently discard this evidence. diff --git a/src/adcp/types/_generated.py b/src/adcp/types/_generated.py index 604ae5731..ef00d1721 100644 --- a/src/adcp/types/_generated.py +++ b/src/adcp/types/_generated.py @@ -14,7 +14,7 @@ DO NOT EDIT MANUALLY. Generated from: https://github.com/adcontextprotocol/adcp/tree/main/schemas -Generation date: 2026-10-11 15:56:03 UTC +Generation date: 2026-10-19 19:53:59 UTC """ # ruff: noqa: E501, I001 @@ -1344,12 +1344,16 @@ Dimensions1, DisclosureCapability, Format, + FormatAssetUnion, FormatCard, FormatCardDetailed, + GroupFormatAssetUnion, Renders, Renders1, Responsive, SelectionMode, + UnknownFormatAsset, + UnknownGroupAsset, ) from adcp.types.domains.core.format_id import FormatReferenceStructuredObject from adcp.types.domains.core.format_option_ref import ( @@ -4859,6 +4863,7 @@ "ForecastVendorMetricValue", "ForecastableMetric", "Format", + "FormatAssetUnion", "FormatCard", "FormatCardDetailed", "FormatId", @@ -4995,6 +5000,7 @@ "GovernedAction", "GradingProfile", "GrantStatus", + "GroupFormatAssetUnion", "Gtin", "Handoff", "HdrFormat", @@ -6449,6 +6455,8 @@ "UnavailableReason", "Unit", "UniversalMacro", + "UnknownFormatAsset", + "UnknownGroupAsset", "UnknownHandling", "UnmatchedLocaleAction", "UnmetRequirement", diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index 276f99e93..e9b0ca238 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -36,7 +36,7 @@ from typing import Annotated as _Annotated from typing import Any, Literal, TypeAlias -from pydantic import BeforeValidator, ConfigDict, Discriminator, Tag +from pydantic import BeforeValidator, Discriminator from adcp.types import _generated as _g from adcp.types._generated import ( @@ -1902,149 +1902,27 @@ def _repeatable_asset_group_class() -> type: # via UnknownFormatAsset / UnknownGroupAsset fallback arms. # # The callable Discriminator + Tag pattern is the Pydantic v2 ≥2.5 canonical -# approach for open discriminated unions. _apply_forward_compat() in -# _forward_compat.py patches Format.assets and Assets94.assets with these -# types at import time using model_rebuild(force=True). - -_KNOWN_INDIVIDUAL_ASSET_TYPES: frozenset[str] = frozenset( - { - "image", - "video", - "audio", - "text", - "markdown", - "html", - "css", - "javascript", - "vast", - "daast", - "url", - "webhook", - "brief", - "catalog", - } -) - -_KNOWN_GROUP_ASSET_TYPES: frozenset[str] = frozenset( - { - "image", - "video", - "audio", - "text", - "markdown", - "html", - "css", - "javascript", - "vast", - "daast", - "url", - "webhook", - } -) - - -def _format_asset_discriminator(v: Any) -> str: - """Route to the correct Tag for Format.assets callable Discriminator.""" - if isinstance(v, dict): - item_type: str = v.get("item_type", "individual") - asset_type: str = v.get("asset_type", "") - else: - item_type = getattr(v, "item_type", "individual") - asset_type = getattr(v, "asset_type", "") - if item_type == "repeatable_group": - return "repeatable_group" - return asset_type if asset_type in _KNOWN_INDIVIDUAL_ASSET_TYPES else "_unknown" - - -def _group_asset_discriminator(v: Any) -> str: - """Route to the correct Tag for Assets94.assets callable Discriminator.""" - if isinstance(v, dict): - asset_type: str = v.get("asset_type", "") - else: - asset_type = getattr(v, "asset_type", "") - return asset_type if asset_type in _KNOWN_GROUP_ASSET_TYPES else "_unknown" - - -class UnknownFormatAsset(_BaseIndividualAsset): - """Fallback arm for individual asset_type values not in the SDK's known set. - - When the AdCP protocol adds a new asset_type before the SDK is updated, - responses containing that type parse successfully as UnknownFormatAsset - instead of raising ValidationError for the entire list_creative_formats - response. Structural fields (asset_id, required) are still validated; - type-specific fields are preserved in __pydantic_extra__. - - Access extra wire fields via ``asset.__pydantic_extra__ or {}``. - - This type is read-path only. Do not use it in creative manifests or - emit-side requests — the request path keeps strict Literal validation. - """ - - # extra='allow' is intentionally hardcoded, not inherited from the - # ADCP_STRICT_VALIDATION env-var policy on AdCPBaseModel. The whole - # purpose of this fallback arm is to preserve unknown fields from the wire - # rather than drop or reject them — both behaviors defeat the goal. - model_config = ConfigDict(extra="allow") - asset_type: str - - -class UnknownGroupAsset(_BaseGroupAsset): - """Fallback arm for group asset_type values not in the SDK's known set. - - Same forward-compat guarantee as UnknownFormatAsset but for assets nested - inside a RepeatableAssetGroup (Assets94.assets). Access extra wire fields - via ``asset.__pydantic_extra__ or {}``. - """ - - model_config = ConfigDict(extra="allow") - asset_type: str - - -FormatAssetUnion = _Annotated[ - _Annotated[_ImageFormatAssetInternal, Tag("image")] - | _Annotated[_VideoFormatAssetInternal, Tag("video")] - | _Annotated[_AudioFormatAssetInternal, Tag("audio")] - | _Annotated[_TextFormatAssetInternal, Tag("text")] - | _Annotated[_MarkdownFormatAssetInternal, Tag("markdown")] - | _Annotated[_HtmlFormatAssetInternal, Tag("html")] - | _Annotated[_CssFormatAssetInternal, Tag("css")] - | _Annotated[_JavascriptFormatAssetInternal, Tag("javascript")] - | _Annotated[_VastFormatAssetInternal, Tag("vast")] - | _Annotated[_DaastFormatAssetInternal, Tag("daast")] - | _Annotated[_UrlFormatAssetInternal, Tag("url")] - | _Annotated[_WebhookFormatAssetInternal, Tag("webhook")] - | _Annotated[_BriefFormatAssetInternal, Tag("brief")] - | _Annotated[_CatalogFormatAssetInternal, Tag("catalog")] - | _Annotated[_RepeatableAssetGroupInternal, Tag("repeatable_group")] - | _Annotated[UnknownFormatAsset, Tag("_unknown")], - Discriminator(_format_asset_discriminator), -] -"""Open discriminated union for Format.assets. - -Replaces the generated closed union to add UnknownFormatAsset as a fallback -arm. Applied to Format.assets via _forward_compat._apply_forward_compat(). -""" - -GroupFormatAssetUnion = _Annotated[ - _Annotated[_ImageFormatGroupAssetInternal, Tag("image")] - | _Annotated[_VideoFormatGroupAssetInternal, Tag("video")] - | _Annotated[_AudioFormatGroupAssetInternal, Tag("audio")] - | _Annotated[_TextFormatGroupAssetInternal, Tag("text")] - | _Annotated[_MarkdownFormatGroupAssetInternal, Tag("markdown")] - | _Annotated[_HtmlFormatGroupAssetInternal, Tag("html")] - | _Annotated[_CssFormatGroupAssetInternal, Tag("css")] - | _Annotated[_JavascriptFormatGroupAssetInternal, Tag("javascript")] - | _Annotated[_VastFormatGroupAssetInternal, Tag("vast")] - | _Annotated[_DaastFormatGroupAssetInternal, Tag("daast")] - | _Annotated[_UrlFormatGroupAssetInternal, Tag("url")] - | _Annotated[_WebhookFormatGroupAssetInternal, Tag("webhook")] - | _Annotated[UnknownGroupAsset, Tag("_unknown")], - Discriminator(_group_asset_discriminator), -] -"""Open discriminated union for Assets94.assets (RepeatableAssetGroup slots). - -Applied to Assets94.assets via _forward_compat._apply_forward_compat(). -""" +# approach for open discriminated unions. Both unions are GENERATED into +# core/format.py by _OPEN_ASSET_UNIONS in scripts/post_generate_fixes.py, and +# Format.assets and the repeatable group's assets carry them by generation, so +# the four names below are re-exports rather than definitions. +# +# The vocabulary is why that matters. It used to be two hand-written frozensets +# here — 14 individual and 12 group members — beside a generated union of 21 and +# 21, and the callable discriminator reads the frozenset BEFORE pydantic reaches +# the arms. So seven individual asset types the pinned schema declares (zip, +# display_tag, published_post, card, pixel_tracker, vast_tracker, daast_tracker) +# and nine group types parsed as the unknown arm, with their generated classes +# sitting unused. The generator knows every arm's asset_type literal because it +# emitted that literal, so there is nothing left here to declare and nothing to +# go stale. + +from adcp.types.domains.core.format import ( + FormatAssetUnion, + GroupFormatAssetUnion, + UnknownFormatAsset, + UnknownGroupAsset, +) # ============================================================================ # CROSS-MODULE NAME COLLISION ALIASES (#911, Step 2) @@ -2698,7 +2576,9 @@ class UnknownGroupAsset(_BaseGroupAsset): # Forward-compat fallback arms (novel asset_type values parse as these) "UnknownFormatAsset", "UnknownGroupAsset", - # Open union types (Format.assets / Assets94.assets after _forward_compat patch) + # Open union types, generated into core/format.py and carried by + # Format.assets / the repeatable group's assets by generation + # (_OPEN_ASSET_UNIONS in scripts/post_generate_fixes.py) "FormatAssetUnion", "GroupFormatAssetUnion", "ImageFormatAsset", diff --git a/src/adcp/types/domains/core/__init__.py b/src/adcp/types/domains/core/__init__.py index d9ec4a083..60f8a9c31 100644 --- a/src/adcp/types/domains/core/__init__.py +++ b/src/adcp/types/domains/core/__init__.py @@ -10,7 +10,7 @@ Nothing here is renamed. Auto-generated from the generated domain tree. DO NOT EDIT MANUALLY. -Generation date: 2026-10-11 15:56:10 UTC +Generation date: 2026-10-19 19:54:10 UTC """ # ruff: noqa: E501, I001 @@ -763,11 +763,15 @@ Dimensions, Dimensions1, DisclosureCapability, + FormatAssetUnion, FormatCard, FormatCardDetailed, + GroupFormatAssetUnion, Renders, Renders1, Responsive, + UnknownFormatAsset, + UnknownGroupAsset, ) from adcp.types.domains.core.format_id import FormatReferenceStructuredObject from adcp.types.domains.core.format_option_ref import ( @@ -2272,6 +2276,7 @@ "ForecastRange", "ForecastRateRange", "ForecastVendorMetricValue", + "FormatAssetUnion", "FormatCard", "FormatCardDetailed", "FormatKind", @@ -2326,6 +2331,7 @@ "GovernanceAgent", "GradingProfile", "GrantStatus", + "GroupFormatAssetUnion", "HistoryItem", "HotelItem", "HtmlAssetRequirements", @@ -3101,6 +3107,8 @@ "Trust", "UnavailableReason", "UniversalMacro", + "UnknownFormatAsset", + "UnknownGroupAsset", "UnknownHandling", "UpdateFrequency", "UpdateMediaBuyInputRequired", diff --git a/src/adcp/types/domains/core/format.py b/src/adcp/types/domains/core/format.py index b2132f820..67e3670fc 100644 --- a/src/adcp/types/domains/core/format.py +++ b/src/adcp/types/domains/core/format.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/format.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-13T11:29:35+00:00 from __future__ import annotations @@ -59,6 +59,9 @@ ) +from pydantic import Discriminator, Tag + + class Responsive(AdCPBaseModel): width: StrictBool height: StrictBool @@ -532,6 +535,98 @@ class Assets45(BaseGroupAsset): ] +class UnknownGroupAsset(BaseGroupAsset): + """Fallback arm for an ``asset_type`` outside this pin's vocabulary. + + A response carrying an asset type defined after this SDK's pin parses as + this class instead of failing, which would zero out every format in the + response. The structural fields are still validated; the type-specific ones + are kept in ``__pydantic_extra__``. Read them via + ``asset.__pydantic_extra__ or {}``. + + ``extra='allow'`` is deliberate and not inherited from the strict-validation + policy on ``AdCPBaseModel``: dropping or refusing the unknown fields defeats + the whole purpose of the arm. Read path only -- a request keeps its closed + ``Literal`` arms. + """ + + model_config = ConfigDict(extra='allow') + asset_type: str + + +#: The ``asset_type`` values this pin has a class for. Derived from the arms +#: above, so a schema that adds a type adds its class AND its tag together. +_KNOWN_BASE_GROUP_ASSET_TYPES: frozenset[str] = frozenset( + { + 'image', + 'video', + 'audio', + 'text', + 'markdown', + 'html', + 'css', + 'javascript', + 'zip', + 'vast', + 'daast', + 'url', + 'webhook', + 'brief', + 'display_tag', + 'published_post', + 'card', + 'pixel_tracker', + 'vast_tracker', + 'daast_tracker', + 'catalog', + } +) + + +def _group_asset_discriminator(value: Any) -> str: + """Route a wire asset to its ``Tag``, or to the fallback.""" + asset_type = ( + value.get('asset_type', '') + if isinstance(value, dict) + else getattr(value, 'asset_type', '') + ) + if isinstance(asset_type, str) and asset_type in _KNOWN_BASE_GROUP_ASSET_TYPES: + return asset_type + return '_unknown' + + +GroupFormatAssetUnion = Annotated[ + Annotated[Assets31, Tag('image')] + | Annotated[Assets32, Tag('video')] + | Annotated[Assets33, Tag('audio')] + | Annotated[Assets34, Tag('text')] + | Annotated[Assets35, Tag('markdown')] + | Annotated[Assets36, Tag('html')] + | Annotated[Assets37, Tag('css')] + | Annotated[Assets38, Tag('javascript')] + | Annotated[Assets39, Tag('zip')] + | Annotated[Assets40, Tag('vast')] + | Annotated[Assets41, Tag('daast')] + | Annotated[Assets42, Tag('url')] + | Annotated[Assets43, Tag('webhook')] + | Annotated[Assets44, Tag('brief')] + | Annotated[Assets46, Tag('display_tag')] + | Annotated[Assets47, Tag('published_post')] + | Annotated[Assets48, Tag('card')] + | Annotated[Assets49, Tag('pixel_tracker')] + | Annotated[Assets50, Tag('vast_tracker')] + | Annotated[Assets51, Tag('daast_tracker')] + | Annotated[Assets45, Tag('catalog')] + | Annotated[UnknownGroupAsset, Tag('_unknown')], + Discriminator(_group_asset_discriminator), +] +"""Open discriminated union for the slots inside a repeatable asset group. + +The generated union closes over the group asset classes; this adds the +fallback arm an unrecognised ``asset_type`` needs. +""" + + class Assets29(AdCPBaseModel): item_type: Annotated[ Literal['repeatable_group'], @@ -563,7 +658,7 @@ class Assets29(AdCPBaseModel): ), ] = SelectionMode.sequential assets: Annotated[ - list[Assets30], Field(description='Assets within each repetition of this group') + list[GroupFormatAssetUnion], Field(description='Assets within each repetition of this group') ] @@ -2253,6 +2348,107 @@ def __getattr__(self, name: str) -> Any: +class UnknownFormatAsset(BaseIndividualAsset): + """Fallback arm for an ``asset_type`` outside this pin's vocabulary. + + A response carrying an asset type defined after this SDK's pin parses as + this class instead of failing, which would zero out every format in the + response. The structural fields are still validated; the type-specific ones + are kept in ``__pydantic_extra__``. Read them via + ``asset.__pydantic_extra__ or {}``. + + ``extra='allow'`` is deliberate and not inherited from the strict-validation + policy on ``AdCPBaseModel``: dropping or refusing the unknown fields defeats + the whole purpose of the arm. Read path only -- a request keeps its closed + ``Literal`` arms. + """ + + model_config = ConfigDict(extra='allow') + asset_type: str + + +#: The ``asset_type`` values this pin has a class for. Derived from the arms +#: above, so a schema that adds a type adds its class AND its tag together. +_KNOWN_BASE_INDIVIDUAL_ASSET_TYPES: frozenset[str] = frozenset( + { + 'image', + 'video', + 'audio', + 'text', + 'markdown', + 'html', + 'css', + 'javascript', + 'zip', + 'vast', + 'daast', + 'url', + 'webhook', + 'brief', + 'display_tag', + 'published_post', + 'card', + 'pixel_tracker', + 'vast_tracker', + 'daast_tracker', + 'catalog', + } +) + + +def _format_asset_discriminator(value: Any) -> str: + """Route a wire asset to its ``Tag``, or to the fallback.""" + item_type = ( + value.get('item_type', 'individual') + if isinstance(value, dict) + else getattr(value, 'item_type', 'individual') + ) + if item_type == 'repeatable_group': + return 'repeatable_group' + asset_type = ( + value.get('asset_type', '') + if isinstance(value, dict) + else getattr(value, 'asset_type', '') + ) + if isinstance(asset_type, str) and asset_type in _KNOWN_BASE_INDIVIDUAL_ASSET_TYPES: + return asset_type + return '_unknown' + + +FormatAssetUnion = Annotated[ + Annotated[Assets, Tag('image')] + | Annotated[Assets9, Tag('video')] + | Annotated[Assets10, Tag('audio')] + | Annotated[Assets11, Tag('text')] + | Annotated[Assets12, Tag('markdown')] + | Annotated[Assets13, Tag('html')] + | Annotated[Assets14, Tag('css')] + | Annotated[Assets15, Tag('javascript')] + | Annotated[Assets16, Tag('zip')] + | Annotated[Assets17, Tag('vast')] + | Annotated[Assets18, Tag('daast')] + | Annotated[Assets19, Tag('url')] + | Annotated[Assets20, Tag('webhook')] + | Annotated[Assets21, Tag('brief')] + | Annotated[Assets23, Tag('display_tag')] + | Annotated[Assets24, Tag('published_post')] + | Annotated[Assets25, Tag('card')] + | Annotated[Assets26, Tag('pixel_tracker')] + | Annotated[Assets27, Tag('vast_tracker')] + | Annotated[Assets28, Tag('daast_tracker')] + | Annotated[Assets22, Tag('catalog')] + | Annotated[Assets29, Tag('repeatable_group')] + | Annotated[UnknownFormatAsset, Tag('_unknown')], + Discriminator(_format_asset_discriminator), +] +"""Open discriminated union for ``Format.assets``. + +The generated union closes over the individual asset classes plus the +repeatable group; this adds the fallback arm an unrecognised +``asset_type`` needs. +""" + + class Format(AdCPBaseModel): model_config = ConfigDict( extra='allow', @@ -2290,30 +2486,7 @@ class Format(AdCPBaseModel): ), ] = None assets: Annotated[ - list[ - Assets - | Assets9 - | Assets10 - | Assets11 - | Assets12 - | Assets13 - | Assets14 - | Assets15 - | Assets16 - | Assets17 - | Assets18 - | Assets19 - | Assets20 - | Assets21 - | Assets22 - | Assets23 - | Assets24 - | Assets25 - | Assets26 - | Assets27 - | Assets28 - | Assets29 - ] + list[FormatAssetUnion] | None, Field( description="Array of all assets supported for this format. Each asset is identified by its asset_id, which must be used as the key in creative manifests. Use the 'required' boolean on each asset to indicate whether it's mandatory." diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 584513a88..02cbf506e 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -1033,7 +1033,7 @@ "ForecastRangeUnit": "enums.forecast_range_unit", "ForecastableMetric": "enums.forecastable_metric", "Format": "adcp.types.canonical_creative", - "FormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[adcp.types.aliases:UnknownFormatAsset,instance:pydantic.types:Tag],union[core.format:Assets,instance:pydantic.types:Tag],union[core.format:Assets10,instance:pydantic.types:Tag],union[core.format:Assets11,instance:pydantic.types:Tag],union[core.format:Assets12,instance:pydantic.types:Tag],union[core.format:Assets13,instance:pydantic.types:Tag],union[core.format:Assets14,instance:pydantic.types:Tag],union[core.format:Assets15,instance:pydantic.types:Tag],union[core.format:Assets17,instance:pydantic.types:Tag],union[core.format:Assets18,instance:pydantic.types:Tag],union[core.format:Assets19,instance:pydantic.types:Tag],union[core.format:Assets20,instance:pydantic.types:Tag],union[core.format:Assets21,instance:pydantic.types:Tag],union[core.format:Assets22,instance:pydantic.types:Tag],union[core.format:Assets29,instance:pydantic.types:Tag],union[core.format:Assets9,instance:pydantic.types:Tag]]]", + "FormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets,instance:pydantic.types:Tag],union[core.format:Assets10,instance:pydantic.types:Tag],union[core.format:Assets11,instance:pydantic.types:Tag],union[core.format:Assets12,instance:pydantic.types:Tag],union[core.format:Assets13,instance:pydantic.types:Tag],union[core.format:Assets14,instance:pydantic.types:Tag],union[core.format:Assets15,instance:pydantic.types:Tag],union[core.format:Assets16,instance:pydantic.types:Tag],union[core.format:Assets17,instance:pydantic.types:Tag],union[core.format:Assets18,instance:pydantic.types:Tag],union[core.format:Assets19,instance:pydantic.types:Tag],union[core.format:Assets20,instance:pydantic.types:Tag],union[core.format:Assets21,instance:pydantic.types:Tag],union[core.format:Assets22,instance:pydantic.types:Tag],union[core.format:Assets23,instance:pydantic.types:Tag],union[core.format:Assets24,instance:pydantic.types:Tag],union[core.format:Assets25,instance:pydantic.types:Tag],union[core.format:Assets26,instance:pydantic.types:Tag],union[core.format:Assets27,instance:pydantic.types:Tag],union[core.format:Assets28,instance:pydantic.types:Tag],union[core.format:Assets29,instance:pydantic.types:Tag],union[core.format:Assets9,instance:pydantic.types:Tag],union[core.format:UnknownFormatAsset,instance:pydantic.types:Tag]]]", "FormatCard": "core.format", "FormatCardDetailed": "core.format", "FormatIdParameter": "enums.format_id_parameter", @@ -1122,7 +1122,7 @@ "GovernanceAgent": "account.sync_governance_request", "GovernanceAgentNotAcceptedDetails": "union[instance:pydantic.fields:FieldInfo,union[error_details.governance_agent_not_accepted:GovernanceAgentNotAcceptedDetails1,error_details.governance_agent_not_accepted:GovernanceAgentNotAcceptedDetails2]]", "GovernanceAuthentication": "account.sync_governance_request:Authentication", - "GroupFormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[adcp.types.aliases:UnknownGroupAsset,instance:pydantic.types:Tag],union[core.format:Assets31,instance:pydantic.types:Tag],union[core.format:Assets32,instance:pydantic.types:Tag],union[core.format:Assets33,instance:pydantic.types:Tag],union[core.format:Assets34,instance:pydantic.types:Tag],union[core.format:Assets35,instance:pydantic.types:Tag],union[core.format:Assets36,instance:pydantic.types:Tag],union[core.format:Assets37,instance:pydantic.types:Tag],union[core.format:Assets38,instance:pydantic.types:Tag],union[core.format:Assets40,instance:pydantic.types:Tag],union[core.format:Assets41,instance:pydantic.types:Tag],union[core.format:Assets42,instance:pydantic.types:Tag],union[core.format:Assets43,instance:pydantic.types:Tag]]]", + "GroupFormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets31,instance:pydantic.types:Tag],union[core.format:Assets32,instance:pydantic.types:Tag],union[core.format:Assets33,instance:pydantic.types:Tag],union[core.format:Assets34,instance:pydantic.types:Tag],union[core.format:Assets35,instance:pydantic.types:Tag],union[core.format:Assets36,instance:pydantic.types:Tag],union[core.format:Assets37,instance:pydantic.types:Tag],union[core.format:Assets38,instance:pydantic.types:Tag],union[core.format:Assets39,instance:pydantic.types:Tag],union[core.format:Assets40,instance:pydantic.types:Tag],union[core.format:Assets41,instance:pydantic.types:Tag],union[core.format:Assets42,instance:pydantic.types:Tag],union[core.format:Assets43,instance:pydantic.types:Tag],union[core.format:Assets44,instance:pydantic.types:Tag],union[core.format:Assets45,instance:pydantic.types:Tag],union[core.format:Assets46,instance:pydantic.types:Tag],union[core.format:Assets47,instance:pydantic.types:Tag],union[core.format:Assets48,instance:pydantic.types:Tag],union[core.format:Assets49,instance:pydantic.types:Tag],union[core.format:Assets50,instance:pydantic.types:Tag],union[core.format:Assets51,instance:pydantic.types:Tag],union[core.format:UnknownGroupAsset,instance:pydantic.types:Tag]]]", "Gtin": "core.catalog", "HtmlContent": "core.assets.html_asset:HtmlAsset", "HtmlFormatAsset": "core.format:Assets13", @@ -1580,8 +1580,8 @@ "Transform": "core.catalog_field_mapping", "TrustedMatch": "core.product", "Unit": "enums.dimension_unit:DimensionUnit", - "UnknownFormatAsset": "adcp.types.aliases", - "UnknownGroupAsset": "adcp.types.aliases", + "UnknownFormatAsset": "core.format", + "UnknownGroupAsset": "core.format", "UnsupportedRefinementDimensionDetails": "error_details.unsupported_refinement_dimension", "UpdateCollectionListRequest": "collection.update_collection_list_request", "UpdateCollectionListResponse": "collection.update_collection_list_response", @@ -2849,6 +2849,7 @@ "ForecastVendorMetricValue": "core.forecast_vendor_metric_value", "ForecastableMetric": "enums.forecastable_metric", "Format": "core.format", + "FormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets,instance:pydantic.types:Tag],union[core.format:Assets10,instance:pydantic.types:Tag],union[core.format:Assets11,instance:pydantic.types:Tag],union[core.format:Assets12,instance:pydantic.types:Tag],union[core.format:Assets13,instance:pydantic.types:Tag],union[core.format:Assets14,instance:pydantic.types:Tag],union[core.format:Assets15,instance:pydantic.types:Tag],union[core.format:Assets16,instance:pydantic.types:Tag],union[core.format:Assets17,instance:pydantic.types:Tag],union[core.format:Assets18,instance:pydantic.types:Tag],union[core.format:Assets19,instance:pydantic.types:Tag],union[core.format:Assets20,instance:pydantic.types:Tag],union[core.format:Assets21,instance:pydantic.types:Tag],union[core.format:Assets22,instance:pydantic.types:Tag],union[core.format:Assets23,instance:pydantic.types:Tag],union[core.format:Assets24,instance:pydantic.types:Tag],union[core.format:Assets25,instance:pydantic.types:Tag],union[core.format:Assets26,instance:pydantic.types:Tag],union[core.format:Assets27,instance:pydantic.types:Tag],union[core.format:Assets28,instance:pydantic.types:Tag],union[core.format:Assets29,instance:pydantic.types:Tag],union[core.format:Assets9,instance:pydantic.types:Tag],union[core.format:UnknownFormatAsset,instance:pydantic.types:Tag]]]", "FormatCard": "core.format", "FormatCardDetailed": "core.format", "FormatId": "media_buy.acceptance_policy_requirement", @@ -2985,6 +2986,7 @@ "GovernedAction": "governance.get_plan_audit_logs_response", "GradingProfile": "core.registry_event", "GrantStatus": "core.rights_constraint", + "GroupFormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets31,instance:pydantic.types:Tag],union[core.format:Assets32,instance:pydantic.types:Tag],union[core.format:Assets33,instance:pydantic.types:Tag],union[core.format:Assets34,instance:pydantic.types:Tag],union[core.format:Assets35,instance:pydantic.types:Tag],union[core.format:Assets36,instance:pydantic.types:Tag],union[core.format:Assets37,instance:pydantic.types:Tag],union[core.format:Assets38,instance:pydantic.types:Tag],union[core.format:Assets39,instance:pydantic.types:Tag],union[core.format:Assets40,instance:pydantic.types:Tag],union[core.format:Assets41,instance:pydantic.types:Tag],union[core.format:Assets42,instance:pydantic.types:Tag],union[core.format:Assets43,instance:pydantic.types:Tag],union[core.format:Assets44,instance:pydantic.types:Tag],union[core.format:Assets45,instance:pydantic.types:Tag],union[core.format:Assets46,instance:pydantic.types:Tag],union[core.format:Assets47,instance:pydantic.types:Tag],union[core.format:Assets48,instance:pydantic.types:Tag],union[core.format:Assets49,instance:pydantic.types:Tag],union[core.format:Assets50,instance:pydantic.types:Tag],union[core.format:Assets51,instance:pydantic.types:Tag],union[core.format:UnknownGroupAsset,instance:pydantic.types:Tag]]]", "Gtin": "core.catalog", "Handoff": "sponsored_intelligence.si_send_message_response", "HdrFormat": "core.assets.video_asset", @@ -4439,6 +4441,8 @@ "UnavailableReason": "core.account_change", "Unit": "core.audience_evidence", "UniversalMacro": "enums.universal_macro", + "UnknownFormatAsset": "core.format", + "UnknownGroupAsset": "core.format", "UnknownHandling": "core.demographic_targeting_capability", "UnmatchedLocaleAction": "core.creative_localization", "UnmetRequirement": "error_details.execution_requirement_unmet", @@ -5731,6 +5735,7 @@ "ForecastRange": "core.forecast_range", "ForecastRateRange": "core.forecast_rate_range", "ForecastVendorMetricValue": "core.forecast_vendor_metric_value", + "FormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets,instance:pydantic.types:Tag],union[core.format:Assets10,instance:pydantic.types:Tag],union[core.format:Assets11,instance:pydantic.types:Tag],union[core.format:Assets12,instance:pydantic.types:Tag],union[core.format:Assets13,instance:pydantic.types:Tag],union[core.format:Assets14,instance:pydantic.types:Tag],union[core.format:Assets15,instance:pydantic.types:Tag],union[core.format:Assets16,instance:pydantic.types:Tag],union[core.format:Assets17,instance:pydantic.types:Tag],union[core.format:Assets18,instance:pydantic.types:Tag],union[core.format:Assets19,instance:pydantic.types:Tag],union[core.format:Assets20,instance:pydantic.types:Tag],union[core.format:Assets21,instance:pydantic.types:Tag],union[core.format:Assets22,instance:pydantic.types:Tag],union[core.format:Assets23,instance:pydantic.types:Tag],union[core.format:Assets24,instance:pydantic.types:Tag],union[core.format:Assets25,instance:pydantic.types:Tag],union[core.format:Assets26,instance:pydantic.types:Tag],union[core.format:Assets27,instance:pydantic.types:Tag],union[core.format:Assets28,instance:pydantic.types:Tag],union[core.format:Assets29,instance:pydantic.types:Tag],union[core.format:Assets9,instance:pydantic.types:Tag],union[core.format:UnknownFormatAsset,instance:pydantic.types:Tag]]]", "FormatCard": "core.format", "FormatCardDetailed": "core.format", "FormatKind": "core.canonical_format_option", @@ -5785,6 +5790,7 @@ "GovernanceAgent": "core.account", "GradingProfile": "core.registry_event", "GrantStatus": "core.rights_constraint", + "GroupFormatAssetUnion": "union[instance:pydantic.types:Discriminator,union[union[core.format:Assets31,instance:pydantic.types:Tag],union[core.format:Assets32,instance:pydantic.types:Tag],union[core.format:Assets33,instance:pydantic.types:Tag],union[core.format:Assets34,instance:pydantic.types:Tag],union[core.format:Assets35,instance:pydantic.types:Tag],union[core.format:Assets36,instance:pydantic.types:Tag],union[core.format:Assets37,instance:pydantic.types:Tag],union[core.format:Assets38,instance:pydantic.types:Tag],union[core.format:Assets39,instance:pydantic.types:Tag],union[core.format:Assets40,instance:pydantic.types:Tag],union[core.format:Assets41,instance:pydantic.types:Tag],union[core.format:Assets42,instance:pydantic.types:Tag],union[core.format:Assets43,instance:pydantic.types:Tag],union[core.format:Assets44,instance:pydantic.types:Tag],union[core.format:Assets45,instance:pydantic.types:Tag],union[core.format:Assets46,instance:pydantic.types:Tag],union[core.format:Assets47,instance:pydantic.types:Tag],union[core.format:Assets48,instance:pydantic.types:Tag],union[core.format:Assets49,instance:pydantic.types:Tag],union[core.format:Assets50,instance:pydantic.types:Tag],union[core.format:Assets51,instance:pydantic.types:Tag],union[core.format:UnknownGroupAsset,instance:pydantic.types:Tag]]]", "HistoryItem": "core.tasks_get_response", "HotelItem": "core.hotel_item", "HtmlAssetRequirements": "core.requirements.html_asset_requirements", @@ -6560,6 +6566,8 @@ "Trust": "core.brand_response_authorization_result", "UnavailableReason": "core.account_change", "UniversalMacro": "core.assets.asset_union", + "UnknownFormatAsset": "core.format", + "UnknownGroupAsset": "core.format", "UnknownHandling": "core.demographic_targeting_capability", "UpdateFrequency": "core.assets.asset_union", "UpdateMediaBuyInputRequired": "core.async_response_refs.media_buy.update_media_buy_async_response_input_required", diff --git a/tests/test_forward_compat_assets.py b/tests/test_forward_compat_assets.py index 320e7e3a0..2faa902ca 100644 --- a/tests/test_forward_compat_assets.py +++ b/tests/test_forward_compat_assets.py @@ -1,14 +1,24 @@ """Regression tests for issue #742: open-union forward compatibility. -Verifies that Format.assets and RepeatableAssetGroup.assets tolerate novel -asset_type values (e.g., 'pixel_tracker') without raising ValidationError, -and that known asset types still parse correctly with full type narrowing. +Verifies that Format.assets and RepeatableAssetGroup.assets tolerate an +asset_type this pin has no class for, without raising ValidationError, and that +every asset_type the pin DOES have a class for still parses as that class. + +The novel value is DERIVED, not written down. Four tests here used +``pixel_tracker`` as their stand-in novel type and asserted it parsed as +``UnknownFormatAsset``. ``pixel_tracker`` is declared by the pinned schema and +has had a generated class all along, so those assertions were encoding the +defect: the vocabulary the union routed by was a hand-written frozenset in +aliases.py that had fallen 7 behind the generated individual classes and 9 +behind the group ones, and the tests agreed with the frozenset instead of with +the schema. A hardcoded "novel" value can become declared by a schema refresh +without the test noticing, which is how these four came to be wrong. """ from __future__ import annotations import pytest -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from adcp.types import ( FormatAssetUnion, @@ -18,6 +28,7 @@ UnknownFormatAsset, UnknownGroupAsset, ) +from adcp.types.domains.core import format as format_module from adcp.types.domains.core.format import Assets94, Format @@ -25,6 +36,41 @@ def _make_format_id(name: str) -> dict: return {"agent_url": "https://test.example.com", "id": name} +def _declared_asset_types(base: type) -> dict[str, type]: + """``asset_type`` -> the class this pin generated for it, for one asset base. + + Read off the live classes rather than listed, which is the whole point: the + union's own vocabulary is derived the same way, so a schema refresh moves + both together. Asserted non-empty because a derivation that matches nothing + passes everything. + """ + found = { + value.model_fields["asset_type"].default: value + for value in vars(format_module).values() + if isinstance(value, type) + and issubclass(value, base) + and value is not base + and "asset_type" in getattr(value, "model_fields", {}) + and isinstance(value.model_fields["asset_type"].default, str) + } + assert found, f"no generated subclass of {base.__name__} declares an asset_type" + return found + + +def _an_undeclared_asset_type(base: type) -> str: + """An ``asset_type`` this pin has no class for, checked against the ones it has.""" + candidate = "x_future_asset_type" + assert candidate not in _declared_asset_types(base), ( + f"{candidate!r} became a declared asset_type; pick another stand-in, and " + "check whether the tests below were grading routing or only acceptance" + ) + return candidate + + +_UNDECLARED_INDIVIDUAL = _an_undeclared_asset_type(format_module.BaseIndividualAsset) +_UNDECLARED_GROUP = _an_undeclared_asset_type(format_module.BaseGroupAsset) + + class TestUnknownFormatAssetParsing: """Format.assets accepts novel asset_type values after _forward_compat patch.""" @@ -37,7 +83,7 @@ def test_novel_asset_type_parses_as_unknown(self): "assets": [ { "asset_id": "impression_tracker", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_INDIVIDUAL, "item_type": "individual", "required": False, }, @@ -48,7 +94,7 @@ def test_novel_asset_type_parses_as_unknown(self): assert len(fmt.assets) == 1 asset = fmt.assets[0] assert isinstance(asset, UnknownFormatAsset) - assert asset.asset_type == "pixel_tracker" + assert asset.asset_type == _UNDECLARED_INDIVIDUAL assert asset.asset_id == "impression_tracker" def test_known_asset_type_still_parses_correctly(self): @@ -87,7 +133,7 @@ def test_mixed_known_and_unknown_assets_parse(self): }, { "asset_id": "tracker", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_INDIVIDUAL, "item_type": "individual", "required": False, }, @@ -104,7 +150,7 @@ def test_mixed_known_and_unknown_assets_parse(self): assert len(fmt.assets) == 3 assert isinstance(fmt.assets[0], ImageFormatAsset) assert isinstance(fmt.assets[1], UnknownFormatAsset) - assert fmt.assets[1].asset_type == "pixel_tracker" + assert fmt.assets[1].asset_type == _UNDECLARED_INDIVIDUAL def test_unknown_asset_preserves_extra_fields(self): """Extra fields on unknown assets are preserved via extra='allow'.""" @@ -115,7 +161,7 @@ def test_unknown_asset_preserves_extra_fields(self): "assets": [ { "asset_id": "track", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_INDIVIDUAL, "item_type": "individual", "required": False, "tracking_url": "https://example.com/track", @@ -191,7 +237,7 @@ def test_novel_group_asset_parses_as_unknown(self): "assets": [ { "asset_id": "track", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_GROUP, "required": False, }, ], @@ -199,7 +245,7 @@ def test_novel_group_asset_parses_as_unknown(self): ) assert len(group.assets) == 1 assert isinstance(group.assets[0], UnknownGroupAsset) - assert group.assets[0].asset_type == "pixel_tracker" + assert group.assets[0].asset_type == _UNDECLARED_GROUP def test_known_group_asset_still_parses(self): """Known asset_type inside a group still routes to the typed class.""" @@ -232,7 +278,7 @@ def test_requires_asset_id(self): with pytest.raises(ValidationError): UnknownFormatAsset.model_validate( { - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_INDIVIDUAL, "item_type": "individual", "required": False, # asset_id missing @@ -245,7 +291,7 @@ def test_requires_required_field(self): UnknownFormatAsset.model_validate( { "asset_id": "track", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_INDIVIDUAL, "item_type": "individual", # required missing } @@ -264,31 +310,42 @@ def test_asset_type_is_required_not_empty_default(self): ) def test_unknown_format_asset_is_unknown_form(self): - """UnknownFormatAsset is not a known asset type by construction.""" + """UnknownFormatAsset carries an asset_type this pin has no class for. + + The vocabulary is derived, not restated. This assertion used to list 14 + asset types by hand — the same stale frozenset the union routed by, in a + second copy, which is why ``pixel_tracker`` satisfied it while being a + type the schema declares and the generator had a class for. + """ asset = UnknownFormatAsset.model_validate( { "asset_id": "x", - "asset_type": "pixel_tracker", + "asset_type": _UNDECLARED_INDIVIDUAL, "item_type": "individual", "required": False, } ) - assert asset.asset_type not in ( - "image", - "video", - "audio", - "text", - "markdown", - "html", - "css", - "javascript", - "vast", - "daast", - "url", - "webhook", - "brief", - "catalog", - ) + assert asset.asset_type not in _declared_asset_types(format_module.BaseIndividualAsset) + + +class TestMalformedAssetTypeIsAValidationError: + """A non-string ``asset_type`` must fail validation, not crash the discriminator.""" + + @pytest.mark.parametrize("asset_type", [["image"], {"kind": "image"}, 7, None]) + @pytest.mark.parametrize( + ("union", "item"), + [ + pytest.param(FormatAssetUnion, {"item_type": "individual"}, id="individual"), + pytest.param(GroupFormatAssetUnion, {}, id="group"), + ], + ) + def test_routes_to_the_fallback_and_is_refused( + self, union: object, item: dict, asset_type: object + ) -> None: + with pytest.raises(ValidationError): + TypeAdapter(union).validate_python( + {**item, "asset_id": "logo", "required": True, "asset_type": asset_type} + ) class TestPublicAPIExports: @@ -304,3 +361,112 @@ def test_imports_from_adcp_types(self): assert UnknownGroupAsset is not None assert FormatAssetUnion is not None assert GroupFormatAssetUnion is not None + + +class TestDeclaredAssetTypesAreNotDegraded: + """Every asset_type this pin generated a class for must reach that class. + + The open union exists so an asset_type defined AFTER this pin survives. It + must not swallow the ones defined at it. The vocabulary it routed by was a + hand-written frozenset 7 behind the generated individual classes and 9 + behind the group ones, so seven individual and nine group types arrived as + UnknownFormatAsset / UnknownGroupAsset with their typed fields in + __pydantic_extra__, while the generated classes for all of them sat unused. + The vocabulary is now emitted from the arms themselves, and this grades that + end to end — at the PARSE level, because the callable discriminator reads + the vocabulary before pydantic reaches the arms, so counting arms says + nothing about where a document lands. + + Parametrized over the generated classes rather than a list, so an asset type + a schema refresh adds is covered the moment its class is generated. + """ + + @pytest.mark.parametrize( + "asset_type,expected", + sorted(_declared_asset_types(format_module.BaseIndividualAsset).items()), + ) + def test_each_declared_individual_type_reaches_its_class( + self, asset_type: str, expected: type + ) -> None: + fmt = Format.model_validate( + { + "format_id": _make_format_id("declared"), + "name": "Declared", + "assets": [ + { + "asset_id": "slot", + "asset_type": asset_type, + "item_type": "individual", + "required": True, + } + ], + } + ) + assert fmt.assets is not None + asset = fmt.assets[0] + assert type(asset) is expected + assert not isinstance(asset, UnknownFormatAsset) + # A degraded asset keeps its type-specific fields as untyped extras, so + # an empty extra dict is part of what "reached its class" means. + assert not (asset.__pydantic_extra__ or {}) + + @pytest.mark.parametrize( + "asset_type,expected", + sorted(_declared_asset_types(format_module.BaseGroupAsset).items()), + ) + def test_each_declared_group_type_reaches_its_class( + self, asset_type: str, expected: type + ) -> None: + group = Assets94.model_validate( + { + "asset_group_id": "slide", + "item_type": "repeatable_group", + "required": True, + "min_count": 1, + "max_count": 5, + "assets": [{"asset_id": "slot", "asset_type": asset_type, "required": True}], + } + ) + slot = group.assets[0] + assert type(slot) is expected + assert not isinstance(slot, UnknownGroupAsset) + assert not (slot.__pydantic_extra__ or {}) + + def test_the_derivation_covers_what_the_pinned_schema_declares(self) -> None: + """The parametrized population is the schema's, not a subset of it. + + Without this, a generator that stopped emitting a class for one asset + type would shrink the population above and stay green — the two tests + would grade fewer rows and say nothing about the one that vanished. + """ + import json + from pathlib import Path + + from adcp.validation.version import resolve_bundle_key + + root = Path(__file__).parent.parent + pinned = (root / "src/adcp/ADCP_VERSION").read_text().strip() + schema_path = root / "schemas/cache" / resolve_bundle_key(pinned) / "core/format.json" + assert schema_path.is_file(), f"no pinned format schema at {schema_path}" + arms = json.loads(schema_path.read_text())["properties"]["assets"]["items"]["oneOf"] + + def slot(item_type: str) -> dict: + matching = [ + arm + for arm in arms + if arm.get("properties", {}).get("item_type", {}).get("const") == item_type + ] + assert len(matching) == 1, f"expected one {item_type!r} arm, got {len(matching)}" + return matching[0] + + # The two arms nest to different depths and are selected by their + # item_type const, not by position. + individual = {a["properties"]["asset_type"]["const"] for a in slot("individual")["oneOf"]} + group = { + a["properties"]["asset_type"]["const"] + for a in slot("repeatable_group")["properties"]["assets"]["items"]["oneOf"] + } + assert individual, "the individual arm declares no asset_type consts" + assert group, "the repeatable-group arm declares no asset_type consts" + assert set(_declared_asset_types(format_module.BaseIndividualAsset)) == individual + assert set(_declared_asset_types(format_module.BaseGroupAsset)) == group diff --git a/tests/type_checks/format_asset_unions.py b/tests/type_checks/format_asset_unions.py new file mode 100644 index 000000000..bf13985bb --- /dev/null +++ b/tests/type_checks/format_asset_unions.py @@ -0,0 +1,27 @@ +"""Adopter-facing type contract for the open format asset unions. + +``FormatAssetUnion`` and ``GroupFormatAssetUnion`` are ``Annotated`` aliases, so +they must be usable as type expressions (``list[FormatAssetUnion]``) rather than +only as runtime values. +""" + +from pydantic import TypeAdapter + +from adcp.types import FormatAssetUnion, GroupFormatAssetUnion, UnknownFormatAsset + +assets: list[FormatAssetUnion] = TypeAdapter(list[FormatAssetUnion]).validate_python( + [ + { + "item_type": "individual", + "asset_id": "logo", + "asset_type": "vendor_widget", + "required": True, + } + ] +) +group_assets: list[GroupFormatAssetUnion] = TypeAdapter( + list[GroupFormatAssetUnion] +).validate_python([]) +unknown: FormatAssetUnion = UnknownFormatAsset( + item_type="individual", asset_id="logo", asset_type="vendor_widget", required=True +) From b07adbc59c66cef8a7ada8189ab7739fc77c7733 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 22 Oct 2026 23:01:28 -0400 Subject: [PATCH 06/54] fix(canonical-formats): read an omitted pixel_ratio as 1x on the supported side too (#1553) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(canonical-formats): read an omitted pixel_ratio as 1x on the supported side too `format_is_supported` applied the implied-1x rule only to the requested reference. A supported 300x250 that omits `pixel_ratio` was skipped as "unfixed", so it accepted a 2x creative — the mirror image of the gap #1518 closed for a 2x product receiving a request that omits the field. `core/format-id.json` describes the omitted field as "the backward-compatible 1x variant" without naming a side, so read it the same way on both. The reading applies only to a dimensioned reference: `pixel_ratio` "Requires `width` and `height`", so a bare `display_image` template names no variant to be 1x of and stays broad. A legacy `display_300x250` upgrades to a dimensioned reference and is therefore the 1x variant. `formats_are_equivalent` keeps its documented loose contract (an omitted parameter is unspecified on either side), which is already symmetric. Refs #1518, #1517 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(canonical-formats): apply implied-1x pixel_ratio in formats_are_equivalent too `formats_are_equivalent` still treated an omitted `pixel_ratio` as unspecified, so it called 300x250 and 300x250 at 2x the same format while `format_is_supported` refused one for the other. An omitted width or duration names the template, a family covering every variant, so "unspecified" is right for those. An omitted density on a sized reference names one variant — the 1x render of a 300x250 image, not the 600x500 one — so read it as 1x here as well. A size-less template still has no density and stays broad. The schema sanitizer's legacy-tuple shape check listed the reference's properties by hand and missed `pixel_ratio`; derive the set from the model. Refs #1518, #1517 Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- src/adcp/canonical_formats/compat_helpers.py | 48 +++++++---- src/adcp/types/canonical_creative.py | 11 ++- tests/test_canonical_formats_compatibility.py | 83 ++++++++++++++++++- 3 files changed, 126 insertions(+), 16 deletions(-) diff --git a/src/adcp/canonical_formats/compat_helpers.py b/src/adcp/canonical_formats/compat_helpers.py index a9a563043..b85b7c267 100644 --- a/src/adcp/canonical_formats/compat_helpers.py +++ b/src/adcp/canonical_formats/compat_helpers.py @@ -45,11 +45,29 @@ #: ``core/format-id.json`` describes omitting ``pixel_ratio`` as selecting #: "the backward-compatible 1x variant", which is a value, not silence. An #: omitted width or duration means "template, unparameterized"; an omitted -#: density means 1x. So a product fixed at 2x refuses a request that says -#: nothing, while one fixed at 1x still serves it. +#: density means 1x — on either side. So a product fixed at 2x refuses a +#: request that says nothing, a product that says nothing refuses a 2x +#: request, and one fixed at 1x still serves a request that says nothing. +#: Both helpers read it this way, so equivalence never contradicts gating +#: on density. _IMPLIED_WHEN_ABSENT: dict[str, float] = {"pixel_ratio": 1.0} +def _parameter_value(fid: FormatId, field: str) -> Any: + """The value ``fid`` gives ``field``, reading an implied default for absence. + + The implied value applies to whichever side omits the field — a supported + 300x250 with no density is the 1x variant just as a requested one is. It + applies only to a dimensioned reference, because ``pixel_ratio`` "Requires + ``width`` and ``height``": a template such as bare ``display_image`` has + no 1x variant to select and stays broad. + """ + value = getattr(fid, field) + if value is None and fid.width is not None and fid.height is not None: + return _IMPLIED_WHEN_ABSENT.get(field) + return value + + def _coerce_format_id( value: FormatIdInput, *, @@ -132,9 +150,12 @@ def formats_are_equivalent( """Return true when two format IDs identify the same canonical family. Both inputs are first passed through :func:`upgrade_legacy_format_id`. - Declared parameters must not conflict, but an omitted parameter on either - side is treated as unspecified rather than a mismatch. Use - :func:`format_is_supported` for product/capability gating, where a + Declared parameters must not conflict, but an omitted width, height or + duration on either side is treated as unspecified rather than a mismatch: + omitting them names the template, which covers every variant. An omitted + ``pixel_ratio`` on a sized reference is not unspecified — it names the 1x + variant — so 300x250 and 300x250 at 2x are different formats here too. + Use :func:`format_is_supported` for product/capability gating, where a supported fixed size, duration or pixel density requires the request to state that value. """ @@ -146,8 +167,8 @@ def formats_are_equivalent( return False for field in _PARAMETER_FIELDS: - left_value = getattr(left, field) - right_value = getattr(right, field) + left_value = _parameter_value(left, field) + right_value = _parameter_value(right, field) if left_value is not None and right_value is not None and left_value != right_value: return False return True @@ -170,8 +191,10 @@ def format_is_supported( ``pixel_ratio`` is the one parameter whose absence carries a value rather than meaning "unspecified": the schema describes omitting it as selecting - the backward-compatible 1x variant. A product fixed at 2x therefore - refuses a request that omits the field, and one fixed at 1x accepts it. + the backward-compatible 1x variant. That reading applies to both sides + of a dimensioned reference: a product fixed at 2x refuses a request that + omits the field, a product that omits it (so is 1x) refuses a 2x request, + and a product fixed at 1x accepts a request that omits it. """ req = _upgrade_legacy_format_id( requested, default_agent_url=default_agent_url, param="requested" @@ -183,13 +206,10 @@ def format_is_supported( return False for field in _PARAMETER_FIELDS: - supported_value = getattr(sup, field) + supported_value = _parameter_value(sup, field) if supported_value is None: continue - requested_value = getattr(req, field) - if requested_value is None: - requested_value = _IMPLIED_WHEN_ABSENT.get(field) - if requested_value != supported_value: + if _parameter_value(req, field) != supported_value: return False return True diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 6c7809df3..ff015f99b 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -109,6 +109,9 @@ CreativeManifest as _CanonicalCreativeManifestWire, ) from adcp.types.domains.core.creative_variant import CreativeVariant as _LegacyCreativeVariant +from adcp.types.domains.core.format_id import ( + FormatReferenceStructuredObject as _LegacyFormatReference, +) from adcp.types.domains.core.macro_resolution_capability import MacroProcessingCapability from adcp.types.domains.core.package import Package as _LegacyPackage from adcp.types.domains.core.placement import Placement as _LegacyPlacement @@ -542,11 +545,17 @@ def _legacy_creative_identity_path( return None +#: Every property a legacy format reference declares, derived from the model so +#: a parameter added to ``core/format-id.json`` (as ``pixel_ratio`` was) is +#: recognised without being named here. +_LEGACY_FORMAT_REFERENCE_KEYS = frozenset(_LegacyFormatReference.model_fields) + + def _looks_like_legacy_format_tuple(schema: dict[str, Any], properties: dict[str, Any]) -> bool: keys = set(properties) title = str(schema.get("title", "")).lower() return {"agent_url", "id"} <= keys and ( - "format" in title or keys <= {"agent_url", "id", "width", "height", "duration_ms"} + "format" in title or keys <= _LEGACY_FORMAT_REFERENCE_KEYS ) diff --git a/tests/test_canonical_formats_compatibility.py b/tests/test_canonical_formats_compatibility.py index 29ba34857..8c125c7cf 100644 --- a/tests/test_canonical_formats_compatibility.py +++ b/tests/test_canonical_formats_compatibility.py @@ -15,6 +15,7 @@ project_legacy_format_id, ) from adcp.types import FormatCard +from adcp.types.canonical_creative import sanitize_canonical_schema from adcp.types.legacy import LegacyFormatId as FormatId from adcp.validation.schema_loader import get_named_schema_document @@ -252,7 +253,7 @@ def test_format_is_supported_refuses_a_request_that_omits_a_required_pixel_ratio """ supported = {**_BASE, "pixel_ratio": 2} - assert formats_are_equivalent(_BASE, supported) + assert not formats_are_equivalent(_BASE, supported) assert not format_is_supported(_BASE, supported) @@ -282,3 +283,83 @@ def test_upgrade_legacy_format_id_keeps_every_parameter_it_was_given() -> None: assert (upgraded.width, upgraded.height) == (300, 250) assert upgraded.duration_ms == 15000 assert upgraded.pixel_ratio == 2 + + +#: ``(supported pixel_ratio, requested pixel_ratio, served)`` for two otherwise +#: identical 300x250 references; ``None`` omits the field. The omitted form is +#: the 1x variant on *both* sides, so the table is symmetric in its verdicts. +_PIXEL_RATIO_MATRIX: list[tuple[float | None, float | None, bool]] = [ + (None, None, True), + (None, 1, True), + (None, 2, False), + (1, None, True), + (2, None, False), + (1, 1, True), + (2, 2, True), + (1, 2, False), + (2, 1, False), +] + + +def _with_pixel_ratio(pixel_ratio: float | None) -> dict[str, object]: + return _BASE if pixel_ratio is None else {**_BASE, "pixel_ratio": pixel_ratio} + + +@pytest.mark.parametrize(("supported", "requested", "served"), _PIXEL_RATIO_MATRIX) +def test_format_is_supported_reads_an_omitted_pixel_ratio_as_1x_on_both_sides( + supported: float | None, requested: float | None, served: bool +) -> None: + """A supported 300x250 that omits ``pixel_ratio`` is the 1x variant. + + ``core/format-id.json`` describes the omitted field as "the + backward-compatible 1x variant" without naming a side, so a product that + omits it serves only 1x — it refuses a 2x creative exactly as a 2x product + refuses a creative that omits the field. + + ``formats_are_equivalent`` reads the omission the same way, so for two + references of the same size it agrees with gating in both directions. + """ + requested_ref = _with_pixel_ratio(requested) + supported_ref = _with_pixel_ratio(supported) + + assert format_is_supported(requested_ref, supported_ref) is served + assert formats_are_equivalent(requested_ref, supported_ref) is served + assert formats_are_equivalent(supported_ref, requested_ref) is served + + +@pytest.mark.parametrize("requested", [None, 1, 2]) +def test_a_template_supported_format_accepts_any_pixel_ratio(requested: float | None) -> None: + """The implied 1x needs dimensions, because ``pixel_ratio`` requires them. + + A bare ``display_image`` names no 300x250 variant to be 1x of, so it stays + the broad template it was and serves a request at any density. + """ + template = {"agent_url": CANONICAL_CREATIVE_AGENT_URL, "id": "display_image"} + + assert format_is_supported(_with_pixel_ratio(requested), template) + assert formats_are_equivalent(_with_pixel_ratio(requested), template) + + +def test_a_legacy_size_supported_format_is_the_1x_variant() -> None: + """A legacy ``display_300x250`` upgrades to a dimensioned, 1x reference.""" + legacy = {"agent_url": CANONICAL_CREATIVE_AGENT_URL, "id": "display_300x250"} + + assert format_is_supported(_BASE, legacy) + assert not format_is_supported({**_BASE, "pixel_ratio": 2}, legacy) + + +def test_schema_sanitizer_recognises_an_untitled_reference_carrying_pixel_ratio() -> None: + """The legacy-tuple shape check knows every parameter the model declares. + + An untitled object holding only format-reference properties is a legacy + reference, so its ``agent_url`` is stripped — including when it carries + ``pixel_ratio``, which a hand-written key list left out. + """ + reference = { + "type": "object", + "properties": {key: {} for key in ("agent_url", "id", "width", "height", "pixel_ratio")}, + } + + sanitized = sanitize_canonical_schema({"type": "object", "properties": {"ref": reference}}) + + assert "agent_url" not in sanitized["properties"]["ref"]["properties"] From 0bac033511d5b73908f2720d670b84a2642d2dba Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 22 Oct 2026 23:01:33 -0400 Subject: [PATCH 07/54] fix(webhooks): echo reporting_webhook.operation_id on scheduled delivery reports (#1552) AdCP 3.3 (adcp#7895, closing adcp#7885) standardizes an optional reporting_webhook.operation_id with push_notification_config.operation_id's constraints: sellers echo it verbatim on every media_buy_delivery report and MUST NOT send reports to a registration that omits it. - Align the 3.2 ReportingWebhook.operation_id extension's description with the 3.3 semantics; pin constraint parity with PushNotificationConfig so the 9.x extension and the SDK 10 generated field agree. - Add create_media_buy_delivery_webhook_payload and WebhookSender.prepare_media_buy_delivery / send_media_buy_delivery, which build the envelope from the stored registration (URL, operation_id, token). A registration without operation_id raises ReportingWebhookOperationIdMissingError before any network I/O and logs a structured WARNING (adcp_event=reporting_webhook.operation_id_missing). - create_mcp_webhook_payload no longer defaults notification_id to "{task_id}.terminal" for media_buy_delivery: delivery reports are point-in-time events deduped by idempotency_key. - Docs: seller migration for existing registrations, buyer guidance. Refs #1092 Co-authored-by: Claude Opus 5.5 (1M context) --- MIGRATION_v8_to_v9.md | 1 + .../migration-from-fragmented-senders.md | 9 +- docs/webhooks/reporting-operation-id-3.2.md | 141 +++++++---- skills/build-seller-agent/SKILL.md | 3 +- src/adcp/types/_forward_compat.py | 18 +- src/adcp/webhook_sender.py | 79 ++++++ src/adcp/webhooks.py | 142 ++++++++++- tests/test_reporting_webhook_operation_id.py | 239 ++++++++++++++++-- 8 files changed, 549 insertions(+), 83 deletions(-) diff --git a/MIGRATION_v8_to_v9.md b/MIGRATION_v8_to_v9.md index d7b9b9a00..36b1095ff 100644 --- a/MIGRATION_v8_to_v9.md +++ b/MIGRATION_v8_to_v9.md @@ -18,6 +18,7 @@ changes below before upgrading each client, server and reporting worker. | 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). | | New source requests and sealed artifacts include their owning consumer | Read `request.identity.consumer_id` when implementing adapters. Historical ownerless artifacts retain their original hashes, but active owned generations refuse ownerless or differently owned frozen acquisitions; reconcile retained state and admit a new generation instead of relabelling evidence. See [reporting ownership migration](docs/reporting-caller-ownership-migration.md). | | URL canonicalization gives an empty path its `/` whatever the query does | The `@target-uri` signature base changes for bare-authority URLs such as `https://seller.example.com`. Dial signed clients at the explicit root path, which canonicalizes identically on both versions, or move an unrespellable agent URL's signer and verifier in one window. Identifier matching on `adagents.json`, `brand.json`, TMP and the governance issuer only becomes more permissive. See [the empty-path canonicalization fix](docs/request-signing-migration.md#6-upgrading-across-the-empty-path-canonicalization-fix). | +| Scheduled delivery reports correlate by the registration's `operation_id` | Buyers: set `operation_id` on `reporting_webhook`. Sellers: send reports with `WebhookSender.send_media_buy_delivery` or `create_media_buy_delivery_webhook_payload`, which echo the stored value. Registrations stored without one raise `ReportingWebhookOperationIdMissingError` and log a structured warning; ask those buyers to update the registration rather than inventing an ID. `create_mcp_webhook_payload` no longer defaults `notification_id` to `"{task_id}.terminal"` for `media_buy_delivery`. See [reporting correlation](docs/webhooks/reporting-operation-id-3.2.md). | | Initial automatic source reads default to a deterministic five-minute jitter window | Reads wait for the selected offering's declared availability and then spread within the available delivery headroom. Configure `read_jitter_window=timedelta(0)` to opt out. Pass an explicit window to custom producer factories only when they accept and forward the new keyword. Manual acquisitions, persisted retries and official-close timing retain their behavior. See [reporting service timing](docs/reliable-reporting-service.md). | The credential rule applies to all configured carriers, including opted-in diff --git a/docs/webhooks/migration-from-fragmented-senders.md b/docs/webhooks/migration-from-fragmented-senders.md index 5b1c0e7cc..e8eb2eac3 100644 --- a/docs/webhooks/migration-from-fragmented-senders.md +++ b/docs/webhooks/migration-from-fragmented-senders.md @@ -293,7 +293,8 @@ If your current sender doesn't have one of these properties, this is what you're ## Reporting-stream correlation on AdCP 3.2 -For scheduled `media_buy_delivery` notifications, see the -[interim reporting operation ID guide](reporting-operation-id-3.2.md). -It covers the optional registration extension, seller persistence and echo, -and updating existing registrations before resuming scheduled delivery. +For scheduled `media_buy_delivery` notifications, use +`WebhookSender.send_media_buy_delivery` with the stored `reporting_webhook` +registration. See the [reporting operation ID guide](reporting-operation-id-3.2.md), +which covers the registration field, seller echo and skip-and-flag behavior, and +updating existing registrations before scheduled delivery resumes. diff --git a/docs/webhooks/reporting-operation-id-3.2.md b/docs/webhooks/reporting-operation-id-3.2.md index 4f0d19815..149caf8db 100644 --- a/docs/webhooks/reporting-operation-id-3.2.md +++ b/docs/webhooks/reporting-operation-id-3.2.md @@ -1,20 +1,28 @@ # Reporting correlation on AdCP 3.2 -AdCP 3.2 delivery webhook envelopes require `operation_id`, but the published -`reporting_webhook` registration schema still omits it through 3.2.3. -[adcp#7895](https://github.com/adcontextprotocol/adcp/pull/7895) merged the optional -core field for 3.3 and closed -[adcp#7885](https://github.com/adcontextprotocol/adcp/issues/7885). The -[3.3 release PR](https://github.com/adcontextprotocol/adcp/pull/7897) remains -pending. Until that release is published and synchronized into the SDK, use the -opt-in 3.2 agreement below. - -The SDK restores an optional `ReportingWebhook.operation_id` as an interim -extension. The published registration schema permits additional fields. -Use it only after confirming seller support during integration onboarding; -a 3.2 seller may accept and ignore an unknown field. This agreement does not -change the released 3.2 schema or require all 3.2 implementations to support it. -It needs no `ext.{namespace}` wrapper inside `reporting_webhook`. +Every `media_buy_delivery` webhook envelope requires `operation_id`, and the +seller must echo a value the buyer supplied. The published 3.2.x +`reporting_webhook` registration schema has no field for it, so a buyer that +registers only `reporting_webhook` had no way to supply one. + +AdCP 3.3 fixes this. [adcp#7895](https://github.com/adcontextprotocol/adcp/pull/7895) +(closing [adcp#7885](https://github.com/adcontextprotocol/adcp/issues/7885)) adds +an optional `reporting_webhook.operation_id` with the same constraints as +`push_notification_config.operation_id`: 1–255 characters from `A–Z`, `a–z`, +`0–9`, `_`, `.`, `:` and `-`. The seller MUST echo it verbatim on every +`media_buy_delivery` report to that registration and MUST NOT derive it from the +URL. A seller MUST NOT send `media_buy_delivery` reports to a registration that +omits it. The field is expected to become required in 4.0. + +SDK 9 is pinned to AdCP 3.2, so it carries `ReportingWebhook.operation_id` as an +SDK extension with that exact name, optionality and set of constraints. The +3.2.x schema permits additional fields here, so no `ext.{namespace}` wrapper is +needed. On 3.2 the field is opt-in: a 3.2 seller may accept and ignore it, so +confirm seller support during onboarding. When the SDK moves to AdCP 3.3, the +generated model declares the field and the extension stops applying, with no +wire change. + +## Buyers: set `operation_id` on `reporting_webhook` ```python from adcp.types import ReportingWebhook @@ -28,34 +36,79 @@ registration = ReportingWebhook( }, reporting_frequency="daily", ) -wire_registration = registration.model_dump(mode="json") ``` -Supply that registration through `buy_products`, `accept_proposal`, -`control_media_buy`, or the legacy `create_media_buy` / `update_media_buy` -API where supported. IDs use 1–255 characters from `A–Z`, `a–z`, `0–9`, -`_`, `.`, `:`, and `-`, matching `PushNotificationConfig.operation_id`. -Omission and `None` remain valid; default serialization omits an absent ID. -Explicit malformed values fail model validation. Historical version-scoped -models keep their pinned schema contracts, including the beta.6-required field -in `adcp.types.v32`. - -For a seller implementing this agreement, persist the ID with the reporting -registration, then pass that exact value to `WebhookSender.send_mcp(operation_id=...)` -for every `media_buy_delivery` report. Preserve it on retries and keep existing -authentication, required envelope fields, and per-delivery idempotency handling. -The reporting-stream ID is independent of any task-status callback ID and does -not replace a delivery's `idempotency_key`. Never derive it from the URL or -fabricate it from a seller resource ID. - -For existing registrations without a buyer-supplied ID, flag the missing value -and suppress scheduled sends on this interim path. Ask buyers to update their -registration with an ID before resuming delivery. `get_media_buy_delivery` -remains available while arranging support. Updating the SDK alone does not -backfill seller storage or restart reports. - -Verify buyer serialization → seller validation → durable registration → -scheduled send → authenticated receipt end to end before deployment. When a -later protocol release defines the core field, its definition takes precedence -over this interim agreement; remove the compatibility patch once generated -models retain the field and the regression suite still passes. +Generate a unique value per reporting registration (a UUID works). It is +independent of any `push_notification_config.operation_id` on the same request, +and it is not a per-delivery `idempotency_key`. Pass the registration to +`buy_products`, `accept_proposal` or `control_media_buy`, or to the legacy +`create_media_buy` / `update_media_buy` where supported. + +Correlate received reports by the payload's `operation_id`, not by URL. If you +already registered `reporting_webhook` without an ID, update the registration +with one. A seller that follows the fail-closed rule sends you no scheduled +reports until you do. Use `get_media_buy_delivery` in the meantime. + +Omitting the field, or passing `None`, is valid, and default serialization +leaves an absent ID out. A malformed value fails model validation. Historical +version-scoped models keep their pinned schemas, including the beta.6 contract +in `adcp.types.v32`, where the field is required. + +## Sellers: echo the stored value, skip registrations without one + +Store the registration as the buyer sent it, for example with +`registration.model_dump_json()`. When a report is due, build the webhook from +that stored registration. The URL, `operation_id` and `token` then all come from +the same record: + +```python +from adcp.webhooks import ReportingWebhookOperationIdMissingError, WebhookSender + +async with WebhookSender.from_bearer_token(credentials) as sender: + for buy in due_media_buys: + try: + result = await sender.send_media_buy_delivery( + registration=buy.reporting_webhook, # stored model or JSON dict + task_id=f"delivery_report_{buy.media_buy_id}_{buy.sequence:06d}", + result=report_for(buy), # MediaBuyDeliveryWebhookResult or dict + ) + except ReportingWebhookOperationIdMissingError: + flag_for_buyer(buy) # registration has no operation_id; no request was sent + continue + if not result.ok: + await sender.resend(result) +``` + +For the legacy Bearer/HMAC path, pass the same registration to both helpers: +`deliver(registration, create_media_buy_delivery_webhook_payload(registration, task_id=..., result=...))`. +Durable outboxes can call `WebhookSender.prepare_media_buy_delivery(...)`, +persist the returned `PreparedWebhook`, and send it later with `send_prepared`. + +What the helpers guarantee: + +- `operation_id` and `token` are copied verbatim from the registration. The SDK + never derives an ID from the URL, `media_buy_id` or anything else. +- A registration without `operation_id` raises + `ReportingWebhookOperationIdMissingError` before any network I/O. The SDK also + logs one WARNING on the `adcp.webhooks` logger, with + `extra={"adcp_event": "reporting_webhook.operation_id_missing", "task_type": + "media_buy_delivery", "notification_type": ..., "media_buy_ids": (...)}`. The + URL and credentials are never logged. Use this record for a metric or alert, + and keep the exception for the per-registration flag. +- A stored `operation_id` that is malformed (for example, edited by hand) fails + `ReportingWebhook` validation instead of being sent. +- The envelope is `task_type="media_buy_delivery"` and `status="completed"`, + with your `task_id`. `notification_id` is omitted, because delivery reports + are point-in-time events deduped by `idempotency_key`. + `create_mcp_webhook_payload` no longer gives `media_buy_delivery` the + `"{task_id}.terminal"` default. + +### Migrating existing registrations + +Upgrading the SDK does not backfill seller storage or restart reports. +Registrations stored before buyers could send `operation_id` have none, and the +helpers skip them. Do not fill one in. Tell the affected buyers to update +`reporting_webhook` with an `operation_id`, through `update_media_buy` or +`control_media_buy`, and resume scheduled sends once the updated registration is +stored. Before deploying, test the whole path: buyer serialization, seller +validation, durable storage, scheduled send, and authenticated receipt. diff --git a/skills/build-seller-agent/SKILL.md b/skills/build-seller-agent/SKILL.md index 6d989ea3d..1e2186381 100644 --- a/skills/build-seller-agent/SKILL.md +++ b/skills/build-seller-agent/SKILL.md @@ -599,7 +599,8 @@ response.raise_for_status() ``` Notes: -- Copy `config.operation_id` verbatim into every MCP webhook payload. Do not derive it from the task, media buy, or callback URL. Legacy persisted reporting registrations without one must be flagged or skipped until the buyer supplies it. +- Copy `config.operation_id` verbatim into every MCP webhook payload. Do not derive it from the task, media buy, or callback URL. +- For scheduled delivery reports to a stored `reporting_webhook`, use `sender.send_media_buy_delivery(registration=stored, task_id=..., result=report)` (or `create_media_buy_delivery_webhook_payload` with `deliver`). It echoes the registration's `operation_id` and `token`, and raises `ReportingWebhookOperationIdMissingError` for a legacy registration without one. Catch it, skip that send, and ask the buyer to update the registration. See `docs/webhooks/reporting-operation-id-3.2.md`. - `deliver` hashes/signs the exact bytes it POSTs for HMAC-SHA256; for Bearer it attaches the credential as `Authorization`. Either way, the signer and the wire cannot disagree. - `deliver` emits a `DeprecationWarning` on first use; migrate to `WebhookSender` for 4.0. - If your buyer relies on `config.token` echo, pass `token_field="push_token"` (pick a name you and the receiver agree on — there is no spec-defined field name). diff --git a/src/adcp/types/_forward_compat.py b/src/adcp/types/_forward_compat.py index f9d4837b7..a3e8a4f92 100644 --- a/src/adcp/types/_forward_compat.py +++ b/src/adcp/types/_forward_compat.py @@ -145,10 +145,15 @@ max_length=255, pattern="^[A-Za-z0-9_.:-]{1,255}$", description=( - "Opt-in AdCP 3.2 reporting-stream correlation extension pending adcp#7885. " - "Cooperating sellers store and echo the buyer-supplied value verbatim in " - "media_buy_delivery webhook payloads. Confirm seller support before use; " - "registrations without a value cannot enable this interim delivery path." + "Buyer-supplied correlation identifier for the reporting registration. " + "The seller MUST echo this value verbatim into every webhook payload's " + "`operation_id` field on each `media_buy_delivery` report sent to this " + "registration, and MUST NOT derive it by parsing the reporting webhook URL. " + "Independent of any `push_notification_config.operation_id`. Optional for " + "schema compatibility, but a seller MUST NOT emit `media_buy_delivery` " + "webhooks to a registration that omits it (fail closed). Standardized in " + "AdCP 3.3 (adcp#7895); on 3.2 this is an opt-in SDK extension with the " + "identical wire shape, so confirm seller support before relying on it." ), ), ] @@ -449,9 +454,10 @@ def _apply_forward_compat() -> None: # Refresh its cached nested validator as well as the package model itself. _GeneratedCreateMediaBuyRequest.model_rebuild(force=True) - # 3.2.1 permits additional registration fields but does not declare this ID. + # 3.2.x permits additional registration fields but does not declare this ID. # Keep the extension outside generated code so a schema refresh cannot drop - # it again. An eventual upstream core definition takes precedence. + # it again. AdCP 3.3 adds the same optional field with the same constraints + # (adcp#7895); once a bundle declares it, this guard makes the patch a no-op. if "operation_id" not in ReportingWebhook.model_fields: # Pydantic accepts annotated unions at runtime; its helper's annotation # is narrower than that supported dynamic input. diff --git a/src/adcp/webhook_sender.py b/src/adcp/webhook_sender.py index 467c7be6a..7bca83b7d 100644 --- a/src/adcp/webhook_sender.py +++ b/src/adcp/webhook_sender.py @@ -56,7 +56,10 @@ from adcp.types import ( AdcpProtocol, GeneratedTaskStatus, + McpWebhookPayload, + MediaBuyDeliveryWebhookResult, NotificationConfig, + ReportingWebhook, TaskType, WholesaleFeedEvent, WholesaleFeedWebhook, @@ -763,6 +766,14 @@ def prepare_mcp( idempotency_key=idempotency_key, token=token, ) + return WebhookSender._prepare_mcp_payload(url, payload, extra_headers) + + @staticmethod + def _prepare_mcp_payload( + url: str, + payload: McpWebhookPayload, + extra_headers: Mapping[str, str] | None, + ) -> PreparedWebhook: body_dict = { **to_wire_dict(payload), "idempotency_key": payload.idempotency_key, @@ -781,6 +792,73 @@ def prepare_mcp( extra_headers=dict(extra_headers) if extra_headers else {}, ) + async def send_media_buy_delivery( + self, + *, + registration: ReportingWebhook | Mapping[str, Any], + task_id: str, + result: MediaBuyDeliveryWebhookResult | dict[str, Any], + status: GeneratedTaskStatus | str = "completed", + timestamp: datetime | None = None, + message: str | None = None, + idempotency_key: str | None = None, + extra_headers: Mapping[str, str] | None = None, + ) -> WebhookDeliveryResult: + """Prepare and send one scheduled delivery report to a stored registration. + + Raises :class:`~adcp.webhooks.ReportingWebhookOperationIdMissingError` + without network I/O when the registration has no ``operation_id``. + """ + return await self.send_prepared( + self.prepare_media_buy_delivery( + registration=registration, + task_id=task_id, + result=result, + status=status, + timestamp=timestamp, + message=message, + idempotency_key=idempotency_key, + extra_headers=extra_headers, + ) + ) + + @staticmethod + def prepare_media_buy_delivery( + *, + registration: ReportingWebhook | Mapping[str, Any], + task_id: str, + result: MediaBuyDeliveryWebhookResult | dict[str, Any], + status: GeneratedTaskStatus | str = "completed", + timestamp: datetime | None = None, + message: str | None = None, + idempotency_key: str | None = None, + extra_headers: Mapping[str, str] | None = None, + ) -> PreparedWebhook: + """Prepare a ``media_buy_delivery`` webhook for a ``reporting_webhook``. + + The URL, ``operation_id`` and ``token`` all come from the same stored + registration, so the report echoes exactly what the buyer registered. + See :func:`~adcp.webhooks.create_media_buy_delivery_webhook_payload`. + + :raises ReportingWebhookOperationIdMissingError: the registration has + no ``operation_id``. Skip the send and flag it for the buyer. + """ + webhook = ( + registration + if isinstance(registration, ReportingWebhook) + else ReportingWebhook.model_validate(registration) + ) + payload = create_media_buy_delivery_webhook_payload( + webhook, + task_id=task_id, + result=result, + status=status, + timestamp=timestamp, + message=message, + idempotency_key=idempotency_key, + ) + return WebhookSender._prepare_mcp_payload(str(webhook.url), payload, extra_headers) + async def send_prepared( self, prepared: PreparedWebhook, @@ -1799,6 +1877,7 @@ async def _send_bytes( # cycle — see the note next to the removed top-level import. from adcp.webhooks import ( # noqa: E402 create_mcp_webhook_payload, + create_media_buy_delivery_webhook_payload, create_webhook_challenge_payload, generate_webhook_idempotency_key, to_wire_dict, diff --git a/src/adcp/webhooks.py b/src/adcp/webhooks.py index f6b6c9158..7d61c2237 100644 --- a/src/adcp/webhooks.py +++ b/src/adcp/webhooks.py @@ -24,6 +24,7 @@ import hashlib import hmac import json +import logging import secrets import time import uuid @@ -61,7 +62,13 @@ verify_webhook_from_agent_url, verify_webhook_signature, ) -from adcp.types import AdcpProtocol, GeneratedTaskStatus, McpWebhookPayload, TaskType +from adcp.types import ( + AdcpProtocol, + GeneratedTaskStatus, + McpWebhookPayload, + ReportingWebhook, + TaskType, +) from adcp.types.base import AdCPBaseModel from adcp.webhook_receiver import ( LegacyHmacFallback, @@ -74,6 +81,8 @@ ) from adcp.webhook_transport_hooks import TransportHook, apply_hooks +logger = logging.getLogger(__name__) + # `task_type` → `protocol` mapping. Mirrors the JS reference # implementation's `TOOL_PROTOCOL_MAP` in # `adcontextprotocol/adcp-client:src/lib/server/decisioning/runtime/protocol-for-tool.ts` @@ -162,6 +171,8 @@ def create_mcp_webhook_payload( notification_id: Stable identity for one logical notification. Terminal task webhooks default to ``"{task_id}.terminal"`` so re-emissions under different delivery keys still converge. + ``media_buy_delivery`` reports get no default: they are + point-in-time events deduped by ``idempotency_key``. message: Human-readable summary of task state. context_id: Session/conversation identifier. protocol: AdCP protocol this task belongs to (see :class:`AdcpProtocol`). @@ -226,12 +237,14 @@ def create_mcp_webhook_payload( ) status_value = status.value if hasattr(status, "value") else str(status) - if notification_id is None and status_value in { - "completed", - "failed", - "canceled", - "rejected", - }: + task_type_value = task_type.value if hasattr(task_type, "value") else str(task_type) + # Delivery reports are point-in-time events, not terminal task outcomes: + # the spec omits notification_id and dedupes them by idempotency_key. + if ( + notification_id is None + and task_type_value != "media_buy_delivery" + and status_value in {"completed", "failed", "canceled", "rejected"} + ): notification_id = f"{task_id}.terminal" # Auto-derive `protocol` from `task_type` when caller doesn't override. @@ -279,6 +292,118 @@ def create_mcp_webhook_payload( return payload +#: Structured ``adcp_event`` value logged when a scheduled delivery report is +#: suppressed because its ``reporting_webhook`` registration has no +#: buyer-supplied ``operation_id``. +REPORTING_WEBHOOK_OPERATION_ID_MISSING = "reporting_webhook.operation_id_missing" + + +class ReportingWebhookOperationIdMissingError(ValueError): + """A ``reporting_webhook`` registration carries no buyer-supplied ``operation_id``. + + ``media_buy_delivery`` webhooks require ``operation_id``, and the seller + MUST echo the registration's value rather than invent one or parse it from + the URL. A registration without it cannot receive scheduled reports: skip + the send and ask the buyer to update ``reporting_webhook`` with an + ``operation_id``. ``get_media_buy_delivery`` stays available meanwhile. + + The SDK logs one WARNING on the ``adcp.webhooks`` logger before raising, + with ``extra`` fields ``adcp_event`` (:data:`REPORTING_WEBHOOK_OPERATION_ID_MISSING`), + ``task_type``, ``notification_type`` and ``media_buy_ids``. The URL and + credentials are never logged. + """ + + code = REPORTING_WEBHOOK_OPERATION_ID_MISSING + + def __init__(self, *, media_buy_ids: tuple[str, ...] = ()) -> None: + self.media_buy_ids = media_buy_ids + super().__init__( + "reporting_webhook has no buyer-supplied operation_id; media_buy_delivery " + "webhooks to this registration are suppressed. Ask the buyer to update " + "reporting_webhook.operation_id; never derive one from the URL or a " + "seller resource ID." + ) + + +def _delivery_report_media_buy_ids(result: Mapping[str, Any]) -> tuple[str, ...]: + deliveries = result.get("media_buy_deliveries") + if not isinstance(deliveries, list): + return () + return tuple( + delivery["media_buy_id"] + for delivery in deliveries + if isinstance(delivery, Mapping) and isinstance(delivery.get("media_buy_id"), str) + ) + + +def create_media_buy_delivery_webhook_payload( + registration: ReportingWebhook | Mapping[str, Any], + *, + task_id: str, + result: PydanticBaseModel | dict[str, Any], + status: GeneratedTaskStatus | str = "completed", + timestamp: datetime | None = None, + message: str | None = None, + idempotency_key: str | None = None, +) -> McpWebhookPayload: + """Build a scheduled ``media_buy_delivery`` webhook for a stored registration. + + ``operation_id`` and ``token`` are copied verbatim from ``registration`` + (a :class:`ReportingWebhook` or its persisted JSON form), so the reporting + stream correlates with the value the buyer registered. ``notification_id`` + is omitted: delivery reports are deduped by ``idempotency_key``. + + Args: + registration: The buyer's ``reporting_webhook`` as stored by the seller. + task_id: Seller-generated identifier for this report, for example + ``"delivery_report_67_2026_04_000031"``. + result: The ``MediaBuyDeliveryWebhookResult`` body, kept byte-for-byte. + status: Envelope status, normally ``"completed"``. It is distinct from + ``media_buy_deliveries[].status``. + timestamp: When the report was first generated. Defaults to now (UTC). + message: Optional human-readable summary. + idempotency_key: The first attempt's key when rebuilding for a retry. + + Raises: + ReportingWebhookOperationIdMissingError: ``registration`` has no + ``operation_id``. Skip this send; do not fabricate a value. + pydantic.ValidationError: ``registration`` is malformed, including a + stored ``operation_id`` outside the 1–255 ``[A-Za-z0-9_.:-]`` form. + """ + webhook = ( + registration + if isinstance(registration, ReportingWebhook) + else ReportingWebhook.model_validate(registration) + ) + if not webhook.operation_id: + result_dict = ( + result.model_dump(mode="json") if isinstance(result, PydanticBaseModel) else result + ) + media_buy_ids = _delivery_report_media_buy_ids(result_dict) + logger.warning( + "Suppressed media_buy_delivery webhook: reporting_webhook has no " + "buyer-supplied operation_id", + extra={ + "adcp_event": REPORTING_WEBHOOK_OPERATION_ID_MISSING, + "task_type": "media_buy_delivery", + "notification_type": result_dict.get("notification_type"), + "media_buy_ids": media_buy_ids, + }, + ) + raise ReportingWebhookOperationIdMissingError(media_buy_ids=media_buy_ids) + return create_mcp_webhook_payload( + task_id=task_id, + status=status, + task_type=TaskType.media_buy_delivery, + result=result, + timestamp=timestamp, + operation_id=webhook.operation_id, + message=message, + idempotency_key=idempotency_key, + token=webhook.token, + ) + + def get_adcp_signed_headers_for_webhook( headers: dict[str, Any], secret: str, @@ -2025,6 +2150,9 @@ def _validate_header_value(name: str, value: Any) -> None: # Sender — payload builders "create_a2a_webhook_payload", "create_mcp_webhook_payload", + "create_media_buy_delivery_webhook_payload", + "REPORTING_WEBHOOK_OPERATION_ID_MISSING", + "ReportingWebhookOperationIdMissingError", "generate_webhook_idempotency_key", "generate_webhook_challenge_value", "create_webhook_challenge_payload", diff --git a/tests/test_reporting_webhook_operation_id.py b/tests/test_reporting_webhook_operation_id.py index 2dfee4171..6727ce410 100644 --- a/tests/test_reporting_webhook_operation_id.py +++ b/tests/test_reporting_webhook_operation_id.py @@ -1,8 +1,9 @@ -"""Opt-in 3.2 reporting correlation survives validation and serialization.""" +"""Reporting-webhook operation_id: registration, storage, and scheduled delivery.""" from __future__ import annotations import json +import logging from pathlib import Path from typing import Any @@ -19,13 +20,22 @@ ControlMediaBuyRequest, CreateMediaBuyRequest, McpWebhookPayload, + MediaBuyDeliveryWebhookResult, + PushNotificationConfig, ReportingWebhook, UpdateMediaBuyRequest, ) from adcp.types.v32 import AcceptProposalRequest -from adcp.validation.schema_loader import get_portable_schema +from adcp.validation.schema_loader import get_named_validator, get_portable_schema from adcp.validation.version import resolve_bundle_key -from adcp.webhook_sender import WebhookSender +from adcp.webhooks import ( + REPORTING_WEBHOOK_OPERATION_ID_MISSING, + ReportingWebhookOperationIdMissingError, + WebhookSender, + create_mcp_webhook_payload, + create_media_buy_delivery_webhook_payload, + deliver, +) _AUTHENTICATION = { "schemes": ["Bearer"], @@ -165,6 +175,60 @@ def test_nested_registration_validation_rejects_bad_reporting_id( ) +_DELIVERY_REPORT = { + "notification_type": "scheduled", + "sequence_number": 3, + "reporting_period": { + "start": "2026-10-01T00:00:00Z", + "end": "2026-10-02T00:00:00Z", + }, + "currency": "USD", + "media_buy_deliveries": [ + { + "media_buy_id": "mb_001", + "status": "active", + "totals": {"impressions": 1000, "spend": 45.0}, + "by_package": [], + } + ], +} + + +def _validate_wire_delivery_webhook(body: bytes) -> dict[str, Any]: + """Validate the posted bytes against the pinned bundle, envelope and result.""" + wire = json.loads(body) + for path, value in ( + ("core/mcp-webhook-payload.json", wire), + ("media-buy/media-buy-delivery-webhook-result.json", wire["result"]), + ): + validator = get_named_validator(path, version="3.2") + assert validator is not None + validator.validate(value) + return wire + + +def test_reporting_operation_id_constraints_match_push_notification_config() -> None: + """AdCP 3.3 (adcp#7895) adopts push_notification_config.operation_id's constraints. + + Pinning parity keeps this 3.2 extension and the SDK 10 generated field in + agreement, so the forward-compat patch can retire without a wire change. + """ + reporting = ReportingWebhook.model_fields["operation_id"] + push = PushNotificationConfig.model_fields["operation_id"] + assert not reporting.is_required() + assert reporting.default is None + assert reporting.annotation == push.annotation + assert [repr(item) for item in reporting.metadata] == [repr(item) for item in push.metadata] + schema = ReportingWebhook.model_json_schema()["properties"]["operation_id"] + assert schema["anyOf"][0] == { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{1,255}$", + } + assert "MUST NOT emit `media_buy_delivery`" in schema["description"] + + @pytest.mark.asyncio async def test_stored_registration_correlates_delivery_and_retry(tmp_path: Path) -> None: registration = ReportingWebhook( @@ -172,18 +236,20 @@ async def test_stored_registration_correlates_delivery_and_retry(tmp_path: Path) authentication=_AUTHENTICATION, reporting_frequency="daily", operation_id="op_reporting_001", + token="buyer-reporting-echo-token", ) stored = tmp_path / "registration.json" stored.write_text(registration.model_dump_json()) - restored = ReportingWebhook.model_validate_json(stored.read_text()) - assert restored.operation_id is not None + restored = json.loads(stored.read_text()) received: list[bytes] = [] def receive(request: httpx.Request) -> httpx.Response: + assert str(request.url) == str(registration.url) assert request.headers["authorization"] == "Bearer buyer-reporting-token-1234567890" payload = McpWebhookPayload.model_validate_json(request.content) - assert payload.operation_id == restored.operation_id + assert payload.operation_id == "op_reporting_001" assert payload.task_type.value == "media_buy_delivery" + assert payload.status.value == "completed" received.append(request.content) return httpx.Response(200) @@ -191,25 +257,156 @@ def receive(request: httpx.Request) -> httpx.Response: async with WebhookSender.from_bearer_token( _AUTHENTICATION["credentials"], client=client ) as sender: - prepared = sender.prepare_mcp( - url=str(restored.url), - operation_id=restored.operation_id, - task_id="reporting-task-001", - task_type="media_buy_delivery", - status="completed", - result={ - "notification_type": "scheduled", - "reporting_period": { - "start": "2026-10-01T00:00:00Z", - "end": "2026-10-02T00:00:00Z", - }, - "media_buy_deliveries": [], - }, + prepared = sender.prepare_media_buy_delivery( + registration=restored, + task_id="delivery_report_mb_001_000003", + result=_DELIVERY_REPORT, ) assert (await sender.send_prepared(prepared)).ok assert (await sender.send_prepared(prepared)).ok assert received[0] == received[1] - assert json.loads(received[0])["idempotency_key"] != restored.operation_id + wire = _validate_wire_delivery_webhook(received[0]) + assert wire["operation_id"] == "op_reporting_001" + assert wire["token"] == "buyer-reporting-echo-token" + assert wire["result"] == _DELIVERY_REPORT + assert wire["idempotency_key"] != wire["operation_id"] + # Point-in-time delivery events carry no notification_id. + assert "notification_id" not in wire + + +@pytest.mark.asyncio +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +async def test_legacy_deliver_echoes_registered_operation_id() -> None: + registration = ReportingWebhook( + url="https://buyer.example/reporting", + authentication=_AUTHENTICATION, + reporting_frequency="daily", + operation_id="op_reporting_legacy", + ) + received: list[httpx.Request] = [] + + def receive(request: httpx.Request) -> httpx.Response: + received.append(request) + return httpx.Response(204) + + async with httpx.AsyncClient(transport=httpx.MockTransport(receive)) as client: + response = await deliver( + registration, + create_media_buy_delivery_webhook_payload( + registration, task_id="delivery_report_000001", result=_DELIVERY_REPORT + ), + client=client, + ) + assert response.status_code == 204 + wire = _validate_wire_delivery_webhook(received[0].content) + assert wire["operation_id"] == "op_reporting_legacy" + assert received[0].headers["authorization"] == f"Bearer {_AUTHENTICATION['credentials']}" + + +@pytest.mark.asyncio +async def test_scheduled_sends_skip_and_flag_registrations_without_operation_id( + caplog: pytest.LogCaptureFixture, +) -> None: + """A legacy registration is suppressed and flagged, never given a minted ID.""" + legacy = { + "url": "https://buyer.example/reporting/mb_legacy?op=not-a-source", + "authentication": _AUTHENTICATION, + "reporting_frequency": "daily", + } + current = {**legacy, "url": "https://buyer.example/reporting", "operation_id": "op_current"} + received: list[dict[str, Any]] = [] + skipped: list[ReportingWebhookOperationIdMissingError] = [] + + def receive(request: httpx.Request) -> httpx.Response: + received.append(json.loads(request.content)) + return httpx.Response(200) + + caplog.set_level(logging.WARNING, logger="adcp.webhooks") + async with httpx.AsyncClient(transport=httpx.MockTransport(receive)) as client: + async with WebhookSender.from_bearer_token( + _AUTHENTICATION["credentials"], client=client + ) as sender: + # A seller's scheduler loop over stored registrations. + for media_buy_id, stored in (("mb_legacy", legacy), ("mb_current", current)): + report = { + **_DELIVERY_REPORT, + "media_buy_deliveries": [ + { + **_DELIVERY_REPORT["media_buy_deliveries"][0], + "media_buy_id": media_buy_id, + } + ], + } + try: + result = await sender.send_media_buy_delivery( + registration=stored, + task_id=f"delivery_report_{media_buy_id}", + result=report, + ) + except ReportingWebhookOperationIdMissingError as exc: + skipped.append(exc) + continue + assert result.ok + + assert [payload["operation_id"] for payload in received] == ["op_current"] + assert len(skipped) == 1 + assert skipped[0].code == REPORTING_WEBHOOK_OPERATION_ID_MISSING + assert skipped[0].media_buy_ids == ("mb_legacy",) + [record] = [r for r in caplog.records if r.name == "adcp.webhooks"] + assert record.levelno == logging.WARNING + assert record.adcp_event == REPORTING_WEBHOOK_OPERATION_ID_MISSING # type: ignore[attr-defined] + assert record.task_type == "media_buy_delivery" # type: ignore[attr-defined] + assert record.notification_type == "scheduled" # type: ignore[attr-defined] + assert record.media_buy_ids == ("mb_legacy",) # type: ignore[attr-defined] + rendered = record.getMessage() + str(skipped[0]) + assert "buyer.example" not in rendered + assert _AUTHENTICATION["credentials"] not in rendered + + +def test_typed_registration_without_operation_id_is_refused() -> None: + registration = ReportingWebhook( + url="https://buyer.example/reporting", + authentication=_AUTHENTICATION, + reporting_frequency="daily", + ) + with pytest.raises(ReportingWebhookOperationIdMissingError): + create_media_buy_delivery_webhook_payload( + registration, + task_id="delivery_report_000001", + result=MediaBuyDeliveryWebhookResult.model_validate(_DELIVERY_REPORT), + ) + + +def test_tampered_stored_operation_id_fails_validation() -> None: + with pytest.raises(ValidationError, match="operation_id"): + WebhookSender.prepare_media_buy_delivery( + registration={ + "url": "https://buyer.example/reporting", + "authentication": _AUTHENTICATION, + "reporting_frequency": "daily", + "operation_id": "not valid", + }, + task_id="delivery_report_000001", + result=_DELIVERY_REPORT, + ) + + +def test_generic_mcp_builder_omits_terminal_id_only_for_delivery_reports() -> None: + delivery = create_mcp_webhook_payload( + task_id="delivery_report_000001", + status="completed", + task_type="media_buy_delivery", + operation_id="op_reporting_001", + result=_DELIVERY_REPORT, + ) + task = create_mcp_webhook_payload( + task_id="task_001", + status="completed", + task_type="create_media_buy", + operation_id="op_task_001", + ) + assert delivery.notification_id is None + assert task.notification_id == "task_001.terminal" def test_beta6_versioned_request_schema_preserves_reporting_operation_id() -> None: From 38d046a809bab9c684d8c0cac761f8d59a50072d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 22 Oct 2026 23:01:38 -0400 Subject: [PATCH 08/54] feat(reporting): sync empty-ledger replacement and owned-ledger rollback for 9.0 adopters (#1554) * feat(reporting): replace empty ledgers from sync migrations, and roll back the owned ledger Add replace_empty_legacy_reporting_sync for Alembic and other synchronous migration frameworks. It accepts a sync psycopg 3 or psycopg2 connection, or a SQLAlchemy Connection on either driver (op.get_bind()), and joins the caller's transaction in a savepoint, so the drop commits atomically with the revision's version bump and a refusal leaves the caller's transaction usable. It never starts an event loop. Add replace_empty_owned_reporting (async and sync), the reverse: under the same isolation check, schema advisory lock and ACCESS EXCLUSIVE table locks it drops an empty caller-owned ledger so an 8.x image can start again. Rows written by installation and startup (the unpruned change-feed horizon, row-storage bindings) do not count as state; a registered quarantine archive is refused. All four entry points share one statement generator, so the sync and async paths enforce identical guards. Functions that reference a reporting table or its row type are now dropped before the tables, and the rest after them, so CHECK constraints on packaged functions no longer block removal. The migration guide gains an Alembic recipe and an 8.x rollback section. Refs #1449, #1441 Co-Authored-By: Claude Opus 5.5 (1M context) * docs(types): use Format to read and publish declarations, ProductFormatDeclaration to author them Stored declarations can predate the strict root-schema check, so grading them on every read turns data quality into failed product listings. Say plainly that Format is the lenient read and publish path and ProductFormatDeclaration the authoring check, and show how to report issues without failing the read. Refs #1449 Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- MIGRATION_v8_to_v9.md | 2 +- docs/reporting-caller-ownership-migration.md | 93 ++++ docs/types-9-migration.md | 54 +- pyproject.toml | 3 + src/adcp/reporting/migration.py | 467 +++++++++++++----- .../reporting/_empty_ledger_support.py | 236 +++++++++ .../test_reporting_empty_ledger_alembic.py | 131 +++++ ...test_reporting_empty_legacy_replacement.py | 21 + ...reporting_empty_legacy_replacement_sync.py | 242 +++++++++ .../test_reporting_empty_owned_replacement.py | 304 ++++++++++++ tests/test_product_format_declaration.py | 39 ++ .../type_checks/reporting_caller_ownership.py | 10 + 12 files changed, 1462 insertions(+), 140 deletions(-) create mode 100644 tests/conformance/reporting/_empty_ledger_support.py create mode 100644 tests/conformance/reporting/test_reporting_empty_ledger_alembic.py create mode 100644 tests/conformance/reporting/test_reporting_empty_legacy_replacement_sync.py create mode 100644 tests/conformance/reporting/test_reporting_empty_owned_replacement.py diff --git a/MIGRATION_v8_to_v9.md b/MIGRATION_v8_to_v9.md index 36b1095ff..85fd16530 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. 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). | +| `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. `Format` stays the lenient path for reading and publishing stored declarations; `ProductFormatDeclaration` is the authoring check. See [product declarations](docs/types-9-migration.md#product-declarations-are-graded-against-their-root-schema) and [strict authoring and lenient publishing](docs/types-9-migration.md#strict-authoring-lenient-reading-and-publishing). | | 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/reporting-caller-ownership-migration.md b/docs/reporting-caller-ownership-migration.md index 80a3c7ae0..09f2f4e37 100644 --- a/docs/reporting-caller-ownership-migration.md +++ b/docs/reporting-caller-ownership-migration.md @@ -92,6 +92,99 @@ Keep reporting admission disabled until every process uses the owned API. The table locks provide database quiescence for the empty replacement; they do not make mixed-version reporting safe afterward. +With no transaction open on the connection, the async function commits its own +transaction, whether or not the connection is in autocommit mode. Inside a +transaction already open on the connection, it runs as a savepoint of that +transaction and commits with it. + +### From an Alembic revision + +`replace_empty_legacy_reporting_sync` is the same replacement for synchronous +migration frameworks. Pass it Alembic's `op.get_bind()`: a SQLAlchemy +`Connection` on psycopg2 (the default `postgresql://` driver) or psycopg 3 +(`postgresql+psycopg://`). A raw sync psycopg 3 or psycopg2 connection also +works. Unless the connection is in autocommit mode with no transaction open, +the replacement joins the caller's transaction and never commits it. The drop +therefore commits atomically +with the revision's version bump. If anything later in the run fails, both roll +back. It never starts an event loop, so it is safe when the migration runner is +itself called from async code. + +```python +"""Replace the empty pre-ownership reporting ledger.""" + +import sqlalchemy as sa +from alembic import op + +from adcp.reporting.migration import ( + replace_empty_legacy_reporting_sync, + replace_empty_owned_reporting_sync, +) + + +def _ledger_columns() -> set[str]: + inspector = sa.inspect(op.get_bind()) + if not inspector.has_table("reporting_configurations"): + return set() + return {column["name"] for column in inspector.get_columns("reporting_configurations")} + + +def upgrade() -> None: + columns = _ledger_columns() + if columns and "consumer_id" not in columns: # an 8.x ledger + replace_empty_legacy_reporting_sync(op.get_bind()) + + +def downgrade() -> None: + if "consumer_id" in _ledger_columns(): # see "Rolling back to an 8.x image" + replace_empty_owned_reporting_sync(op.get_bind()) +``` + +The shape check only makes the revision a no-op on a fresh database. The helpers +re-verify the shape under the schema lock and refuse anything unexpected. The +table locks are held until the migration transaction commits. To fail fast +rather than wait behind running queries, run `SET LOCAL lock_timeout = '15s'` +before the call. Do not call `create_schema()` in the same revision. It uses +another connection, which waits for the schema lock this transaction holds while +the revision waits for it. PostgreSQL cannot detect that deadlock, because it +spans two client connections, so the migration hangs. The owned schema is +created when the service starts, as usual. A refusal rolls back +only the helper's own savepoint, and the exception then fails the revision. +Offline (`--sql`) mode has no connection and is not supported. + +## Rolling back to an 8.x image + +An 8.x image cannot run against the caller-owned ledger. Its startup keeps the +existing owned tables, and its INSERTs omit `consumer_id`. Before deploying an +8.x image again: + +1. Stop every SDK 9 reporting process, including admission, readers and workers. + Keep them stopped until the 8.x image is deployed. +2. If the owned ledger never admitted any reporting state, remove it with + `replace_empty_owned_reporting(operator_connection)`. In an Alembic + `downgrade()`, use `replace_empty_owned_reporting_sync(op.get_bind())` as + shown above. Both give the same guarantees as the replacement: READ COMMITTED + isolation, the schema advisory lock, and `ACCESS EXCLUSIVE` on every + `reporting_*` and `adcp_reporting_*` table before any row is checked. They + then remove those tables, sequences and functions. Rows that installation and + startup write do not count as state: the change-feed horizon before its first + prune, and registered row-storage bindings. `adcp_persistence_installation`, + the row-storage installation identity, is kept. 8.x ignores it, and a later + upgrade reuses it. +3. Deploy the 8.x image. It creates its own ledger on start. To upgrade again + later, follow [An entirely empty ledger](#an-entirely-empty-ledger). + +The removal refuses, and changes nothing, when any reporting table holds another +row, when active row security could hide rows, or when objects outside the +reporting surface depend on it. An 8.x image cannot represent owned rows, so +there is no supported downgrade of a populated ledger. Keep running SDK 9, or +restore the backup taken before the upgrade, which loses everything written +since. A ledger created by `migrate_legacy_reporting` is refused with a specific +error, because the archive registry names its quarantine schema. The legacy rows +live in that archive, and removing the owned ledger would not restore them. +Restore the pre-upgrade backup instead. Do not move archived tables back by +hand: they carry the quarantine triggers and the maintenance role's ownership. + ## Rolling deployment boundary The owned configuration and obligation tables require `consumer_id NOT NULL`. diff --git a/docs/types-9-migration.md b/docs/types-9-migration.md index f5a1db5bc..0f99968e7 100644 --- a/docs/types-9-migration.md +++ b/docs/types-9-migration.md @@ -167,12 +167,58 @@ 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. +#### Strict authoring, lenient reading and publishing + +The two classes have different jobs: + +- **`Format` is the lenient read and publish path.** It accepts any + `format_kind` string and an open `params` bag, as adcp 8 did. Use it to load + stored declarations, to publish them on `Product.format_options`, and to + consume another agent's declarations. +- **`ProductFormatDeclaration` is the strict authoring check.** It grades a + declaration against the root schema. Use it where a declaration is created or + edited, so you can refuse it before you store it. + +Stored data can predate the strict check. Examples are a declaration written +before 9.0, or a malformed `params` bag a seller chose to pass through. Either +can fail `ProductFormatDeclaration` even though the declaration served fine +under adcp 8. Grading those on every read turns a data-quality issue into a +failed product listing, so publish stored declarations through `Format`: + +```python +from adcp.types import Format, Product, ProductFormatDeclaration + +# Authoring: refuse what the schema refuses, before it is stored. +declaration = ProductFormatDeclaration.model_validate(submitted) + +# Reading and publishing stored options: lenient, as in adcp 8. +options = [Format.model_validate(raw) for raw in stored_options] +product = Product(product_id="p1", format_options=options, ...) +``` + +To report data quality without failing the read, run the strict check and keep +its errors: + +```python +from pydantic import ValidationError + +def declaration_issues(raw: dict) -> list[dict]: + try: + ProductFormatDeclaration.model_validate(raw) + except ValidationError as exc: + return exc.errors() # a root-rule failure's "type" is its JSON Schema keyword + return [] +``` + +The list includes every field error, but the root rules stop at their first +failure. Treat it as a signal that the declaration needs attention, not as a +complete inventory of what is wrong. + #### 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`. +`Product.format_options` is `list[Format]`. When a declaration has just passed +the strict check, publish the validated object itself. There is no conversion +step: a `ProductFormatDeclaration` *is* a `Format`. ```python from adcp.types import Product, ProductFormatDeclaration diff --git a/pyproject.toml b/pyproject.toml index 9af5e1913..8317e6be6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -177,6 +177,9 @@ dev = [ # propagate. Required at runtime by examples/v3_reference_seller/ # src/app.py and seed.py. "alembic>=1.13.0", + # SQLAlchemy's default PostgreSQL driver: the sync reporting replacements + # are tested inside Alembic revisions on it as well as on psycopg 3. + "psycopg2-binary>=2.9.9", "pre-commit>=4.4.0", # In-memory span exporter used by the optional observability tests. The # library itself imports only opentelemetry-api and never configures an SDK. diff --git a/src/adcp/reporting/migration.py b/src/adcp/reporting/migration.py index c6e0acde0..aeebc5d9c 100644 --- a/src/adcp/reporting/migration.py +++ b/src/adcp/reporting/migration.py @@ -7,8 +7,10 @@ from __future__ import annotations +import contextlib import hashlib import re +from collections.abc import Generator from dataclasses import dataclass from importlib.resources import files from typing import Any, Literal @@ -23,6 +25,9 @@ "legacy_generation_digest", "migrate_legacy_reporting", "replace_empty_legacy_reporting", + "replace_empty_legacy_reporting_sync", + "replace_empty_owned_reporting", + "replace_empty_owned_reporting_sync", ] @@ -81,6 +86,290 @@ async def require_owned_schema_or_empty(connection: Any) -> None: ) +@dataclass(frozen=True) +class _Statement: + """One statement of an empty-ledger replacement, as either driver runs it.""" + + query: Any + params: tuple[Any, ...] | None = None + returns_rows: bool = False + + +_Rows = list[tuple[Any, ...]] +# A replacement is written once, as a generator that yields each statement and +# is sent back its rows. Every guard and every statement lives there; the sync +# and async drivers below only execute, so both enforce the same checks in the +# same order. ``sql`` is the driver's composition module: psycopg 3 and psycopg2 +# expose the same ``sql.SQL``/``sql.Identifier`` API. +_Steps = Generator[_Statement, _Rows, None] + +_SCHEMA_LOCK = ( + "SELECT pg_advisory_xact_lock(hashtext('adcp.reporting.schema'),hashtext(current_schema()))" +) +_ARCHIVE_REGISTRY = "adcp_reporting_ownership_archives" +# Rows that schema installation or service startup writes, by the predicate they +# match before any reporting happens: the change-feed horizon singleton until the +# first prune, and the row-storage bindings each boot registers (row bytes are +# tracked in the row-set, chunk and write-intent tables, which must be empty). +# They are configuration, not reporting state, so alone they leave a ledger empty. +_STARTUP_ROWS = { + "adcp_reporting_change_horizon": "pruned_through_seq = 0 AND pruned_through_xid = '0'", + "adcp_reporting_row_bindings": "TRUE", +} +_DEPENDENT_OBJECTS_STILL_EXIST = "2BP01" +_SAVEPOINT = "adcp_reporting_replacement" + + +def _query(query: Any, params: tuple[Any, ...] | None = None) -> _Statement: + return _Statement(query, params, returns_rows=True) + + +def _drop_functions(sql: Any, names: list[str]) -> _Statement: + # regprocedure text is emitted by PostgreSQL, never caller-supplied SQL. + return _Statement( + sql.SQL("DROP FUNCTION {} RESTRICT").format(sql.SQL(", ").join(map(sql.SQL, names))) + ) + + +def _replacement_steps(sql: Any, *, owned: bool) -> _Steps: + """Lock, verify and drop an empty reporting surface of the expected shape. + + ``owned=False`` removes a pre-ownership (8.x) ledger before ``create_schema``; + ``owned=True`` removes the caller-owned ledger before an 8.x image starts. + """ + surface = "owned" if owned else "legacy" + isolation = (yield _query("SHOW transaction_isolation"))[0][0] + if isolation != "read committed": + raise ReportingOwnershipMigrationError( + f"empty {surface} replacement requires READ COMMITTED isolation" + ) + yield _Statement(_SCHEMA_LOCK) + current = (yield _query("SELECT current_schema()"))[0][0] + columns = yield _query( + "SELECT column_name FROM information_schema.columns WHERE table_schema=%s" + " AND table_name='reporting_configurations'", + (current,), + ) + if owned: + if ("consumer_id",) not in columns: + raise ReportingOwnershipMigrationError("expected a caller-owned reporting schema") + elif not columns or ("consumer_id",) in columns: + raise ReportingOwnershipMigrationError("expected an unmigrated legacy reporting schema") + tables = yield _query( + "SELECT c.oid, c.relname, c.relkind FROM pg_class c JOIN pg_namespace n" + " ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relkind IN ('r','p','f')" + " AND (c.relname LIKE 'reporting\\_%%' ESCAPE '\\'" + " OR c.relname LIKE 'adcp\\_reporting\\_%%' ESCAPE '\\') ORDER BY c.relname", + (current,), + ) + if any(kind == "f" for _, _, kind in tables): + raise ReportingOwnershipMigrationError("foreign reporting tables cannot be verified") + oids = [oid for oid, _, _ in tables] + for _, table, _ in tables: + yield _Statement( + sql.SQL("LOCK TABLE {} IN ACCESS EXCLUSIVE MODE").format(sql.Identifier(current, table)) + ) + hidden_rows = yield _query( + "SELECT 1 FROM pg_class WHERE oid=ANY(%s::oid[]) AND row_security_active(oid) LIMIT 1", + (oids,), + ) + if hidden_rows: + raise ReportingOwnershipMigrationError( + f"row security prevents verifying all {surface} reporting rows" + ) + # Dropping a partitioned table can implicitly remove children. Only + # children in the same verified reporting surface may be removed. + outside_children = yield _query( + "SELECT 1 FROM pg_inherits WHERE inhparent=ANY(%s::oid[])" + " AND NOT inhrelid=ANY(%s::oid[]) LIMIT 1", + (oids, oids), + ) + if outside_children: + raise ReportingOwnershipMigrationError("reporting tables have external children") + if owned and any(table == _ARCHIVE_REGISTRY for _, table, _ in tables): + # A registered archive means migrate_legacy_reporting moved a populated + # legacy ledger aside. Dropping the registry would orphan that archive, + # and the legacy rows are not restored by removing the owned ledger. + registered = yield _query( + sql.SQL("SELECT 1 FROM {} LIMIT 1").format(sql.Identifier(current, _ARCHIVE_REGISTRY)) + ) + if registered: + raise ReportingOwnershipMigrationError( + "owned reporting registers a legacy quarantine archive; restore the" + " pre-upgrade backup instead" + ) + for _, table, _ in tables: + rows = sql.SQL("SELECT 1 FROM {}").format(sql.Identifier(current, table)) + if table in _STARTUP_ROWS: + rows += sql.SQL(" WHERE NOT ({})").format(sql.SQL(_STARTUP_ROWS[table])) + populated = yield _query(rows + sql.SQL(" LIMIT 1")) + if populated: + raise ReportingOwnershipMigrationError( + "owned reporting contains rows; nothing was removed" + if owned + else "legacy reporting contains rows; use migrate_legacy_reporting" + ) + triggers = yield _query( + "SELECT t.tgname, c.relname FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid" + " WHERE t.tgrelid=ANY(%s::oid[]) AND NOT t.tgisinternal", + (oids,), + ) + # Remove only triggers on the locked, verified empty tables. + for trigger, table in triggers: + yield _Statement( + sql.SQL("DROP TRIGGER {} ON {} RESTRICT").format( + sql.Identifier(trigger), sql.Identifier(current, table) + ) + ) + # A function that takes a table's row type (or an array of it), or whose + # SQL-standard body references a table, depends on it and goes before the + # tables. A CHECK constraint depends on the function it calls, so every + # other function goes after them. + functions = yield _query( + "SELECT p.oid::regprocedure::text, EXISTS (SELECT 1 FROM pg_depend d" + " LEFT JOIN pg_type t ON d.refclassid='pg_type'::regclass AND t.oid=d.refobjid" + " LEFT JOIN pg_type e ON e.oid=t.typelem" + " WHERE d.classid='pg_proc'::regclass AND d.objid=p.oid" + " AND (d.refclassid='pg_class'::regclass AND d.refobjid=ANY(%s::oid[])" + " OR t.typrelid=ANY(%s::oid[]) OR e.typrelid=ANY(%s::oid[])))" + " FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace WHERE n.nspname=%s" + " AND (p.proname LIKE 'reporting\\_%%' ESCAPE '\\'" + " OR p.proname LIKE 'adcp\\_reporting\\_%%' ESCAPE '\\') ORDER BY 1", + (oids, oids, oids, current), + ) + row_typed = [name for name, typed in functions if typed] + if row_typed: + yield _drop_functions(sql, row_typed) + if tables: + yield _Statement( + sql.SQL("DROP TABLE {} RESTRICT").format( + sql.SQL(", ").join(sql.Identifier(current, table) for _, table, _ in tables) + ) + ) + untyped = [name for name, typed in functions if not typed] + if untyped: + yield _drop_functions(sql, untyped) + sequences = yield _query( + "SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace" + " WHERE n.nspname=%s AND c.relkind='S'" + " AND (c.relname LIKE 'reporting\\_%%' ESCAPE '\\'" + " OR c.relname LIKE 'adcp\\_reporting\\_%%' ESCAPE '\\')", + (current,), + ) + if sequences: + yield _Statement( + sql.SQL("DROP SEQUENCE {} RESTRICT").format( + sql.SQL(", ").join(sql.Identifier(current, name) for (name,) in sequences) + ) + ) + + +def _dependency_error(*, owned: bool) -> ReportingOwnershipMigrationError: + surface, outcome = ("owned", "removed") if owned else ("legacy", "replaced") + return ReportingOwnershipMigrationError( + f"{surface} reporting has external dependencies; nothing was {outcome}" + ) + + +def _is_dependency_failure(exc: BaseException) -> bool: + # psycopg 3 exposes the SQLSTATE as ``sqlstate``, psycopg2 as ``pgcode``. + code = getattr(exc, "sqlstate", None) or getattr(exc, "pgcode", None) + return code == _DEPENDENT_OBJECTS_STILL_EXIST + + +async def _replace_async(connection: Any, *, owned: bool) -> None: + from psycopg import sql + + steps = _replacement_steps(sql, owned=owned) + try: + async with connection.transaction(): + statement = next(steps) + while True: + cursor = await connection.execute(statement.query, statement.params) + rows = await cursor.fetchall() if statement.returns_rows else [] + try: + statement = steps.send(rows) + except StopIteration: + break + except Exception as exc: + if _is_dependency_failure(exc): + raise _dependency_error(owned=owned) from exc + raise + + +def _sync_driver(connection: Any) -> tuple[Any, Any]: + """Return the DB-API connection to run on and its ``sql`` composition module. + + A SQLAlchemy ``Connection`` (Alembic's ``op.get_bind()``) is unwrapped to its + driver connection, which carries the same transaction. As any statement on + it would, this begins the SQLAlchemy transaction if none is open. + """ + driver = connection + if hasattr(getattr(connection, "connection", None), "driver_connection"): + if not connection.in_transaction(): + connection.begin() + driver = connection.connection.driver_connection + try: + import psycopg + except ImportError: # pragma: no cover - psycopg2-only installation + pass + else: + if isinstance(driver, psycopg.Connection): + return driver, psycopg.sql + try: + import psycopg2.extensions # type: ignore[import-untyped,import-not-found,unused-ignore] + import psycopg2.sql # type: ignore[import-untyped,import-not-found,unused-ignore] + except ImportError: + pass + else: + if isinstance(driver, psycopg2.extensions.connection): + return driver, psycopg2.sql + raise TypeError( + "the sync replacement needs a sync psycopg 3 or psycopg2 connection, or a" + " SQLAlchemy Connection on either driver; await the async variant otherwise" + ) + + +def _replace_sync(connection: Any, *, owned: bool) -> None: + driver, sql = _sync_driver(connection) + steps = _replacement_steps(sql, owned=owned) + # Outside any transaction on an autocommit connection, the replacement is its + # own transaction. Otherwise it joins the caller's (the driver opens one on a + # non-autocommit connection), never commits it, and runs in a savepoint so a + # refusal leaves the caller's transaction usable. Both drivers report libpq's + # transaction status, where 0 is idle. + if driver.autocommit and driver.info.transaction_status == 0: + undo: tuple[str, ...] = ("ROLLBACK",) + begin, release = "BEGIN", "COMMIT" + else: + begin = f"SAVEPOINT {_SAVEPOINT}" + release = f"RELEASE SAVEPOINT {_SAVEPOINT}" + undo = (f"ROLLBACK TO SAVEPOINT {_SAVEPOINT}", release) + try: + with driver.cursor() as cursor: + cursor.execute(begin) + try: + statement = next(steps) + while True: + cursor.execute(statement.query, statement.params) + rows = cursor.fetchall() if statement.returns_rows else [] + try: + statement = steps.send(rows) + except StopIteration: + break + except BaseException: + # A broken connection cannot undo; report the original failure. + with contextlib.suppress(Exception): + for command in undo: + cursor.execute(command) + raise + cursor.execute(release) + except Exception as exc: + if _is_dependency_failure(exc): + raise _dependency_error(owned=owned) from exc + raise + + async def replace_empty_legacy_reporting(connection: Any) -> None: """Remove an entirely empty pre-ownership ledger without creating an archive. @@ -89,143 +378,51 @@ async def replace_empty_legacy_reporting(connection: Any) -> None: Populated ledgers and dependencies outside the reporting surface are refused; failure rolls back every drop. Reporting admission must remain disabled until all processes use the caller-owned API. + + With no transaction open on the connection, this commits its own + transaction, on an autocommit connection or not. Inside a transaction + already open on the connection, it runs as a savepoint of it. + From a synchronous migration such as an Alembic revision, use + :func:`replace_empty_legacy_reporting_sync`. """ - from psycopg import errors, sql + await _replace_async(connection, owned=False) - try: - async with connection.transaction(): - isolation = (await (await connection.execute("SHOW transaction_isolation")).fetchone())[ - 0 - ] - if isolation != "read committed": - raise ReportingOwnershipMigrationError( - "empty legacy replacement requires READ COMMITTED isolation" - ) - await connection.execute( - "SELECT pg_advisory_xact_lock(hashtext('adcp.reporting.schema')," - "hashtext(current_schema()))" - ) - current = (await (await connection.execute("SELECT current_schema()")).fetchone())[0] - columns = await ( - await connection.execute( - "SELECT column_name FROM information_schema.columns WHERE table_schema=%s" - " AND table_name='reporting_configurations'", - (current,), - ) - ).fetchall() - if not columns or ("consumer_id",) in columns: - raise ReportingOwnershipMigrationError( - "expected an unmigrated legacy reporting schema" - ) - tables = await ( - await connection.execute( - "SELECT c.oid, c.relname, c.relkind FROM pg_class c JOIN pg_namespace n" - " ON n.oid=c.relnamespace WHERE n.nspname=%s AND c.relkind IN ('r','p','f')" - " AND (c.relname LIKE 'reporting\\_%%' ESCAPE '\\'" - " OR c.relname LIKE 'adcp\\_reporting\\_%%' ESCAPE '\\') ORDER BY c.relname", - (current,), - ) - ).fetchall() - if any(kind == "f" for _, _, kind in tables): - raise ReportingOwnershipMigrationError( - "foreign reporting tables cannot be verified" - ) - for _, table, _ in tables: - await connection.execute( - sql.SQL("LOCK TABLE {}.{} IN ACCESS EXCLUSIVE MODE").format( - sql.Identifier(current), sql.Identifier(table) - ) - ) - hidden_rows = await ( - await connection.execute( - "SELECT 1 FROM pg_class WHERE oid=ANY(%s) AND row_security_active(oid) LIMIT 1", - ([oid for oid, _, _ in tables],), - ) - ).fetchone() - if hidden_rows: - raise ReportingOwnershipMigrationError( - "row security prevents verifying all legacy reporting rows" - ) - # Dropping a partitioned table can implicitly remove children. Only - # children in the same verified reporting surface may be removed. - outside_children = await ( - await connection.execute( - "SELECT 1 FROM pg_inherits WHERE inhparent=ANY(%s)" - " AND NOT inhrelid=ANY(%s) LIMIT 1", - ([oid for oid, _, _ in tables], [oid for oid, _, _ in tables]), - ) - ).fetchone() - if outside_children: - raise ReportingOwnershipMigrationError("reporting tables have external children") - for _, table, _ in tables: - populated = await ( - await connection.execute( - sql.SQL("SELECT 1 FROM {}.{} LIMIT 1").format( - sql.Identifier(current), sql.Identifier(table) - ) - ) - ).fetchone() - if populated: - raise ReportingOwnershipMigrationError( - "legacy reporting contains rows; use migrate_legacy_reporting" - ) - triggers = await ( - await connection.execute( - "SELECT t.tgname, c.relname FROM pg_trigger t JOIN pg_class c" - " ON c.oid=t.tgrelid" - " WHERE t.tgrelid=ANY(%s) AND NOT t.tgisinternal", - ([oid for oid, _, _ in tables],), - ) - ).fetchall() - # Trigger functions can accept a reporting table's composite type. - # Remove only triggers on the locked, verified empty tables, then - # drop functions before their argument types disappear with tables. - for trigger, table in triggers: - await connection.execute( - sql.SQL("DROP TRIGGER {} ON {}.{} RESTRICT").format( - sql.Identifier(trigger), sql.Identifier(current), sql.Identifier(table) - ) - ) - functions = await ( - await connection.execute( - "SELECT p.oid::regprocedure::text FROM pg_proc p JOIN pg_namespace n" - " ON n.oid=p.pronamespace WHERE n.nspname=%s" - " AND (p.proname LIKE 'reporting\\_%%' ESCAPE '\\'" - " OR p.proname LIKE 'adcp\\_reporting\\_%%' ESCAPE '\\')", - (current,), - ) - ).fetchall() - if functions: - # regprocedure is emitted by PostgreSQL, never caller-supplied SQL. - await connection.execute( - sql.SQL("DROP FUNCTION {} RESTRICT").format( - sql.SQL(", ").join(sql.SQL(name) for (name,) in functions) - ) - ) - await connection.execute( - sql.SQL("DROP TABLE {} RESTRICT").format( - sql.SQL(", ").join(sql.Identifier(current, table) for _, table, _ in tables) - ) - ) - sequences = await ( - await connection.execute( - "SELECT c.relname FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace" - " WHERE n.nspname=%s AND c.relkind='S'" - " AND (c.relname LIKE 'reporting\\_%%' ESCAPE '\\'" - " OR c.relname LIKE 'adcp\\_reporting\\_%%' ESCAPE '\\')", - (current,), - ) - ).fetchall() - if sequences: - await connection.execute( - sql.SQL("DROP SEQUENCE {} RESTRICT").format( - sql.SQL(", ").join(sql.Identifier(current, name) for (name,) in sequences) - ) - ) - except errors.DependentObjectsStillExist as exc: - raise ReportingOwnershipMigrationError( - "legacy reporting has external dependencies; nothing was replaced" - ) from exc + +def replace_empty_legacy_reporting_sync(connection: Any) -> None: + """Synchronous :func:`replace_empty_legacy_reporting`, inside the caller's transaction. + + Accepts a sync psycopg 3 or psycopg2 connection, or a SQLAlchemy + ``Connection`` on either driver, such as Alembic's ``op.get_bind()``. Unless + the connection is in autocommit mode with no transaction open, the drop joins + the caller's transaction and commits with it, so it is atomic with a + migration's version bump; the locks are held until then. A refusal rolls back only this + function's savepoint. It never starts an event loop. + """ + _replace_sync(connection, owned=False) + + +async def replace_empty_owned_reporting(connection: Any) -> None: + """Remove an entirely empty caller-owned ledger, so an 8.x image can start. + + The reverse of :func:`replace_empty_legacy_reporting`, with the same + guarantees: READ COMMITTED isolation, the schema advisory lock, then + ``ACCESS EXCLUSIVE`` on every ``reporting_*`` and ``adcp_reporting_*`` table + before any row is checked. A populated table, a registered legacy + quarantine archive, active row security or a dependency outside the + reporting surface is refused, and nothing is removed. Every reporting + process must be stopped and stay stopped until the 8.x image is deployed. + """ + await _replace_async(connection, owned=True) + + +def replace_empty_owned_reporting_sync(connection: Any) -> None: + """Synchronous :func:`replace_empty_owned_reporting`, inside the caller's transaction. + + Accepts the same connections, with the same transaction behaviour, as + :func:`replace_empty_legacy_reporting_sync` — for example from an Alembic + ``downgrade()``. + """ + _replace_sync(connection, owned=True) async def migrate_legacy_reporting( diff --git a/tests/conformance/reporting/_empty_ledger_support.py b/tests/conformance/reporting/_empty_ledger_support.py new file mode 100644 index 000000000..e0443da4a --- /dev/null +++ b/tests/conformance/reporting/_empty_ledger_support.py @@ -0,0 +1,236 @@ +"""Sync connections for the empty-ledger replacements, on every supported driver. + +Each test owns one random schema. ``DRIVERS`` names the connections an Alembic +revision can hand the sync helpers: raw psycopg 3 and psycopg2 connections, and +SQLAlchemy connections over either driver. +""" + +from __future__ import annotations + +import asyncio +import os +import secrets +import threading +import time +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from importlib.resources import files +from pathlib import Path +from typing import Any + +import pytest + +DRIVERS = ("psycopg", "psycopg2", "sqlalchemy+psycopg", "sqlalchemy+psycopg2") + +# Every packaged reporting migration, in an order that installs: the complete +# owned surface any combination of stores can create. +OWNED_SQL = ( + "reporting_ledger.sql", + "reporting_ledger_account_generations.sql", + "reporting_ledger_obligation_currency.sql", + "reporting_ledger_reconciliation.sql", + "reporting_notification_outbox.sql", + "reporting_webhook_activity.sql", + "reporting_provisional_observations.sql", + "reporting_materializer.sql", + "reporting_receipt_ingestion.sql", + "reporting_feed.sql", + "reporting_status_notifications.sql", + "reporting_status_selector_version.sql", + "reporting_projection.sql", + "reporting_projection_notifications.sql", + "reporting_projection_feed.sql", + "reporting_production.sql", + "reporting_caller_ownership.sql", + "reporting_changes.sql", + "reporting_period_retirement.sql", + "reporting_row_storage.sql", + "reporting_row_migration.sql", + "reporting_inline_storage.sql", + "reporting_buyer_submissions.sql", +) +LEGACY_SQL = ( + "reporting_ledger.sql", + "reporting_ledger_obligation_currency.sql", + "reporting_ledger_reconciliation.sql", +) +_LEGACY_ROOT = Path(__file__).parents[2] / "fixtures/reporting_ownership" + +SURFACE = ( + "SELECT count(*) FROM pg_class c JOIN pg_namespace n ON n.oid=c.relnamespace" + " WHERE n.nspname=current_schema() AND c.relkind IN ('r','p','S')" + " AND c.relname ~ '^(adcp_)?reporting_'" +) +FUNCTIONS = ( + "SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid=p.pronamespace" + " WHERE n.nspname=current_schema()" +) + + +def database_url() -> str: + url = os.environ.get("ADCP_PG_TEST_URL") + if not url: + pytest.skip("ADCP_PG_TEST_URL not set — requires real PostgreSQL") + pytest.importorskip("psycopg") + return url + + +def require_driver(driver: str) -> None: + database_url() + if driver.startswith("sqlalchemy"): + pytest.importorskip("sqlalchemy") + if driver.endswith("psycopg2"): + pytest.importorskip("psycopg2") + + +@contextmanager +def isolated_schema() -> Iterator[str]: + import psycopg + from psycopg import sql + + schema = f"adcp_reporting_empty_{secrets.token_hex(6)}" + with psycopg.connect(database_url(), autocommit=True) as admin: + admin.execute(sql.SQL("CREATE SCHEMA {}").format(sql.Identifier(schema))) + try: + yield schema + finally: + admin.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(sql.Identifier(schema))) + + +def _options(schema: str) -> str: + return f"-csearch_path={schema} -cstatement_timeout=15000" + + +def connect(schema: str, *, autocommit: bool = False) -> Any: + """A sync psycopg 3 connection bound to ``schema``.""" + import psycopg + + return psycopg.connect(database_url(), autocommit=autocommit, options=_options(schema)) + + +@dataclass +class Session: + """One caller connection: ``bind`` is what the sync helper receives.""" + + bind: Any + run: Callable[[str], list[tuple[Any, ...]]] + commit: Callable[[], None] + rollback: Callable[[], None] + + +@contextmanager +def session(schema: str, driver: str) -> Iterator[Session]: + """A non-autocommit caller connection: the helper must join its transaction.""" + url = database_url() + if driver.startswith("sqlalchemy"): + from sqlalchemy import create_engine, make_url + from sqlalchemy.pool import NullPool + + dialect = "postgresql+psycopg2" if driver.endswith("psycopg2") else "postgresql+psycopg" + engine = create_engine( + make_url(url).set(drivername=dialect), + poolclass=NullPool, + connect_args={"options": _options(schema)}, + ) + try: + with engine.connect() as connection: + + def run(statement: str) -> list[tuple[Any, ...]]: + result = connection.exec_driver_sql(statement) + return [tuple(row) for row in result] if result.returns_rows else [] + + yield Session(connection, run, connection.commit, connection.rollback) + finally: + engine.dispose() + return + if driver == "psycopg2": + import psycopg2 + + raw = psycopg2.connect(url, options=_options(schema)) + else: + raw = connect(schema) + + def run_raw(statement: str) -> list[tuple[Any, ...]]: + with raw.cursor() as cursor: + cursor.execute(statement) + return list(cursor.fetchall()) if cursor.description else [] + + try: + yield Session(raw, run_raw, raw.commit, raw.rollback) + finally: + raw.close() + + +def install_legacy(connection: Any) -> None: + """The pre-ownership (8.x) ledger, as an 8.x image creates it.""" + for name in LEGACY_SQL: + connection.execute((_LEGACY_ROOT / name).read_text()) + + +def install_owned(connection: Any) -> None: + root = files("adcp.reporting.ledger") + for name in OWNED_SQL: + connection.execute(root.joinpath(name).read_text()) + connection.execute( + files("adcp.reporting.production").joinpath("service_context.sql").read_text() + ) + + +def create_owned_schema(schema: str, **options: Any) -> None: + """Build the owned ledger the way an adopter does: ``create_schema()``.""" + pytest.importorskip("psycopg_pool") + from psycopg_pool import AsyncConnectionPool + + from adcp.reporting.ledger.pg import PgReportingLedgerStore + + async def create() -> None: + async with AsyncConnectionPool( + database_url(), + kwargs={"options": _options(schema), "autocommit": True}, + min_size=1, + max_size=2, + open=False, + ) as pool: + await PgReportingLedgerStore(pool=pool, **options).create_schema() + + asyncio.run(create()) + + +def surface(connection: Any) -> int: + """Reporting tables and sequences left in the schema.""" + return connection.execute(SURFACE).fetchone()[0] + + +def wait_until_blocked(schema: str, pid: int, thread: threading.Thread) -> None: + """Return once ``pid`` waits on a lock, rather than treating time as evidence.""" + with connect(schema, autocommit=True) as observer: + deadline = time.monotonic() + 10 + while time.monotonic() < deadline: + if observer.execute( + "SELECT 1 FROM pg_locks WHERE pid=%s AND NOT granted", (pid,) + ).fetchone(): + assert thread.is_alive() + return + time.sleep(0.01) + raise AssertionError("the replacement never waited on a lock") + + +class Background(threading.Thread): + """Run a sync replacement on another thread, keeping its outcome.""" + + def __init__(self, target: Callable[[], None]) -> None: + super().__init__(daemon=True) + self._target_call = target + self.error: BaseException | None = None + + def run(self) -> None: + try: + self._target_call() + except BaseException as exc: # surfaced by outcome() + self.error = exc + + def outcome(self) -> BaseException | None: + self.join(timeout=10) + assert not self.is_alive() + return self.error diff --git a/tests/conformance/reporting/test_reporting_empty_ledger_alembic.py b/tests/conformance/reporting/test_reporting_empty_ledger_alembic.py new file mode 100644 index 000000000..d48f27994 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_empty_ledger_alembic.py @@ -0,0 +1,131 @@ +"""The documented Alembic recipe: replacements commit with the version bump.""" + +from __future__ import annotations + +import textwrap +from pathlib import Path + +import pytest + +from ._empty_ledger_support import ( + connect, + create_owned_schema, + database_url, + install_legacy, + isolated_schema, + require_driver, + surface, +) + +_ENV = """ +from alembic import context +from sqlalchemy import create_engine +from sqlalchemy.pool import NullPool + +attributes = context.config.attributes +engine = create_engine( + attributes["url"], poolclass=NullPool, connect_args={"options": attributes["options"]} +) +with engine.connect() as connection: + context.configure(connection=connection, target_metadata=None) + with context.begin_transaction(): + context.run_migrations() +engine.dispose() +""" + +_DOC = Path(__file__).parents[3] / "docs/reporting-caller-ownership-migration.md" + + +def _recipe() -> str: + """The Alembic revision exactly as the migration guide prints it.""" + section = _DOC.read_text().split("### From an Alembic revision", 1)[1] + return section.split("```python\n", 1)[1].split("```", 1)[0] + + +# The guide's revision, with an injected failure after the replacement. +_REVISION = ( + _recipe().replace( + " replace_empty_legacy_reporting_sync(op.get_bind())\n", + " replace_empty_legacy_reporting_sync(op.get_bind())\n" + " if context.config.attributes.get('fail_after_replacement'):\n" + " raise RuntimeError('a later step of this revision failed')\n", + ) + + """ +from alembic import context + +revision = "0001" +down_revision = None +branch_labels = None +depends_on = None +""" +) + + +def _config(tmp_path, schema: str, driver: str, **attributes): + from alembic.config import Config + from sqlalchemy import make_url + + (tmp_path / "versions").mkdir(exist_ok=True) + (tmp_path / "env.py").write_text(textwrap.dedent(_ENV)) + (tmp_path / "script.py.mako").write_text("") + (tmp_path / "versions" / "0001_reporting.py").write_text(_REVISION) + config = Config() + config.set_main_option("script_location", str(tmp_path)) + config.attributes.update( + url=make_url(database_url()).set(drivername=f"postgresql+{driver}"), + options=f"-csearch_path={schema}", + **attributes, + ) + return config + + +def _version(schema: str) -> list[tuple[str]] | None: + with connect(schema) as connection: + if connection.execute("SELECT to_regclass('alembic_version')").fetchone() == (None,): + return None + return connection.execute("SELECT version_num FROM alembic_version").fetchall() + + +@pytest.mark.parametrize("driver", ["psycopg", "psycopg2"]) +def test_alembic_upgrade_and_downgrade_commit_with_the_version(tmp_path, driver): + require_driver(f"sqlalchemy+{driver}") + pytest.importorskip("alembic") + from alembic import command + + with isolated_schema() as schema: + with connect(schema, autocommit=True) as setup: + install_legacy(setup) + legacy = surface(setup) + + failing = _config(tmp_path, schema, driver, fail_after_replacement=True) + with pytest.raises(RuntimeError, match="later step"): + command.upgrade(failing, "head") + with connect(schema) as check: + assert surface(check) == legacy + assert _version(schema) is None + + command.upgrade(_config(tmp_path, schema, driver), "head") + with connect(schema) as check: + assert surface(check) == 0 + assert _version(schema) == [("0001",)] + + # SDK 9 builds its owned ledger when the service starts. + create_owned_schema(schema) + command.downgrade(_config(tmp_path, schema, driver), "base") + with connect(schema, autocommit=True) as check: + assert surface(check) == 0 + # An 8.x image can now create its own ledger again. + install_legacy(check) + assert _version(schema) == [] + + +def test_alembic_recipe_is_a_no_op_without_a_ledger(tmp_path): + require_driver("sqlalchemy+psycopg") + pytest.importorskip("alembic") + from alembic import command + + with isolated_schema() as schema: + command.upgrade(_config(tmp_path, schema, "psycopg"), "head") + assert _version(schema) == [("0001",)] + command.downgrade(_config(tmp_path, schema, "psycopg"), "base") + assert _version(schema) == [] diff --git a/tests/conformance/reporting/test_reporting_empty_legacy_replacement.py b/tests/conformance/reporting/test_reporting_empty_legacy_replacement.py index 2bfa96582..d1611e561 100644 --- a/tests/conformance/reporting/test_reporting_empty_legacy_replacement.py +++ b/tests/conformance/reporting/test_reporting_empty_legacy_replacement.py @@ -99,6 +99,27 @@ async def test_populated_legacy_table_refuses_replacement_atomically(table): )[0] is not None +async def test_sql_body_functions_on_legacy_tables_are_replaced(): + require_rolling_database() + async with isolated_reporting_pool(autocommit=True) as pool: + async with pool.connection() as connection: + await install_legacy(connection) + # A SQL-standard body depends on the table itself, not its row type. + await connection.execute( + "CREATE FUNCTION reporting_configuration_count() RETURNS bigint" + " LANGUAGE SQL BEGIN ATOMIC SELECT count(*) FROM reporting_configurations; END" + ) + await replace_empty_legacy_reporting(connection) + assert ( + await ( + await connection.execute( + "SELECT to_regclass('reporting_configurations')," + " to_regprocedure('reporting_configuration_count()')" + ) + ).fetchone() + ) == (None, None) + + @pytest.mark.parametrize("dependency", ["view", "sequence", "function", "partition"]) async def test_external_dependencies_roll_back_replacement(dependency): require_rolling_database() diff --git a/tests/conformance/reporting/test_reporting_empty_legacy_replacement_sync.py b/tests/conformance/reporting/test_reporting_empty_legacy_replacement_sync.py new file mode 100644 index 000000000..07610a912 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_empty_legacy_replacement_sync.py @@ -0,0 +1,242 @@ +"""The sync empty-legacy replacement runs inside the caller's transaction.""" + +from __future__ import annotations + +import pytest + +from adcp.reporting.migration import ( + ReportingOwnershipMigrationError, + replace_empty_legacy_reporting_sync, +) + +from ._empty_ledger_support import ( + DRIVERS, + Background, + connect, + database_url, + install_legacy, + isolated_schema, + require_driver, + session, + surface, + wait_until_blocked, +) + + +def test_autocommit_connection_commits_its_own_transaction(): + require_driver("psycopg") + with isolated_schema() as schema, connect(schema, autocommit=True) as connection: + install_legacy(connection) + connection.execute( + "CREATE SEQUENCE reporting_unused_sequence; " + "CREATE FUNCTION reporting_unused_function() RETURNS integer " + "LANGUAGE SQL AS 'SELECT 1'; CREATE TABLE application_data(id integer); " + "INSERT INTO application_data VALUES (42)" + ) + replace_empty_legacy_reporting_sync(connection) + with connect(schema) as other: + assert surface(other) == 0 + assert other.execute("SELECT * FROM application_data").fetchall() == [(42,)] + assert other.execute( + "SELECT to_regprocedure('reporting_unused_function()')" + ).fetchone() == (None,) + with pytest.raises(ReportingOwnershipMigrationError, match="unmigrated"): + replace_empty_legacy_reporting_sync(connection) + + +@pytest.mark.parametrize("driver", DRIVERS) +def test_replacement_rolls_back_with_the_outer_transaction(driver): + require_driver(driver) + with isolated_schema() as schema: + with connect(schema, autocommit=True) as setup: + install_legacy(setup) + setup.execute("CREATE TABLE application_data(id integer)") + legacy = surface(setup) + with session(schema, driver) as caller: + # The migration framework's own write, e.g. Alembic's version bump. + caller.run("INSERT INTO application_data VALUES (1)") + replace_empty_legacy_reporting_sync(caller.bind) + assert caller.run("SELECT to_regclass('reporting_configurations')") == [(None,)] + caller.rollback() + with connect(schema) as check: + assert surface(check) == legacy + assert check.execute("SELECT * FROM application_data").fetchall() == [] + + +@pytest.mark.parametrize("driver", DRIVERS) +def test_replacement_commits_with_the_outer_transaction(driver): + require_driver(driver) + with isolated_schema() as schema: + with connect(schema, autocommit=True) as setup: + install_legacy(setup) + setup.execute("CREATE TABLE application_data(id integer)") + with session(schema, driver) as caller: + caller.run("INSERT INTO application_data VALUES (1)") + replace_empty_legacy_reporting_sync(caller.bind) + with connect(schema) as concurrent: + # Nothing is visible, or committed, before the caller commits. + assert surface(concurrent) > 0 + caller.commit() + with connect(schema) as check: + assert surface(check) == 0 + assert check.execute("SELECT * FROM application_data").fetchall() == [(1,)] + + +@pytest.mark.parametrize("driver", DRIVERS) +def test_refusal_rolls_back_only_its_savepoint(driver): + require_driver(driver) + with isolated_schema() as schema: + with connect(schema, autocommit=True) as setup: + install_legacy(setup) + setup.execute( + "CREATE TABLE application_data(id integer); " + "CREATE TABLE reporting_extra_evidence(id integer); " + "INSERT INTO reporting_extra_evidence VALUES (7)" + ) + legacy = surface(setup) + with session(schema, driver) as caller: + caller.run("INSERT INTO application_data VALUES (1)") + with pytest.raises(ReportingOwnershipMigrationError, match="contains rows"): + replace_empty_legacy_reporting_sync(caller.bind) + # The savepoint's schema and table locks went with it. + assert caller.run( + "SELECT count(*) FROM pg_locks WHERE pid=pg_backend_pid()" + " AND (locktype='advisory' OR mode='AccessExclusiveLock')" + ) == [(0,)] + # The caller's transaction is still usable and keeps its own work. + caller.run("INSERT INTO application_data VALUES (2)") + caller.commit() + with connect(schema) as check: + assert surface(check) == legacy + assert check.execute("SELECT * FROM application_data ORDER BY id").fetchall() == [ + (1,), + (2,), + ] + assert check.execute("SELECT * FROM reporting_extra_evidence").fetchall() == [(7,)] + + +def test_external_dependency_rolls_back_inside_the_outer_transaction(): + require_driver("psycopg") + with isolated_schema() as schema: + with connect(schema, autocommit=True) as setup: + install_legacy(setup) + setup.execute( + "CREATE VIEW application_view AS SELECT account_id FROM reporting_configurations" + ) + legacy = surface(setup) + with connect(schema) as caller: + with pytest.raises(ReportingOwnershipMigrationError, match="external dependencies"): + replace_empty_legacy_reporting_sync(caller) + assert caller.execute("SELECT count(*) FROM application_view").fetchone() == (0,) + caller.commit() + assert surface(caller) == legacy + + +def test_replacement_waits_for_writer_then_refuses_committed_rows(): + require_driver("psycopg") + with isolated_schema() as schema: + with connect(schema, autocommit=True) as setup: + install_legacy(setup) + setup.execute("CREATE TABLE reporting_late_evidence(id integer)") + with connect(schema) as writer, connect(schema) as caller: + writer.execute("INSERT INTO reporting_late_evidence VALUES (1)") + pid = caller.info.backend_pid + replacement = Background(lambda: replace_empty_legacy_reporting_sync(caller)) + replacement.start() + try: + wait_until_blocked(schema, pid, replacement) + finally: + writer.commit() + error = replacement.outcome() + assert isinstance(error, ReportingOwnershipMigrationError) + assert "contains rows" in str(error) + caller.rollback() + with connect(schema) as check: + assert check.execute("SELECT id FROM reporting_late_evidence").fetchall() == [(1,)] + + +def test_concurrent_replacements_serialize_on_the_schema_lock(): + require_driver("psycopg") + with isolated_schema() as schema: + with connect(schema, autocommit=True) as setup: + install_legacy(setup) + with connect(schema) as first, connect(schema) as second: + replace_empty_legacy_reporting_sync(first) + # ``first`` holds the advisory and table locks until it commits. + replacement = Background(lambda: replace_empty_legacy_reporting_sync(second)) + replacement.start() + try: + wait_until_blocked(schema, second.info.backend_pid, replacement) + finally: + first.commit() + error = replacement.outcome() + assert isinstance(error, ReportingOwnershipMigrationError) + assert "unmigrated" in str(error) + second.rollback() + assert surface(second) == 0 + + +@pytest.mark.parametrize("isolation", ["REPEATABLE READ", "SERIALIZABLE"]) +def test_snapshot_isolation_refuses_and_keeps_the_outer_transaction(isolation): + require_driver("psycopg") + with isolated_schema() as schema: + with connect(schema, autocommit=True) as setup: + install_legacy(setup) + setup.execute("CREATE TABLE application_data(id integer)") + legacy = surface(setup) + with connect(schema) as caller: + caller.execute(f"SET TRANSACTION ISOLATION LEVEL {isolation}") + caller.execute("INSERT INTO application_data VALUES (1)") + with pytest.raises(ReportingOwnershipMigrationError, match="READ COMMITTED"): + replace_empty_legacy_reporting_sync(caller) + caller.commit() + assert surface(caller) == legacy + assert caller.execute("SELECT * FROM application_data").fetchall() == [(1,)] + + +def test_async_and_unknown_connections_are_refused(): + psycopg = pytest.importorskip("psycopg") + + class Unknown: + autocommit = False + + with pytest.raises(TypeError, match="sync psycopg 3 or psycopg2"): + replace_empty_legacy_reporting_sync(Unknown()) + # An AsyncConnection is never driven synchronously, and no loop is started. + async_connection = object.__new__(psycopg.AsyncConnection) + with pytest.raises(TypeError, match="async variant"): + replace_empty_legacy_reporting_sync(async_connection) + + +@pytest.mark.parametrize("driver", ["psycopg", "psycopg2"]) +def test_autocommit_connection_inside_an_open_transaction_uses_a_savepoint(driver): + require_driver(driver) + with isolated_schema() as schema: + with connect(schema, autocommit=True) as setup: + install_legacy(setup) + setup.execute("CREATE TABLE application_data(id integer)") + legacy = surface(setup) + if driver == "psycopg": + with connect(schema, autocommit=True) as caller: + with pytest.raises(RuntimeError, match="later step"): + with caller.transaction(): + caller.execute("INSERT INTO application_data VALUES (1)") + replace_empty_legacy_reporting_sync(caller) + raise RuntimeError("a later step failed") + else: + import psycopg2 + + caller = psycopg2.connect(database_url(), options=f"-csearch_path={schema}") + try: + caller.autocommit = True + with caller.cursor() as cursor: + cursor.execute("BEGIN") + cursor.execute("INSERT INTO application_data VALUES (1)") + replace_empty_legacy_reporting_sync(caller) + cursor.execute("ROLLBACK") + finally: + caller.close() + with connect(schema) as check: + # The helper neither committed the caller's transaction nor its own drop. + assert surface(check) == legacy + assert check.execute("SELECT * FROM application_data").fetchall() == [] diff --git a/tests/conformance/reporting/test_reporting_empty_owned_replacement.py b/tests/conformance/reporting/test_reporting_empty_owned_replacement.py new file mode 100644 index 000000000..d24145da5 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_empty_owned_replacement.py @@ -0,0 +1,304 @@ +"""An empty caller-owned ledger can be removed so an 8.x image can start again.""" + +from __future__ import annotations + +import asyncio + +import pytest + +from adcp.reporting.migration import ( + ReportingOwnershipMigrationError, + replace_empty_legacy_reporting, + replace_empty_owned_reporting, + replace_empty_owned_reporting_sync, +) + +from ._empty_ledger_support import ( + DRIVERS, + FUNCTIONS, + Background, + connect, + create_owned_schema, + database_url, + install_legacy, + install_owned, + isolated_schema, + require_driver, + session, + surface, + wait_until_blocked, +) +from ._generation_support import isolated_reporting_pool, require_rolling_database + + +@pytest.mark.parametrize("installed", ["create_schema", "every packaged migration"]) +def test_rollback_removes_the_owned_surface_and_8x_ddl_installs(installed): + require_driver("psycopg") + with isolated_schema() as schema: + if installed == "create_schema": + create_owned_schema(schema, change_feed=True, row_storage=True) + with connect(schema, autocommit=True) as connection: + if installed != "create_schema": + install_owned(connection) + connection.execute( + "CREATE TABLE application_data(id integer); " + "INSERT INTO application_data VALUES (42)" + ) + assert surface(connection) > 0 + # Installation and startup write configuration rows, not reporting state. + assert connection.execute( + "SELECT count(*) FROM adcp_reporting_change_horizon" + ).fetchone() == (1,) + if installed == "create_schema": + assert connection.execute( + "SELECT count(*) > 0 FROM adcp_reporting_row_bindings" + ).fetchone() == (True,) + replace_empty_owned_reporting_sync(connection) + assert surface(connection) == 0 + assert connection.execute(FUNCTIONS).fetchone() == (0,) + assert connection.execute("SELECT * FROM application_data").fetchall() == [(42,)] + # The deployment-wide row-storage identity is not reporting state. + assert connection.execute( + "SELECT to_regclass('adcp_persistence_installation') IS NOT NULL" + ).fetchone() == (True,) + # The 8.x image creates its own ledger from scratch, and SDK 9 can + # replace that empty ledger again when the upgrade is retried. + install_legacy(connection) + asyncio.run(_replace_legacy(schema)) + create_owned_schema(schema) + with connect(schema, autocommit=True) as connection: + replace_empty_owned_reporting_sync(connection) + assert surface(connection) == 0 + with pytest.raises(ReportingOwnershipMigrationError, match="caller-owned"): + replace_empty_owned_reporting_sync(connection) + + +async def _replace_legacy(schema: str) -> None: + import psycopg + + async with await psycopg.AsyncConnection.connect( + database_url(), autocommit=True, options=f"-csearch_path={schema}" + ) as connection: + await replace_empty_legacy_reporting(connection) + + +def test_functions_depending_on_owned_tables_are_removed(): + require_driver("psycopg") + with isolated_schema() as schema: + create_owned_schema(schema) + with connect(schema, autocommit=True) as connection: + connection.execute( + "CREATE FUNCTION reporting_configuration_count() RETURNS bigint" + " LANGUAGE SQL BEGIN ATOMIC SELECT count(*) FROM reporting_configurations; END;" + " CREATE FUNCTION reporting_configuration_ids(c reporting_configurations[])" + " RETURNS integer LANGUAGE SQL AS 'SELECT 1'" + ) + replace_empty_owned_reporting_sync(connection) + assert surface(connection) == 0 + assert connection.execute(FUNCTIONS).fetchone() == (0,) + + +@pytest.mark.parametrize("driver", DRIVERS) +def test_rollback_is_atomic_with_the_outer_transaction(driver): + require_driver(driver) + with isolated_schema() as schema: + create_owned_schema(schema) + with connect(schema, autocommit=True) as setup: + setup.execute("CREATE TABLE application_data(id integer)") + owned = surface(setup) + with session(schema, driver) as caller: + caller.run("INSERT INTO application_data VALUES (1)") + replace_empty_owned_reporting_sync(caller.bind) + assert caller.run("SELECT to_regclass('reporting_configurations')") == [(None,)] + caller.rollback() + with connect(schema) as check: + assert surface(check) == owned + assert check.execute("SELECT * FROM application_data").fetchall() == [] + with session(schema, driver) as caller: + caller.run("INSERT INTO application_data VALUES (2)") + replace_empty_owned_reporting_sync(caller.bind) + caller.commit() + with connect(schema) as check: + assert surface(check) == 0 + assert check.execute("SELECT * FROM application_data").fetchall() == [(2,)] + + +@pytest.mark.parametrize( + ("state", "message"), + [ + ("INSERT INTO reporting_extra_evidence VALUES (1)", "contains rows"), + ("INSERT INTO adcp_reporting_extra_evidence VALUES (1)", "contains rows"), + ( + "UPDATE adcp_reporting_change_horizon SET pruned_through_seq = 5", + "contains rows", + ), + ( + "INSERT INTO adcp_reporting_ownership_archives" + " VALUES ('adcp_reporting_quarantine_release')", + "quarantine archive", + ), + ], +) +def test_retained_state_refuses_rollback_atomically(state, message): + require_driver("psycopg") + with isolated_schema() as schema: + create_owned_schema(schema, change_feed=True) + with connect(schema, autocommit=True) as connection: + connection.execute( + "CREATE TABLE reporting_extra_evidence(id integer); " + "CREATE TABLE adcp_reporting_extra_evidence(id integer)" + ) + connection.execute(state) + owned = surface(connection) + functions = connection.execute(FUNCTIONS).fetchone() + with pytest.raises(ReportingOwnershipMigrationError, match=message): + replace_empty_owned_reporting_sync(connection) + assert surface(connection) == owned + assert connection.execute(FUNCTIONS).fetchone() == functions + + +@pytest.mark.parametrize("legacy", [True, False]) +def test_rollback_requires_an_owned_ledger(legacy): + require_driver("psycopg") + with isolated_schema() as schema, connect(schema, autocommit=True) as connection: + if legacy: + install_legacy(connection) + before = surface(connection) + with pytest.raises(ReportingOwnershipMigrationError, match="caller-owned"): + replace_empty_owned_reporting_sync(connection) + assert surface(connection) == before + + +def test_external_dependency_refuses_rollback(): + require_driver("psycopg") + with isolated_schema() as schema: + create_owned_schema(schema) + with connect(schema, autocommit=True) as connection: + connection.execute( + "CREATE VIEW application_view AS SELECT account_id FROM reporting_configurations" + ) + owned = surface(connection) + with pytest.raises(ReportingOwnershipMigrationError, match="nothing was removed"): + replace_empty_owned_reporting_sync(connection) + assert surface(connection) == owned + assert connection.execute(FUNCTIONS).fetchone()[0] > 0 + + +def test_rollback_waits_for_writer_then_refuses_committed_rows(): + require_driver("psycopg") + with isolated_schema() as schema: + create_owned_schema(schema) + with connect(schema, autocommit=True) as setup: + setup.execute("CREATE TABLE reporting_late_evidence(id integer)") + with connect(schema) as writer, connect(schema) as caller: + writer.execute("INSERT INTO reporting_late_evidence VALUES (1)") + rollback = Background(lambda: replace_empty_owned_reporting_sync(caller)) + rollback.start() + try: + wait_until_blocked(schema, caller.info.backend_pid, rollback) + finally: + writer.commit() + error = rollback.outcome() + assert isinstance(error, ReportingOwnershipMigrationError) + assert "contains rows" in str(error) + caller.rollback() + with connect(schema) as check: + assert check.execute("SELECT id FROM reporting_late_evidence").fetchall() == [(1,)] + + +def test_rollback_blocks_create_schema_until_it_commits(): + require_driver("psycopg") + with isolated_schema() as schema: + create_owned_schema(schema) + with connect(schema) as caller: + replace_empty_owned_reporting_sync(caller) + # A 9.0 process booting now waits on the schema lock rather than + # observing, or recreating, a half-removed ledger. + boot = Background(lambda: create_owned_schema(schema)) + boot.start() + try: + with connect(schema, autocommit=True) as observer: + for _ in range(1000): + waiting = observer.execute( + "SELECT 1 FROM pg_locks l JOIN pg_stat_activity a ON a.pid=l.pid" + " WHERE NOT l.granted AND l.locktype='advisory'" + " AND a.pid <> %s AND a.datname=current_database()", + (caller.info.backend_pid,), + ).fetchone() + if waiting: + break + boot.join(timeout=0.01) + assert waiting and boot.is_alive() + finally: + caller.commit() + assert boot.outcome() is None + with connect(schema) as check: + # The boot ran after the removal committed, so it built a fresh ledger. + assert check.execute( + "SELECT count(*) FROM information_schema.columns" + " WHERE table_schema=current_schema()" + " AND table_name='reporting_configurations' AND column_name='consumer_id'" + ).fetchone() == (1,) + + +@pytest.mark.parametrize("isolation", ["REPEATABLE READ", "SERIALIZABLE"]) +def test_snapshot_isolation_refuses_rollback(isolation): + require_driver("psycopg") + with isolated_schema() as schema: + create_owned_schema(schema) + with connect(schema) as caller: + caller.execute(f"SET TRANSACTION ISOLATION LEVEL {isolation}") + with pytest.raises(ReportingOwnershipMigrationError, match="READ COMMITTED"): + replace_empty_owned_reporting_sync(caller) + caller.commit() + assert surface(caller) > 0 + + +async def test_async_rollback_matches_the_sync_guarantees(): + require_rolling_database() + from adcp.reporting.ledger.pg import PgReportingLedgerStore + + async with isolated_reporting_pool(autocommit=True) as pool: + await PgReportingLedgerStore(pool=pool, change_feed=True).create_schema() + async with pool.connection() as connection, pool.connection() as writer: + await connection.execute("CREATE TABLE reporting_late_evidence(id integer)") + async with writer.transaction(): + await writer.execute("INSERT INTO reporting_late_evidence VALUES (1)") + task = asyncio.create_task(replace_empty_owned_reporting(connection)) + try: + for _ in range(100): + async with pool.connection() as observer: + waiting = await ( + await observer.execute( + "SELECT 1 FROM pg_locks WHERE pid=%s AND NOT granted", + (connection.info.backend_pid,), + ) + ).fetchone() + if waiting: + break + await asyncio.sleep(0.01) + assert waiting + assert not task.done() + except BaseException: + task.cancel() + await asyncio.gather(task, return_exceptions=True) + raise + with pytest.raises(ReportingOwnershipMigrationError, match="contains rows"): + await asyncio.wait_for(task, timeout=5) + await connection.execute("DROP TABLE reporting_late_evidence") + await replace_empty_owned_reporting(connection) + assert ( + await ( + await connection.execute("SELECT to_regclass('reporting_configurations')") + ).fetchone() + ) == (None,) + # Inside a transaction already open on the connection, the async + # variant runs as a savepoint: the caller's rollback restores it. + await PgReportingLedgerStore(pool=pool).create_schema() + async with connection.transaction(force_rollback=True): + await replace_empty_owned_reporting(connection) + assert ( + await ( + await connection.execute("SELECT to_regclass('reporting_configurations')") + ).fetchone() + ) != (None,) diff --git a/tests/test_product_format_declaration.py b/tests/test_product_format_declaration.py index 7ff505a99..a276dc7c1 100644 --- a/tests/test_product_format_declaration.py +++ b/tests/test_product_format_declaration.py @@ -22,6 +22,7 @@ Format, LegacyProductFormatDeclaration, Package, + Product, ProductFormatDeclaration, validate_union, ) @@ -284,6 +285,44 @@ def test_open_consumer_format_and_params_helper_remain_available() -> None: assert image.params_as(CanonicalFormatImage).width == 300 +def test_stored_declarations_publish_leniently_while_authoring_stays_strict() -> None: + # A declaration stored before 9.0 whose params a seller chose to pass through. + stored = {"format_kind": "image", "params": {"width": "wide"}} + with pytest.raises(ValidationError) as refused: + ProductFormatDeclaration.model_validate(stored) + # The non-raising data-quality check the types guide shows. + assert [(error["type"], error["loc"]) for error in refused.value.errors()] == [("oneOf", ())] + + product = Product.model_validate( + { + "product_id": "display", + "name": "Display", + "description": "Display inventory", + "publisher_properties": [{"publisher_domain": "example.com", "selection_type": "all"}], + "channels": ["display"], + "delivery_type": "guaranteed", + "reporting_capabilities": { + "available_reporting_frequencies": ["daily"], + "expected_delay_minutes": 60, + "timezone": "UTC", + "supports_webhooks": False, + "available_metrics": ["impressions"], + "date_range_support": "date_range", + }, + "format_options": [Format.model_validate(copy.deepcopy(stored))], + "pricing_options": [ + { + "pricing_option_id": "cpm", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 1, + } + ], + } + ) + assert product.model_dump(mode="json")["format_options"] == [stored] + + def test_concurrent_authoring_validation_uses_independent_resolvers() -> None: def validate_batch(_: int) -> None: for _ in range(20): diff --git a/tests/type_checks/reporting_caller_ownership.py b/tests/type_checks/reporting_caller_ownership.py index 84b2e0274..4b03b76d9 100644 --- a/tests/type_checks/reporting_caller_ownership.py +++ b/tests/type_checks/reporting_caller_ownership.py @@ -19,6 +19,9 @@ legacy_generation_digest, migrate_legacy_reporting, replace_empty_legacy_reporting, + replace_empty_legacy_reporting_sync, + replace_empty_owned_reporting, + replace_empty_owned_reporting_sync, ) @@ -42,6 +45,7 @@ async def ownership( connection, archive_schema="adcp_reporting_quarantine_release", workers_stopped=True ) await replace_empty_legacy_reporting(connection) + await replace_empty_owned_reporting(connection) digest = await legacy_generation_digest( connection, archive_schema="adcp_reporting_quarantine_release", @@ -65,3 +69,9 @@ async def ownership( mapping=mapping, workers_stopped=True, ) + + +def alembic_revision(bind: Any) -> None: + """Sync variants run inside a migration framework's transaction.""" + replace_empty_legacy_reporting_sync(bind) + replace_empty_owned_reporting_sync(bind) From 3db6694228643e2ef73c207bcc559e49d5616f95 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 22 Oct 2026 23:09:06 -0400 Subject: [PATCH 09/54] ci: exempt the #1546 merge commit from the breaking-footer check (#1566) Main CI has failed the conventional-commit policy on every push since 5ce9ee4dc, the GitHub merge commit for #1546. Its body carries only the PR title fix(types)!, while the merged commit 81c2a8b2b carries the BREAKING CHANGE footer, so the release signal is intact. Add the exact sha and subject to HISTORICAL_BREAKING_SUBJECTS, like the two earlier exceptions. Co-authored-by: Claude Opus 5.5 (1M context) --- scripts/check_main_policies.py | 12 +++++++++--- tests/test_main_release_policies.py | 7 +++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/check_main_policies.py b/scripts/check_main_policies.py index 7ce51833b..7ca3e5b9c 100644 --- a/scripts/check_main_policies.py +++ b/scripts/check_main_policies.py @@ -88,9 +88,12 @@ def pages(self, suffix: str, key: str | None = None) -> list[Any]: r"(?:\([^()\n]+\))?(?P!)?: (?P\S.*)$" ) GITHUB_MERGE = re.compile(r"Merge pull request #[1-9][0-9]* from [^\s/]+/\S+") -# These already-merged commits predate the guard. Their breaking subjects -# and reviewed PR migration notes retain the release signal. No other commit, -# including one with the same subject, inherits these historical exceptions. +# These already-merged commits lack the footer in the message this guard reads. +# The first two predate the guard; their breaking subjects and reviewed PR +# migration notes retain the release signal. 5ce9ee4dc is #1546's GitHub merge +# commit: its body carries only the PR title, while the merged commit 81c2a8b2b +# carries the BREAKING CHANGE footer. No other commit, including one with the +# same subject, inherits these historical exceptions. HISTORICAL_BREAKING_SUBJECTS = { "6c5ee7b29ef9d548ae2bb3665032e0c53a8146ac": ( "fix(reporting)!: scope configuration generations by account (#1174)" @@ -98,6 +101,9 @@ def pages(self, suffix: str, key: str | None = None) -> list[Any]: "34c8f6d929aeac3407e2f595104a8e903e572623": ( "feat(reporting)!: enable production tier status and ownership" ), + "5ce9ee4dc9daef34af143b84ff7d660489fef1be": ( + "fix(types)!: allow assignment-only creative syncs" + ), } diff --git a/tests/test_main_release_policies.py b/tests/test_main_release_policies.py index c7f06445f..403d3038e 100644 --- a/tests/test_main_release_policies.py +++ b/tests/test_main_release_policies.py @@ -330,6 +330,13 @@ def test_single_parent_commit_cannot_impersonate_github_merge(history: list[str] ("a" * 40, "feat(reporting)!: enable production tier status and ownership", False), (None, "feat(reporting)!: enable production tier status and ownership", False), ("34c8f6d929aeac3407e2f595104a8e903e572623", BREAKING_SUBJECT, False), + ( + "5ce9ee4dc9daef34af143b84ff7d660489fef1be", + "fix(types)!: allow assignment-only creative syncs", + True, + ), + ("a" * 40, "fix(types)!: allow assignment-only creative syncs", False), + ("5ce9ee4dc9daef34af143b84ff7d660489fef1be", BREAKING_SUBJECT, False), ], ) def test_historical_footer_exception_requires_exact_commit_and_subject( From 41c2be73e6851264f71edbab13fcd801d2a94454 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Thu, 22 Oct 2026 23:57:20 -0400 Subject: [PATCH 10/54] fix(release): tighten V9 reporting and CI readiness checks (#1550) Read current PR messages on CI reruns without changing the validated head. Pin existing reporting exports and give row-storage operators executable, quoted foreign-key remedies with accurate staging-lock documentation. Fixes #1383 Fixes #1493 Refs #1490, #1495, #1510 --- .github/workflows/ci.yml | 3 + docs/reporting-row-storage.md | 30 ++- scripts/check_main_policies.py | 13 +- scripts/export_resolution.py | 5 +- src/adcp/reporting/ledger/row_storage.py | 19 +- ...test_reporting_row_storage_readiness_pg.py | 34 +++ tests/fixtures/public_api_snapshot.json | 196 ++++++++++++++++++ tests/test_main_release_policies.py | 55 +++++ tests/test_public_api.py | 5 + 9 files changed, 341 insertions(+), 19 deletions(-) create mode 100644 tests/conformance/reporting/test_reporting_row_storage_readiness_pg.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd0883bb4..7ccf64763 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1101,6 +1101,7 @@ jobs: if: github.event_name == 'pull_request' || github.ref == 'refs/heads/main' permissions: contents: read + pull-requests: read # Read the current title/body when a check is rerun after an edit. steps: - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 @@ -1110,6 +1111,8 @@ jobs: persist-credentials: false - name: Validate PR source or every integrated main commit and breaking footer run: python3 -m scripts.check_main_policies conventional + env: + GH_TOKEN: ${{ github.token }} downstream-imports: needs: changes diff --git a/docs/reporting-row-storage.md b/docs/reporting-row-storage.md index b7a0dc32a..6725454c3 100644 --- a/docs/reporting-row-storage.md +++ b/docs/reporting-row-storage.md @@ -56,11 +56,22 @@ To install everything this guide uses while developing: `pip install -e '.[dev,p ## Layout notes -`adcp_reporting_row_sets` is the side table that records where a revision's rows live until the shared Core layout -(persistence Stage 3). It matches the JavaScript table column for column except that `obligation_id` has no +`adcp_reporting_row_sets` is the side table that records where a revision's rows live. +It matches the JavaScript table column for column except that `obligation_id` has no foreign key: Python obligations live in `reporting_obligations`, not in the JavaScript `adcp_reporting_obligations` table. If the JavaScript migration created the table first, `ready_row_storage` -refuses with the constraint name; apply this migration first, or drop that constraint. +refuses with the constraint name and a schema-qualified, quoted statement to remove it. +Apply the Python migration first, or, for an existing database with Python as its writer, +remove the JavaScript foreign key before running readiness again. The default constraint name is: + +```sql +ALTER TABLE adcp_reporting_row_sets + DROP CONSTRAINT adcp_reporting_row_sets_obligation_id_fkey; +``` + +Run this in the ledger schema. Use the exact statement from the refusal if the schema or +constraint has a different name. This removes the cross-SDK foreign key; row integrity +continues to be verified against the stored digests. A revision written before protocol-shape control totals (see the release notes) has a binding over `{name, value}` pairs. Its row set records the rows-only digest profile `rows_v1`, and its pair binding is verified against the @@ -302,7 +313,9 @@ A period qualifies once the later of its period end and its newest revision or a `ledger_record_retention_days` is the generation's `status_retention_days`; a shorter value is ignored, because a row retention shorter than the advertised window is non-conformant. -Python keeps revision headers and their records until the shared Core layout (persistence Stage 3). A pruned row set +Python keeps revision headers and their records. Record retirement refuses periods with protected reconciliation +or audit evidence, so production periods carrying those records remain retained; row-byte pruning still runs. +A pruned row set stays as a small marker that makes reads answer as expired; the records are already outside the status projection, whose horizon excludes periods past retention. Versioned buckets, soft delete and retention locks are allowed as backstops, but a `pruned` period is not "erased" while they keep the old bytes. @@ -346,12 +359,13 @@ either verify in the other. Three things must match between the two deployments: - **The same binding definitions.** A binding's identity digest is `sha256(JCS([kind, provider, identity_config]))` over the names the JavaScript SDK uses (`location`, `prefix`, `keyTemplate`), so an object binding that differs in location, prefix or key template is a different binding and is refused rather than silently redirected. -- **The row-storage migration applied by this SDK first**, because the JavaScript `adcp_reporting_row_sets` has a - foreign key to its own obligations table that Python obligations can never satisfy. See "Layout notes". +- **The row-storage migration applied by this SDK first**, or the JavaScript foreign key removed with + `ALTER TABLE adcp_reporting_row_sets DROP CONSTRAINT adcp_reporting_row_sets_obligation_id_fkey;` + in the ledger schema. Python obligations cannot satisfy that foreign key. See "Layout notes" for renamed constraints. -Until the shared Core layout (persistence Stage 3) each SDK keeps its own revision headers, so what is shared today +Each SDK keeps its own revision headers, so what is shared today is the row sets: JavaScript-written row sets verify through `ReportingRowStorageV1.find` and `read_all` here, and the -other way around. +other way around. A shared Core layout is no longer planned; keep one writer SDK per ledger database. The acceptance test runs both SDKs against one PostgreSQL schema and one filesystem bucket root, in both directions: diff --git a/scripts/check_main_policies.py b/scripts/check_main_policies.py index 7ca3e5b9c..73a0f4439 100644 --- a/scripts/check_main_policies.py +++ b/scripts/check_main_policies.py @@ -176,12 +176,21 @@ def event_context() -> tuple[str, str, dict[str, Any]]: return kind, target, event -def conventional(kind: str, target: str, event: dict[str, Any]) -> dict[str, Any]: +def conventional( + kind: str, target: str, event: dict[str, Any], *, api: GitHub | None = None +) -> dict[str, Any]: if kind == "push": commits = main_commits(target) else: pr = event["pull_request"] - validate_message(pr["title"] + "\n\n" + (pr["body"] or "")) + # Reruns retain the original webhook payload, including an obsolete PR + # description. Read the editable message again without changing which + # checkout and commits this run is allowed to validate. + if api is None: + api = GitHub(os.environ["GH_TOKEN"]) + current = api.repo(f"pulls/{pr['number']}") + require(current["head"]["sha"] == pr["head"]["sha"], "PR head changed; run current CI") + validate_message(current["title"] + "\n\n" + (current["body"] or "")) head, base = pr["head"]["sha"], pr["base"]["sha"] require(SHA.fullmatch(head) and SHA.fullmatch(base), "invalid PR source identities") fork = git("merge-base", base, head) diff --git a/scripts/export_resolution.py b/scripts/export_resolution.py index d2c7539cc..702a9e994 100644 --- a/scripts/export_resolution.py +++ b/scripts/export_resolution.py @@ -23,7 +23,8 @@ #: Namespaces the snapshot covers: the two public surfaces an adopter imports #: from, the consolidated generated namespace behind them, and every generated -#: domain module. Built on first use so this module imports nothing from +#: domain module, plus the reporting and ledger public surfaces. Built on first +#: use so this module imports nothing from #: ``adcp`` until asked. #: #: ``error_details`` is deliberately absent. Every name it carries is a class @@ -39,6 +40,8 @@ def snapshot_modules() -> tuple[tuple[str, str], ...]: ("adcp", "adcp"), ("adcp.types", "adcp.types"), ("adcp.types._generated", "adcp.types._generated"), + ("adcp.reporting", "adcp.reporting"), + ("adcp.reporting.ledger", "adcp.reporting.ledger"), *( (f"adcp.types.domains.{domain}", f"adcp.types.domains.{domain}") for domain in adcp.types.domains.DOMAINS diff --git a/src/adcp/reporting/ledger/row_storage.py b/src/adcp/reporting/ledger/row_storage.py index f2da8ebf0..f8b23b18e 100644 --- a/src/adcp/reporting/ledger/row_storage.py +++ b/src/adcp/reporting/ledger/row_storage.py @@ -9,7 +9,7 @@ against digests committed in the database before a row is released. Row location lives in the ``adcp_reporting_row_sets`` side table, keyed by revision -ID, until the shared Core layout. A revision without a row set still carries its +ID. A revision without a row set still carries its rows in the legacy ``reporting_revision_rows`` table. Two binding kinds exist. ``postgres`` stores chunk bodies as ``BYTEA`` in the same @@ -583,13 +583,13 @@ async def _refuse_foreign_core_keys(connection: Any) -> None: """Refuse a row-set table that another SDK created with a core-table foreign key. The JavaScript migration references ``adcp_reporting_obligations``, which - Python obligations never populate (they live in ``reporting_obligations`` - until the shared Core layout). Writing here would fail at the first row - set, so refuse up front with the remedy. + Python obligations never populate (they live in ``reporting_obligations``). + Writing here would fail at the first row set, so refuse up front with the remedy. """ row = await ( await connection.execute( - "SELECT conname FROM pg_constraint WHERE conrelid =" + "SELECT conname, quote_ident(conname), quote_ident(current_schema())" + " FROM pg_constraint WHERE conrelid =" " to_regclass(quote_ident(current_schema()) || '.adcp_reporting_row_sets')" " AND contype = 'f' AND (SELECT relname FROM pg_class WHERE oid = confrelid)" " <> ALL(%s::text[]) LIMIT 1", @@ -601,7 +601,8 @@ async def _refuse_foreign_core_keys(connection: Any) -> None: "STATE_UNAVAILABLE", "adcp_reporting_row_sets has a foreign key to another SDK's core table " f"({row[0]}); apply reporting_row_storage.sql before that SDK's migration, " - "or drop the constraint", + "or remove that foreign key with " + f"ALTER TABLE {row[2]}.adcp_reporting_row_sets DROP CONSTRAINT {row[1]};", ) # -- providers -------------------------------------------------------- @@ -970,8 +971,10 @@ async def find_shareable( Identical rows are those with the same ``row_manifest_sha256``. Sharing never crosses an obligation, so it never crosses a consumer. A header-only revision references the existing chunks instead of storing them again. - Takes ``FOR SHARE`` on the row set so retention cannot retire it before the - caller's transaction commits. + Takes ``FOR SHARE`` for the lifetime of the caller's transaction. Object + staging releases that transaction before the ledger commit, so + ``persist_revision_row_set`` re-checks liveness and takes its own + ``FOR SHARE`` lock inside the commit transaction before sharing chunks. """ if not prepared.encoded.chunks: return None diff --git a/tests/conformance/reporting/test_reporting_row_storage_readiness_pg.py b/tests/conformance/reporting/test_reporting_row_storage_readiness_pg.py new file mode 100644 index 000000000..41a7050a2 --- /dev/null +++ b/tests/conformance/reporting/test_reporting_row_storage_readiness_pg.py @@ -0,0 +1,34 @@ +"""Readiness failures supply a usable remedy for cross-SDK foreign keys.""" + +from __future__ import annotations + +import pytest + +from adcp.reporting.ledger import PgReportingLedgerStore +from adcp.reporting.ledger.row_storage import ReportingRowStorageV1, ReportingRowStoreError + +from ._generation_support import isolated_reporting_pool + + +async def test_foreign_core_key_refusal_supplies_an_executable_quoted_remedy() -> None: + async with isolated_reporting_pool() as pool: + from psycopg import sql + + store = PgReportingLedgerStore(pool=pool, row_storage=True) + await store.create_schema() + async with pool.connection() as connection: + await connection.execute("CREATE TABLE other_sdk_obligations (id TEXT PRIMARY KEY)") + await connection.execute( + sql.SQL( + "ALTER TABLE adcp_reporting_row_sets ADD CONSTRAINT {} " + "FOREIGN KEY (obligation_id) REFERENCES other_sdk_obligations (id)" + ).format(sql.Identifier('other SDK "obligation" key')) + ) + with pytest.raises(ReportingRowStoreError) as caught: + await ReportingRowStorageV1._refuse_foreign_core_keys(connection) + assert caught.value.code == "STATE_UNAVAILABLE" + assert caught.value.detail is not None + remedy = caught.value.detail.split("with ", 1)[1] + assert 'DROP CONSTRAINT "other SDK ""obligation"" key";' in remedy + await connection.execute(remedy) + await ReportingRowStorageV1._refuse_foreign_core_keys(connection) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 02cbf506e..e5cbc964a 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -749,6 +749,202 @@ "verify_agent_for_property": "adcp.adagents" } }, + "adcp.reporting": { + "resolves": { + "ConsumerLoopView": "adcp.reporting._consumer", + "ConsumerStatusCheckpoint": "adcp.reporting._consumer", + "ConsumerStatusCheckpointStore": "adcp.reporting._consumer", + "ConsumerStatusIntent": "adcp.reporting._consumer", + "ConsumerStatusPlanError": "adcp.reporting._consumer", + "ConsumerStatusPostError": "adcp.reporting._consumer", + "ConsumerStatusPostResult": "adcp.reporting._consumer", + "DeterministicReportingClock": "adcp.reporting.testing", + "ExpectedReportingPeriod": "adcp.reporting._reconcile", + "FaultInjectingReportingSealStore": "adcp.reporting._testing_failures", + "FaultInjectingReportingStagingStore": "adcp.reporting._testing_failures", + "InMemoryConsumerStatusCheckpoints": "adcp.reporting._consumer", + "ObligationReconciliation": "adcp.reporting._reconcile", + "ReliableReportingConfigurationError": "adcp.reporting._errors", + "ReliableReportingService": "adcp.reporting.service", + "ReliableReportingServiceError": "adcp.reporting.service_lifecycle", + "ReliableReportingShutdownTimeoutError": "adcp.reporting.service_lifecycle", + "ReliableReportingState": "adcp.reporting.service_lifecycle", + "ReliableReportingUnavailableError": "adcp.reporting.service_lifecycle", + "ReportingAccountContext": "adcp.reporting.service", + "ReportingAdapter": "adcp.reporting.service", + "ReportingCheckpointStore": "adcp.reporting._reconcile", + "ReportingContentReading": "adcp.reporting._consumer", + "ReportingFailureCode": "union[value:'access_denied',value:'integrity_mismatch',value:'reader_incompatible',value:'resource_not_found',value:'transport_failed']", + "ReportingFailurePlan": "adcp.reporting._testing_failures", + "ReportingInspectionContext": "adcp.reporting._reconcile", + "ReportingLedger": "adcp.reporting._reconcile", + "ReportingLifecycleSnapshot": "adcp.reporting._testing_lifecycle", + "ReportingObservation": "adcp.reporting._reconcile", + "ReportingOperationsContactView": "adcp.reporting._consumer", + "ReportingPinnedDefinition": "adcp.reporting._consumer", + "ReportingProductionOptions": "adcp.reporting.service_production", + "ReportingReconciliationClient": "adcp.reporting._reconcile", + "ReportingReconciliationError": "adcp.reporting._reconcile", + "ReportingReconciliationResult": "adcp.reporting._reconcile", + "ReportingServiceOffering": "adcp.reporting.service_production", + "ReportingServiceResource": "adcp.reporting.service_lifecycle", + "ReportingStatusClient": "adcp.reporting._reconcile", + "ReportingTestBarrier": "adcp.reporting._testing_failures", + "ReportingTier": "adcp.reporting._reconcile", + "ScriptedReportingAdapter": "adcp.reporting.testing", + "assert_reporting_lifecycle_replay": "adcp.reporting._testing_lifecycle", + "build_reporting_receipt": "adcp.reporting._reconcile", + "capture_reporting_lifecycle": "adcp.reporting._testing_lifecycle", + "classify_content_mismatch": "adcp.reporting._consumer", + "consumer_status_chain_key": "adcp.reporting._consumer", + "evaluate_reporting_ledger": "adcp.reporting._reconcile", + "load_consumer_loop_view": "adcp.reporting._consumer", + "load_reporting_ledger": "adcp.reporting._reconcile", + "plan_consumer_statuses": "adcp.reporting._consumer", + "post_consumer_statuses": "adcp.reporting._consumer", + "reconcile_reporting": "adcp.reporting._reconcile", + "reconcile_reporting_core": "adcp.reporting._reconcile", + "reporting_tiers": "adcp.reporting._reconcile", + "resolve_checkpointed_leaves": "adcp.reporting._consumer", + "run_reporting_adapter_conformance": "adcp.reporting.testing" + } + }, + "adcp.reporting.ledger": { + "resolves": { + "ConsumerMismatch": "adcp.reporting.ledger.consumer_status", + "ConsumerStatusDisabledError": "adcp.reporting.ledger.consumer_status", + "ConsumerStatusIngest": "adcp.reporting.ledger.consumer_status", + "ConsumerStatusRecord": "adcp.reporting.ledger.models", + "ConsumerStatusValue": "union[value:'content_mismatch',value:'obligation_missing',value:'received',value:'revision_missing',value:'unreadable']", + "CurrencyResolver": "union[instance:builtins:list,union[builtins:str,union[builtins:str]]]", + "FixedCurrencyResolver": "adcp.reporting.ledger.producer", + "InMemoryReportingLedgerStore": "adcp.reporting.ledger.store", + "InMemoryReportingReconciliationStore": "adcp.reporting.ledger.delivery", + "LeasedConfiguration": "adcp.reporting.ledger.store", + "LedgerChange": "adcp.reporting.ledger.models", + "LedgerConflictError": "adcp.reporting.ledger.store", + "LedgerPage": "adcp.reporting.ledger.store", + "LedgerSnapshot": "adcp.reporting.ledger.models", + "ObligationProjection": "adcp.reporting.ledger.health", + "PgReportingLedgerStore": "adcp.reporting.ledger.pg", + "PgReportingReconciliationStore": "adcp.reporting.ledger.delivery_pg", + "ProducerOfferings": "adcp.reporting.ledger.producer", + "REPORTING_SELECTOR_VERSION": "value:2", + "ReportingAdjustmentReceiptRecord": "adcp.reporting.ledger.delivery_models", + "ReportingAdjustmentRecord": "adcp.reporting.ledger.models", + "ReportingCaller": "adcp.reporting.ledger.models", + "ReportingCanonicalDigest": "adcp.reporting.evidence", + "ReportingChangeCursorExpiredError": "adcp.reporting.ledger.change_feed", + "ReportingChangeKindV1": "union[value:'adjustment',value:'obligation',value:'retirement',value:'revision']", + "ReportingChangeRecordV1": "adcp.reporting.ledger.change_feed", + "ReportingChangesPageV1": "adcp.reporting.ledger.change_feed", + "ReportingConfiguration": "adcp.reporting.ledger.models", + "ReportingConfigurationGenerationKey": "adcp.reporting.ledger.models", + "ReportingControlTotalRecord": "adcp.reporting.evidence", + "ReportingCurrencyError": "adcp.reporting.currency", + "ReportingCurrentRevisionsPageV1": "adcp.reporting.ledger.host_reads", + "ReportingDefinitionBinding": "adcp.reporting.ledger.models", + "ReportingDeliveryEscalation": "adcp.reporting.ledger.models", + "ReportingDeliveryPrincipal": "adcp.reporting.ledger.delivery_models", + "ReportingDeliveryRecord": "union[adcp.reporting.ledger.delivery_models:ReportingAdjustmentReceiptRecord,adcp.reporting.ledger.delivery_models:ReportingDestinationBinding,adcp.reporting.ledger.delivery_models:ReportingMaterializationAttempt,adcp.reporting.ledger.delivery_models:ReportingMaterializationCheck,adcp.reporting.ledger.delivery_models:ReportingMaterializationRecord,adcp.reporting.ledger.delivery_models:ReportingObligationDeliveryRecord,adcp.reporting.ledger.delivery_models:ReportingRevisionReceiptRecord]", + "ReportingDeliveryScope": "adcp.reporting.ledger.delivery_models", + "ReportingDestinationBinding": "adcp.reporting.ledger.delivery_models", + "ReportingDestinationStore": "adcp.reporting.ledger.delivery", + "ReportingFinality": "union[value:'official',value:'snapshot']", + "ReportingHealth": "union[value:'action_required',value:'complete',value:'delayed',value:'healthy',value:'waiting']", + "ReportingIssue": "adcp.reporting.ledger.models", + "ReportingIssueLifecycle": "adcp.reporting.ledger.models", + "ReportingIssueStateValue": "union[value:'acknowledged',value:'open',value:'resolved',value:'waived']", + "ReportingLedgerStore": "adcp.reporting.ledger.store", + "ReportingMaterializationAttempt": "adcp.reporting.ledger.delivery_models", + "ReportingMaterializationCheck": "adcp.reporting.ledger.delivery_models", + "ReportingMaterializationKey": "adcp.reporting.ledger.delivery_models", + "ReportingMaterializationRecord": "adcp.reporting.ledger.delivery_models", + "ReportingMaterializationStore": "adcp.reporting.ledger.delivery", + "ReportingMaterializationView": "adcp.reporting.ledger.delivery", + "ReportingMismatchCode": "union[value:'coverage_short',value:'currency_mismatch',value:'metric_missing',value:'period_mismatch',value:'schema_nonconformant',value:'scope_media_buy_missing']", + "ReportingObjectRowBindingV1": "adcp.reporting.ledger.row_storage", + "ReportingObligationDeliveryRecord": "adcp.reporting.ledger.delivery_models", + "ReportingObligationRecord": "adcp.reporting.ledger.models", + "ReportingPeriodBoundary": "adcp.reporting.ledger.models", + "ReportingPeriodRetirementV1": "adcp.reporting.ledger.period_retirement", + "ReportingPhysicalChecksum": "adcp.reporting.ledger.delivery_models", + "ReportingPostgresRowBindingV1": "adcp.reporting.ledger.row_storage", + "ReportingProducer": "adcp.reporting.ledger.producer", + "ReportingProductionStatus": "union[value:'failed',value:'not_due',value:'pending',value:'published']", + "ReportingReceiptKey": "adcp.reporting.ledger.delivery_models", + "ReportingReceiptRecord": "union[adcp.reporting.ledger.delivery_models:ReportingAdjustmentReceiptRecord,adcp.reporting.ledger.delivery_models:ReportingRevisionReceiptRecord]", + "ReportingReceiptStore": "adcp.reporting.ledger.delivery", + "ReportingReconciliationChange": "adcp.reporting.ledger.delivery_changes", + "ReportingReconciliationChangeStore": "adcp.reporting.ledger.delivery_changes:ReportingReconciliationFeedStore", + "ReportingReconciliationCheckpoint": "instance:typing:NewType", + "ReportingReconciliationCursor": "instance:typing:NewType", + "ReportingReconciliationFeedStore": "adcp.reporting.ledger.delivery_changes", + "ReportingReconciliationFilter": "adcp.reporting.ledger.delivery_changes", + "ReportingReconciliationPage": "adcp.reporting.ledger.delivery_changes", + "ReportingReconciliationRecordKind": "union[value:'adjustment_receipt',value:'destination_binding',value:'materialization',value:'materialization_attempt',value:'materialization_check',value:'obligation_delivery',value:'revision_receipt']", + "ReportingReconciliationSnapshot": "adcp.reporting.ledger.delivery", + "ReportingReconciliationSnapshotToken": "adcp.reporting.ledger.delivery_changes", + "ReportingReconciliationStore": "adcp.reporting.ledger.delivery", + "ReportingResourceRecord": "adcp.reporting.ledger.delivery_models", + "ReportingRevisionCorrupt": "adcp.reporting.revision_selection", + "ReportingRevisionNotReady": "adcp.reporting.revision_selection", + "ReportingRevisionReceiptRecord": "adcp.reporting.ledger.delivery_models", + "ReportingRevisionRecord": "adcp.reporting.ledger.models", + "ReportingRevisionSelected": "adcp.reporting.revision_selection", + "ReportingRevisionSelection": "union[adcp.reporting.revision_selection:ReportingRevisionCorrupt,adcp.reporting.revision_selection:ReportingRevisionNotReady,union[instance:typing:TypeVar]]", + "ReportingRowBindingSelectionInputV1": "adcp.reporting.ledger.row_storage", + "ReportingRowIntentSweepV1": "adcp.reporting.ledger.row_retention", + "ReportingRowPage": "adcp.reporting.ledger.store", + "ReportingRowPruneV1": "adcp.reporting.ledger.row_retention", + "ReportingRowStorageOptionsV1": "adcp.reporting.ledger.row_storage", + "ReportingRowStoreError": "adcp.reporting.ledger.row_storage_errors", + "ReportingRowStoreErrorCode": "union[value:'ABORTED',value:'CONTENT_CONFLICT',value:'DEADLINE_EXCEEDED',value:'INVALID_INPUT',value:'PROVIDER_UNAVAILABLE',value:'ROWS_EXPIRED',value:'ROWS_INTEGRITY_FAILED',value:'ROWS_UNAVAILABLE',value:'STATE_UNAVAILABLE',value:'UNSAFE_BINDING']", + "ReportingScheduleSpec": "adcp.reporting.ledger.models", + "ReportingStatusCaller": "adcp.reporting.ledger.status", + "ReportingStatusCallerResolver": "union[instance:builtins:list,union[adcp.reporting.ledger.status:ReportingStatusCaller]]", + "ReportingStatusHandler": "adcp.reporting.ledger.status", + "ReportingStatusNotificationHandler": "adcp.reporting.ledger.status_server", + "ReportingStatusParticipant": "adcp.reporting.ledger.status_snapshot", + "ReportingStatusSnapshot": "adcp.reporting.ledger.status_projection", + "ReportingStatusView": "union[value:'periods',value:'revision',value:'summary']", + "ReportingVerificationRecord": "adcp.reporting.ledger.delivery_models", + "RestatementCheckpoint": "adcp.reporting.ledger.store", + "RestatementCheckpointStore": "adcp.reporting.ledger.store", + "RetryScheduleEntry": "adcp.reporting.ledger.store", + "RetryScheduleStore": "adcp.reporting.ledger.store", + "StatusLifecycleIntent": "adcp.reporting.ledger.status_projection", + "StatusProjectionInput": "adcp.reporting.ledger.status_projection", + "StatusProjectionResult": "adcp.reporting.ledger.status_projection", + "WorkerTurn": "adcp.reporting.ledger.producer", + "adjustment_to_wire": "adcp.reporting.ledger.delivery", + "aggregate_reporting_health": "adcp.reporting.ledger.health", + "check_issue_state_transition": "adcp.reporting.ledger.store", + "consumer_mismatch_issue_key": "adcp.reporting.ledger.consumer_status", + "consumer_statement_conflicts": "adcp.reporting.ledger.consumer_status", + "current_consumer_statement": "adcp.reporting.ledger.consumer_status", + "current_required_revision": "adcp.reporting.ledger.health", + "derive_period": "adcp.reporting.ledger.models", + "iso_duration_to_timedelta": "adcp.reporting.ledger.models", + "issue_id_for": "adcp.reporting.ledger.health", + "issue_id_for_occurrence": "adcp.reporting.ledger.health", + "issue_is_retirable": "adcp.reporting.ledger.store", + "materialization_to_wire": "adcp.reporting.ledger.delivery", + "project_consumer_mismatch": "adcp.reporting.ledger.consumer_status", + "project_obligation_health": "adcp.reporting.ledger.health", + "project_status_scope": "adcp.reporting.ledger.status_projection", + "receipt_to_wire": "adcp.reporting.ledger.delivery", + "recompute_revision_content_sha256": "adcp.reporting.ledger.producer", + "reject_reserved_authoritative_party": "adcp.reporting.ledger.store", + "require_single_currency": "adcp.reporting.currency", + "revision_binding_algorithm": "adcp.reporting.ledger.producer", + "revision_content_sha256": "adcp.reporting.ledger.producer", + "revision_to_wire": "adcp.reporting.ledger.delivery", + "select_reporting_revision": "adcp.reporting.revision_selection", + "stale_received_grace_deadline": "adcp.reporting.ledger.consumer_status", + "validate_currency": "adcp.reporting.currency" + } + }, "adcp.types": { "resolves": { "A2UiComponent": "a2ui.component", diff --git a/tests/test_main_release_policies.py b/tests/test_main_release_policies.py index 403d3038e..425746444 100644 --- a/tests/test_main_release_policies.py +++ b/tests/test_main_release_policies.py @@ -101,6 +101,61 @@ def test_every_post_release_integration_has_real_agreement(history: list[str]) - assert policies.conventional("push", history[-1], {})["commits"] == history[1:] +def test_conventional_rerun_reads_the_corrected_pr_body(history: list[str]) -> None: + api = PolicyAPI(history) + pr = { + "number": 1174, + "head": {"sha": history[-1]}, + "base": {"sha": history[0]}, + "title": BREAKING_SUBJECT, + "body": "Missing the breaking footer in the original webhook.", + } + api.data["pulls/1174"] = {**pr, "body": BREAKING_FOOTER} + + result = policies.conventional("pull_request", history[-1], {"pull_request": pr}, api=api) + + assert result["commits"] == history[1:] + assert api.reads == ["pulls/1174"] + + +def test_conventional_rerun_rejects_a_newly_invalid_pr_message(history: list[str]) -> None: + api = PolicyAPI(history) + pr = { + "number": 1174, + "head": {"sha": history[-1]}, + "base": {"sha": history[0]}, + "title": BREAKING_SUBJECT, + "body": BREAKING_FOOTER, + } + api.data["pulls/1174"] = {**pr, "body": None} + + with pytest.raises(ReleaseRejectedError, match="BREAKING CHANGE"): + policies.conventional("pull_request", history[-1], {"pull_request": pr}, api=api) + + +def test_conventional_rerun_cannot_validate_a_different_pr_head(history: list[str]) -> None: + api = PolicyAPI(history) + pr = { + "number": 1174, + "head": {"sha": history[-1]}, + "base": {"sha": history[0]}, + "title": "fix: valid contribution", + "body": "", + } + api.data["pulls/1174"] = {**pr, "head": {"sha": "a" * 40}} + + with pytest.raises(ReleaseRejectedError, match="PR head changed"): + policies.conventional("pull_request", history[-1], {"pull_request": pr}, api=api) + + +def test_conventional_workflow_can_read_current_pr_metadata() -> None: + workflow = yaml.safe_load((policies.ROOT / ".github/workflows/ci.yml").read_text()) + job = workflow["jobs"]["conventional-commits"] + assert job["permissions"] == {"contents": "read", "pull-requests": "read"} + step = next(s for s in job["steps"] if "check_main_policies conventional" in s.get("run", "")) + assert step["env"]["GH_TOKEN"] == "${{ github.token }}" + + @pytest.mark.parametrize( "case", [ diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 555d070af..4b3fc805a 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -481,6 +481,11 @@ def test_public_api_surface_matches_snapshot(): snapshot = json.loads(snapshot_path.read_text()) regen_cmd = "python scripts/regenerate_public_api_snapshot.py" + assert set(snapshot) == {label for label, _ in snapshot_modules()}, ( + "Snapshot namespaces changed. Review the public surface and regenerate " + f"the snapshot with `{regen_cmd}`." + ) + for label, module_path in snapshot_modules(): module = importlib.import_module(module_path) recorded = snapshot[label]["resolves"] From a40c2f0a71f9110cedc844f125630d3055e209f4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 23 Oct 2026 20:31:17 -0400 Subject: [PATCH 11/54] fix(codegen)!: refuse a JSON null on optional properties whose schema forbids one (#1555) The generator rendered every optional property as `T | None = None`, which admitted an explicit JSON `null` the pinned schema rejects. A pre-generation pass now grades each optional property with the bundled validator and marks the ones that refuse `null`; the first post-generation fix turns the mark into `adcp.types.base.NoExplicitNull`, appended to the field's `Annotated` metadata. The annotation, default and attribute access are unchanged. `NoExplicitNull` is a `json_or_python` core schema: the JSON side is the type without its null arm, so `model_validate_json` refuses `null` with the type's own error and `model_json_schema()` publishes no null arm; the Python side keeps `T | None`, so `Model(field=None)` and `model_validate` on a dict still read `None` as "not supplied", symmetric with `model_dump`'s `exclude_none` default. `ADCP_STRICT_VALIDATION=1` refuses a Python `None` too. Properties whose schema admits `null` are generated without the marker. Also: the eight submitted response arms drop a leftover `validate_default=True`; the three literal-widening fixes carry the marker; the parity suite grades the model in JSON mode and no longer declares the explicit-null divergence. Fixes #1547 Refs #1522, #1414, #1409, #1397, #1359, #1362 BREAKING CHANGE: a JSON document carrying `null` for an optional property whose schema does not admit `null` is now refused by `model_validate_json` and `TypeAdapter.validate_json`, and the published JSON Schema of such a property has no null arm. A Python `None` is still accepted. JSON produced with `exclude_none=False` must be read back in Python mode (`model_validate(json.loads(...))`). Co-Authored-By: Claude Fable 5.1 --- docs/design/type-generation.md | 30 +- docs/validation-contract.md | 17 + scripts/generate_types.py | 175 ++++ scripts/post_generate_fixes.py | 423 ++++++++-- scripts/post_generation_manifest.json | 1 + src/adcp/types/base.py | 107 ++- src/adcp/types/canonical_creative.py | 5 +- src/adcp/types/domains/a2ui/component.py | 6 +- src/adcp/types/domains/a2ui/si_catalog.py | 93 ++- src/adcp/types/domains/a2ui/surface.py | 11 +- src/adcp/types/domains/a2ui/user_action.py | 12 +- .../types/domains/aao/agent_publishers.py | 6 +- .../account/get_account_financials_request.py | 8 +- .../account/list_account_changes_request.py | 16 +- .../account/list_account_changes_response.py | 21 +- .../domains/account/list_accounts_request.py | 14 +- .../domains/account/list_accounts_response.py | 13 +- .../domains/account/report_usage_request.py | 24 +- .../domains/account/report_usage_response.py | 9 +- .../domains/account/sync_accounts_request.py | 39 +- .../account/sync_governance_request.py | 8 +- .../account/sync_governance_response.py | 14 +- src/adcp/types/domains/adagents.py | 96 ++- .../domains/brand/acquire_rights_request.py | 21 +- .../brand/creative_approval_request.py | 14 +- .../brand/creative_approval_response.py | 40 +- .../brand/get_brand_identity_request.py | 9 +- .../types/domains/brand/get_rights_request.py | 16 +- .../domains/brand/revocation_notification.py | 9 +- .../domains/brand/rights_pricing_option.py | 13 +- src/adcp/types/domains/brand/rights_terms.py | 20 +- .../domains/brand/search_brands_request.py | 14 +- .../domains/brand/search_brands_response.py | 38 +- .../domains/brand/update_rights_request.py | 14 +- .../brand/verify_brand_claims_request.py | 31 +- src/adcp/types/domains/brand_discovery.py | 95 ++- .../get_adcp_capabilities_response.py | 786 ++++++++++++++---- .../domains/collection/collection_list.py | 25 +- .../collection_list_changed_webhook.py | 27 +- .../collection/collection_list_filters.py | 12 +- .../create_collection_list_request.py | 17 +- .../create_collection_list_response.py | 8 +- .../delete_collection_list_request.py | 8 +- .../delete_collection_list_response.py | 8 +- .../collection/get_collection_list_request.py | 13 +- .../get_collection_list_response.py | 30 +- .../list_collection_lists_request.py | 14 +- .../list_collection_lists_response.py | 9 +- .../update_collection_list_request.py | 16 +- .../update_collection_list_response.py | 8 +- .../comply_test_controller_request.py | 79 +- .../comply_test_controller_response.py | 75 +- .../get_creative_features_completion.py | 12 +- .../domains/content_standards/artifact.py | 119 ++- .../artifact_webhook_payload.py | 25 +- .../calibrate_content_request.py | 7 +- .../content_standards/content_standards.py | 18 +- .../create_content_standards_request.py | 24 +- .../create_content_standards_response.py | 12 +- .../get_content_standards_request.py | 7 +- .../get_media_buy_artifacts_request.py | 30 +- .../list_content_standards_request.py | 18 +- .../list_content_standards_response.py | 13 +- .../update_content_standards_request.py | 26 +- .../update_content_standards_response.py | 12 +- .../validate_content_delivery_request.py | 33 +- src/adcp/types/domains/core/account.py | 48 +- .../domains/core/account_authorization.py | 7 +- src/adcp/types/domains/core/account_change.py | 21 +- .../core/account_change_recorded_webhook.py | 8 +- .../domains/core/account_identity_change.py | 12 +- .../core/account_identity_change_preview.py | 8 +- src/adcp/types/domains/core/account_ref.py | 8 +- .../core/account_status_changed_webhook.py | 12 +- .../core/account_timezone_capability.py | 7 +- .../core/account_with_authorization.py | 4 +- .../types/domains/core/ad_inventory_config.py | 11 +- .../domains/core/agent_notification_config.py | 11 +- .../core/agent_notification_config_state.py | 15 +- .../core/agent_reporting_destination.py | 7 +- .../core/agent_reporting_destination_state.py | 9 +- .../types/domains/core/agent_signing_key.py | 24 +- .../domains/core/agent_webhook_challenge.py | 5 +- src/adcp/types/domains/core/app_item.py | 26 +- .../types/domains/core/assets/audio_asset.py | 39 +- .../types/domains/core/assets/card_asset.py | 10 +- .../types/domains/core/assets/css_asset.py | 9 +- .../types/domains/core/assets/daast_asset.py | 46 +- .../core/assets/daast_tracker_asset.py | 8 +- .../domains/core/assets/display_tag_asset.py | 25 +- .../types/domains/core/assets/html_asset.py | 17 +- .../types/domains/core/assets/image_asset.py | 18 +- .../domains/core/assets/javascript_asset.py | 14 +- .../domains/core/assets/markdown_asset.py | 7 +- .../core/assets/pixel_tracker_asset.py | 8 +- .../core/assets/published_post_asset.py | 25 +- .../types/domains/core/assets/text_asset.py | 6 +- .../types/domains/core/assets/url_asset.py | 10 +- .../types/domains/core/assets/vast_asset.py | 32 +- .../domains/core/assets/vast_tracker_asset.py | 8 +- .../types/domains/core/assets/video_asset.py | 78 +- .../domains/core/assets/webhook_asset.py | 20 +- .../types/domains/core/assets/zip_asset.py | 18 +- ...ative_features_async_response_submitted.py | 10 +- ...creatives_async_response_input_required.py | 12 +- ...sync_creatives_async_response_submitted.py | 10 +- .../sync_creatives_async_response_working.py | 30 +- ..._creative_async_response_input_required.py | 13 +- ...build_creative_async_response_submitted.py | 10 +- .../build_creative_async_response_working.py | 21 +- ...media_buy_async_response_input_required.py | 13 +- ...eate_media_buy_async_response_submitted.py | 10 +- ...create_media_buy_async_response_working.py | 22 +- ..._products_async_response_input_required.py | 17 +- .../get_products_async_response_submitted.py | 14 +- .../get_products_async_response_working.py | 16 +- ..._catalogs_async_response_input_required.py | 9 +- .../sync_catalogs_async_response_submitted.py | 10 +- .../sync_catalogs_async_response_working.py | 31 +- ...media_buy_async_response_input_required.py | 12 +- ...date_media_buy_async_response_submitted.py | 10 +- ...update_media_buy_async_response_working.py | 22 +- .../get_signals_async_response_submitted.py | 11 +- .../get_signals_async_response_working.py | 13 +- .../domains/core/attestation_capabilities.py | 20 +- .../domains/core/attestation_evaluation.py | 16 +- .../types/domains/core/attestation_issuer.py | 10 +- .../domains/core/attestation_reference.py | 22 +- .../types/domains/core/attestation_subject.py | 11 +- .../types/domains/core/attribution_window.py | 7 +- .../core/audience_activation_method.py | 13 +- .../domains/core/audience_characteristic.py | 19 +- .../types/domains/core/audience_evidence.py | 28 +- .../domains/core/audience_evidence_pin.py | 6 +- .../core/audience_evidence_requirements.py | 30 +- .../core/audience_evidence_selection.py | 8 +- .../types/domains/core/audience_member.py | 9 +- .../types/domains/core/audience_selector.py | 13 +- .../types/domains/core/audience_source.py | 5 +- .../domains/core/authorized_agent_base.py | 7 +- src/adcp/types/domains/core/bidding_policy.py | 9 +- .../domains/core/bidding_policy_capability.py | 12 +- src/adcp/types/domains/core/brand_key.py | 6 +- src/adcp/types/domains/core/brand_ref.py | 34 +- .../brand_response_authorization_result.py | 13 +- .../types/domains/core/budget_allocation.py | 21 +- src/adcp/types/domains/core/budget_range.py | 8 +- .../types/domains/core/business_entity.py | 34 +- .../types/domains/core/cancellation_policy.py | 6 +- .../domains/core/canonical_account_ref.py | 9 +- .../core/canonical_audience_evidence.py | 19 +- .../canonical_audience_evidence_selection.py | 12 +- .../core/canonical_delivery_forecast.py | 26 +- .../domains/core/canonical_forecast_point.py | 41 +- .../canonical_forecast_vendor_metric_value.py | 12 +- .../domains/core/canonical_format_option.py | 34 +- .../core/canonical_measurement_terms.py | 16 +- .../core/canonical_media_buy_action.py | 2 +- .../core/canonical_media_buy_action_fields.py | 15 +- .../core/canonical_media_buy_features.py | 17 +- .../core/canonical_metric_qualifier.py | 20 +- .../core/canonical_optimization_goal.py | 40 +- .../core/canonical_performance_standard.py | 6 +- .../types/domains/core/canonical_placement.py | 68 +- .../domains/core/canonical_pricing_option.py | 32 +- .../types/domains/core/canonical_product.py | 112 ++- .../domains/core/canonical_product_action.py | 10 +- .../domains/core/canonical_projection_ref.py | 6 +- .../canonical_projection_slot_override.py | 10 +- .../types/domains/core/canonical_proposal.py | 27 +- .../core/canonical_reporting_capabilities.py | 53 +- .../core/canonical_reporting_commitment.py | 16 +- .../types/domains/core/canvas_constraint.py | 10 +- .../core/capabilities_changed_webhook.py | 7 +- src/adcp/types/domains/core/catalog.py | 18 +- .../domains/core/catalog_field_mapping.py | 14 +- .../core/catalog_item_availability_state.py | 11 +- .../core/catalog_item_availability_update.py | 8 +- ...catalog_item_availability_update_result.py | 11 +- .../core/catalog_item_delivery_metrics.py | 4 +- .../types/domains/core/catalog_selection.py | 10 +- src/adcp/types/domains/core/catchment.py | 11 +- src/adcp/types/domains/core/collection.py | 29 +- .../core/collection_delivery_metrics.py | 4 +- .../domains/core/collection_distribution.py | 6 +- .../types/domains/core/collection_list_ref.py | 5 +- .../collection_property_delivery_metrics.py | 6 +- .../domains/core/collection_selection.py | 8 +- .../types/domains/core/collection_selector.py | 5 +- .../types/domains/core/committed_metric.py | 17 +- .../core/compact_task_input_required.py | 14 +- .../domains/core/compact_task_submitted.py | 11 +- .../domains/core/compact_task_working.py | 16 +- .../domains/core/creative_approval_scope.py | 8 +- src/adcp/types/domains/core/creative_asset.py | 26 +- .../types/domains/core/creative_assignment.py | 10 +- src/adcp/types/domains/core/creative_brief.py | 35 +- .../domains/core/creative_consumption.py | 11 +- .../domains/core/creative_delivery_metrics.py | 5 +- .../types/domains/core/creative_filters.py | 25 +- .../domains/core/creative_localization.py | 5 +- .../core/creative_localization_readback.py | 5 +- .../types/domains/core/creative_manifest.py | 15 +- .../creative_operation_format_declaration.py | 33 +- .../types/domains/core/creative_policy.py | 13 +- .../domains/core/creative_representation.py | 5 +- .../core/creative_representation_set.py | 5 +- .../types/domains/core/creative_variable.py | 6 +- .../types/domains/core/creative_variant.py | 12 +- .../domains/core/daast_tracker_constraints.py | 12 +- src/adcp/types/domains/core/daypart_target.py | 6 +- .../types/domains/core/deadline_policy.py | 12 +- .../core/delivery_breakdown_controls.py | 7 +- .../types/domains/core/delivery_forecast.py | 14 +- .../domains/core/delivery_metric_aggregate.py | 31 +- .../types/domains/core/delivery_metrics.py | 156 +++- .../types/domains/core/delivery_recipient.py | 8 +- .../domains/core/demographic_age_range.py | 6 +- .../core/demographic_reporting_capability.py | 19 +- .../core/demographic_targeting_capability.py | 11 +- .../core/demographic_targeting_intent.py | 6 +- .../core/demographic_targeting_resolution.py | 14 +- src/adcp/types/domains/core/deployment.py | 18 +- src/adcp/types/domains/core/destination.py | 8 +- .../types/domains/core/destination_item.py | 45 +- .../core/downstream_connection_requirement.py | 28 +- src/adcp/types/domains/core/education_item.py | 48 +- src/adcp/types/domains/core/error.py | 19 +- src/adcp/types/domains/core/evaluator_spec.py | 28 +- src/adcp/types/domains/core/event.py | 18 +- .../types/domains/core/event_custom_data.py | 44 +- .../types/domains/core/event_source_health.py | 11 +- src/adcp/types/domains/core/event_surface.py | 9 +- .../types/domains/core/feature_requirement.py | 9 +- src/adcp/types/domains/core/flight_item.py | 42 +- .../core/forecast_dimension_audience.py | 6 +- .../domains/core/forecast_dimension_geo.py | 7 +- .../core/forecast_dimension_placement.py | 5 +- .../domains/core/forecast_dimension_signal.py | 11 +- src/adcp/types/domains/core/forecast_point.py | 53 +- src/adcp/types/domains/core/forecast_range.py | 16 +- .../types/domains/core/forecast_rate_range.py | 6 +- .../core/forecast_vendor_metric_value.py | 9 +- src/adcp/types/domains/core/format.py | 396 ++++++++- src/adcp/types/domains/core/format_id.py | 8 +- src/adcp/types/domains/core/frequency_cap.py | 9 +- .../domains/core/frequency_cap_constraints.py | 12 +- .../frequency_cap_impression_constraints.py | 12 +- .../frequency_cap_interval_constraints.py | 14 +- .../core/frequency_cap_requirements.py | 15 +- .../domains/core/generation_credential.py | 8 +- .../domains/core/geo_breakdown_support.py | 8 +- .../domains/core/geo_delivery_metrics.py | 6 +- src/adcp/types/domains/core/geo_place_area.py | 8 +- .../core/geo_place_catalog_capability.py | 5 +- .../domains/core/geo_place_catalog_entry.py | 9 +- .../domains/core/geo_place_requirement.py | 5 +- .../types/domains/core/geo_region_support.py | 10 +- .../core/get_geo_place_resolution_request.py | 15 +- .../core/get_geo_place_resolution_response.py | 6 +- src/adcp/types/domains/core/hotel_item.py | 54 +- src/adcp/types/domains/core/impairment.py | 7 +- src/adcp/types/domains/core/indicator.py | 7 +- .../types/domains/core/indicator_bearing.py | 8 +- .../types/domains/core/indicator_scope.py | 5 +- .../core/indicators_changed_webhook.py | 10 +- .../types/domains/core/insertion_order.py | 31 +- src/adcp/types/domains/core/installment.py | 36 +- .../domains/core/installment_deadlines.py | 7 +- .../core/installment_delivery_metrics.py | 5 +- .../installment_property_delivery_metrics.py | 7 +- src/adcp/types/domains/core/job_item.py | 36 +- src/adcp/types/domains/core/keyword_target.py | 6 +- src/adcp/types/domains/core/limited_series.py | 12 +- .../types/domains/core/macro_declaration.py | 9 +- .../core/macro_resolution_capability.py | 15 +- .../domains/core/macro_resolution_result.py | 15 +- .../domains/core/macro_translation_target.py | 10 +- .../types/domains/core/material_deadline.py | 5 +- .../types/domains/core/mcp_webhook_payload.py | 10 +- .../domains/core/measurement_readiness.py | 8 +- .../types/domains/core/measurement_terms.py | 9 +- .../types/domains/core/measurement_window.py | 7 +- src/adcp/types/domains/core/media_buy.py | 39 +- .../core/media_buy_available_action.py | 15 +- .../types/domains/core/media_buy_features.py | 14 +- .../media_buy_frequency_cap_requirement.py | 4 +- .../core/media_buy_frequency_cap_support.py | 4 +- .../types/domains/core/media_buy_support.py | 13 +- .../core/media_buy_support_requirements.py | 11 +- src/adcp/types/domains/core/missing_metric.py | 20 +- .../types/domains/core/notification_config.py | 10 +- src/adcp/types/domains/core/offering.py | 26 +- .../domains/core/offering_asset_group.py | 6 +- .../types/domains/core/operator_identity.py | 5 +- src/adcp/types/domains/core/operator_unit.py | 5 +- .../types/domains/core/opportunity_context.py | 14 +- .../types/domains/core/optimization_goal.py | 21 +- .../types/domains/core/outcome_measurement.py | 5 +- src/adcp/types/domains/core/overlay.py | 9 +- src/adcp/types/domains/core/package.py | 50 +- .../core/package_delivery_metric_value.py | 9 +- .../domains/core/package_format_snapshot.py | 346 +++++++- .../domains/core/package_signal_targeting.py | 18 +- .../core/package_targeting_resolution.py | 6 +- .../types/domains/core/pagination_request.py | 6 +- .../types/domains/core/pagination_response.py | 6 +- .../domains/core/performance_feedback.py | 29 +- .../core/performance_feedback_assertion.py | 26 +- .../core/performance_feedback_metric.py | 20 +- .../domains/core/performance_standard.py | 5 +- src/adcp/types/domains/core/placement.py | 28 +- .../domains/core/placement_definition.py | 33 +- .../core/placement_delivery_metrics.py | 6 +- .../types/domains/core/placement_evidence.py | 15 +- .../domains/core/placement_presentation.py | 12 +- .../placement_property_delivery_metrics.py | 8 +- src/adcp/types/domains/core/placement_ref.py | 5 +- .../types/domains/core/placement_selection.py | 8 +- .../types/domains/core/planned_delivery.py | 40 +- .../core/positive_postal_area_support.py | 25 +- .../types/domains/core/postal_area_support.py | 48 +- .../types/domains/core/preview_provider.py | 5 +- src/adcp/types/domains/core/price.py | 5 +- .../domains/core/principal_changed_webhook.py | 7 +- .../domains/core/principal_declarations.py | 7 +- .../core/principal_declarations_state.py | 6 +- .../types/domains/core/principal_state.py | 10 +- src/adcp/types/domains/core/product.py | 129 ++- .../types/domains/core/product_allocation.py | 18 +- .../domains/core/product_allowed_action.py | 8 +- .../product_audience_evidence_requirements.py | 55 +- .../core/product_card_reference_asset.py | 9 +- .../core/product_execution_requirement.py | 12 +- .../types/domains/core/product_filters.py | 98 ++- .../core/product_format_declaration.py | 244 +++++- .../types/domains/core/product_identity.py | 5 +- .../domains/core/product_offer_filters.py | 101 ++- .../core/product_signal_targeting_option.py | 9 +- .../core/product_targeting_resolution.py | 7 +- src/adcp/types/domains/core/property.py | 8 +- .../domains/core/property_delivery_metrics.py | 5 +- .../types/domains/core/property_list_ref.py | 5 +- src/adcp/types/domains/core/proposal.py | 37 +- .../types/domains/core/protocol_envelope.py | 14 +- src/adcp/types/domains/core/provenance.py | 51 +- .../core/publisher_property_selector.py | 8 +- .../domains/core/push_notification_config.py | 7 +- .../types/domains/core/real_estate_item.py | 64 +- .../types/domains/core/reference_asset.py | 5 +- .../types/domains/core/reference_renderer.py | 5 +- src/adcp/types/domains/core/registry_event.py | 162 ++-- .../domains/core/reporting_adjustment.py | 6 +- .../core/reporting_adjustment_receipt.py | 9 +- .../reporting_canonicalization_contract.py | 6 +- .../domains/core/reporting_capabilities.py | 25 +- .../domains/core/reporting_consumer_status.py | 14 +- .../domains/core/reporting_control_total.py | 6 +- .../types/domains/core/reporting_coverage.py | 8 +- .../reporting_dataset_share_destination.py | 8 +- .../core/reporting_delivery_capabilities.py | 15 +- .../domains/core/reporting_delivery_config.py | 14 +- .../core/reporting_delivery_config_state.py | 23 +- .../core/reporting_delivery_offering.py | 27 +- .../domains/core/reporting_file_entry.py | 10 +- .../core/reporting_ledger_changed_webhook.py | 24 +- .../domains/core/reporting_materialization.py | 18 +- .../domains/core/reporting_obligation.py | 16 +- .../types/domains/core/reporting_receipt.py | 28 +- .../core/reporting_reliability_statistics.py | 12 +- .../core/reporting_report_definition.py | 8 +- .../types/domains/core/reporting_resource.py | 10 +- .../types/domains/core/reporting_revision.py | 14 +- .../types/domains/core/reporting_schedule.py | 6 +- .../core/reporting_schedule_offering.py | 12 +- .../core/reporting_status_changed_webhook.py | 10 +- .../domains/core/reporting_status_issue.py | 34 +- .../domains/core/reporting_verification.py | 12 +- .../types/domains/core/reporting_webhook.py | 6 +- .../core/reporting_write_destination.py | 5 +- .../core/representation_destination.py | 7 +- .../domains/core/representation_rejection.py | 6 +- .../domains/core/representation_selection.py | 6 +- .../requirements/audio_asset_requirements.py | 35 +- .../requirements/catalog_field_binding.py | 11 +- .../core/requirements/catalog_requirements.py | 11 +- .../requirements/css_asset_requirements.py | 6 +- .../requirements/daast_asset_requirements.py | 6 +- .../requirements/html_asset_requirements.py | 8 +- .../requirements/image_asset_requirements.py | 28 +- .../javascript_asset_requirements.py | 12 +- .../markdown_asset_requirements.py | 10 +- .../requirements/offering_asset_constraint.py | 16 +- .../requirements/text_asset_requirements.py | 32 +- .../requirements/url_asset_requirements.py | 13 +- .../requirements/vast_asset_requirements.py | 7 +- .../requirements/video_asset_requirements.py | 74 +- .../webhook_asset_requirements.py | 8 +- src/adcp/types/domains/core/response.py | 5 +- .../core/rights_attestation_evaluation.py | 20 +- .../types/domains/core/rights_constraint.py | 40 +- .../types/domains/core/seller_agent_ref.py | 5 +- .../domains/core/signal_coverage_forecast.py | 54 +- .../types/domains/core/signal_definition.py | 74 +- .../core/signal_definition_enrichment.py | 79 +- src/adcp/types/domains/core/signal_filters.py | 16 +- src/adcp/types/domains/core/signal_listing.py | 15 +- .../core/signal_modeling_disclosure.py | 13 +- src/adcp/types/domains/core/signal_pricing.py | 17 +- .../core/signal_selection_group_rule.py | 8 +- .../types/domains/core/signal_targeting.py | 12 +- .../core/signal_targeting_expression.py | 6 +- .../domains/core/signal_targeting_rules.py | 12 +- src/adcp/types/domains/core/sla_window.py | 6 +- src/adcp/types/domains/core/special.py | 11 +- src/adcp/types/domains/core/store_item.py | 33 +- src/adcp/types/domains/core/talent.py | 5 +- src/adcp/types/domains/core/targeting.py | 56 +- .../types/domains/core/targeting_input.py | 16 +- .../domains/core/targeting_modification.py | 9 +- .../core/targeting_overlay_requirements.py | 102 ++- .../domains/core/targeting_overlay_support.py | 129 +-- .../types/domains/core/tasks_get_request.py | 10 +- .../types/domains/core/tasks_get_response.py | 48 +- .../types/domains/core/tasks_list_request.py | 52 +- .../types/domains/core/tasks_list_response.py | 37 +- .../core/tracker_execution_selector.py | 12 +- src/adcp/types/domains/core/transformer.py | 263 +++++- .../types/domains/core/transformer_param.py | 20 +- .../types/domains/core/truncation_sentinel.py | 6 +- src/adcp/types/domains/core/user_match.py | 22 +- .../core/vast_media_file_requirements.py | 27 +- .../domains/core/vast_tracker_constraints.py | 12 +- src/adcp/types/domains/core/vehicle_item.py | 60 +- ...or_metric_optimization_supported_metric.py | 5 +- .../types/domains/core/vendor_metric_value.py | 25 +- .../domains/core/vendor_pricing_option.py | 19 +- .../domains/core/verification_token_claims.py | 12 +- .../types/domains/core/version_envelope.py | 6 +- src/adcp/types/domains/core/warning.py | 7 +- .../types/domains/core/warning_resource.py | 11 +- .../domains/core/webhook_activity_record.py | 9 +- .../types/domains/core/webhook_challenge.py | 5 +- .../core/webhook_challenge_response.py | 6 +- .../domains/core/wholesale_feed_event.py | 47 +- .../domains/core/wholesale_feed_webhook.py | 8 +- .../domains/creative/audit_observation.py | 12 +- .../creative_assignment_changed_webhook.py | 6 +- .../creative/creative_feature_result.py | 17 +- .../creative/creative_purged_webhook.py | 7 +- .../creative_status_changed_webhook.py | 8 +- .../creative/get_creative_delivery_request.py | 14 +- .../get_creative_delivery_response.py | 27 +- ...ative_features_async_response_submitted.py | 10 +- .../creative/get_creative_features_request.py | 11 +- .../get_creative_features_response.py | 2 +- .../get_creative_features_terminal_success.py | 15 +- .../creative/list_creative_formats_request.py | 28 +- .../list_creative_formats_response.py | 18 +- .../creative/list_creatives_request.py | 35 +- .../creative/list_creatives_response.py | 85 +- .../creative/list_transformers_request.py | 21 +- .../creative/list_transformers_response.py | 13 +- .../creative/preview_creative_request.py | 40 +- .../creative/preview_creative_response.py | 2 +- .../types/domains/creative/preview_render.py | 28 +- ...creatives_async_response_input_required.py | 12 +- ...sync_creatives_async_response_submitted.py | 10 +- .../sync_creatives_async_response_working.py | 30 +- .../creative/sync_creatives_request.py | 27 +- .../creative/sync_creatives_response.py | 2 +- .../creative/validate_input_request.py | 7 +- .../domains/creative/validate_input_result.py | 8 +- .../types/domains/creative/video_brief.py | 14 +- .../error_details/accessibility_violation.py | 6 +- .../domains/error_details/account_moved.py | 5 +- .../error_details/account_setup_required.py | 10 +- .../error_details/action_not_allowed.py | 6 +- .../error_details/audience_too_small.py | 10 +- .../error_details/authorization_required.py | 9 +- .../billing_not_permitted_for_agent.py | 5 +- .../error_details/billing_not_supported.py | 6 +- .../domains/error_details/budget_too_low.py | 12 +- .../types/domains/error_details/conflict.py | 11 +- .../error_details/creative_rejected.py | 16 +- .../governance_agent_not_accepted.py | 5 +- .../domains/error_details/policy_violation.py | 14 +- .../domains/error_details/rate_limited.py | 20 +- .../domains/error_details/requote_required.py | 7 +- .../domains/error_details/stale_response.py | 11 +- .../error_details/vast_version_mismatch.py | 41 +- .../error_details/vendor_error_codes.py | 9 +- .../error_details/version_unsupported.py | 8 +- .../domains/extensions/extension_meta.py | 13 +- .../types/domains/formats/canonical/_base.py | 37 +- .../formats/canonical/agent_placement.py | 11 +- .../domains/formats/canonical/audio_daast.py | 19 +- .../domains/formats/canonical/audio_hosted.py | 38 +- .../domains/formats/canonical/audio_vast.py | 20 +- .../canonical/coordinated_placements.py | 447 +++++++--- .../domains/formats/canonical/display_tag.py | 24 +- .../types/domains/formats/canonical/html5.py | 30 +- .../types/domains/formats/canonical/image.py | 29 +- .../formats/canonical/image_carousel.py | 23 +- .../formats/canonical/native_in_feed.py | 21 +- .../formats/canonical/responsive_creative.py | 55 +- .../seller_rendered_stateful_display.py | 82 +- .../formats/canonical/sponsored_placement.py | 19 +- .../domains/formats/canonical/video_hosted.py | 50 +- .../domains/formats/canonical/video_vast.py | 36 +- .../governance/attribute_definition.py | 10 +- .../governance/audience_constraints.py | 6 +- .../governance/check_governance_request.py | 93 ++- .../governance/check_governance_response.py | 32 +- .../governance/get_plan_audit_logs_request.py | 15 +- .../get_plan_audit_logs_response.py | 191 +++-- .../governance/policy_category_definition.py | 16 +- .../types/domains/governance/policy_entry.py | 38 +- .../types/domains/governance/policy_ref.py | 6 +- .../report_plan_adjustment_request.py | 27 +- .../report_plan_adjustment_response.py | 9 +- .../governance/report_plan_outcome_request.py | 29 +- .../report_plan_outcome_response.py | 23 +- .../governance/reported_outcome_error.py | 17 +- .../domains/governance/sync_plans_request.py | 49 +- .../domains/governance/sync_plans_response.py | 15 +- src/adcp/types/domains/manifest_schema.py | 22 +- .../media_buy/accept_proposal_request.py | 32 +- .../media_buy/accept_proposal_response.py | 31 +- .../domains/media_buy/acceptance_context.py | 20 +- .../media_buy/acceptance_policy_catalog.py | 13 +- .../media_buy/acceptance_policy_profile.py | 14 +- .../acceptance_policy_requirement.py | 53 +- .../media_buy/acceptance_policy_rule.py | 20 +- ..._creative_async_response_input_required.py | 13 +- ...build_creative_async_response_submitted.py | 10 +- .../build_creative_async_response_working.py | 21 +- .../media_buy/build_creative_request.py | 61 +- .../media_buy/build_creative_response.py | 2 +- .../domains/media_buy/buy_products_request.py | 45 +- .../media_buy/buy_products_response.py | 31 +- .../types/domains/media_buy/change_term.py | 12 +- .../media_buy/change_term_constraints.py | 29 +- .../domains/media_buy/commercial_terms.py | 38 +- .../media_buy/control_media_buy_request.py | 36 +- .../media_buy/control_media_buy_response.py | 35 +- ...media_buy_async_response_input_required.py | 13 +- ...eate_media_buy_async_response_submitted.py | 10 +- ...create_media_buy_async_response_working.py | 22 +- .../media_buy/create_media_buy_request.py | 37 +- .../media_buy/create_media_buy_response.py | 2 +- .../media_buy/decline_proposals_request.py | 17 +- .../media_buy/decline_proposals_response.py | 31 +- .../get_media_buy_delivery_request.py | 93 ++- .../get_media_buy_delivery_response.py | 165 +++- .../media_buy/get_media_buys_request.py | 16 +- .../media_buy/get_media_buys_response.py | 123 ++- ..._products_async_response_input_required.py | 17 +- .../get_products_async_response_submitted.py | 14 +- .../get_products_async_response_working.py | 16 +- .../media_buy/get_products_rejected.py | 11 +- .../domains/media_buy/get_products_request.py | 33 +- .../media_buy/get_products_response.py | 41 +- .../get_products_targeting_resolution.py | 6 +- .../media_buy/get_reporting_status_request.py | 22 +- .../get_reporting_status_response.py | 47 +- .../list_creative_formats_request.py | 28 +- .../list_creative_formats_response.py | 17 +- .../media_buy/list_products_request.py | 29 +- .../media_buy/list_products_response.py | 32 +- .../domains/media_buy/log_event_request.py | 8 +- .../media_buy_commitment_response.py | 31 +- .../media_buy_delivery_webhook_result.py | 51 +- .../types/domains/media_buy/outcome_target.py | 8 +- .../domains/media_buy/package_control.py | 28 +- .../domains/media_buy/package_request.py | 52 +- .../types/domains/media_buy/package_update.py | 32 +- .../media_buy/product_discovery_criteria.py | 18 +- .../domains/media_buy/product_purchase.py | 30 +- .../media_buy/product_purchase_input.py | 30 +- .../domains/media_buy/product_refinement.py | 21 +- .../media_buy/proposal_budget_constraint.py | 8 +- .../domains/media_buy/proposal_decline.py | 5 +- .../domains/media_buy/proposal_refinement.py | 61 +- .../provide_performance_feedback_request.py | 7 +- .../media_buy/refine_proposals_request.py | 17 +- .../media_buy/refine_proposals_response.py | 131 ++- .../media_buy/request_proposals_request.py | 23 +- .../media_buy/request_proposals_response.py | 102 ++- .../media_buy/sync_audiences_request.py | 22 +- .../media_buy/sync_audiences_response.py | 2 +- ..._catalogs_async_response_input_required.py | 9 +- .../sync_catalogs_async_response_submitted.py | 10 +- .../sync_catalogs_async_response_working.py | 31 +- .../media_buy/sync_catalogs_request.py | 15 +- .../media_buy/sync_catalogs_response.py | 2 +- .../media_buy/sync_event_sources_request.py | 21 +- .../sync_reporting_receipts_request.py | 14 +- .../sync_reporting_receipts_response.py | 8 +- .../sync_reporting_status_request.py | 9 +- .../sync_reporting_status_response.py | 8 +- ...media_buy_async_response_input_required.py | 12 +- ...date_media_buy_async_response_submitted.py | 10 +- ...update_media_buy_async_response_working.py | 22 +- .../media_buy/update_media_buy_request.py | 28 +- .../media_buy/update_media_buy_response.py | 2 +- .../domains/pricing_options/cpa_option.py | 9 +- .../domains/pricing_options/cpc_option.py | 11 +- .../domains/pricing_options/cpcv_option.py | 11 +- .../domains/pricing_options/cpm_option.py | 11 +- .../domains/pricing_options/cpp_option.py | 13 +- .../domains/pricing_options/cpv_option.py | 11 +- .../pricing_options/flat_rate_option.py | 23 +- .../pricing_options/price_breakdown.py | 8 +- .../domains/pricing_options/price_guidance.py | 20 +- .../pricing_options/revenue_share_option.py | 5 +- .../domains/pricing_options/time_option.py | 15 +- .../domains/pricing_options/vcpm_option.py | 11 +- .../domains/property/authorization_result.py | 11 +- .../property/create_property_list_request.py | 17 +- .../property/create_property_list_response.py | 8 +- .../property/delete_property_list_request.py | 8 +- .../property/delete_property_list_response.py | 8 +- .../types/domains/property/delivery_record.py | 8 +- .../property/get_property_list_request.py | 13 +- .../property/get_property_list_response.py | 14 +- .../property/list_property_lists_request.py | 14 +- .../property/list_property_lists_response.py | 9 +- .../types/domains/property/property_error.py | 8 +- .../domains/property/property_feature.py | 5 +- .../property/property_feature_definition.py | 34 +- .../property/property_feature_result.py | 8 +- .../property/property_feature_value.py | 18 +- .../types/domains/property/property_list.py | 26 +- .../property/property_list_changed_webhook.py | 25 +- .../domains/property/property_list_filters.py | 9 +- .../property/update_property_list_request.py | 16 +- .../property/update_property_list_response.py | 8 +- .../validate_property_delivery_request.py | 9 +- .../validate_property_delivery_response.py | 19 +- .../domains/property/validation_result.py | 27 +- .../protocol/get_adcp_capabilities_request.py | 8 +- .../get_adcp_capabilities_response.py | 304 ++++++- .../domains/protocol/get_principal_request.py | 9 +- .../protocol/get_principal_response.py | 8 +- .../protocol/get_task_status_request.py | 10 +- .../protocol/get_task_status_response.py | 48 +- .../domains/protocol/list_tasks_request.py | 52 +- .../domains/protocol/list_tasks_response.py | 37 +- ...sync_agent_notification_configs_request.py | 8 +- ...ync_agent_notification_configs_response.py | 10 +- .../protocol/sync_principal_request.py | 14 +- .../protocol/sync_principal_response.py | 12 +- .../registries/v1_canonical_mapping.py | 21 +- .../signals/activate_signal_request.py | 11 +- .../get_signals_async_response_submitted.py | 11 +- .../get_signals_async_response_working.py | 13 +- .../domains/signals/get_signals_request.py | 25 +- .../domains/signals/get_signals_response.py | 107 ++- .../sponsored_intelligence/si_capabilities.py | 61 +- .../si_get_offering_request.py | 13 +- .../si_get_offering_response.py | 70 +- .../sponsored_intelligence/si_identity.py | 51 +- .../si_initiate_session_request.py | 17 +- .../si_initiate_session_response.py | 29 +- .../si_send_message_request.py | 20 +- .../si_send_message_response.py | 63 +- .../si_sponsored_context.py | 29 +- .../si_sponsored_context_receipt.py | 15 +- .../si_terminate_session_request.py | 25 +- .../si_terminate_session_response.py | 26 +- .../sponsored_intelligence/si_ui_element.py | 8 +- .../trusted_match/available_package.py | 7 +- .../trusted_match/context_match_request.py | 27 +- .../trusted_match/context_match_response.py | 7 +- src/adcp/types/domains/trusted_match/error.py | 8 +- .../trusted_match/identity_match_request.py | 29 +- .../trusted_match/identity_match_response.py | 6 +- src/adcp/types/domains/trusted_match/offer.py | 10 +- .../domains/trusted_match/offer_price.py | 8 +- .../provider_context_match_response.py | 14 +- .../provider_identity_match_response.py | 5 +- .../trusted_match/provider_registration.py | 20 +- tests/conformance/_schema_parity.py | 9 - tests/conformance/test_schema_model_parity.py | 6 +- tests/test_canceled_literal_default.py | 8 +- tests/test_codegen_invariants.py | 41 +- tests/test_manifest_response_readback.py | 14 +- tests/test_no_explicit_null.py | 289 +++++++ tests/test_post_generate_no_explicit_null.py | 179 ++++ tests/test_targeting_overlay_compat.py | 22 +- tests/test_targeting_overlay_schema.py | 37 +- 692 files changed, 13564 insertions(+), 4486 deletions(-) create mode 100644 tests/test_no_explicit_null.py create mode 100644 tests/test_post_generate_no_explicit_null.py diff --git a/docs/design/type-generation.md b/docs/design/type-generation.md index bfceedf49..fcd883797 100644 --- a/docs/design/type-generation.md +++ b/docs/design/type-generation.md @@ -139,6 +139,22 @@ Each of these operates on the staging copy only. The cached bundle, the schemas ships and any direct-from-schema validation are untouched, which is what keeps the transforms from changing what the SDK validates against. +One more transform runs once over the whole staged tree after every file is written, +because it has to resolve `$ref`s into the documents the generator will read: +`mark_no_explicit_null_properties`. JSON Schema separates two things the generator +conflates — a property outside `required` may be *absent*, and `null` is a valid *value* +only where the declared type admits it — and the generator renders both as +`T | None = None`, so the model accepted `{"creatives": null}` where the bundled validator +reports a `type` error (#1547). The pass grades every optional property with the same +`jsonschema` validator the bundled arm uses (`is_valid(None)` on the property node, refs +resolved against the staged tree), and marks the ones that refuse `null` with +`x-adcp-no-explicit-null: true`. The generator carries the mark into +`Field(json_schema_extra=...)` via `--field-extra-keys`, and the first post-generation fix +turns it into the `NoExplicitNull` annotation (below). The key exists only in the staged +input; the shipped schemas never carry it. A property whose schema admits `null` — a type +list, an `anyOf` with a null arm, a `$ref` to a nullable schema — is not marked and keeps +accepting it. + Two of them carry explicit ceilings rather than trusting a bump not to move the problem. `normalize_version_envelope_composition` raises if it would rewrite more than 12 schemas: *"A bump changed the shape of the problem: re-measure which schemas declare the version @@ -159,8 +175,18 @@ on the file being present and absent. `scripts/post_generate_fixes.py` is 7367 lines holding 130 module-level functions: 72 fix entrypoints, built as one ordered list in `main()` and run in list order, plus 58 helpers. -Three ordering constraints are commented in the list itself and must be preserved: - +Four ordering constraints are commented in the list itself and must be preserved: + +- `point_omittable_fields_at_no_explicit_null` runs first. It removes the + `json_schema_extra={'x-adcp-no-explicit-null': True}` keyword the generator emitted for + every marked property and appends `adcp.types.base.NoExplicitNull` to the field's + `Annotated` metadata, re-rendering the field the way black lays it out, so every later + fix reads exactly the text the generator produced on the pinned flags plus that one + element. The annotation stays `T | None = None`: omitted still reads as `None`, a JSON + `null` is refused (with the type's own error; a Python `None` stays the spelling of absence + unless `ADCP_STRICT_VALIDATION` is set), and the `null` arm leaves the published JSON + Schema. A marked field whose annotation already refuses `None` (a + `Literal` with a default, a field an `allOf` arm made required) only loses the keyword. - `point_integer_fields_at_the_schema_integer_type` retypes every `type: integer`, including an integer root's own `root` annotation, so it must precede `rewrite_scalar_rootmodels` — run after, and an integer root is still spelled diff --git a/docs/validation-contract.md b/docs/validation-contract.md index db368cf72..f8e532f4e 100644 --- a/docs/validation-contract.md +++ b/docs/validation-contract.md @@ -13,6 +13,23 @@ another composition keyword. `x-adcp-validation` carries behavioral rules that neither Pydantic nor a standard JSON Schema validator interprets generically; the SDK implements security- and commitment-critical instances explicitly. +Absence and `null` are two different things, and the models keep them apart +the way the schemas do. A property outside `required` may be omitted, and +reads back as `None`; an explicit JSON `null` is a value, valid only where the +schema's type admits it. The generator marks every optional property whose +schema refuses `null` with `adcp.types.base.NoExplicitNull`: a JSON document +carrying `null` there is refused (`model_validate_json`, with the type's own +error, e.g. `list_type`) and `model_json_schema()` publishes no null arm, while +the annotation stays `T | None = None`. A property whose schema lists `null` +(`"type": ["string", "null"]`, an `anyOf` with a null arm) keeps accepting it. + +On the Python side `None` remains the spelling of "not supplied": +`Model(field=None)` and `model_validate` on a dict accept it, it reads back as +`None`, and `model_dump()` (which defaults to `exclude_none=True`) leaves it +off the wire — the same document an omitted property produces. Set +`ADCP_STRICT_VALIDATION=1` (the switch that also forbids unknown fields) to +refuse a Python `None` as well, for suites that want to catch it. + ## Wire-boundary behavior - SDK clients validate requests before sending and responses after receiving, diff --git a/scripts/generate_types.py b/scripts/generate_types.py index ba53c3730..6a6dfd536 100755 --- a/scripts/generate_types.py +++ b/scripts/generate_types.py @@ -19,6 +19,7 @@ import subprocess import sys import tempfile +import warnings from pathlib import Path from typing import Any @@ -953,6 +954,174 @@ def flatten_root_object_validation_union(schema: dict, schema_path: Path) -> dic return schema +#: Pre-generation mark on an optional property whose schema does not admit +#: ``null``. The generator carries it into ``Field(json_schema_extra=...)`` +#: (``--field-extra-keys``), and ``post_generate_fixes.point_omittable_fields_at_no_explicit_null`` +#: turns that into the ``adcp.types.base.NoExplicitNull`` annotation. The key +#: exists only in the temporary generator input; the shipped schemas never +#: carry it. +NO_EXPLICIT_NULL_KEY = "x-adcp-no-explicit-null" + + +def _file_uri_resource(path: Path): + """Load *path* as a ``referencing`` resource addressed by its file URI. + + The flattened tree spells every cross-file ``$ref`` as a relative path + (``../core/account_ref.json``). Addressing each document by its own file + URI, rather than the canonical ``$id`` the bundle declares, makes those + paths resolve against the tree the generator actually reads. + """ + from referencing import Resource + from referencing.jsonschema import DRAFT7 + + with open(path) as f: + contents = json.load(f) + if isinstance(contents, dict): + contents["$id"] = path.resolve().as_uri() + return Resource.from_contents(contents, default_specification=DRAFT7) + + +def _property_admits_null(validator, node: dict[str, Any]) -> bool: + """Whether the schema *node* accepts JSON ``null``, as the bundled validator decides. + + A node that states its own ``type`` (or ``enum`` / ``const``) answers by + inspection — that is the overwhelming majority, and it is exactly what the + validator's ``type`` keyword would check. Every other spelling — a + ``$ref``, ``anyOf``/``oneOf``/``allOf``/``not``, a conditional, a bare + object with no ``type`` — goes to the validator itself, so the model and + the JSON-Schema arm reach one verdict without this pass re-deriving the + keyword semantics. (On CI the validator path dominated the run: every + ``$ref`` re-read its target, and 74k properties took minutes.) + """ + declared = node.get("type") + composes = any(key in node for key in ("$ref", "anyOf", "oneOf", "allOf", "not", "if")) + if not composes: + if isinstance(declared, str): + return declared == "null" + if isinstance(declared, list): + return "null" in declared + if "enum" in node and isinstance(node["enum"], list): + return None in node["enum"] + if "const" in node: + return node["const"] is None + return bool(validator.evolve(schema=node).is_valid(None)) + + +def _required_names(node: Any, resolver, seen: frozenset[str] = frozenset()) -> set[str]: + """Every property *node* requires, including through ``allOf`` and ``$ref``. + + A branch that composes a base schema (``allOf: [{"$ref": ...}]``) and + restates one of its properties leaves ``required`` to the base. The + generator merges the two and renders the field required; grading the + restated property as optional would mark it, and the mark alone is what + makes the generator stop merging it (#1547). So a property any composed + schema requires is not optional here either. + """ + if not isinstance(node, dict): + return set() + required = set(node.get("required", []) if isinstance(node.get("required"), list) else []) + ref = node.get("$ref") + if isinstance(ref, str) and ref not in seen: + resolved = resolver.lookup(ref) + required |= _required_names(resolved.contents, resolved.resolver, seen | {ref}) + for arm in node.get("allOf", []) if isinstance(node.get("allOf"), list) else []: + required |= _required_names(arm, resolver, seen) + return required + + +def _mark_no_explicit_null(node: Any, validator, resolver, counter: list[int]) -> None: + if isinstance(node, list): + for item in node: + _mark_no_explicit_null(item, validator, resolver, counter) + return + if not isinstance(node, dict): + return + properties = node.get("properties") + if isinstance(properties, dict): + required_names = _required_names(node, resolver) + for name, property_schema in properties.items(): + if name in required_names or not isinstance(property_schema, dict): + continue + if not _property_admits_null(validator, property_schema): + property_schema[NO_EXPLICIT_NULL_KEY] = True + counter[0] += 1 + for key, value in node.items(): + if key == NO_EXPLICIT_NULL_KEY: + continue + _mark_no_explicit_null(value, validator, resolver, counter) + + +def mark_no_explicit_null_properties(temp_dir: Path) -> int: + """Mark every optional property whose schema refuses an explicit ``null``. + + JSON Schema separates two things the generator conflates: a property + outside ``required`` may be *absent*, and ``null`` is a valid *value* only + where the declared type admits it. The generator renders both as + ``T | None = None``, so without this pass the model accepts + ``{"creatives": null}`` where the bundled schema — and so the SDK's own + ``validate_request`` — reports a ``type`` error (#1547). + + This runs over the whole flattened tree once every file is written, so a + ``$ref`` resolves to the document the generator will read. Each property + is graded by the same ``jsonschema`` validator the bundled arm uses, and + a property it says cannot be ``null`` is marked with + :data:`NO_EXPLICIT_NULL_KEY`. A property whose schema admits ``null`` is + left alone and keeps accepting it. Returns the number of marked properties. + """ + from jsonschema.validators import validator_for + from referencing import Registry + + def retrieve(uri: str): + from urllib.parse import urlparse + from urllib.request import url2pathname + + parsed = urlparse(uri) + if parsed.scheme == "file": + return _file_uri_resource(Path(url2pathname(parsed.path))) + # The references in PRESERVE_CANONICAL_URL_REFS keep their canonical + # URL; the generator reads those from a mirror of the same cache, and + # this pass reads them from the flattened tree the mirror was built from. + canonical = re.fullmatch(r"https://adcontextprotocol\.org/schemas/[^/]+/(.+)", uri) + if canonical is None: + raise LookupError(uri) + relative = Path(*(part.replace("-", "_") for part in Path(canonical.group(1)).parts)) + return _file_uri_resource(temp_dir / relative) + + # Every document in the tree, keyed by its file URI, so a ``$ref`` never + # retrieves from disk: a validator built per file starts from this one + # registry rather than re-reading each target it reaches. + registry = Registry(retrieve=retrieve).with_resources( + (path.resolve().as_uri(), _file_uri_resource(path)) + for path in sorted(temp_dir.rglob("*.json")) + ) + marked = 0 + for schema_file in sorted(temp_dir.rglob("*.json")): + with open(schema_file) as f: + schema = json.load(f) + if not isinstance(schema, dict): + continue + # Validate with a copy addressed by file URI; write the original back, + # so the mark is the only thing this pass adds to the generator input. + document = copy.deepcopy(schema) + document["$id"] = schema_file.resolve().as_uri() + with warnings.catch_warnings(): + # A few root documents name themselves rather than a metaschema in + # ``$schema``; jsonschema grades those with its latest draft and + # says so. The verdict on ``null`` is the same under every draft. + warnings.simplefilter("ignore", DeprecationWarning) + validator_class = validator_for(document) + validator = validator_class(document, registry=registry) + resolver = registry.resolver(base_uri=document["$id"]) + counter = [0] + _mark_no_explicit_null(schema, validator, resolver, counter) + if counter[0]: + with open(schema_file, "w") as f: + json.dump(schema, f, indent=2) + marked += counter[0] + print(f" Marked {marked} optional properties that refuse an explicit null") + return marked + + def flatten_schemas(temp_dir: Path): """ Copy schemas to temp directory, preserving directory structure. @@ -1047,6 +1216,7 @@ def flatten_schemas(temp_dir: Path): print(f" {rel_path}") count = len(schema_files) + mark_no_explicit_null_properties(temp_dir) if _VERSION_ENVELOPE_NORMALIZED > _VERSION_ENVELOPE_NORMALIZED_CEILING: raise ValueError( f"version-envelope normalization rewrote {_VERSION_ENVELOPE_NORMALIZED} schemas, " @@ -1161,6 +1331,11 @@ def _run_datamodel_codegen(input_path: Path, output_path: Path) -> subprocess.Co "--set-default-enum-member", "--enum-field-as-literal", "one", + # Carry the optional-but-not-nullable mark set by + # ``mark_no_explicit_null_properties`` into ``Field(json_schema_extra=...)``, + # where the post-generation pass turns it into ``NoExplicitNull``. + "--field-extra-keys", + NO_EXPLICIT_NULL_KEY, "--allow-remote-refs", ] diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index bb525cb40..ca322f248 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -1039,6 +1039,301 @@ def point_integer_fields_at_the_schema_integer_type() -> None: print(f" Pointed integer fields at SchemaInt in {modified} file(s)") +#: The ``Field`` keyword the generator emits for ``generate_types.NO_EXPLICIT_NULL_KEY``. +_NO_EXPLICIT_NULL_EXTRA = "json_schema_extra={'x-adcp-no-explicit-null': True}" +_NO_EXPLICIT_NULL_MARKER = "NoExplicitNull" + + +def _generated_line_width() -> int: + """The line length the generator's black pass used. + + datamodel-code-generator hands black ``settings_path=output``, so black + reads ``[tool.black] line-length`` from the nearest ``pyproject.toml`` + above the generated tree — this repository's. The rewrite below re-renders + a field the way black laid it out, so it must measure against the same + width. + """ + try: + pyproject = (REPO_ROOT / "pyproject.toml").read_text() + except FileNotFoundError: + return 88 + section = re.search(r"^\[tool\.black\]\n(.*?)(?=^\[|\Z)", pyproject, re.MULTILINE | re.DOTALL) + configured = section and re.search(r"^line-length\s*=\s*(\d+)", section.group(1), re.MULTILINE) + return int(configured.group(1)) if configured else 88 + + +_GENERATED_LINE_WIDTH = _generated_line_width() + + +def _absolute_offsets(source: bytes) -> list[int]: + """Byte offset of each line start. ``ast`` column offsets count UTF-8 bytes.""" + offsets = [0] + for line in source.splitlines(keepends=True): + offsets.append(offsets[-1] + len(line)) + return offsets + + +def _node_span(node: ast.AST, offsets: list[int]) -> tuple[int, int]: + assert node.end_lineno is not None and node.end_col_offset is not None + return ( + offsets[node.lineno - 1] + node.col_offset, + offsets[node.end_lineno - 1] + node.end_col_offset, + ) + + +def _segment(source: bytes, offsets: list[int], node: ast.AST) -> str: + """The source text of *node*, via precomputed byte offsets. + + ``ast.get_source_segment`` re-splits the whole module on every call, which + is quadratic over a generated module's fields. Spans are in bytes because + that is what ``ast`` column offsets count — a description holding an em + dash shifts every later column on its line. + """ + start, end = _node_span(node, offsets) + return source[start:end].decode("utf-8") + + +def _no_explicit_null_keyword(call: ast.expr) -> ast.keyword | None: + """The ``json_schema_extra`` keyword carrying the mark, if *call* is that ``Field(...)``.""" + if not ( + isinstance(call, ast.Call) and isinstance(call.func, ast.Name) and call.func.id == "Field" + ): + return None + for keyword in call.keywords: + if keyword.arg != "json_schema_extra" or not isinstance(keyword.value, ast.Dict): + continue + keys = [k.value for k in keyword.value.keys if isinstance(k, ast.Constant)] + if keys == ["x-adcp-no-explicit-null"]: + return keyword + return None + + +def _field_call_without_mark( + call: ast.Call, keyword: ast.keyword, source: bytes, offsets: list[int] +) -> list[str] | None: + """The ``Field(...)`` call's remaining arguments, each as source text. + + Returns ``None`` when the mark was the only argument, so the caller drops + the ``Field`` metadata altogether. + """ + arguments = [_segment(source, offsets, positional) for positional in call.args] + arguments += [ + _segment(source, offsets, other) for other in call.keywords if other is not keyword + ] + return arguments or None + + +def _render_field_call(arguments: list[str], indent: int) -> str: + """Render ``Field(...)`` at *indent* the way black lays out a call. + + One line when it fits; otherwise the arguments hugged on one inner line + when that fits; otherwise one argument per line with a trailing comma. + The ``+ 1`` leaves room for the comma the enclosing subscript adds. + """ + joined = ", ".join(arguments) + one_line = f"Field({joined})" + if "\n" not in one_line and indent + len(one_line) + 1 <= _GENERATED_LINE_WIDTH: + return one_line + inner_indent = " " * (indent + 4) + # A single argument has no delimiter to split on, so black keeps it on + # one inner line however long it is, and adds no trailing comma. + if "\n" not in joined and ( + len(arguments) == 1 or len(inner_indent) + len(joined) <= _GENERATED_LINE_WIDTH + ): + return f"Field(\n{inner_indent}{joined}\n{' ' * indent})" + lines = ["Field("] + for argument in arguments: + lines.append(f"{inner_indent}{argument},") + lines.append(f"{' ' * indent})") + return "\n".join(lines) + + +def _render_annotated( + prefix: str, type_source: str, metadata: list[str | list[str]], suffix: str +) -> str: + """Render ``prefix Annotated[type, *metadata] suffix`` the way black would. + + A metadata entry is either source text or, for the ``Field(...)`` call, + its argument list, which is laid out for whichever form is chosen. + """ + default = re.fullmatch(r" = (.+)", suffix) + + def split_right_hand_side(annotation: str) -> str | None: + # Black splits the right-hand side first: when everything up to + # ``= (`` fits, the default goes on its own line and the annotation + # stays whole. + head = f"{prefix}{annotation} = (" + if default is None or "\n" in head or len(head) > _GENERATED_LINE_WIDTH: + return None + return f"{head}\n {default.group(1)}\n )" + + if not metadata: + bare = f"{prefix}{type_source}{suffix}" + if "\n" not in type_source and len(bare) <= _GENERATED_LINE_WIDTH: + return bare + if "\n" not in type_source and (rhs := split_right_hand_side(type_source)) is not None: + return rhs + # Black splits a trailing subscript at its bracket; anything else it + # wraps in parentheses. A type that already spans lines was inside + # such parentheses, and keeps its own layout. + subscript = re.fullmatch(r"([\w.]+)\[(.+)\]", type_source, re.DOTALL) + if "\n" not in type_source and subscript is not None: + return f"{prefix}{subscript.group(1)}[\n {subscript.group(2)}\n ]{suffix}" + return f"{prefix}(\n {type_source}\n ){suffix}" + + def flat(element: str | list[str], indent: int) -> str: + return _render_field_call(element, indent) if isinstance(element, list) else element + + one_line_elements = [type_source, *(flat(element, 0) for element in metadata)] + inner = ", ".join(one_line_elements) + one_line = f"{prefix}Annotated[{inner}]{suffix}" + if "\n" not in one_line and len(one_line) <= _GENERATED_LINE_WIDTH: + return one_line + if "\n" not in inner and (rhs := split_right_hand_side(f"Annotated[{inner}]")) is not None: + return rhs + hugged = f" {inner}" + if "\n" not in hugged and len(hugged) <= _GENERATED_LINE_WIDTH: + return f"{prefix}Annotated[\n{hugged}\n ]{suffix}" + lines = [f"{prefix}Annotated["] + for element in [type_source, *metadata]: + lines.append(f" {flat(element, 8)},") + lines.append(f" ]{suffix}") + return "\n".join(lines) + + +def _rewrite_no_explicit_null_marks( + source: str, marker: str | None = _NO_EXPLICIT_NULL_MARKER +) -> tuple[str, int]: + """Turn every marked ``Field`` keyword into the ``NoExplicitNull`` annotation. + + Returns the rewritten source and the number of fields that now carry the + marker. A marked field whose annotation already refuses ``None`` (a + ``Literal`` with a default, a required field an ``allOf`` arm supplied) + only loses the keyword. With ``marker=None`` the keyword is removed and + nothing is appended, which is how the layout is graded against the + generator's own: the result must be byte-identical to a run without the + mark. + """ + raw = source.encode("utf-8") + tree = ast.parse(raw) + offsets = _absolute_offsets(raw) + edits: list[tuple[int, int, bytes]] = [] + marked = 0 + for node in ast.walk(tree): + if not isinstance(node, ast.AnnAssign) or not isinstance(node.target, ast.Name): + continue + annotation = node.annotation + if not ( + isinstance(annotation, ast.Subscript) + and isinstance(annotation.value, ast.Name) + and annotation.value.id == "Annotated" + and isinstance(annotation.slice, ast.Tuple) + ): + continue + elements = annotation.slice.elts + keyword = None + for element in elements[1:]: + keyword = _no_explicit_null_keyword(element) + if keyword is not None: + field_call = element + break + if keyword is None: + continue + type_source = _segment(raw, offsets, elements[0]) + metadata: list[str | list[str]] = [] + for element in elements[1:]: + if element is field_call: + assert isinstance(element, ast.Call) + arguments = _field_call_without_mark(element, keyword, raw, offsets) + if arguments is not None: + metadata.append(arguments) + continue + metadata.append(_segment(raw, offsets, element)) + annotation_names = {n.id for n in ast.walk(elements[0]) if isinstance(n, ast.Name)} | { + n.value for n in ast.walk(elements[0]) if isinstance(n, ast.Constant) + } + if None in annotation_names and marker is not None: + metadata.append(marker) + marked += 1 + # Measure from the start of the line: the indentation counts toward + # the width black lays the statement out against. + line_start = offsets[node.lineno - 1] + _statement_start, statement_end = _node_span(node, offsets) + annotation_start, annotation_end = _node_span(annotation, offsets) + prefix = raw[line_start:annotation_start].decode("utf-8") + suffix = raw[annotation_end:statement_end].decode("utf-8") + # A default black split onto its own line sits in parentheses the + # AST does not span: ``= (\n None\n )``. Read it back as + # ``= None`` and consume the closing parenthesis. + wrapped = re.fullmatch(r" = \(\s*(.+?)\s*(\))?", suffix, re.DOTALL) + if wrapped is not None: + if wrapped.group(2) is None: + closing = re.match(rb"\s*\)", raw[statement_end:]) + assert closing is not None, (node.target.id, suffix) + statement_end += closing.end() + suffix = f" = {wrapped.group(1)}" + rendered = _render_annotated(prefix, type_source, metadata, suffix) + edits.append((line_start, statement_end, rendered.encode("utf-8"))) + for start, end, replacement in sorted(edits, reverse=True): + raw = raw[:start] + replacement + raw[end:] + return raw.decode("utf-8"), marked + + +def _ensure_no_explicit_null_import(source: str) -> str: + """Import ``NoExplicitNull`` from ``adcp.types.base`` when the module lacks it.""" + if re.search(r"^from adcp\.types\.base import .*\bNoExplicitNull\b", source, re.MULTILINE): + return source + if re.search(r"^from adcp\.types\.base import ", source, re.MULTILINE): + return add_to_import(source, "adcp.types.base", _NO_EXPLICIT_NULL_MARKER) + statement = f"from adcp.types.base import {_NO_EXPLICIT_NULL_MARKER}\n" + anchor = _import_pattern("pydantic").search(source) + if anchor is not None: + return source[: anchor.start()] + statement + source[anchor.start() :] + # No pydantic import to sit beside (a fixture module): after the typing + # import, else after ``from __future__``, else at the top. + for pattern in (r"^from typing import [^\n]*\n", r"^from __future__ import [^\n]*\n\n?"): + match = re.search(pattern, source, re.MULTILINE) + if match is not None: + return source[: match.end()] + statement + source[match.end() :] + return statement + source + + +def point_omittable_fields_at_no_explicit_null() -> None: + """Refuse an explicit ``null`` on every optional property whose schema refuses one. + + ``generate_types.mark_no_explicit_null_properties`` grades each optional + property with the bundled validator and marks the ones that cannot be + ``null``; the generator carries the mark as a ``Field(json_schema_extra=...)`` + keyword. This pass removes that keyword and appends + ``adcp.types.base.NoExplicitNull`` to the field's ``Annotated`` metadata, + so the annotation stays ``T | None = None`` — omitted still reads as + ``None`` — while an explicit ``null`` is refused on input and the ``null`` + arm leaves the published JSON Schema. See #1547. + """ + modified = 0 + marked = 0 + for path in sorted(OUTPUT_DIR.rglob("*.py")): + source = path.read_text() + if _NO_EXPLICIT_NULL_EXTRA not in source: + continue + source, count = _rewrite_no_explicit_null_marks(source) + assert _NO_EXPLICIT_NULL_EXTRA not in source, path + # A module whose only ``Field`` or ``Annotated`` was the mark's is left + # exactly as the generator writes it without the mark. + if "Field(" not in source: + source = drop_from_imports(source, "pydantic", "Field") + if "Annotated[" not in source: + source = drop_from_imports(source, "typing", "Annotated") + # Dropping the only ``typing`` import leaves its blank line behind. + source = re.sub(r"\n{3,}(from |import )", r"\n\n\1", source, count=1) + if count: + source = _ensure_no_explicit_null_import(source) + path.write_text(source) + modified += 1 + marked += count + print(f" Pointed {marked} optional field(s) at NoExplicitNull in {modified} file(s)") + + def remove_imports_shadowed_by_a_local_class() -> None: """Drop an import whose name a class in the same module redefines. @@ -3406,15 +3701,21 @@ def _ensure_sequence_import(content: str) -> str: _CANCELED_FIELD_RE = re.compile( r"( canceled: Annotated\[\n )" r"Literal\[True\]" - r"(,\n Field\(\n description=[^\n]*\n \),\n \])" + r"(,\n Field\(\n description=[^\n]*\n \),\n)" + r"( \])" r" = True" ) +#: The widened form: optional, so ``None`` when omitted, and — as the schema +#: declares a boolean — never an explicit ``null`` (#1547). +_WIDENED_LITERAL_TRUE = r"\1Literal[True] | None\2 NoExplicitNull,\n\3 = None" + _UNCHANGED_FIELD_RE = re.compile( r"( unchanged: Annotated\[\n )" r"Literal\[True\]" - r"(,\n Field\(\n description=.*?\n \),\n \])" + r"(,\n Field\(\n description=.*?\n \),\n)" + r"( \])" r" = True", re.DOTALL, ) @@ -3454,9 +3755,11 @@ def fix_canceled_literal_defaults() -> None: source = py_file.read_text() new_source, count = _CANCELED_FIELD_RE.subn( - r"\1Literal[True] | None\2 = None", + _WIDENED_LITERAL_TRUE, source, ) + if count: + new_source = _ensure_no_explicit_null_import(new_source) if count == 0: print(f" {py_file.relative_to(OUTPUT_DIR)}: no destructive canceled defaults found") @@ -3497,9 +3800,11 @@ def fix_unchanged_literal_defaults() -> None: source = py_file.read_text() new_source, count = _UNCHANGED_FIELD_RE.subn( - r"\1Literal[True] | None\2 = None", + _WIDENED_LITERAL_TRUE, source, ) + if count: + new_source = _ensure_no_explicit_null_import(new_source) if count == 0: print(f" {py_file.relative_to(OUTPUT_DIR)}: no unchanged defaults found") @@ -3548,16 +3853,29 @@ def fix_optional_boolean_literal_defaults() -> None: if not isinstance(node.value, ast.Constant) or node.value.value is not literal_value: continue annotation = node.annotation + widened = f"Literal[{literal_value}] | None" if ( isinstance(annotation, ast.Subscript) and _subscript_base_name(annotation) == "Annotated" ): - annotation = _first_subscript_arg(annotation) - assert annotation is not None - for part, replacement in ( - (annotation, f"Literal[{literal_value}] | None".encode()), - (node.value, b"None"), - ): + # Widen the type and slot the marker in before the closing + # bracket, after the existing metadata. + first = _first_subscript_arg(annotation) + assert first is not None + closing = offsets[annotation.end_lineno - 1] + annotation.end_col_offset - 1 + assert source[closing : closing + 1] == b"]" + exploded = ( + b"\n" + in source[offsets[annotation.lineno - 1] + annotation.col_offset : closing] + ) + # The closing bracket of an exploded subscript already sits on + # its own indented line; add one element at the element indent. + marker = b" NoExplicitNull,\n " if exploded else b", NoExplicitNull" + edits.append((closing, closing, marker)) + replacements = [(first, widened.encode())] + else: + replacements = [(annotation, f"Annotated[{widened}, NoExplicitNull]".encode())] + for part, replacement in (*replacements, (node.value, b"None")): edits.append( ( offsets[part.lineno - 1] + part.col_offset, @@ -3569,7 +3887,8 @@ def fix_optional_boolean_literal_defaults() -> None: if edits: for start, end, replacement in sorted(edits, reverse=True): source = source[:start] + replacement + source[end:] - py_file.write_bytes(source) + text = _ensure_typing_import(source.decode(), "Annotated") + py_file.write_text(_ensure_no_explicit_null_import(text)) print( f" {py_file.relative_to(OUTPUT_DIR)}: fixed {len(edits) // 2} boolean const field(s)" ) @@ -4005,9 +4324,7 @@ def _require_identity_match_dimensions(self) -> TmpProviderRegistration1: print(" trusted_match/identity_match_response.py response class not found") return response_class = response_class_match.group(1) - source = ( - source.rstrip() - + f""" + source = source.rstrip() + f""" @model_validator(mode='after') def _validate_tmpx_provider_ids(self) -> {response_class}: @@ -4022,7 +4339,6 @@ def _validate_tmpx_provider_ids(self) -> {response_class}: raise ValueError('tmpx_providers keys must be valid provider_id values') return self """ - ) identity_match_response.write_text(source.rstrip() + "\n") print(" trusted_match/identity_match_response.py: added runtime validators") @@ -4857,9 +5173,7 @@ def restore_flattened_contract_field_types() -> None: if "@model_validator(mode='before')" not in source: source = ensure_pydantic_import(source, "model_validator") - source = ( - source.rstrip() - + """ + source = source.rstrip() + """ @model_validator(mode='before') @classmethod @@ -4874,7 +5188,6 @@ def _reject_seller_bound_manifest_fields(cls, data: Any) -> Any: ) return data """ - ) representation_target.write_text(source) print(" core/creative_representation.py: restored canonical format contract") @@ -4907,9 +5220,7 @@ def enforce_transformer_output_contract() -> None: if "class Transformer(" not in source: raise RuntimeError("transformer.py: Transformer class not found") source = ensure_pydantic_import(source, "model_validator") - target.write_text( - source.rstrip() - + """ + target.write_text(source.rstrip() + """ @model_validator(mode='after') def _require_output_format_declaration(self) -> Transformer: @@ -4924,8 +5235,7 @@ def _require_output_format_declaration(self) -> Transformer: 'one of output_capability_ids or deprecated output_format_ids is required' ) return self -""" - ) +""") print(" core/transformer.py: enforced output declaration requirement") @@ -5525,11 +5835,16 @@ def emit_response_class(self, class_name: str, arm: dict[str, Any]) -> str: ) if inherits_protocol_envelope: self.needs_protocol_envelope = True - lines = [f"class {class_name}({bases}):"] - if is_submitted: - lines.append(" model_config = ConfigDict(extra='allow', validate_default=True)") - else: - lines.append(" model_config = ConfigDict(extra='allow')") + # Submitted arms once paired ``validate_default=True`` with + # ``use_enum_values`` so the ``status`` default came out as its + # value; the enum is a ``StrEnum`` now and the default is already + # the literal's member. Validating defaults would also run every + # inherited ``= None`` default through ``NoExplicitNull`` (#1547), + # which refuses it, so the arms take the base's default policy. + lines = [ + f"class {class_name}({bases}):", + " model_config = ConfigDict(extra='allow')", + ] is_sync_media_buy_success = self.base in { "CreateMediaBuyResponse", "UpdateMediaBuyResponse", @@ -5847,8 +6162,7 @@ def fix_verify_brand_claim_models() -> None: bulk_response = OUTPUT_DIR / "brand" / "verify_brand_claims_response.py" if request.exists() and "claim_type:" not in request.read_text(): - request.write_text( - """# generated by datamodel-codegen: + request.write_text("""# generated by datamodel-codegen: # filename: brand/verify_brand_claim_request.json from __future__ import annotations @@ -5880,13 +6194,11 @@ class VerifyBrandClaimRequest(AdcpVersionEnvelope): dict[str, Any], Field(description='Claim payload. Shape varies by claim_type.'), ] -""" - ) +""") print(" brand/verify_brand_claim_request.py: restored claim fields") if response.exists() and "VerifyBrandClaimSuccessResponse" not in response.read_text(): - response.write_text( - """# generated by datamodel-codegen: + response.write_text("""# generated by datamodel-codegen: # filename: brand/verify_brand_claim_response.json from __future__ import annotations @@ -5945,8 +6257,7 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): VerifyBrandClaimResponse = VerifyBrandClaimSuccessResponse | VerifyBrandClaimErrorResponse -""" - ) +""") print(" brand/verify_brand_claim_response.py: restored response arms") response_schema = SCHEMA_DIR / "brand" / "verify-brand-claim-response.json" @@ -5955,8 +6266,7 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): and response_schema.exists() and '"signed_response"' in response_schema.read_text() ): - response.write_text( - """# generated by datamodel-codegen: + response.write_text("""# generated by datamodel-codegen: # filename: brand/verify_brand_claim_response.json from __future__ import annotations @@ -6107,8 +6417,7 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): VerifyBrandClaimResponse = VerifyBrandClaimSuccessResponse | VerifyBrandClaimErrorResponse -""" - ) +""") print(" brand/verify_brand_claim_response.py: restored signed response fields") bulk_response_schema = SCHEMA_DIR / "brand" / "verify-brand-claims-response.json" @@ -6117,8 +6426,7 @@ class VerifyBrandClaimErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): and bulk_response_schema.exists() and '"signed_response"' in bulk_response_schema.read_text() ): - bulk_response.write_text( - """# generated by datamodel-codegen: + bulk_response.write_text("""# generated by datamodel-codegen: # filename: brand/verify_brand_claims_response.json from __future__ import annotations @@ -6301,8 +6609,7 @@ class VerifyBrandClaimsErrorResponse(AdcpVersionEnvelope, ProtocolEnvelope): VerifyBrandClaimsResponse = VerifyBrandClaimsResponseBulk | VerifyBrandClaimsErrorResponse -""" - ) +""") print(" brand/verify_brand_claims_response.py: restored signed response fields") if bulk_response.exists(): @@ -6834,9 +7141,7 @@ def fix_legacy_purchase_accepted_losses() -> None: ) validator_marker = " def _accepted_losses_match_schema(" if validator_marker not in fixed and "field_validator" in fixed: - fixed = ( - fixed.rstrip() - + """ + fixed = fixed.rstrip() + """ @field_validator('selected_product_ids') @@ -6864,7 +7169,6 @@ def _accepted_losses_match_schema( raise ValueError('accepted_losses must include the required compatibility losses') return values """ - ) if fixed != source: target.write_text(fixed) print(" media_buy/legacy_purchase_continuation_input.py: narrowed accepted_losses") @@ -6995,9 +7299,7 @@ def _require_portable_bound(self) -> {class_name}: ) class_start = source.find("class MediaBuyChangeTerm(AdCPBaseModel):") if class_start >= 0 and "def _validate_constraint_action" not in source[class_start:]: - source = ( - source.rstrip() - + """ + source = source.rstrip() + """ @model_validator(mode='after') def _validate_constraint_action(self) -> MediaBuyChangeTerm: @@ -7017,9 +7319,7 @@ def _validate_constraint_action(self) -> MediaBuyChangeTerm: if action not in allowed.get(kind, set()): raise ValueError('constraint kind is incompatible with action') return self -""" - + "\n" - ) +""" + "\n" term_path.write_text(source) print(" media_buy/change_term.py: restored constraint/action compatibility") @@ -7032,9 +7332,7 @@ def _validate_constraint_action(self) -> MediaBuyChangeTerm: ) class_start = source.find("class CommercialTerms(AdCPBaseModel):") if class_start >= 0 and "def _validate_change_term_set" not in source[class_start:]: - source = ( - source.rstrip() - + """ + source = source.rstrip() + """ @model_validator(mode='after') def _validate_change_term_set(self) -> CommercialTerms: @@ -7083,9 +7381,7 @@ def _validate_change_term_set(self) -> CommercialTerms: ): raise ValueError('change-term earliest effective time exceeds latest time') return self -""" - + "\n" - ) +""" + "\n" terms_path.write_text(source) print(" media_buy/commercial_terms.py: restored change-term set invariants") @@ -7989,6 +8285,11 @@ def main(argv: list[str] | None = None) -> int: print("Applying post-generation fixes...") fixes = [ + # First: every later fix reads the field text the generator produced + # on the pinned flags, and this pass restores exactly that text (the + # mark's keyword is the only thing the generator added) plus the + # ``NoExplicitNull`` element it appends to a marked field. + point_omittable_fields_at_no_explicit_null, add_model_validator_to_product, fix_preview_render_self_reference, fix_brand_manifest_references, diff --git a/scripts/post_generation_manifest.json b/scripts/post_generation_manifest.json index 008136d54..3fdf39234 100644 --- a/scripts/post_generation_manifest.json +++ b/scripts/post_generation_manifest.json @@ -71,6 +71,7 @@ "preserve_format_reference_agent_url_wire_string": true, "insert_task_message_markers": true, "point_integer_fields_at_the_schema_integer_type": true, + "point_omittable_fields_at_no_explicit_null": true, "remove_imports_shadowed_by_a_local_class": true, "remove_unused_pydantic_field_imports": true, "emit_open_asset_unions": true, diff --git a/src/adcp/types/base.py b/src/adcp/types/base.py index 61f78d880..1bfc4832d 100644 --- a/src/adcp/types/base.py +++ b/src/adcp/types/base.py @@ -19,6 +19,7 @@ BaseModel, BeforeValidator, ConfigDict, + GetCoreSchemaHandler, SerializerFunctionWrapHandler, StrictInt, StrictStr, @@ -26,7 +27,7 @@ WithJsonSchema, model_serializer, ) -from pydantic_core import PydanticSerializationError +from pydantic_core import PydanticSerializationError, core_schema from adcp._deferred_adapters import deferred_adapter from adcp.types._geo_place_keys import GeoPlaceSystemKey as GeoPlaceSystemKey @@ -310,6 +311,110 @@ def _url_adapter() -> TypeAdapter[AnyUrl]: ] +def _without_nullable(schema: Any) -> core_schema.CoreSchema: + """The schema with its ``nullable`` wrapper removed, if it has one. + + The wrapper is usually outermost. A field another annotation already + rebuilt — the targeting-overlay bridge builds its own ``json_or_python`` + with validator functions around the type — carries it further in, and + only the JSON side of such a schema matters here. + """ + kind = schema["type"] + if kind == "nullable": + return cast(core_schema.CoreSchema, schema["schema"]) + if kind == "json-or-python": + return cast( + core_schema.CoreSchema, + {**schema, "json_schema": _without_nullable(schema["json_schema"])}, + ) + if kind in ("function-before", "function-after", "function-wrap", "json", "default"): + stripped = {**schema, "schema": _without_nullable(schema["schema"])} + # A validator function may publish a separate input schema; the JSON + # Schema is generated from that one when it is present. + if "json_schema_input_schema" in stripped: + stripped["json_schema_input_schema"] = _without_nullable( + stripped["json_schema_input_schema"] + ) + return cast(core_schema.CoreSchema, stripped) + return cast(core_schema.CoreSchema, schema) + + +class _NoExplicitNull: + """Annotated metadata: the property may be omitted, but a JSON ``null`` is refused. + + A JSON Schema property outside ``required`` whose type does not admit + ``null`` permits exactly one way of not supplying it — leaving it out. + The generator renders every optional property as ``T | None = None``, + and that annotation is the right Python surface: the attribute reads as + ``None`` when the property was omitted, and ``model_dump`` (which + defaults to ``exclude_none=True``) leaves it off the wire again. What + the annotation got wrong is *input*: it also admitted an explicit + ``null``, which the schema — and the SDK's own JSON-Schema arm, + :func:`adcp.validation.validate_request` — rejects. + + This marker closes that gap without changing the annotation. It builds + a ``json_or_python`` core schema: the JSON side is the type without its + null arm, so a document carrying ``null`` fails with the type's own + error (``list_type``, ``model_type``, ...) exactly as a required field + would, and ``model_json_schema()`` publishes no null arm; the Python + side keeps ``T | None``, so ``Model(field=None)`` and ``model_validate`` + on a dict still read ``None`` as the SDK's spelling of "not supplied", + symmetric with ``model_dump``. The ``= None`` default is not validated, + so omission reads back as ``None`` either way. No Python function sits + in the validation path, so nested validation keeps pydantic's native + JSON handling (duplicate-key diagnostics, error order). + + ``ADCP_STRICT_VALIDATION`` — the switch that also forbids unknown + fields — makes the Python side refuse ``None`` too, for suites that want + to catch a caller spelling absence as an explicit ``None``. A property + whose schema *does* admit ``null`` is generated without the marker and + keeps accepting it. + + The generator marks every such property from the schema + (``scripts/generate_types.py``, ``mark_no_explicit_null_properties``) + and ``scripts/post_generate_fixes.py`` points the mark here. See #1547. + """ + + __slots__ = () + + def __get_pydantic_core_schema__( + self, source: Any, handler: GetCoreSchemaHandler + ) -> core_schema.CoreSchema: + nullable = handler(source) + strict = _without_nullable(nullable) + return core_schema.json_or_python_schema( + json_schema=strict, + python_schema=strict if _EXTRA_POLICY == "forbid" else nullable, + ) + + def __repr__(self) -> str: + return "NoExplicitNull" + + # One marker, however a ``FieldInfo`` carrying it is copied: a canonical + # redeclaration takes ``copy.deepcopy`` of the generated field + # (``canonical_creative._inherit``), and a test asks ``NoExplicitNull in + # field.metadata``. + def __copy__(self) -> _NoExplicitNull: + return self + + def __deepcopy__(self, memo: dict[int, Any]) -> _NoExplicitNull: + return self + + def __reduce__(self) -> str: + return "NoExplicitNull" + + def __eq__(self, other: object) -> bool: + return isinstance(other, _NoExplicitNull) + + def __hash__(self) -> int: + return hash(_NoExplicitNull) + + +#: Marks an optional property whose schema does not admit ``null``: omit it or +#: supply a value. See :class:`_NoExplicitNull`. +NoExplicitNull = _NoExplicitNull() + + class AdCPBaseModel(BaseModel): """Base model for AdCP types with spec-compliant serialization. diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index ff015f99b..994079d44 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -101,7 +101,7 @@ PRODUCT_FORMAT_DECLARATION_SCHEMA, check_declaration_rules, ) -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind from adcp.types.domains.core.creative_asset import CreativeAsset as _CanonicalCreativeWire from adcp.types.domains.core.creative_filters import CreativeFilters as _LegacyCreativeFilters @@ -321,6 +321,9 @@ def _has_boundary_serialization_metadata(annotation: Any) -> bool: """Find functional serializers, including ones inside container annotations.""" if isinstance(annotation, (PlainSerializer, WrapSerializer)): return True + if annotation is NoExplicitNull: + # Validation-only: it changes nothing about how a value is dumped. + return False if hasattr(annotation, "__get_pydantic_core_schema__"): is_adcp_model = isinstance(annotation, type) and issubclass(annotation, AdCPBaseModel) if not is_adcp_model and getattr(annotation, "__module__", "").split(".")[0] != "pydantic": diff --git a/src/adcp/types/domains/a2ui/component.py b/src/adcp/types/domains/a2ui/component.py index 6b4065cd3..140976431 100644 --- a/src/adcp/types/domains/a2ui/component.py +++ b/src/adcp/types/domains/a2ui/component.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: a2ui/component.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -16,7 +16,7 @@ class A2UiComponent(AdCPBaseModel): ) id: Annotated[str, Field(description='Unique identifier for this component within the surface')] parentId: Annotated[ - str | None, Field(description='ID of the parent component (null for root)') + str | None, Field(description='ID of the parent component (null for root)'), NoExplicitNull ] = None component: Annotated[ dict[str, dict[str, Any]], diff --git a/src/adcp/types/domains/a2ui/si_catalog.py b/src/adcp/types/domains/a2ui/si_catalog.py index 096662c7e..455b52dde 100644 --- a/src/adcp/types/domains/a2ui/si_catalog.py +++ b/src/adcp/types/domains/a2ui/si_catalog.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: a2ui/si_catalog.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, Field, StrictBool from . import bound_value @@ -32,7 +32,7 @@ class SiComponentCatalog(AdCPBaseModel): Literal['si-standard'], Field(description='SI standard component catalog') ] = 'si-standard' components: Annotated[ - list[Component] | None, Field(description='Available component types') + list[Component] | None, Field(description='Available component types'), NoExplicitNull ] = None @@ -45,7 +45,7 @@ class Variant(StrEnum): class Text(AdCPBaseModel): text: Annotated[bound_value.A2UiBoundValue, Field(description='Text content to display')] - variant: Variant | None = Variant.body + variant: Annotated[Variant | None, NoExplicitNull] = Variant.body class Action(AdCPBaseModel): @@ -53,6 +53,7 @@ class Action(AdCPBaseModel): context: Annotated[ dict[str, bound_value.A2UiBoundValue] | None, Field(description='Context data to include with action'), + NoExplicitNull, ] = None @@ -65,57 +66,73 @@ class Variant4(StrEnum): class Button(AdCPBaseModel): label: Annotated[bound_value.A2UiBoundValue, Field(description='Button label text')] action: Annotated[Action, Field(description='Action to trigger on click')] - variant: Variant4 | None = Variant4.primary - disabled: bound_value.A2UiBoundValue | None = None + variant: Annotated[Variant4 | None, NoExplicitNull] = Variant4.primary + disabled: Annotated[bound_value.A2UiBoundValue | None, NoExplicitNull] = None class Link(AdCPBaseModel): label: Annotated[bound_value.A2UiBoundValue, Field(description='Link text')] url: Annotated[bound_value.A2UiBoundValue, Field(description='URL to navigate to')] - external: Annotated[StrictBool | None, Field(description='Opens in new tab if true')] = True + external: Annotated[ + StrictBool | None, Field(description='Opens in new tab if true'), NoExplicitNull + ] = True class Image(AdCPBaseModel): src: Annotated[bound_value.A2UiBoundValue, Field(description='Image source URL')] alt: Annotated[bound_value.A2UiBoundValue, Field(description='Alt text for accessibility')] - width: Annotated[SchemaInt | None, Field(description='Image width in pixels')] = None - height: Annotated[SchemaInt | None, Field(description='Image height in pixels')] = None + width: Annotated[ + SchemaInt | None, Field(description='Image width in pixels'), NoExplicitNull + ] = None + height: Annotated[ + SchemaInt | None, Field(description='Image height in pixels'), NoExplicitNull + ] = None class Action23(AdCPBaseModel): name: str - context: dict[str, bound_value.A2UiBoundValue] | None = None + context: Annotated[dict[str, bound_value.A2UiBoundValue] | None, NoExplicitNull] = None class Card(AdCPBaseModel): title: Annotated[bound_value.A2UiBoundValue, Field(description='Card title')] - subtitle: Annotated[bound_value.A2UiBoundValue | None, Field(description='Card subtitle')] = ( - None - ) - image: Annotated[bound_value.A2UiBoundValue | None, Field(description='Card image URL')] = None + subtitle: Annotated[ + bound_value.A2UiBoundValue | None, Field(description='Card subtitle'), NoExplicitNull + ] = None + image: Annotated[ + bound_value.A2UiBoundValue | None, Field(description='Card image URL'), NoExplicitNull + ] = None badge: Annotated[ - bound_value.A2UiBoundValue | None, Field(description="Badge text (e.g., 'New', 'Sale')") + bound_value.A2UiBoundValue | None, + Field(description="Badge text (e.g., 'New', 'Sale')"), + NoExplicitNull, + ] = None + action: Annotated[ + Action23 | None, Field(description='Action to trigger on card click'), NoExplicitNull + ] = None + children: Annotated[ + list[str] | None, Field(description='Child component IDs'), NoExplicitNull ] = None - action: Annotated[Action23 | None, Field(description='Action to trigger on card click')] = None - children: Annotated[list[str] | None, Field(description='Child component IDs')] = None class ProductCard(AdCPBaseModel): title: Annotated[bound_value.A2UiBoundValue, Field(description='Product name')] price: Annotated[bound_value.A2UiBoundValue, Field(description='Price display string')] - image: Annotated[bound_value.A2UiBoundValue | None, Field(description='Product image URL')] = ( - None - ) + image: Annotated[ + bound_value.A2UiBoundValue | None, Field(description='Product image URL'), NoExplicitNull + ] = None description: Annotated[ - bound_value.A2UiBoundValue | None, Field(description='Product description') + bound_value.A2UiBoundValue | None, Field(description='Product description'), NoExplicitNull ] = None badge: Annotated[ - bound_value.A2UiBoundValue | None, Field(description="Badge text (e.g., 'Best Seller')") + bound_value.A2UiBoundValue | None, + Field(description="Badge text (e.g., 'Best Seller')"), + NoExplicitNull, ] = None ctaLabel: Annotated[ - bound_value.A2UiBoundValue | None, Field(description='CTA button label') + bound_value.A2UiBoundValue | None, Field(description='CTA button label'), NoExplicitNull ] = None - action: Action23 | None = None + action: Annotated[Action23 | None, NoExplicitNull] = None class Template(AdCPBaseModel): @@ -132,9 +149,11 @@ class List(AdCPBaseModel): items: Annotated[bound_value.A2UiBoundValue, Field(description='Path to array in data model')] template: Annotated[Template, Field(description='Template for list items')] emptyMessage: Annotated[ - bound_value.A2UiBoundValue | None, Field(description='Message to show when list is empty') + bound_value.A2UiBoundValue | None, + Field(description='Message to show when list is empty'), + NoExplicitNull, ] = None - layout: Layout | None = Layout.vertical + layout: Annotated[Layout | None, NoExplicitNull] = Layout.vertical class Align(StrEnum): @@ -154,15 +173,15 @@ class Justify(StrEnum): class Row(AdCPBaseModel): children: Annotated[list[str], Field(description='Child component IDs')] - gap: Annotated[str | None, Field(description='Gap between children')] = '8px' - align: Align | None = Align.center - justify: Justify | None = Justify.start + gap: Annotated[str | None, Field(description='Gap between children'), NoExplicitNull] = '8px' + align: Annotated[Align | None, NoExplicitNull] = Align.center + justify: Annotated[Justify | None, NoExplicitNull] = Justify.start class Column(AdCPBaseModel): children: Annotated[list[str], Field(description='Child component IDs')] - gap: Annotated[str | None, Field(description='Gap between children')] = '8px' - align: Align | None = Align.stretch + gap: Annotated[str | None, Field(description='Gap between children'), NoExplicitNull] = '8px' + align: Annotated[Align | None, NoExplicitNull] = Align.stretch class Type(StrEnum): @@ -174,17 +193,19 @@ class IntegrationAction(AdCPBaseModel): type: Annotated[Type, Field(description='Integration type')] label: Annotated[bound_value.A2UiBoundValue, Field(description='Button label')] url: Annotated[ - bound_value.A2UiBoundValue | None, Field(description='Integration endpoint URL') + bound_value.A2UiBoundValue | None, + Field(description='Integration endpoint URL'), + NoExplicitNull, ] = None highlighted: Annotated[ - StrictBool | None, Field(description='Visually emphasize this action') + StrictBool | None, Field(description='Visually emphasize this action'), NoExplicitNull ] = False class Apps(AdCPBaseModel): - appId: str | None = None - deepLink: str | None = None - url: AnyUrl | None = None + appId: Annotated[str | None, NoExplicitNull] = None + deepLink: Annotated[str | None, NoExplicitNull] = None + url: Annotated[AnyUrl | None, NoExplicitNull] = None class AppHandoff(AdCPBaseModel): diff --git a/src/adcp/types/domains/a2ui/surface.py b/src/adcp/types/domains/a2ui/surface.py index 2bac4e883..3d33108aa 100644 --- a/src/adcp/types/domains/a2ui/surface.py +++ b/src/adcp/types/domains/a2ui/surface.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: a2ui/surface.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import component @@ -18,7 +18,7 @@ class A2UiSurface(AdCPBaseModel): ) surfaceId: Annotated[str, Field(description='Unique identifier for this surface')] catalogId: Annotated[ - str | None, Field(description='Component catalog to use for rendering') + str | None, Field(description='Component catalog to use for rendering'), NoExplicitNull ] = 'standard' components: Annotated[ list[component.A2UiComponent], @@ -27,7 +27,10 @@ class A2UiSurface(AdCPBaseModel): rootId: Annotated[ str | None, Field(description='ID of the root component (if not specified, first component is root)'), + NoExplicitNull, ] = None dataModel: Annotated[ - dict[str, Any] | None, Field(description='Application data that components can bind to') + dict[str, Any] | None, + Field(description='Application data that components can bind to'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/a2ui/user_action.py b/src/adcp/types/domains/a2ui/user_action.py index 44c5cf991..00494a516 100644 --- a/src/adcp/types/domains/a2ui/user_action.py +++ b/src/adcp/types/domains/a2ui/user_action.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: a2ui/user_action.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field @@ -18,7 +18,9 @@ class Action(AdCPBaseModel): str, Field(description="Action identifier (e.g., 'view_product', 'add_to_cart')") ] context: Annotated[ - dict[str, Any] | None, Field(description='Context data resolved from data model bindings') + dict[str, Any] | None, + Field(description='Context data resolved from data model bindings'), + NoExplicitNull, ] = None @@ -29,4 +31,6 @@ class A2UiUserAction(AdCPBaseModel): surfaceId: Annotated[str, Field(description='ID of the surface where the action occurred')] componentId: Annotated[str, Field(description='ID of the component that triggered the action')] action: Annotated[Action, Field(description='The action that was triggered')] - timestamp: Annotated[AwareDatetime | None, Field(description='When the action occurred')] = None + timestamp: Annotated[ + AwareDatetime | None, Field(description='When the action occurred'), NoExplicitNull + ] = None diff --git a/src/adcp/types/domains/aao/agent_publishers.py b/src/adcp/types/domains/aao/agent_publishers.py index da5d89b10..a3c6b9485 100644 --- a/src/adcp/types/domains/aao/agent_publishers.py +++ b/src/adcp/types/domains/aao/agent_publishers.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: aao/agent_publishers.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool @@ -66,12 +66,14 @@ class PublisherEntry(AdCPBaseModel): Field( description="Canonical list of `property_id`s under THIS `publisher_domain` that the agent's selectors resolve to. Present iff the request included `?include=properties`; absent otherwise. The set is the same population `properties_authorized` counts, surfaced as IDs so consumers can run full set-diff against a federated fetch (count-equality is not set-equality — a publisher rotating N properties leaves the count unchanged but the set entirely different). Per-publisher scope; never network-wide. Order is unspecified; consumers should treat as a set." ), + NoExplicitNull, ] = None signing_keys_pinned: Annotated[ StrictBool | None, Field( description="Whether the publisher's adagents.json entry for this agent pins `signing_keys[]`. When true, the agent's signed responses MUST verify against the pinned key set regardless of the agent's own JWKS. Operators should treat true as a signal that their published JWKS must match the publisher's pin." ), + NoExplicitNull, ] = None status: Annotated[ Status, diff --git a/src/adcp/types/domains/account/get_account_financials_request.py b/src/adcp/types/domains/account/get_account_financials_request.py index 772d2141a..891625ffa 100644 --- a/src/adcp/types/domains/account/get_account_financials_request.py +++ b/src/adcp/types/domains/account/get_account_financials_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/get_account_financials_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref @@ -30,6 +31,7 @@ class GetAccountFinancialsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Date range for the spend summary. Defaults to the current billing cycle if omitted.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/account/list_account_changes_request.py b/src/adcp/types/domains/account/list_account_changes_request.py index 127523131..983bf97e5 100644 --- a/src/adcp/types/domains/account/list_account_changes_request.py +++ b/src/adcp/types/domains/account/list_account_changes_request.py @@ -1,16 +1,16 @@ # generated by datamodel-codegen: # filename: account/list_account_changes_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest, SchemaInt -from adcp.types._scalar import ScalarStr - from adcp.types._str_enum import StrEnum from typing import Annotated +from adcp.types.base import NoExplicitNull +from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field from ..core import account_ref @@ -40,6 +40,7 @@ class ListAccountChangesRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -49,6 +50,7 @@ class ListAccountChangesRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None account: Annotated[ account_ref.AccountReference, @@ -63,12 +65,14 @@ class ListAccountChangesRequest(AdcpRequest, AdcpVersionEnvelope): max_length=4096, min_length=1, ), + NoExplicitNull, ] = None starting_position: Annotated[ StartingPosition | None, Field( description='Initial position when cursor is absent. Mutually exclusive with cursor; sellers enforce this semantic rule at runtime so the emitted MCP input schema can remain a plain root object. earliest intentionally begins at the oldest retained change. latest returns a checkpoint at the current seller-ingestion high-water and is used before a race-free snapshot bootstrap.' ), + NoExplicitNull, ] = StartingPosition.earliest resource_types: Annotated[ list[ResourceType] | None, @@ -77,6 +81,7 @@ class ListAccountChangesRequest(AdcpRequest, AdcpVersionEnvelope): max_length=50, min_length=1, ), + NoExplicitNull, ] = None max_results: Annotated[ SchemaInt | None, @@ -85,6 +90,7 @@ class ListAccountChangesRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=100, ), + NoExplicitNull, ] = 50 - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/account/list_account_changes_response.py b/src/adcp/types/domains/account/list_account_changes_response.py index e6f932a82..5024f54e0 100644 --- a/src/adcp/types/domains/account/list_account_changes_response.py +++ b/src/adcp/types/domains/account/list_account_changes_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/list_account_changes_response.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator @@ -60,12 +60,14 @@ class SourceCoverageItem(AdCPBaseModel): Field( description='Earliest upstream time covered by this source after connection or retention limits.' ), + NoExplicitNull, ] = None observed_through: Annotated[ AwareDatetime | None, Field(description='Latest upstream point the seller has successfully observed.'), + NoExplicitNull, ] = None - last_successful_sync_at: AwareDatetime | None = None + last_successful_sync_at: Annotated[AwareDatetime | None, NoExplicitNull] = None stale_after_seconds: Annotated[ SchemaInt | None, Field( @@ -73,6 +75,7 @@ class SourceCoverageItem(AdCPBaseModel): ge=1, le=2592000, ), + NoExplicitNull, ] = None resource_types: Annotated[list[ResourceType], Field(max_length=50)] @@ -92,6 +95,7 @@ class ListAccountChangesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve description='Matching changes in oldest-first total account order. Timestamps are descriptive and do not define this order.', max_length=100, ), + NoExplicitNull, ] = None cursor: Annotated[ str | None, @@ -100,24 +104,28 @@ class ListAccountChangesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve max_length=4096, min_length=1, ), + NoExplicitNull, ] = None has_more: Annotated[ StrictBool | None, Field( description='True when more retained matching changes were available at generation time. False means caught up to seller ingestion, not necessarily to an unavailable or delayed connected source.' ), + NoExplicitNull, ] = None available_since: Annotated[ AwareDatetime | None, Field( description='Oldest time for which the seller currently retains change records for this account and caller. The capability guarantees at least 90 days after adoption; pre-adoption history is not fabricated.' ), + NoExplicitNull, ] = None generated_at: Annotated[ AwareDatetime | None, Field( description='Seller time when this page and its source-coverage watermarks were generated.' ), + NoExplicitNull, ] = None source_coverage: Annotated[ list[SourceCoverageItem] | None, @@ -125,10 +133,11 @@ class ListAccountChangesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve description='Account-specific feed and connector coverage. Buyers use this to distinguish feed catch-up from upstream freshness. Omission means no additional connected-source coverage is declared.', max_length=50, ), + NoExplicitNull, ] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None status: Status22 @model_validator(mode='after') diff --git a/src/adcp/types/domains/account/list_accounts_request.py b/src/adcp/types/domains/account/list_accounts_request.py index ab82c8a2f..5ad26c8aa 100644 --- a/src/adcp/types/domains/account/list_accounts_request.py +++ b/src/adcp/types/domains/account/list_accounts_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/list_accounts_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,6 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref @@ -36,23 +37,27 @@ class ListAccountsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Optional exact account filter. Use `account_id` to retrieve one known seller/storefront account, or the complete natural key (`brand` + `operator` + optional `operator_unit`, fixed `currency`, buyer-selected account `timezone`, and `sandbox`) for buyer-declared accounts. When present, the seller returns only matching accounts visible to the authenticated caller.' ), + NoExplicitNull, ] = None status: Annotated[ Status | None, Field(description='Filter accounts by status. Omit to return accounts in all statuses.'), + NoExplicitNull, ] = None - pagination: pagination_request.PaginationRequest | None = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None sandbox: Annotated[ StrictBool | None, Field( description='Filter by sandbox status. true returns only sandbox accounts, false returns only production accounts. Omit to return all accounts. Primarily used with account-id namespaces where sandbox accounts are pre-existing test accounts on the platform.' ), + NoExplicitNull, ] = None include_webhook_activity: Annotated[ StrictBool | None, Field( description='When true, request recent webhook delivery attempts for each returned account in account.webhook_activity[]. Sellers MAY omit webhook_activity if they do not expose this debug log; when present, three-state semantics match the shared webhook_activity[] contract.' ), + NoExplicitNull, ] = False webhook_activity_limit: Annotated[ SchemaInt | None, @@ -61,6 +66,7 @@ class ListAccountsRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=200, ), + NoExplicitNull, ] = 50 - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/account/list_accounts_response.py b/src/adcp/types/domains/account/list_accounts_response.py index f0194d25e..1898146a8 100644 --- a/src/adcp/types/domains/account/list_accounts_response.py +++ b/src/adcp/types/domains/account/list_accounts_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/list_accounts_response.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_with_authorization @@ -30,8 +31,10 @@ class ListAccountsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): ), ] errors: Annotated[ - list[error.Error] | None, Field(description='Task-specific errors and warnings') + list[error.Error] | None, + Field(description='Task-specific errors and warnings'), + NoExplicitNull, ] = None - pagination: pagination_response.PaginationResponse | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/account/report_usage_request.py b/src/adcp/types/domains/account/report_usage_request.py index cb6fa0e5c..4d4569273 100644 --- a/src/adcp/types/domains/account/report_usage_request.py +++ b/src/adcp/types/domains/account/report_usage_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/report_usage_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import account_ref @@ -30,6 +30,7 @@ class UsageItem(AdCPBaseModel): Field( description='Seller-assigned media buy identifier. Links this usage record to a specific media buy.' ), + NoExplicitNull, ] = None vendor_cost: Annotated[ StrictFloat, @@ -44,10 +45,12 @@ class UsageItem(AdCPBaseModel): Field( description="Pricing option identifier from the vendor's discovery response (e.g., get_signals, list_content_standards). The vendor uses this to verify the correct rate was applied." ), + NoExplicitNull, ] = None impressions: Annotated[ SchemaInt | None, Field(description='Impressions delivered using this vendor service.', ge=0), + NoExplicitNull, ] = None media_spend: Annotated[ StrictFloat | None, @@ -55,6 +58,7 @@ class UsageItem(AdCPBaseModel): description='Media spend in currency for the period. Required when a percent_of_media pricing model was used, so the vendor can verify the applied rate.', ge=0.0, ), + NoExplicitNull, ] = None conversions: Annotated[ StrictFloat | None, @@ -62,6 +66,7 @@ class UsageItem(AdCPBaseModel): description='Number of attributed conversion events for the reporting period. Optional analytics context for revenue_share reconciliation.', ge=0.0, ), + NoExplicitNull, ] = None conversion_value: Annotated[ StrictFloat | None, @@ -69,6 +74,7 @@ class UsageItem(AdCPBaseModel): description='Total monetary value of attributed conversions for the reporting period, in currency. Optional analytics context for revenue_share reconciliation; it is not the billing basis.', ge=0.0, ), + NoExplicitNull, ] = None commissionable_value: Annotated[ StrictFloat | None, @@ -76,52 +82,61 @@ class UsageItem(AdCPBaseModel): description='Settled attributed value eligible for commission, in currency. Required when pricing_option_id selects a revenue_share option. The receiver verifies vendor_cost = round_currency(commissionable_value × the selected commission_rate).', ge=0.0, ), + NoExplicitNull, ] = None signal_agent_segment_id: Annotated[ str | None, Field(description='Signal identifier from get_signals. Required for signals agents.'), + NoExplicitNull, ] = None standards_id: Annotated[ str | None, Field( description='Content standards configuration identifier. Required for governance agents.' ), + NoExplicitNull, ] = None rights_id: Annotated[ str | None, Field( description='Rights grant identifier from acquire_rights. Required for brand/rights agents. Links usage records to specific rights grants for cap tracking, billing verification, and overage calculation.' ), + NoExplicitNull, ] = None creative_id: Annotated[ str | None, Field( description='Creative identifier from build_creative or list_creatives. Required for creative agents. Links usage records to specific creatives for billing verification.' ), + NoExplicitNull, ] = None build_variant_id: Annotated[ str | None, Field( description='Optional. When the reported creative_id was promoted from a specific build_creative variant leaf but the creative_id differs from the source build_variant_id, carry that source build_variant_id so billing reconciliation can link this usage record back to the exact produced leaf for audit (pricing_option_id alone is not unique across leaves). On the canonical path where creative_id is the build_variant_id, omit this field and use creative_id as the join key. Omit for creatives with no build-variant lineage.' ), + NoExplicitNull, ] = None property_list_id: Annotated[ str | None, Field( description='Property list identifier from list_property_lists. Required for property list agents. Links usage records to specific property lists for billing verification.' ), + NoExplicitNull, ] = None final: Annotated[ StrictBool | None, Field( description="Whether this usage record represents the reporter's final, billing-authoritative numbers for the reporting period. **Absent means unknown** — the reporter has not declared finality on this record. Set `true` only when the reporter has actually settled the numbers (e.g., 3PAS month-end close after SIVT scrubbing, conversion dedup, and view-through windows have closed; vendor file post-C7 for broadcast). Set `false` when pushing preliminary measurements (daily pacing pushes, intra-period progress) that are still settling. Receivers MUST NOT invoice on `final: false` records, and MUST NOT invoice on records where `final` is absent for buys whose `measurement_terms.billing_measurement` names this reporter as authoritative — request a final record first. Receivers MAY invoice on absent for buys with no `measurement_terms.billing_measurement` (3.0-style usage where the receiver treats reports as authoritative on receipt) and for non-media-buy variants (signals, governance, creative, brand — domains with no provisional state concept). When the same `(account, media_buy_id, reporting_period)` is later reported with `final: true`, that record supersedes any prior records for the period." ), + NoExplicitNull, ] = None finalized_at: Annotated[ AwareDatetime | None, Field( description="ISO 8601 timestamp at which the reporter considered these numbers final. Present only when `final: true`. Anchors any deadline declared in the buy's `measurement_terms.billing_measurement.finalization_deadline_hours`." ), + NoExplicitNull, ] = None measurement_window: Annotated[ str | None, @@ -130,6 +145,7 @@ class UsageItem(AdCPBaseModel): examples=['live', 'c3', 'c7', 'post_ivt', 'post_sivt', 'downloads_30d'], max_length=50, ), + NoExplicitNull, ] = None @@ -156,5 +172,5 @@ class ReportUsageRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/account/report_usage_response.py b/src/adcp/types/domains/account/report_usage_response.py index 97da5b656..cb73d9716 100644 --- a/src/adcp/types/domains/account/report_usage_response.py +++ b/src/adcp/types/domains/account/report_usage_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/report_usage_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -29,10 +30,12 @@ class ReportUsageResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): Field( description="Validation errors for individual records. The field property identifies which record failed (e.g., 'usage[1].pricing_option_id')." ), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field(description='When true, the account is a sandbox account and no billing occurred.'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/account/sync_accounts_request.py b/src/adcp/types/domains/account/sync_accounts_request.py index 4e077a0b5..e8d7ef4fb 100644 --- a/src/adcp/types/domains/account/sync_accounts_request.py +++ b/src/adcp/types/domains/account/sync_accounts_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/sync_accounts_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref, brand_ref, business_entity @@ -33,6 +33,7 @@ class Accounts(AdCPBaseModel): Field( description='Settings-update key. When present, this entry targets an existing account by `account_id` (seller-owned account namespace) or natural key (buyer-declared account settings-update against a previously-provisioned account). Mutually exclusive with the flat `brand` + `operator` + `billing` provisioning trio. When `account` is present, the seller MUST NOT create a new account — entries that would otherwise trigger provisioning are rejected with `UNSUPPORTED_PROVISIONING`.' ), + NoExplicitNull, ] = None revision: Annotated[ SchemaInt | None, @@ -40,18 +41,21 @@ class Accounts(AdCPBaseModel): description='Expected current account revision for optimistic concurrency in settings-update mode. Required whenever operator_identity is present; optional for existing non-identity settings updates. The seller MUST compare it atomically with the write, reject a mismatch with CONFLICT, and leave the account unchanged. Obtain it from list_accounts or the most recent sync_accounts result. Reads, dry runs, validation failures, and exact idempotency replays do not increment revision; every persisted settings or identity-change state transition does. MUST be absent in provisioning mode.', ge=1, ), + NoExplicitNull, ] = None operator_identity: Annotated[ operator_identity_1.OperatorIdentity | None, Field( description='Complete desired operator identity for settings-update mode. Omit this field to leave operator identity unchanged. When present, omission of operator_unit within the object removes the existing unit. Changing only operator_unit_1.name updates display metadata; changing operator_unit_1.id or adding/removing a unit rekeys the same account within the current operator. Changing operator requests an inter-entity handoff and MUST enter pending_approval until the seller verifies the current account authority, verified brand authorization, destination-operator acceptance, and any operator-scoped billing and grant transition. The seller MUST preserve account_id and account-scoped historical resources, MUST reject collisions without merging, and MUST apply no identity change if continuity cannot be preserved. MUST be accompanied by revision and MUST be absent in provisioning mode.' ), + NoExplicitNull, ] = None destination_billing_entity: Annotated[ business_entity.BusinessEntity | None, Field( description="Complete staged billing identity for the requested destination operator during an operator-domain handoff on an account whose billing party is operator. This value is write-only while approval is pending and MUST NOT replace or be echoed as the account's canonical billing_entity until the handoff applies atomically. Required by the protocol when an operator-billed account changes operator; otherwise MUST be absent. Requires operator_identity and revision and MUST be absent in provisioning mode." ), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference, @@ -71,6 +75,7 @@ class Accounts(AdCPBaseModel): Field( description='Optional operator-owned business unit, agency seat, or platform account for provisioning mode. operator_unit_1.id participates in the natural key; name is mapping/display metadata. MUST be absent in settings-update mode.' ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -78,6 +83,7 @@ class Accounts(AdCPBaseModel): description='Optional immutable ISO 4217 transaction currency for a currency-bound advertiser object. Consult `account.supported_account_currency_modes` before provisioning. When supplied, it participates in the natural key and all media buys on the account use it. Omit for per-media-buy currency selection. MUST be absent in settings-update mode.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None timezone: Annotated[ str | None, @@ -85,6 +91,7 @@ class Accounts(AdCPBaseModel): description='Immutable operational timezone selected for an account_fixed advertiser object. Required in provisioning mode when get_adcp_capabilities.account.timezone declares account_selection: buyer_selected, and the value MUST be one of supported_timezones. Omit for seller_fixed or seller_assigned modes. When supplied, it participates in the natural key. MUST be absent in settings-update mode.', min_length=1, ), + NoExplicitNull, ] = None billing: Annotated[ billing_party.BillingParty, @@ -97,24 +104,28 @@ class Accounts(AdCPBaseModel): Field( description='Business entity details for the party responsible for payment. The agent provides this so the seller has the legal name, tax IDs, address, and bank details needed for formal B2B invoicing. Permitted in both modes — sellers MAY accept refinements in settings-update mode (e.g., updated bank details).' ), + NoExplicitNull, ] = None payment_terms: Annotated[ payment_terms_1.PaymentTerms | None, Field( description='Payment terms for this account. The seller must either accept these terms or reject the account — terms are never silently remapped. When omitted, the seller applies its default terms. Permitted in both modes.' ), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field( description='When true, provision this as a sandbox account with no real platform calls or billing. Only applicable to buyer-declared accounts (require_operator_auth: false) in provisioning mode. For account-id namespaces, sandbox accounts are pre-existing test accounts discovered via list_accounts or supplied out-of-band.' ), + NoExplicitNull, ] = None preferred_reporting_protocol: Annotated[ cloud_storage_protocol.CloudStorageProtocol | None, Field( description="Buyer's preferred cloud storage protocol for offline reporting delivery. The seller provisions the account's reporting_bucket using this protocol if supported. When omitted, the seller chooses from its supported offline_delivery_protocols. Only meaningful when the seller's reporting_delivery_methods includes 'offline'." ), + NoExplicitNull, ] = None reporting_delivery_configs: Annotated[ list[reporting_delivery_config.ReportingDeliveryConfiguration] | None, @@ -122,6 +133,7 @@ class Accounts(AdCPBaseModel): description="Caller-owned desired state for durable reporting delivery on this account. Declarative replacement is scoped to (authenticated caller, resolved account): omission leaves that caller's set unchanged; [] deactivates that caller's set and starts grant revocation; another caller's entries MUST NOT be read, replaced, or deleted. Entries are keyed by immutable (delivery_config_id, delivery_config_version); duplicate tuples MUST reject the entire account entry, and reusing a tuple with changed content MUST be rejected. Each generation binds the exact report_definition_id advertised by its offering. destination.mode provision asks the seller to verify caller disclosure authority and destination/recipient control from non-secret provider coordinates; destination.mode existing reuses a caller-scoped immutable destination-generation reference, including one registered through sync_agent_configuration. The account configuration independently authorizes disclosure for this feed and scope, so possession of a reusable reference is never account authority. Unknown, unauthorized, and cross-caller refs MUST be indistinguishable. Credentials never transit AdCP, including nested extension fields. Permitted in both provisioning and settings-update modes. Sellers accepting this field MUST advertise media_buy.reporting_delivery in experimental_features and echo resolved secret-free state on sync_accounts and list_accounts.", max_length=16, ), + NoExplicitNull, ] = None notification_configs: Annotated[ list[notification_config.NotificationConfig] | None, @@ -129,6 +141,7 @@ class Accounts(AdCPBaseModel): description='Account-level webhook subscriptions for notifications whose lifecycle outlives any single media buy (`creative.status_changed`, optional `creative.assignment_changed`, `indicators.changed`, `creative.purged`, `account.status_changed`, wholesale feed change payloads, and future account-anchored resource events after those event types are added to `notification-config.json`). Indicator and assignment registrations are prospective: activation does not replay current conditions, so buyers establish a complete baseline through `get_media_buys` by enumerating known IDs or requesting every status and exhausting pagination, without an indicator filter. Durable account lifecycle transitions such as later `payment_required`, `suspended`, `closed`, or recovery to `active` use `account.status_changed` on this surface; the one-shot `sync_accounts.push_notification_config` channel remains scoped to the async result of the original provisioning task. Declarative replace semantics: when this field is present, the buyer sends the full desired array and the seller replaces the account\'s current set with that array, keyed by account-scoped `subscriber_id`. Omit this field to leave existing subscribers unchanged; send `[]` to remove all subscribers. Re-sending an existing `subscriber_id` for the account replaces that subscriber\'s config rather than creating a duplicate; persisted entries whose `subscriber_id` does not appear in the sent array are removed, so the seller MUST NOT merge the new array with persisted state. Paused entries (`active: false`) use the same replacement semantics; a buyer that wants to preserve a paused subscriber MUST re-include it with `active: false`. Duplicate `subscriber_id` values within one submitted array are rejected. Permitted in both provisioning and settings-update modes. Each entry registers a URL, the event types the subscriber wants, and optional legacy auth — see [`notification-config.json`](/schemas/core/notification-config.json). The seller MUST echo applied state on the response and on `list_accounts` reads, with `authentication.credentials` omitted (write-only). Sellers MUST reject entries whose `event_types` include any type whose contract anchors at a media buy or below (today: `scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) or at the agent (today: `capabilities.changed`) as per-account validation failures with `INVALID_REQUEST` or `VALIDATION_ERROR` and `error.field` pointing at the invalid `event_types` entry — those events do not belong on this surface. Wholesale feed webhook registrations carry the actual change payload in `/schemas/core/wholesale-feed-webhook.json`; canonical product subscribers repair through `list_products(if_feed_version)`, legacy product subscribers through `get_products(if_wholesale_feed_version)`, and signal subscribers through `get_signals(if_wholesale_feed_version)`. Account status change registrations carry the invalidation payload in `/schemas/core/account-status-changed-webhook.json`; receivers use `list_accounts` to repair or reconcile. This is distinct from sync_catalogs, which manages buyer-provided campaign input feeds on a seller account.\n\nActivation proof: before activating a new or changed active subscriber, the seller MUST validate the URL, complete the account-level webhook proof-of-control challenge, and only then persist or expose the subscriber as `active: true`. For `account.status_changed`, sellers MUST assign `account_id` before completing proof so subsequent status transitions can identify the account and be repaired through `list_accounts`, even when external approval remains pending. A valid existing proof for the same `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)` tuple MAY be reused; changing any element of that tuple requires fresh proof. The challenge POST itself MUST be signed with the seller\'s RFC 9421 webhook profile key and MUST include seller_agent_url, delivery_auth, and event_types so the receiver can verify the pending registration before echoing the challenge. New signers use `adcp_use: "request-signing"`; deprecated `webhook-signing` keys remain accepted during the compatibility window. Entries sent with `active: false` may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and those entries MUST NOT receive fires until reactivated. If proof fails or times out, the seller rejects the account entry with `action: "failed"`, leaves the prior notification_configs[] set unchanged, and reports `VALIDATION_ERROR` (or `INVALID_REQUEST` for malformed URLs) at the failing `notification_configs[j].url` field.\n\n**Cap rationale:** `maxItems: 16` is a practical fan-out cap (governance + buyer ingestion + audit bus + dx team + a few partner hooks). The cap exists to prevent unbounded subscriber arrays in storage and to bound the seller\'s per-event fan-out work. Sellers that hit the cap with legitimate subscribers should surface this on the protocol roadmap rather than work around it.', max_length=16, ), + NoExplicitNull, ] = None @@ -148,24 +161,28 @@ class Accounts1(AdCPBaseModel): description='Expected current account revision for optimistic concurrency in settings-update mode. Required whenever operator_identity is present; optional for existing non-identity settings updates. The seller MUST compare it atomically with the write, reject a mismatch with CONFLICT, and leave the account unchanged. Obtain it from list_accounts or the most recent sync_accounts result. Reads, dry runs, validation failures, and exact idempotency replays do not increment revision; every persisted settings or identity-change state transition does. MUST be absent in provisioning mode.', ge=1, ), + NoExplicitNull, ] = None operator_identity: Annotated[ operator_identity_1.OperatorIdentity | None, Field( description='Complete desired operator identity for settings-update mode. Omit this field to leave operator identity unchanged. When present, omission of operator_unit within the object removes the existing unit. Changing only operator_unit_1.name updates display metadata; changing operator_unit_1.id or adding/removing a unit rekeys the same account within the current operator. Changing operator requests an inter-entity handoff and MUST enter pending_approval until the seller verifies the current account authority, verified brand authorization, destination-operator acceptance, and any operator-scoped billing and grant transition. The seller MUST preserve account_id and account-scoped historical resources, MUST reject collisions without merging, and MUST apply no identity change if continuity cannot be preserved. MUST be accompanied by revision and MUST be absent in provisioning mode.' ), + NoExplicitNull, ] = None destination_billing_entity: Annotated[ business_entity.BusinessEntity | None, Field( description="Complete staged billing identity for the requested destination operator during an operator-domain handoff on an account whose billing party is operator. This value is write-only while approval is pending and MUST NOT replace or be echoed as the account's canonical billing_entity until the handoff applies atomically. Required by the protocol when an operator-billed account changes operator; otherwise MUST be absent. Requires operator_identity and revision and MUST be absent in provisioning mode." ), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Brand reference identifying the advertiser. Required for **provisioning mode**; MUST be absent in settings-update mode. Only the BrandKey projection — `domain`, `brand_id`, and the canonicalized `countries[]` set — participates in account identity. Mutable or per-call BrandRef fields such as `industries`, `data_subject_contestation`, and `brand_kit_override` MUST NOT affect lookup, idempotency, or account creation. New 3.2 producers SHOULD send only the BrandKey fields; the broader BrandRef remains accepted on this existing 3.x task for compatibility.' ), + NoExplicitNull, ] = None operator: Annotated[ str | None, @@ -173,12 +190,14 @@ class Accounts1(AdCPBaseModel): description="Domain of the entity operating on the brand's behalf (e.g., 'pinnacle-media.com'). When the brand operates directly, this is the brand's domain. Verified against the brand's authorized_operators in brand.json. Required for **provisioning mode**; MUST be absent in settings-update mode.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None operator_unit: Annotated[ operator_unit_1.OperatorUnit | None, Field( description='Optional operator-owned business unit, agency seat, or platform account for provisioning mode. operator_unit_1.id participates in the natural key; name is mapping/display metadata. MUST be absent in settings-update mode.' ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -186,6 +205,7 @@ class Accounts1(AdCPBaseModel): description='Optional immutable ISO 4217 transaction currency for a currency-bound advertiser object. Consult `account.supported_account_currency_modes` before provisioning. When supplied, it participates in the natural key and all media buys on the account use it. Omit for per-media-buy currency selection. MUST be absent in settings-update mode.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None timezone: Annotated[ str | None, @@ -193,36 +213,42 @@ class Accounts1(AdCPBaseModel): description='Immutable operational timezone selected for an account_fixed advertiser object. Required in provisioning mode when get_adcp_capabilities.account.timezone declares account_selection: buyer_selected, and the value MUST be one of supported_timezones. Omit for seller_fixed or seller_assigned modes. When supplied, it participates in the natural key. MUST be absent in settings-update mode.', min_length=1, ), + NoExplicitNull, ] = None billing: Annotated[ billing_party.BillingParty | None, Field( description='Who the seller invoices for this buyer–storefront account relationship. Required for **provisioning mode**; MUST be absent in settings-update mode (the invoiced party is fixed at provisioning time and cannot be changed via settings-update). This field does not select a payment rail, clearing intermediary, or per-media-buy settlement route.' ), + NoExplicitNull, ] = None billing_entity: Annotated[ business_entity.BusinessEntity | None, Field( description='Business entity details for the party responsible for payment. The agent provides this so the seller has the legal name, tax IDs, address, and bank details needed for formal B2B invoicing. Permitted in both modes — sellers MAY accept refinements in settings-update mode (e.g., updated bank details).' ), + NoExplicitNull, ] = None payment_terms: Annotated[ payment_terms_1.PaymentTerms | None, Field( description='Payment terms for this account. The seller must either accept these terms or reject the account — terms are never silently remapped. When omitted, the seller applies its default terms. Permitted in both modes.' ), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field( description='When true, provision this as a sandbox account with no real platform calls or billing. Only applicable to buyer-declared accounts (require_operator_auth: false) in provisioning mode. For account-id namespaces, sandbox accounts are pre-existing test accounts discovered via list_accounts or supplied out-of-band.' ), + NoExplicitNull, ] = None preferred_reporting_protocol: Annotated[ cloud_storage_protocol.CloudStorageProtocol | None, Field( description="Buyer's preferred cloud storage protocol for offline reporting delivery. The seller provisions the account's reporting_bucket using this protocol if supported. When omitted, the seller chooses from its supported offline_delivery_protocols. Only meaningful when the seller's reporting_delivery_methods includes 'offline'." ), + NoExplicitNull, ] = None reporting_delivery_configs: Annotated[ list[reporting_delivery_config.ReportingDeliveryConfiguration] | None, @@ -230,6 +256,7 @@ class Accounts1(AdCPBaseModel): description="Caller-owned desired state for durable reporting delivery on this account. Declarative replacement is scoped to (authenticated caller, resolved account): omission leaves that caller's set unchanged; [] deactivates that caller's set and starts grant revocation; another caller's entries MUST NOT be read, replaced, or deleted. Entries are keyed by immutable (delivery_config_id, delivery_config_version); duplicate tuples MUST reject the entire account entry, and reusing a tuple with changed content MUST be rejected. Each generation binds the exact report_definition_id advertised by its offering. destination.mode provision asks the seller to verify caller disclosure authority and destination/recipient control from non-secret provider coordinates; destination.mode existing reuses a caller-scoped immutable destination-generation reference, including one registered through sync_agent_configuration. The account configuration independently authorizes disclosure for this feed and scope, so possession of a reusable reference is never account authority. Unknown, unauthorized, and cross-caller refs MUST be indistinguishable. Credentials never transit AdCP, including nested extension fields. Permitted in both provisioning and settings-update modes. Sellers accepting this field MUST advertise media_buy.reporting_delivery in experimental_features and echo resolved secret-free state on sync_accounts and list_accounts.", max_length=16, ), + NoExplicitNull, ] = None notification_configs: Annotated[ list[notification_config.NotificationConfig] | None, @@ -237,6 +264,7 @@ class Accounts1(AdCPBaseModel): description='Account-level webhook subscriptions for notifications whose lifecycle outlives any single media buy (`creative.status_changed`, optional `creative.assignment_changed`, `indicators.changed`, `creative.purged`, `account.status_changed`, wholesale feed change payloads, and future account-anchored resource events after those event types are added to `notification-config.json`). Indicator and assignment registrations are prospective: activation does not replay current conditions, so buyers establish a complete baseline through `get_media_buys` by enumerating known IDs or requesting every status and exhausting pagination, without an indicator filter. Durable account lifecycle transitions such as later `payment_required`, `suspended`, `closed`, or recovery to `active` use `account.status_changed` on this surface; the one-shot `sync_accounts.push_notification_config` channel remains scoped to the async result of the original provisioning task. Declarative replace semantics: when this field is present, the buyer sends the full desired array and the seller replaces the account\'s current set with that array, keyed by account-scoped `subscriber_id`. Omit this field to leave existing subscribers unchanged; send `[]` to remove all subscribers. Re-sending an existing `subscriber_id` for the account replaces that subscriber\'s config rather than creating a duplicate; persisted entries whose `subscriber_id` does not appear in the sent array are removed, so the seller MUST NOT merge the new array with persisted state. Paused entries (`active: false`) use the same replacement semantics; a buyer that wants to preserve a paused subscriber MUST re-include it with `active: false`. Duplicate `subscriber_id` values within one submitted array are rejected. Permitted in both provisioning and settings-update modes. Each entry registers a URL, the event types the subscriber wants, and optional legacy auth — see [`notification-config.json`](/schemas/core/notification-config.json). The seller MUST echo applied state on the response and on `list_accounts` reads, with `authentication.credentials` omitted (write-only). Sellers MUST reject entries whose `event_types` include any type whose contract anchors at a media buy or below (today: `scheduled`, `final`, `delayed`, `adjusted`, `window_update`, `impairment`) or at the agent (today: `capabilities.changed`) as per-account validation failures with `INVALID_REQUEST` or `VALIDATION_ERROR` and `error.field` pointing at the invalid `event_types` entry — those events do not belong on this surface. Wholesale feed webhook registrations carry the actual change payload in `/schemas/core/wholesale-feed-webhook.json`; canonical product subscribers repair through `list_products(if_feed_version)`, legacy product subscribers through `get_products(if_wholesale_feed_version)`, and signal subscribers through `get_signals(if_wholesale_feed_version)`. Account status change registrations carry the invalidation payload in `/schemas/core/account-status-changed-webhook.json`; receivers use `list_accounts` to repair or reconcile. This is distinct from sync_catalogs, which manages buyer-provided campaign input feeds on a seller account.\n\nActivation proof: before activating a new or changed active subscriber, the seller MUST validate the URL, complete the account-level webhook proof-of-control challenge, and only then persist or expose the subscriber as `active: true`. For `account.status_changed`, sellers MUST assign `account_id` before completing proof so subsequent status transitions can identify the account and be repaired through `list_accounts`, even when external approval remains pending. A valid existing proof for the same `(account_id, subscriber_id, normalized url, authentication mode/credential binding, normalized event_types)` tuple MAY be reused; changing any element of that tuple requires fresh proof. The challenge POST itself MUST be signed with the seller\'s RFC 9421 webhook profile key and MUST include seller_agent_url, delivery_auth, and event_types so the receiver can verify the pending registration before echoing the challenge. New signers use `adcp_use: "request-signing"`; deprecated `webhook-signing` keys remain accepted during the compatibility window. Entries sent with `active: false` may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time, and those entries MUST NOT receive fires until reactivated. If proof fails or times out, the seller rejects the account entry with `action: "failed"`, leaves the prior notification_configs[] set unchanged, and reports `VALIDATION_ERROR` (or `INVALID_REQUEST` for malformed URLs) at the failing `notification_configs[j].url` field.\n\n**Cap rationale:** `maxItems: 16` is a practical fan-out cap (governance + buyer ingestion + audit bus + dx team + a few partner hooks). The cap exists to prevent unbounded subscriber arrays in storage and to bound the seller\'s per-event fan-out work. Sellers that hit the cap with legitimate subscribers should surface this on the protocol roadmap rather than work around it.', max_length=16, ), + NoExplicitNull, ] = None @@ -265,18 +293,21 @@ class SyncAccountsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='When true, accounts previously synced by this agent but not included in this request will be deactivated. Scoped to the authenticated agent — does not affect accounts managed by other agents. Use with caution.' ), + NoExplicitNull, ] = False dry_run: Annotated[ StrictBool | None, Field( description='When true, preview what would change without applying. Returns what would be created/updated/deactivated.' ), + NoExplicitNull, ] = False push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Webhook for async notifications when account status changes (e.g., pending_approval transitions to active).' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/account/sync_governance_request.py b/src/adcp/types/domains/account/sync_governance_request.py index fc4eb9da3..06909f781 100644 --- a/src/adcp/types/domains/account/sync_governance_request.py +++ b/src/adcp/types/domains/account/sync_governance_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/sync_governance_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..core import account_ref @@ -86,5 +86,5 @@ class SyncGovernanceRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/account/sync_governance_response.py b/src/adcp/types/domains/account/sync_governance_response.py index 01c891b6a..fd533d4bd 100644 --- a/src/adcp/types/domains/account/sync_governance_response.py +++ b/src/adcp/types/domains/account/sync_governance_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/sync_governance_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -11,7 +11,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..core import account_ref @@ -56,8 +56,8 @@ class SyncGovernanceResponse2(SyncGovernanceResponse): min_length=1, ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Account(AdCPBaseModel): @@ -80,12 +80,14 @@ class Account(AdCPBaseModel): max_length=1, min_length=1, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description="Per-account errors (only present when status is 'failed')", min_length=1 ), + NoExplicitNull, ] = None @@ -94,5 +96,5 @@ class SyncGovernanceResponse1(SyncGovernanceResponse): extra='allow', ) accounts: Annotated[list[Account], Field(description='Per-account sync results')] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/adagents.py b/src/adcp/types/domains/adagents.py index e2a9f0307..4bd2d200b 100644 --- a/src/adcp/types/domains/adagents.py +++ b/src/adcp/types/domains/adagents.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: adagents.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel, StrictBool, model_validator @@ -32,6 +32,7 @@ class AdcpAgentsAuthorization1(AdCPBaseModel): field_schema: Annotated[ str | None, Field(alias='$schema', description='JSON Schema identifier for this adagents.json file'), + NoExplicitNull, ] = None authoritative_location: Annotated[ AnyUrl, @@ -42,6 +43,7 @@ class AdcpAgentsAuthorization1(AdCPBaseModel): last_updated: Annotated[ AwareDatetime | None, Field(description='ISO 8601 timestamp indicating when this reference was last updated'), + NoExplicitNull, ] = None @@ -88,6 +90,7 @@ class Contact(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None domain: Annotated[ str | None, @@ -95,6 +98,7 @@ class Contact(AdCPBaseModel): description='Primary domain of the entity managing this file', pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None seller_id: Annotated[ str | None, @@ -103,6 +107,7 @@ class Contact(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None tag_id: Annotated[ str | None, @@ -111,12 +116,14 @@ class Contact(AdCPBaseModel): max_length=100, min_length=1, ), + NoExplicitNull, ] = None privacy_policy_url: Annotated[ AnyUrl | None, Field( description="URL to the entity's privacy policy. Used for consumer consent flows when interacting with this sales agent." ), + NoExplicitNull, ] = None @@ -149,6 +156,7 @@ class RevokedPublisherDomain(AdCPBaseModel): Field( description='Reason for revocation. **Operator-internal self-classification for review routing — not a public accusation.** `relationship_ended` is the routine commercial case. `compliance_violation` SHOULD be used only when the network has itself determined the publisher is out of policy; for un-adjudicated third-party allegations (regulator inquiries, advertiser complaints, ongoing investigations), use `other` to avoid making a discoverable adverse statement. `publisher_request` is for publisher-initiated exits. Compare to sellers.json, which deliberately carries no reason field for the same exposure concern.' ), + NoExplicitNull, ] = None @@ -215,7 +223,9 @@ class PropertyFeature(AdCPBaseModel): ), ] publisher_id: Annotated[ - str | None, Field(description='Optional publisher identifier at this agent (for lookup)') + str | None, + Field(description='Optional publisher identifier at this agent (for lookup)'), + NoExplicitNull, ] = None @@ -262,6 +272,7 @@ class AuthorizedAgents1(AuthorizedAgentBaseFields): description="Optional collection constraints. When present, authorization only applies to inventory associated with these collections. A selector without collection_ids grants for all collections declared in that selector's publisher_domain adagents.json (the bulk-grant form for owner-sold carriage).", min_length=1, ), + NoExplicitNull, ] = None placement_ids: Annotated[ list[str] | None, @@ -269,6 +280,7 @@ class AuthorizedAgents1(AuthorizedAgentBaseFields): description='Optional placement constraints. When present, authorization only applies to these placement IDs from the top-level placements array in this file.', min_length=1, ), + NoExplicitNull, ] = None placement_tags: Annotated[ list[str] | None, @@ -276,18 +288,21 @@ class AuthorizedAgents1(AuthorizedAgentBaseFields): description='Optional placement tag constraints. When present, authorization only applies to placements whose tags include any of these publisher-defined values.', min_length=1, ), + NoExplicitNull, ] = None delegation_type: Annotated[ DelegationType | None, Field( description="Commercial relationship for this inventory path. 'direct' means the publisher treats this as a direct way to buy from them, even if a third party operates the software. 'delegated' means the agent is authorized to sell on the publisher's behalf. 'ad_network' means the inventory is sold as part of a network/package context rather than as the publisher's direct endpoint." ), + NoExplicitNull, ] = None exclusive: Annotated[ StrictBool | None, Field( description="Whether this agent is the publisher's sole authorized path for the scoped inventory slice. When false or absent, other authorized agents may also sell the same inventory." ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -295,13 +310,17 @@ class AuthorizedAgents1(AuthorizedAgentBaseFields): description='Optional ISO 3166-1 alpha-2 country codes limiting where this authorization applies. Omit for worldwide authorization.', min_length=1, ), + NoExplicitNull, ] = None effective_from: Annotated[ AwareDatetime | None, Field(description='Optional start time for this authorization window.'), + NoExplicitNull, ] = None effective_until: Annotated[ - AwareDatetime | None, Field(description='Optional end time for this authorization window.') + AwareDatetime | None, + Field(description='Optional end time for this authorization window.'), + NoExplicitNull, ] = None @@ -326,6 +345,7 @@ class AuthorizedAgents2(AuthorizedAgentBaseFields): description="Optional collection constraints. When present, authorization only applies to inventory associated with these collections. A selector without collection_ids grants for all collections declared in that selector's publisher_domain adagents.json (the bulk-grant form for owner-sold carriage).", min_length=1, ), + NoExplicitNull, ] = None placement_ids: Annotated[ list[str] | None, @@ -333,6 +353,7 @@ class AuthorizedAgents2(AuthorizedAgentBaseFields): description='Optional placement constraints. When present, authorization only applies to these placement IDs from the top-level placements array in this file.', min_length=1, ), + NoExplicitNull, ] = None placement_tags: Annotated[ list[str] | None, @@ -340,18 +361,21 @@ class AuthorizedAgents2(AuthorizedAgentBaseFields): description='Optional placement tag constraints. When present, authorization only applies to placements whose tags include any of these publisher-defined values.', min_length=1, ), + NoExplicitNull, ] = None delegation_type: Annotated[ DelegationType | None, Field( description="Commercial relationship for this inventory path. 'direct' means the publisher treats this as a direct way to buy from them, even if a third party operates the software. 'delegated' means the agent is authorized to sell on the publisher's behalf. 'ad_network' means the inventory is sold as part of a network/package context rather than as the publisher's direct endpoint." ), + NoExplicitNull, ] = None exclusive: Annotated[ StrictBool | None, Field( description="Whether this agent is the publisher's sole authorized path for the scoped inventory slice. When false or absent, other authorized agents may also sell the same inventory." ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -359,13 +383,17 @@ class AuthorizedAgents2(AuthorizedAgentBaseFields): description='Optional ISO 3166-1 alpha-2 country codes limiting where this authorization applies. Omit for worldwide authorization.', min_length=1, ), + NoExplicitNull, ] = None effective_from: Annotated[ AwareDatetime | None, Field(description='Optional start time for this authorization window.'), + NoExplicitNull, ] = None effective_until: Annotated[ - AwareDatetime | None, Field(description='Optional end time for this authorization window.') + AwareDatetime | None, + Field(description='Optional end time for this authorization window.'), + NoExplicitNull, ] = None @@ -392,6 +420,7 @@ class AuthorizedAgents4(AuthorizedAgentBaseFields): description="Optional collection constraints. When present, authorization only applies to inventory associated with these collections. A selector without collection_ids grants for all collections declared in that selector's publisher_domain adagents.json (the bulk-grant form for owner-sold carriage).", min_length=1, ), + NoExplicitNull, ] = None placement_ids: Annotated[ list[str] | None, @@ -399,6 +428,7 @@ class AuthorizedAgents4(AuthorizedAgentBaseFields): description='Optional placement constraints. When present, authorization only applies to these placement IDs from the top-level placements array in this file.', min_length=1, ), + NoExplicitNull, ] = None placement_tags: Annotated[ list[str] | None, @@ -406,18 +436,21 @@ class AuthorizedAgents4(AuthorizedAgentBaseFields): description='Optional placement tag constraints. When present, authorization only applies to placements whose tags include any of these publisher-defined values.', min_length=1, ), + NoExplicitNull, ] = None delegation_type: Annotated[ DelegationType | None, Field( description="Commercial relationship for this inventory path. 'direct' means the publisher treats this as a direct way to buy from them, even if a third party operates the software. 'delegated' means the agent is authorized to sell on the publisher's behalf. 'ad_network' means the inventory is sold as part of a network/package context rather than as the publisher's direct endpoint." ), + NoExplicitNull, ] = None exclusive: Annotated[ StrictBool | None, Field( description="Whether this agent is the publisher's sole authorized path for the scoped inventory slice. When false or absent, other authorized agents may also sell the same inventory." ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -425,13 +458,17 @@ class AuthorizedAgents4(AuthorizedAgentBaseFields): description='Optional ISO 3166-1 alpha-2 country codes limiting where this authorization applies. Omit for worldwide authorization.', min_length=1, ), + NoExplicitNull, ] = None effective_from: Annotated[ AwareDatetime | None, Field(description='Optional start time for this authorization window.'), + NoExplicitNull, ] = None effective_until: Annotated[ - AwareDatetime | None, Field(description='Optional end time for this authorization window.') + AwareDatetime | None, + Field(description='Optional end time for this authorization window.'), + NoExplicitNull, ] = None @@ -612,6 +649,7 @@ class AuthorizedAgents3(AuthorizedAgentBaseFields): description="Optional collection constraints. When present, authorization only applies to inventory associated with these collections. A selector without collection_ids grants for all collections declared in that selector's publisher_domain adagents.json (the bulk-grant form for owner-sold carriage).", min_length=1, ), + NoExplicitNull, ] = None placement_ids: Annotated[ list[str] | None, @@ -619,6 +657,7 @@ class AuthorizedAgents3(AuthorizedAgentBaseFields): description='Optional placement constraints. When present, authorization only applies to these placement IDs from the top-level placements array in this file.', min_length=1, ), + NoExplicitNull, ] = None placement_tags: Annotated[ list[str] | None, @@ -626,18 +665,21 @@ class AuthorizedAgents3(AuthorizedAgentBaseFields): description='Optional placement tag constraints. When present, authorization only applies to placements whose tags include any of these publisher-defined values.', min_length=1, ), + NoExplicitNull, ] = None delegation_type: Annotated[ DelegationType | None, Field( description="Commercial relationship for this inventory path. 'direct' means the publisher treats this as a direct way to buy from them, even if a third party operates the software. 'delegated' means the agent is authorized to sell on the publisher's behalf. 'ad_network' means the inventory is sold as part of a network/package context rather than as the publisher's direct endpoint." ), + NoExplicitNull, ] = None exclusive: Annotated[ StrictBool | None, Field( description="Whether this agent is the publisher's sole authorized path for the scoped inventory slice. When false or absent, other authorized agents may also sell the same inventory." ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -645,13 +687,17 @@ class AuthorizedAgents3(AuthorizedAgentBaseFields): description='Optional ISO 3166-1 alpha-2 country codes limiting where this authorization applies. Omit for worldwide authorization.', min_length=1, ), + NoExplicitNull, ] = None effective_from: Annotated[ AwareDatetime | None, Field(description='Optional start time for this authorization window.'), + NoExplicitNull, ] = None effective_until: Annotated[ - AwareDatetime | None, Field(description='Optional end time for this authorization window.') + AwareDatetime | None, + Field(description='Optional end time for this authorization window.'), + NoExplicitNull, ] = None @@ -763,12 +809,14 @@ class AdcpAgentsAuthorization27(AdCPBaseModel): field_schema: Annotated[ str | None, Field(alias='$schema', description='JSON Schema identifier for this adagents.json file'), + NoExplicitNull, ] = None contact: Annotated[ Contact | None, Field( description='Contact information for the entity managing this adagents.json file (may be publisher or third-party operator)' ), + NoExplicitNull, ] = None catalog_etag: Annotated[ str | None, @@ -777,6 +825,7 @@ class AdcpAgentsAuthorization27(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None catalog_role: Annotated[ Literal['community_format_registry'] | None, @@ -790,18 +839,21 @@ class AdcpAgentsAuthorization27(AdCPBaseModel): description='Array of all properties covered by this adagents.json file. Defines the canonical property list that authorized agents reference.', min_length=1, ), + NoExplicitNull, ] = None revoked_publisher_domains: Annotated[ list[RevokedPublisherDomain] | None, Field( description="Publisher domains explicitly removed from this managed network. Validators MUST treat any publisher domain listed here as no-longer-authorized, taking precedence over any appearance of the same domain in `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]`, in `authorized_agents[].properties[].publisher_domain` (`inline_properties` authorization type), or in top-level `properties[].publisher_domain`. Lets a network propagate per-publisher revocations on the next refresh instead of waiting for the file-level 7-day cache cap. Validators MUST hold previously-observed publisher revocations, keyed by authority and publisher_domain, for 7 days from the validator's first observation; changing revoked_at MUST NOT reset that first observation, even if the entry vanishes from a subsequent fetch — this closes the rollback gap where an attacker re-serves a stale file with the revocation removed. Networks SHOULD retain entries for at least 7 days after `revoked_at` so validators that didn't observe the original entry still pick it up on refresh." ), + NoExplicitNull, ] = None collections: Annotated[ list[collection.Collection] | None, Field( description='Collections produced or distributed by this publisher. Declares the content programs whose inventory is sold through authorized agents. Products in get_products responses reference these collections by collection_id.' ), + NoExplicitNull, ] = None placements: Annotated[ list[placement_definition.PlacementDefinition] | None, @@ -809,24 +861,28 @@ class AdcpAgentsAuthorization27(AdCPBaseModel): description='Canonical placement definitions for properties in this file. Products SHOULD reuse these placement_id values when exposing inventory in get_products, and authorized agents can scope authorization to these placement IDs.', min_length=1, ), + NoExplicitNull, ] = None superseded_by: Annotated[ AnyUrl | None, Field( description='Optional pointer indicating this adagents.json file has been superseded by another adagents.json at a different URL. Used by the AAO community-mirror lifecycle: when a platform (e.g., Meta) adopts AdCP and publishes its own adagents.json at `/.well-known/adagents.json`, the AAO mirror file at `creative.adcontextprotocol.org/translated//adagents.json` sets `superseded_by` to the platform-hosted URL. Buyer SDKs encountering a file with `superseded_by` SHOULD short-circuit and re-fetch from the named URL rather than serving stale content from the mirror. The mirror SHOULD continue serving with `superseded_by` set for ≥1 minor release after platform adoption so buyer caches keyed on the mirror URL get an explicit migration signal rather than a silent break.' ), + NoExplicitNull, ] = None tags: Annotated[ dict[str, Tags] | None, Field( description='Metadata for each tag referenced by properties. Provides human-readable context for property tag values.' ), + NoExplicitNull, ] = None placement_tags: Annotated[ dict[str, PlacementTags] | None, Field( description='Metadata for each tag referenced by placements. Provides human-readable context for publisher-defined placement tag values used in grouping and authorization.' ), + NoExplicitNull, ] = None authorized_agents: Annotated[ list[AuthorizedAgents], @@ -837,12 +893,14 @@ class AdcpAgentsAuthorization27(AdCPBaseModel): last_updated: Annotated[ AwareDatetime | None, Field(description='ISO 8601 timestamp indicating when this file was last updated'), + NoExplicitNull, ] = None property_features: Annotated[ list[PropertyFeature] | None, Field( description='[AdCP 3.0] Optional list of agents that provide property feature data (certifications, scores, compliance status). Used for discovery - actual data is accessed through property list filters.' ), + NoExplicitNull, ] = None signals: Annotated[ list[signal_definition.SignalDefinition] | None, @@ -850,12 +908,14 @@ class AdcpAgentsAuthorization27(AdCPBaseModel): description="Signal definitions published by this domain. Each entry defines a signal id within this file's publishing-domain namespace; entries do not include signal_ref objects. Signals Protocol discovery and media-buy product targeting reference these through signal_ref scope 'data_provider', with data_provider_domain set to this file's publishing domain and signal_id set to signals[].id.", min_length=1, ), + NoExplicitNull, ] = None signal_tags: Annotated[ dict[str, SignalTags] | None, Field( description='Metadata for each tag referenced by signals. Provides human-readable context for signal tag values.' ), + NoExplicitNull, ] = None @@ -873,12 +933,14 @@ class AdcpAgentsAuthorization29(AdcpAgentsAuthorization22, AdcpAgentsAuthorizati field_schema: Annotated[ str | None, Field(alias='$schema', description='JSON Schema identifier for this adagents.json file'), + NoExplicitNull, ] = None contact: Annotated[ Contact | None, Field( description='Contact information for the entity managing this adagents.json file (may be publisher or third-party operator)' ), + NoExplicitNull, ] = None catalog_etag: Annotated[ str | None, @@ -887,6 +949,7 @@ class AdcpAgentsAuthorization29(AdcpAgentsAuthorization22, AdcpAgentsAuthorizati max_length=255, min_length=1, ), + NoExplicitNull, ] = None catalog_role: Annotated[ Literal['community_format_registry'] | None, @@ -900,18 +963,21 @@ class AdcpAgentsAuthorization29(AdcpAgentsAuthorization22, AdcpAgentsAuthorizati description='Array of all properties covered by this adagents.json file. Defines the canonical property list that authorized agents reference.', min_length=1, ), + NoExplicitNull, ] = None revoked_publisher_domains: Annotated[ list[RevokedPublisherDomain2] | None, Field( description="Publisher domains explicitly removed from this managed network. Validators MUST treat any publisher domain listed here as no-longer-authorized, taking precedence over any appearance of the same domain in `authorized_agents[].publisher_properties[].publisher_domain` / `.publisher_domains[]`, in `authorized_agents[].properties[].publisher_domain` (`inline_properties` authorization type), or in top-level `properties[].publisher_domain`. Lets a network propagate per-publisher revocations on the next refresh instead of waiting for the file-level 7-day cache cap. Validators MUST hold previously-observed publisher revocations, keyed by authority and publisher_domain, for 7 days from the validator's first observation; changing revoked_at MUST NOT reset that first observation, even if the entry vanishes from a subsequent fetch — this closes the rollback gap where an attacker re-serves a stale file with the revocation removed. Networks SHOULD retain entries for at least 7 days after `revoked_at` so validators that didn't observe the original entry still pick it up on refresh." ), + NoExplicitNull, ] = None collections: Annotated[ list[collection.Collection] | None, Field( description='Collections produced or distributed by this publisher. Declares the content programs whose inventory is sold through authorized agents. Products in get_products responses reference these collections by collection_id.' ), + NoExplicitNull, ] = None placements: Annotated[ list[placement_definition.PlacementDefinition] | None, @@ -919,24 +985,28 @@ class AdcpAgentsAuthorization29(AdcpAgentsAuthorization22, AdcpAgentsAuthorizati description='Canonical placement definitions for properties in this file. Products SHOULD reuse these placement_id values when exposing inventory in get_products, and authorized agents can scope authorization to these placement IDs.', min_length=1, ), + NoExplicitNull, ] = None superseded_by: Annotated[ AnyUrl | None, Field( description='Optional pointer indicating this adagents.json file has been superseded by another adagents.json at a different URL. Used by the AAO community-mirror lifecycle: when a platform (e.g., Meta) adopts AdCP and publishes its own adagents.json at `/.well-known/adagents.json`, the AAO mirror file at `creative.adcontextprotocol.org/translated//adagents.json` sets `superseded_by` to the platform-hosted URL. Buyer SDKs encountering a file with `superseded_by` SHOULD short-circuit and re-fetch from the named URL rather than serving stale content from the mirror. The mirror SHOULD continue serving with `superseded_by` set for ≥1 minor release after platform adoption so buyer caches keyed on the mirror URL get an explicit migration signal rather than a silent break.' ), + NoExplicitNull, ] = None tags: Annotated[ dict[str, Tags] | None, Field( description='Metadata for each tag referenced by properties. Provides human-readable context for property tag values.' ), + NoExplicitNull, ] = None placement_tags: Annotated[ dict[str, PlacementTags] | None, Field( description='Metadata for each tag referenced by placements. Provides human-readable context for publisher-defined placement tag values used in grouping and authorization.' ), + NoExplicitNull, ] = None authorized_agents: Annotated[ list[AuthorizedAgents14], @@ -947,12 +1017,14 @@ class AdcpAgentsAuthorization29(AdcpAgentsAuthorization22, AdcpAgentsAuthorizati last_updated: Annotated[ AwareDatetime | None, Field(description='ISO 8601 timestamp indicating when this file was last updated'), + NoExplicitNull, ] = None property_features: Annotated[ list[PropertyFeature] | None, Field( description='[AdCP 3.0] Optional list of agents that provide property feature data (certifications, scores, compliance status). Used for discovery - actual data is accessed through property list filters.' ), + NoExplicitNull, ] = None signals: Annotated[ list[signal_definition.SignalDefinition] | None, @@ -960,12 +1032,14 @@ class AdcpAgentsAuthorization29(AdcpAgentsAuthorization22, AdcpAgentsAuthorizati description="Signal definitions published by this domain. Each entry defines a signal id within this file's publishing-domain namespace; entries do not include signal_ref objects. Signals Protocol discovery and media-buy product targeting reference these through signal_ref scope 'data_provider', with data_provider_domain set to this file's publishing domain and signal_id set to signals[].id.", min_length=1, ), + NoExplicitNull, ] = None signal_tags: Annotated[ dict[str, SignalTags] | None, Field( description='Metadata for each tag referenced by signals. Provides human-readable context for signal tag values.' ), + NoExplicitNull, ] = None @@ -973,28 +1047,28 @@ class AdcpAgentsAuthorization210(AdcpAgentsAuthorization27): model_config = ConfigDict( extra='allow', ) - properties: Annotated[list[property.Property], Field(description='Array of all properties covered by this adagents.json file. Defines the canonical property list that authorized agents reference.', min_length=1), Field(min_length=1)] + properties: Annotated[list[property.Property], Field(description='Array of all properties covered by this adagents.json file. Defines the canonical property list that authorized agents reference.', min_length=1), NoExplicitNull, Field(min_length=1)] class AdcpAgentsAuthorization211(AdcpAgentsAuthorization27): model_config = ConfigDict( extra='allow', ) - placements: Annotated[list[placement_definition.PlacementDefinition], Field(description='Canonical placement definitions for properties in this file. Products SHOULD reuse these placement_id values when exposing inventory in get_products, and authorized agents can scope authorization to these placement IDs.', min_length=1), Field(min_length=1)] + placements: Annotated[list[placement_definition.PlacementDefinition], Field(description='Canonical placement definitions for properties in this file. Products SHOULD reuse these placement_id values when exposing inventory in get_products, and authorized agents can scope authorization to these placement IDs.', min_length=1), NoExplicitNull, Field(min_length=1)] class AdcpAgentsAuthorization212(AdcpAgentsAuthorization27): model_config = ConfigDict( extra='allow', ) - collections: Annotated[list[collection.Collection], Field(description='Collections produced or distributed by this publisher. Declares the content programs whose inventory is sold through authorized agents. Products in get_products responses reference these collections by collection_id.'), Field(min_length=1)] + collections: Annotated[list[collection.Collection], Field(description='Collections produced or distributed by this publisher. Declares the content programs whose inventory is sold through authorized agents. Products in get_products responses reference these collections by collection_id.'), NoExplicitNull, Field(min_length=1)] class AdcpAgentsAuthorization213(AdcpAgentsAuthorization27): model_config = ConfigDict( extra='allow', ) - signals: Annotated[list[signal_definition.SignalDefinition], Field(description="Signal definitions published by this domain. Each entry defines a signal id within this file's publishing-domain namespace; entries do not include signal_ref objects. Signals Protocol discovery and media-buy product targeting reference these through signal_ref scope 'data_provider', with data_provider_domain set to this file's publishing domain and signal_id set to signals[].id.", min_length=1), Field(min_length=1)] + signals: Annotated[list[signal_definition.SignalDefinition], Field(description="Signal definitions published by this domain. Each entry defines a signal id within this file's publishing-domain namespace; entries do not include signal_ref objects. Signals Protocol discovery and media-buy product targeting reference these through signal_ref scope 'data_provider', with data_provider_domain set to this file's publishing domain and signal_id set to signals[].id.", min_length=1), NoExplicitNull, Field(min_length=1)] class AdcpAgentsAuthorization2( diff --git a/src/adcp/types/domains/brand/acquire_rights_request.py b/src/adcp/types/domains/brand/acquire_rights_request.py index fe4ad0724..993c65aa0 100644 --- a/src/adcp/types/domains/brand/acquire_rights_request.py +++ b/src/adcp/types/domains/brand/acquire_rights_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: brand/acquire_rights_request.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from datetime import date from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -39,6 +39,7 @@ class Campaign(AdCPBaseModel): countries: Annotated[ list[Country] | None, Field(description='Countries where the campaign will run (ISO 3166-1 alpha-2)'), + NoExplicitNull, ] = None format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -46,6 +47,7 @@ class Campaign(AdCPBaseModel): deprecated=True, description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Legacy named formats that will be produced. Use `format_kinds` and optional `format_option_refs`.', ), + NoExplicitNull, ] = None format_kinds: Annotated[ list[str] | None, @@ -53,6 +55,7 @@ class Campaign(AdCPBaseModel): description='Canonical creative format kinds that will be produced under the rights grant.', min_length=1, ), + NoExplicitNull, ] = None format_option_refs: Annotated[ list[format_option_ref.FormatOptionReference] | None, @@ -60,6 +63,7 @@ class Campaign(AdCPBaseModel): description='Exact canonical publisher or product format options covered by the planned rights use, when kind alone is insufficient.', min_length=1, ), + NoExplicitNull, ] = None estimated_impressions: Annotated[ SchemaInt | None, @@ -67,13 +71,17 @@ class Campaign(AdCPBaseModel): description="Estimated total impressions for the campaign. Required when the brand agent will project commitment against a governance plan AND the selected pricing_option has model: 'cpm' — projection equals (pricing_option.price / 1000) × estimated_impressions evaluated in pricing_option.currency. The brand agent will project commitment whenever the request is governance-aware via either (a) an intent-phase governance_context token on the protocol envelope, or (b) `account` resolving to an account that has a governance agent previously bound via sync_governance. Brand agents MUST reject with INVALID_REQUEST (field: campaign.estimated_impressions) in either path when CPM-priced rights are requested and this field is omitted or zero; implementer-chosen defaults are non-conformant. See the acquire_rights task reference for the full validation contract including currency-mismatch handling.", ge=0, ), + NoExplicitNull, + ] = None + start_date: Annotated[ + date | None, Field(description='Campaign start date (ISO 8601)'), NoExplicitNull ] = None - start_date: Annotated[date | None, Field(description='Campaign start date (ISO 8601)')] = None end_date: Annotated[ date | None, Field( description='Campaign end date (ISO 8601). Brand agents MUST reject with INVALID_REQUEST (field: campaign.end_date) when end_date is in the past at the time of the request — acquiring rights for an elapsed window produces a zero-duration grant and is almost always a buyer-side bug.' ), + NoExplicitNull, ] = None @@ -89,6 +97,7 @@ class AcquireRightsRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None rights_id: Annotated[ str, Field(description='Rights offering identifier from get_rights response') @@ -102,6 +111,7 @@ class AcquireRightsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account context for this acquisition. Used by the brand agent to resolve any governance agent previously bound for this brand+operator pair via sync_governance. When both an inline governance_context token and a bound governance agent are present, the token is verified against that resolved relationship. An agent advertising adcp.governance_enforcement for acquire_rights MUST resolve an account and returns ACCOUNT_REQUIRED when neither the request nor an existing resource supplies one; it MUST NOT infer that a missing token means ungoverned. Legacy non-claiming agents may continue to treat omission of both fields as ungoverned during 3.x. Pass a natural key (brand, operator, optional sandbox) or a seller-assigned account_id from list_accounts.' ), + NoExplicitNull, ] = None campaign: Annotated[Campaign, Field(description='Campaign details for rights clearance')] revocation_webhook: Annotated[ @@ -115,6 +125,7 @@ class AcquireRightsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Webhook for async status updates if the acquisition requires approval. The rights agent sends a webhook notification when the status transitions to acquired or rejected.' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -125,5 +136,5 @@ class AcquireRightsRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/brand/creative_approval_request.py b/src/adcp/types/domains/brand/creative_approval_request.py index 1b5e5c17e..b7468825c 100644 --- a/src/adcp/types/domains/brand/creative_approval_request.py +++ b/src/adcp/types/domains/brand/creative_approval_request.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: brand/creative_approval_request.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..core import context as context_1 @@ -24,6 +25,7 @@ class CreativeApprovalRequest(AdcpVersionEnvelope): Field( description='Buyer-assigned creative identifier. Equivalent to OpenRTB crid. Used to track approval status across resubmissions.' ), + NoExplicitNull, ] = None creative_url: Annotated[ AnyUrl, Field(description='URL where the creative asset can be retrieved for review') @@ -31,15 +33,19 @@ class CreativeApprovalRequest(AdcpVersionEnvelope): creative_format: Annotated[ format_id.FormatReferenceStructuredObject | None, Field(description='Format of the creative being submitted'), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Description of the creative for reviewer context') + str | None, + Field(description='Description of the creative for reviewer context'), + NoExplicitNull, ] = None metadata: Annotated[ dict[str, Any] | None, Field( description='Additional creative metadata (duration, dimensions, target audience, etc.)' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -50,5 +56,5 @@ class CreativeApprovalRequest(AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/brand/creative_approval_response.py b/src/adcp/types/domains/brand/creative_approval_response.py index 7593c775e..f0da03818 100644 --- a/src/adcp/types/domains/brand/creative_approval_response.py +++ b/src/adcp/types/domains/brand/creative_approval_response.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: brand/creative_approval_response.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal +from adcp.types.base import NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from ..core import context as context_1 @@ -27,16 +28,17 @@ class CreativeApprovalResponse1(AdcpVersionEnvelope, ProtocolEnvelope): ] = 'approved' rights_id: str creative_id: Annotated[ - str | None, Field(description="Echo of the buyer's creative identifier") + str | None, Field(description="Echo of the buyer's creative identifier"), NoExplicitNull ] = None - creative_url: AnyUrl | None = None - approved_at: AwareDatetime | None = None + creative_url: Annotated[AnyUrl | None, NoExplicitNull] = None + approved_at: Annotated[AwareDatetime | None, NoExplicitNull] = None conditions: Annotated[ list[str] | None, Field(description="Conditions on the approval (e.g., 'approved for NL market only')"), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class CreativeApprovalResponse2(AdcpVersionEnvelope, ProtocolEnvelope): @@ -46,18 +48,19 @@ class CreativeApprovalResponse2(AdcpVersionEnvelope, ProtocolEnvelope): approval_status: Annotated[Literal['rejected'], Field(description='Creative was rejected')] = 'rejected' rights_id: str creative_id: Annotated[ - str | None, Field(description="Echo of the buyer's creative identifier") + str | None, Field(description="Echo of the buyer's creative identifier"), NoExplicitNull ] = None - creative_url: AnyUrl | None = None + creative_url: Annotated[AnyUrl | None, NoExplicitNull] = None reason: Annotated[str, Field(description='Why the creative was rejected')] suggestions: Annotated[ list[str] | None, Field( description='Actionable feedback for revision. If present, the buyer can revise and resubmit the creative. If absent, the rejection is final for this creative concept.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class CreativeApprovalResponse3(AdcpVersionEnvelope, ProtocolEnvelope): @@ -69,20 +72,23 @@ class CreativeApprovalResponse3(AdcpVersionEnvelope, ProtocolEnvelope): ] = 'pending_review' rights_id: str creative_id: Annotated[ - str | None, Field(description="Echo of the buyer's creative identifier") + str | None, Field(description="Echo of the buyer's creative identifier"), NoExplicitNull ] = None - creative_url: AnyUrl | None = None + creative_url: Annotated[AnyUrl | None, NoExplicitNull] = None estimated_response_time: Annotated[ - str | None, Field(description="Expected time for review (e.g., '24h', '2 business days')") + str | None, + Field(description="Expected time for review (e.g., '24h', '2 business days')"), + NoExplicitNull, ] = None status_url: Annotated[ AnyUrl | None, Field( description='URL to poll for updated approval status. GET this URL to receive a creative-approval-response. Poll at reasonable intervals (suggested: every 5 minutes, back off after 1 hour to every 30 minutes). Stop polling after estimated_response_time has elapsed and the approval_status is still pending_review.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class CreativeApprovalResponse4(AdcpVersionEnvelope, ProtocolEnvelope): @@ -90,8 +96,8 @@ class CreativeApprovalResponse4(AdcpVersionEnvelope, ProtocolEnvelope): extra='allow', ) errors: Annotated[list[error.Error], Field(min_length=1)] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None CreativeApprovalResponse = Annotated[ diff --git a/src/adcp/types/domains/brand/get_brand_identity_request.py b/src/adcp/types/domains/brand/get_brand_identity_request.py index 564a740e9..1e4e31398 100644 --- a/src/adcp/types/domains/brand/get_brand_identity_request.py +++ b/src/adcp/types/domains/brand/get_brand_identity_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: brand/get_brand_identity_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,6 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -42,12 +43,14 @@ class GetBrandIdentityRequest(AdcpRequest, AdcpVersionEnvelope): description='Optional identity sections to include in the response. When omitted, all sections the caller is authorized to see are returned. Core fields (brand_id, house, names) are always returned and do not need to be requested.', min_length=1, ), + NoExplicitNull, ] = None use_case: Annotated[ str | None, Field( description="Intended use case, so the agent can tailor the response. A 'voice_synthesis' use case returns voice configs; a 'likeness' use case returns high-res photos and appearance guidelines." ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/brand/get_rights_request.py b/src/adcp/types/domains/brand/get_rights_request.py index aac1f77b2..abe51f1c5 100644 --- a/src/adcp/types/domains/brand/get_rights_request.py +++ b/src/adcp/types/domains/brand/get_rights_request.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: brand/get_rights_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest -from adcp.types._scalar import ScalarStr - from typing import Annotated +from adcp.types.base import NoExplicitNull +from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool from ..core import brand_ref @@ -49,32 +49,38 @@ class GetRightsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="The buyer's brand. The agent fetches the buyer's brand.json for compatibility filtering (e.g., dietary conflicts, competitor exclusions)." ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, Field( description='Countries where rights are needed (ISO 3166-1 alpha-2). Filters to rights available in these markets.' ), + NoExplicitNull, ] = None brand_id: Annotated[ str | None, Field( description="Search within a specific brand's rights. If omitted, searches across the agent's full roster." ), + NoExplicitNull, ] = None right_type: Annotated[ right_type_1.RightType | None, Field(description='Filter by type of rights (talent, music, stock_media, etc.)'), + NoExplicitNull, ] = None include_excluded: Annotated[ StrictBool | None, Field( description='Include filtered-out results in the excluded array with reasons. Defaults to false.' ), + NoExplicitNull, ] = False pagination: Annotated[ pagination_request.PaginationRequest | None, Field(description='Pagination parameters for large result sets'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/brand/revocation_notification.py b/src/adcp/types/domains/brand/revocation_notification.py index e6424cfac..e501e9023 100644 --- a/src/adcp/types/domains/brand/revocation_notification.py +++ b/src/adcp/types/domains/brand/revocation_notification.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: brand/revocation_notification.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import context as context_1 @@ -42,6 +42,7 @@ class RevocationNotification(AdCPBaseModel): description='If present, only these uses are revoked (partial revocation). If absent, all uses under the grant are revoked.', min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/brand/rights_pricing_option.py b/src/adcp/types/domains/brand/rights_pricing_option.py index 13e7695ff..25f0ab3c7 100644 --- a/src/adcp/types/domains/brand/rights_pricing_option.py +++ b/src/adcp/types/domains/brand/rights_pricing_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: brand/rights_pricing_option.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..core import ext as ext_1 @@ -44,16 +44,21 @@ class RightsPricingOption(AdCPBaseModel): period: Annotated[ rights_billing_period.RightsBillingPeriod | None, Field(description='Billing period for flat_rate and time-based models'), + NoExplicitNull, ] = None impression_cap: Annotated[ SchemaInt | None, Field(description='Maximum impressions included in this pricing option per period', ge=1), + NoExplicitNull, ] = None overage_cpm: Annotated[ StrictFloat | None, Field(description='CPM rate applied to impressions exceeding the impression_cap', ge=0.0), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Human-readable description of this pricing option') + str | None, + Field(description='Human-readable description of this pricing option'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/brand/rights_terms.py b/src/adcp/types/domains/brand/rights_terms.py index 7e9bc7637..10c526c46 100644 --- a/src/adcp/types/domains/brand/rights_terms.py +++ b/src/adcp/types/domains/brand/rights_terms.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: brand/rights_terms.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from datetime import date from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictFloat @@ -23,8 +23,8 @@ class Exclusivity(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - scope: str | None = None - countries: list[Country] | None = None + scope: Annotated[str | None, NoExplicitNull] = None + countries: Annotated[list[Country] | None, NoExplicitNull] = None class RightsTerms(AdCPBaseModel): @@ -34,12 +34,12 @@ class RightsTerms(AdCPBaseModel): pricing_option_id: str amount: Annotated[StrictFloat, Field(ge=0.0)] currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] - period: rights_billing_period.RightsBillingPeriod | None = None + period: Annotated[rights_billing_period.RightsBillingPeriod | None, NoExplicitNull] = None uses: list[right_use.RightUse] - impression_cap: Annotated[SchemaInt | None, Field(ge=1)] = None - overage_cpm: Annotated[StrictFloat | None, Field(ge=0.0)] = None - start_date: date | None = None - end_date: date | None = None + impression_cap: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + overage_cpm: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + start_date: Annotated[date | None, NoExplicitNull] = None + end_date: Annotated[date | None, NoExplicitNull] = None exclusivity: Annotated[ - Exclusivity | None, Field(description='Exclusivity terms if applicable') + Exclusivity | None, Field(description='Exclusivity terms if applicable'), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/brand/search_brands_request.py b/src/adcp/types/domains/brand/search_brands_request.py index 605de3dc2..e55ff6263 100644 --- a/src/adcp/types/domains/brand/search_brands_request.py +++ b/src/adcp/types/domains/brand/search_brands_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: brand/search_brands_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarStr - from typing import Annotated +from adcp.types.base import NoExplicitNull +from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field from ..core import brand_ref @@ -40,6 +40,7 @@ class SearchBrandsRequest(AdcpVersionEnvelope): description='Optional industry filter. The agent restricts results to brands operating in these industries. Values follow the advertiser-industry taxonomy; the agent SHOULD accept unknown values gracefully per the advertiser-industry extension policy.', min_length=1, ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -47,16 +48,19 @@ class SearchBrandsRequest(AdcpVersionEnvelope): description='Optional market filter. Returns brands with rights availability in at least one of the specified countries (ISO 3166-1 alpha-2).', min_length=1, ), + NoExplicitNull, ] = None buyer_brand: Annotated[ brand_ref.BrandReference | None, Field( description="The buyer's brand. When provided, the agent SHOULD apply the same compatibility filtering as get_rights — filtering out brands with category conflicts, competitor exclusions, or dietary restrictions that would prevent licensing. When omitted, results are returned without compatibility filtering." ), + NoExplicitNull, ] = None pagination: Annotated[ pagination_request.PaginationRequest | None, Field(description='Pagination parameters for large result sets'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/brand/search_brands_response.py b/src/adcp/types/domains/brand/search_brands_response.py index e6fa16795..c17283dbe 100644 --- a/src/adcp/types/domains/brand/search_brands_response.py +++ b/src/adcp/types/domains/brand/search_brands_response.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: brand/search_brands_response.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator @@ -79,10 +79,14 @@ class Logo(AdCPBaseModel): ) url: Annotated[AnyUrl, Field(description='URL to the logo asset')] orientation: Annotated[ - Orientation | None, Field(description='Logo aspect ratio orientation') + Orientation | None, Field(description='Logo aspect ratio orientation'), NoExplicitNull ] = None - background: Annotated[Background | None, Field(description='Background compatibility')] = None - variant: Annotated[Variant | None, Field(description='Logo variant type')] = None + background: Annotated[ + Background | None, Field(description='Background compatibility'), NoExplicitNull + ] = None + variant: Annotated[Variant | None, Field(description='Logo variant type'), NoExplicitNull] = ( + None + ) class Country(ScalarStr): @@ -98,16 +102,18 @@ class Rights(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - available_uses: list[right_use.RightUse] | None = None + available_uses: Annotated[list[right_use.RightUse] | None, NoExplicitNull] = None countries: Annotated[ list[Country] | None, Field( description='Countries where rights are available (ISO 3166-1 alpha-2). If omitted, rights are available worldwide.' ), + NoExplicitNull, ] = None excluded_countries: Annotated[ list[ExcludedCountry] | None, Field(description='Countries excluded from availability (ISO 3166-1 alpha-2)'), + NoExplicitNull, ] = None @@ -126,24 +132,28 @@ class SearchBrandResult(AdCPBaseModel): Field( description='The house (corporate entity) associated with this brand. Present when a house affiliation is on record. Use relationship_trust to determine whether this association has been reciprocally confirmed by the house.' ), + NoExplicitNull, ] = None relationship_trust: Annotated[ RelationshipTrust | None, Field( description="Trust verdict for the brand-to-house relationship, computed from the mutual-assertion model. inline = brand defined inside the house's own document; mutual = both sides publish the relationship (trust-extending); leaf_only = brand claims house but house has not reciprocated or declaration is expired; house_only = house claims brand, brand has not reciprocated; standalone = brand claims no house; unverifiable = claimed house could not be checked. When absent, trust has not yet been computed — absent MUST NOT be interpreted as standalone. Use get_brand_identity or verify_brand_claim for authoritative trust resolution." ), + NoExplicitNull, ] = None relationship_verified_at: Annotated[ AwareDatetime | None, Field( description='When the mutual-assertion edge was last confirmed by both sides. Present only when relationship_trust is mutual.' ), + NoExplicitNull, ] = None claimed_house_domain: Annotated[ str | None, Field( description='Canonical domain of the house this brand self-declares as its parent. Not trust-extending on its own. Present for leaf_only and unverifiable trust states where the brand has made a claim that could not be or has not been reciprocated.' ), + NoExplicitNull, ] = None names: Annotated[ list[dict[str, str]], @@ -151,30 +161,36 @@ class SearchBrandResult(AdCPBaseModel): description="Localized brand names with BCP 47 locale code keys (e.g., 'en_US', 'fr_CA')." ), ] - description: Annotated[str | None, Field(description='Brand description')] = None + description: Annotated[str | None, Field(description='Brand description'), NoExplicitNull] = ( + None + ) industries: Annotated[ list[advertiser_industry.AdvertiserIndustry] | None, Field( description='Brand industries, following the advertiser-industry taxonomy', min_length=1 ), + NoExplicitNull, ] = None keller_type: Annotated[ KellerType | None, Field( description='Brand architecture type: master (primary brand of house), sub_brand (carries parent name), endorsed (independent identity backed by parent), independent (operates separately)' ), + NoExplicitNull, ] = None logos: Annotated[ list[Logo] | None, Field( description='Standard-resolution logos for display during discovery. High-res variants are gated behind get_brand_identity with account authorization.' ), + NoExplicitNull, ] = None rights: Annotated[ Rights | None, Field( description='Rights availability summary. Signals whether this brand has licensable rights and in which markets. For detailed pricing and rights records, use get_rights.' ), + NoExplicitNull, ] = None @@ -182,16 +198,18 @@ class SearchBrandsResponse(AdcpVersionEnvelope, ProtocolEnvelope): brands: Annotated[ list[SearchBrandResult] | None, Field(description='Brand stubs matching the query, ranked by relevance'), + NoExplicitNull, ] = None pagination: Annotated[ pagination_response.PaginationResponse | None, Field( description='Pagination metadata. Present when has_more is true or total_count is known.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - errors: Annotated[list[error.Error] | None, Field(min_length=1)] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, Field(min_length=1), NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> SearchBrandsResponse: diff --git a/src/adcp/types/domains/brand/update_rights_request.py b/src/adcp/types/domains/brand/update_rights_request.py index f9a11a174..0309d4dd1 100644 --- a/src/adcp/types/domains/brand/update_rights_request.py +++ b/src/adcp/types/domains/brand/update_rights_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: brand/update_rights_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,6 +9,7 @@ from datetime import date from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref @@ -30,6 +31,7 @@ class UpdateRightsRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None rights_id: Annotated[ str, Field(description='Rights grant identifier from acquire_rights response') @@ -39,12 +41,14 @@ class UpdateRightsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account context for this update. Used by the brand agent to resolve any governance agent previously bound for this brand+operator pair via sync_governance — update_rights is a modification-phase governance trigger (per `/docs/governance/campaign/specification#spend-commit-invocation`) and the brand agent consults the bound agent when computing the incremental commit delta. When both an inline governance_context token (on the protocol envelope) and a bound governance agent are present, the inline token wins. Pass a natural key (brand, operator, optional sandbox) or a seller-assigned account_id from list_accounts. The estimated_impressions / commit-delta projection rule for governance-aware updates is tracked separately and not yet normative on this task.' ), + NoExplicitNull, ] = None end_date: Annotated[ date | None, Field( description='New end date for the rights grant (must be >= current end_date). Extending the grant may re-issue generation credentials with updated expiration.' ), + NoExplicitNull, ] = None impression_cap: Annotated[ SchemaInt | None, @@ -52,22 +56,26 @@ class UpdateRightsRequest(AdcpRequest, AdcpVersionEnvelope): description='New impression cap for the grant. Must be >= impressions already delivered.', ge=1, ), + NoExplicitNull, ] = None pricing_option_id: Annotated[ str | None, Field( description="Switch to a different pricing option from the original get_rights offering. The new option must be compatible with the existing grant's uses and countries." ), + NoExplicitNull, ] = None paused: Annotated[ StrictBool | None, Field( description='Pause or resume the rights grant. When paused, generation credentials are suspended and creative delivery should stop. When resumed, credentials are re-activated.' ), + NoExplicitNull, ] = None push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field(description='Webhook for async update notifications if the update requires approval'), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -78,5 +86,5 @@ class UpdateRightsRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/brand/verify_brand_claims_request.py b/src/adcp/types/domains/brand/verify_brand_claims_request.py index 487612174..160679181 100644 --- a/src/adcp/types/domains/brand/verify_brand_claims_request.py +++ b/src/adcp/types/domains/brand/verify_brand_claims_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: brand/verify_brand_claims_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -34,9 +34,12 @@ class Claim(AdCPBaseModel): description='Stable brand identifier the leaf uses for itself. Optional but recommended.', pattern='^[a-z0-9_]+$', ), + NoExplicitNull, ] = None observed_at: Annotated[ - AwareDatetime | None, Field(description="When the caller observed the leaf's claim.") + AwareDatetime | None, + Field(description="When the caller observed the leaf's claim."), + NoExplicitNull, ] = None @@ -60,9 +63,11 @@ class Claim1(AdCPBaseModel): ), ] claimant_says: Annotated[ - str | None, Field(description='Optional context — what the claimant published or said.') + str | None, + Field(description='Optional context — what the claimant published or said.'), + NoExplicitNull, ] = None - observed_at: AwareDatetime | None = None + observed_at: Annotated[AwareDatetime | None, NoExplicitNull] = None class ClaimEntry2(AdCPBaseModel): @@ -91,8 +96,8 @@ class Property(AdCPBaseModel): ) type: property_type.PropertyType identifier: str - store: Store | None = None - region: str | None = None + store: Annotated[Store | None, NoExplicitNull] = None + region: Annotated[str | None, NoExplicitNull] = None class Claim2(AdCPBaseModel): @@ -101,7 +106,9 @@ class Claim2(AdCPBaseModel): ) property: Annotated[Property, Field(description='Shape matches brand.json properties[] entry.')] use_case: Annotated[ - str | None, Field(description='Optional caller-declared use case.', max_length=100) + str | None, + Field(description='Optional caller-declared use case.', max_length=100), + NoExplicitNull, ] = None @@ -123,10 +130,10 @@ class Claim3(AdCPBaseModel): extra='allow', ) mark: Annotated[str, Field(max_length=200, min_length=1)] - registry: Annotated[str | None, Field(max_length=50)] = None - number: Annotated[str | None, Field(max_length=100)] = None - countries: list[Country] | None = None - use_case: Annotated[str | None, Field(max_length=100)] = None + registry: Annotated[str | None, Field(max_length=50), NoExplicitNull] = None + number: Annotated[str | None, Field(max_length=100), NoExplicitNull] = None + countries: Annotated[list[Country] | None, NoExplicitNull] = None + use_case: Annotated[str | None, Field(max_length=100), NoExplicitNull] = None class ClaimEntry4(AdCPBaseModel): diff --git a/src/adcp/types/domains/brand_discovery.py b/src/adcp/types/domains/brand_discovery.py index 40a69e556..6a61e1ab3 100644 --- a/src/adcp/types/domains/brand_discovery.py +++ b/src/adcp/types/domains/brand_discovery.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: _brand_discovery.json -# timestamp: 2026-10-11T15:55:30+00:00 +# timestamp: 2026-10-21T11:05:59+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Dict, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat, ScalarInt, ScalarStr from pydantic import ( AnyUrl, @@ -727,18 +727,18 @@ class DataSubjectContestation1(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - url: AnyUrl | None = None - email: EmailStr | None = None - languages: list[str] | None = None + url: Annotated[AnyUrl | None, NoExplicitNull] = None + email: Annotated[EmailStr | None, NoExplicitNull] = None + languages: Annotated[list[str] | None, NoExplicitNull] = None class Colors1(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - primary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$')] = None - secondary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$')] = None - accent: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$')] = None + primary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$'), NoExplicitNull] = None + secondary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$'), NoExplicitNull] = None + accent: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$'), NoExplicitNull] = None class BrandIdModel(ScalarStr): @@ -768,12 +768,14 @@ class AiTool(AdCPBaseModel): Field( description="Version identifier for the AI tool or model (e.g., '25.1', '0125', '2.1'). For generative models, use the model version rather than the API version." ), + NoExplicitNull, ] = None provider: Annotated[ str | None, Field( description="Organization that provides the AI tool (e.g., 'OpenAI', 'Stability AI', 'Google')" ), + NoExplicitNull, ] = None @@ -797,6 +799,7 @@ class DeclaredBy(AdCPBaseModel): agent_url: Annotated[ AnyUrl | None, Field(description='URL of the agent or service that declared this provenance'), + NoExplicitNull, ] = None role: Annotated[Role, Field(description='Role of the declaring party in the supply chain')] @@ -822,6 +825,7 @@ class VerifyAgent(AdCPBaseModel): Field( description="Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `encypher.markers_present_v2`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." ), + NoExplicitNull, ] = None @@ -840,6 +844,7 @@ class VerifyAgent1(AdCPBaseModel): Field( description="Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `imatag.watermark_detected`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." ), + NoExplicitNull, ] = None @@ -858,7 +863,9 @@ class VerificationItem(AdCPBaseModel): ), ] verified_time: Annotated[ - AwareDatetime | None, Field(description='When the verification was performed (ISO 8601)') + AwareDatetime | None, + Field(description='When the verification was performed (ISO 8601)'), + NoExplicitNull, ] = None result: Annotated[Result, Field(description='Verification outcome')] confidence: Annotated[ @@ -866,9 +873,10 @@ class VerificationItem(AdCPBaseModel): Field( description='Confidence score of the verification result (0.0 to 1.0)', ge=0.0, le=1.0 ), + NoExplicitNull, ] = None details_url: Annotated[ - AnyUrl | None, Field(description='URL to the full verification report') + AnyUrl | None, Field(description='URL to the full verification report'), NoExplicitNull ] = None @@ -2010,6 +2018,7 @@ class EmbeddedProvenanceItem(AdCPBaseModel): Field( description="Standard the embedding conforms to, if any (e.g., 'c2pa' for C2PA Section A.7 text manifest embedding)" ), + NoExplicitNull, ] = None provider: Annotated[ str, @@ -2022,9 +2031,12 @@ class EmbeddedProvenanceItem(AdCPBaseModel): Field( description="Buyer's representation that this embedding can be verified by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist). MAY be omitted for self-verifiable embeddings (e.g., a C2PA text manifest with a public key the seller already trusts)." ), + NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, Field(description='When the provenance data was embedded (ISO 8601)') + AwareDatetime | None, + Field(description='When the provenance data was embedded (ISO 8601)'), + NoExplicitNull, ] = None @@ -2043,13 +2055,17 @@ class Watermark(AdCPBaseModel): Field( description="Buyer's representation that this watermark can be detected by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist)." ), + NoExplicitNull, ] = None c2pa_action: Annotated[ C2paWatermarkAction | None, Field(description='C2PA action classification for this watermark'), + NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, Field(description='When the watermark was applied (ISO 8601)') + AwareDatetime | None, + Field(description='When the watermark was applied (ISO 8601)'), + NoExplicitNull, ] = None @@ -2059,6 +2075,7 @@ class RenderGuidance(AdCPBaseModel): Field( description='How long the disclosure must persist during content playback or display' ), + NoExplicitNull, ] = None min_duration_ms: Annotated[ SchemaInt | None, @@ -2066,6 +2083,7 @@ class RenderGuidance(AdCPBaseModel): description="Minimum display duration in milliseconds for initial persistence. Recommended when persistence is initial — without it, the duration is at the publisher's discretion. At serve time the publisher reads this from provenance since the brief is not available.", ge=1, ), + NoExplicitNull, ] = None positions: Annotated[ list[DisclosurePosition] | None, @@ -2073,8 +2091,9 @@ class RenderGuidance(AdCPBaseModel): description='Preferred disclosure positions in priority order. The first position a format supports should be used.', min_length=1, ), + NoExplicitNull, ] = None - ext: Ext | None = None + ext: Annotated[Ext | None, NoExplicitNull] = None class Jurisdiction(AdCPBaseModel): @@ -2084,6 +2103,7 @@ class Jurisdiction(AdCPBaseModel): region: Annotated[ str | None, Field(description="Sub-national region code (e.g., 'CA' for California, 'BY' for Bavaria)"), + NoExplicitNull, ] = None regulation: Annotated[ str, @@ -2096,12 +2116,14 @@ class Jurisdiction(AdCPBaseModel): Field( description='Required disclosure label text for this jurisdiction, in the local language' ), + NoExplicitNull, ] = None render_guidance: Annotated[ RenderGuidance | None, Field( description="How the disclosure should be rendered for this jurisdiction. Expresses the declaring party's intent for persistence and position based on regulatory requirements. Publishers control actual rendering but governance agents can audit whether guidance was followed." ), + NoExplicitNull, ] = None @@ -2115,6 +2137,7 @@ class Disclosure(AdCPBaseModel): jurisdictions: Annotated[ list[Jurisdiction] | None, Field(description='Jurisdictions where disclosure obligations apply', min_length=1), + NoExplicitNull, ] = None @@ -2124,46 +2147,54 @@ class Provenance(AdCPBaseModel): Field( description='IPTC-aligned classification of AI involvement in producing this content' ), + NoExplicitNull, ] = None synthetic_depiction: Annotated[ StrictBool | None, Field( description='Assessed declaration of whether the content synthetically depicts a real or fictional person performing or appearing in a way that was generated or materially manipulated rather than captured as depicted. `true` covers both a fully synthetic performer and material manipulation of a real performer; `false` is an assessed declaration that the content does not contain such a depiction. Absence means the content has not been assessed for synthetic depiction. This field does not claim consent, legality, or independent verification, and receivers MUST NOT derive it solely from `digital_source_type`.' ), + NoExplicitNull, ] = None ai_tool: Annotated[ AiTool | None, Field( description='AI system used to generate or modify this content. Aligns with IPTC 2025.1 AI metadata fields and C2PA claim_generator.' ), + NoExplicitNull, ] = None human_oversight: Annotated[ HumanOversight | None, Field( description='Level of human involvement in the AI-assisted creation process. Independent of `disclosure.required` — the protocol does not derive disclosure obligations from oversight level. Some regulations include carve-outs for human-edited or human-directed AI output, but those carve-outs have factual prerequisites the schema cannot evaluate. Asserting `edited` or `directed` does not by itself justify `disclosure.required: false`.' ), + NoExplicitNull, ] = None declared_by: Annotated[ DeclaredBy | None, Field( description='Party declaring this provenance. Identifies who attached the provenance claim, enabling receiving parties to assess trust.' ), + NoExplicitNull, ] = None declared_at: Annotated[ AwareDatetime | None, Field( description='When this provenance claim was made (ISO 8601). Distinct from created_time, which records when the content itself was produced. A provenance claim may be attached well after content creation, for example when retroactively declaring AI involvement for regulatory compliance.' ), + NoExplicitNull, ] = None created_time: Annotated[ AwareDatetime | None, Field(description='When this content was created or generated (ISO 8601)'), + NoExplicitNull, ] = None c2pa: Annotated[ C2pa | None, Field( description='C2PA sidecar manifest reference. Links to a detached cryptographic provenance manifest for this content. Note: file-level C2PA bindings break when ad servers transcode, resize, or re-encode assets. For pipelines with intermediaries, consider embedded_provenance as the primary provenance mechanism.' ), + NoExplicitNull, ] = None embedded_provenance: Annotated[ list[EmbeddedProvenanceItem] | None, @@ -2171,6 +2202,7 @@ class Provenance(AdCPBaseModel): description='Provenance metadata embedded within the content stream. Each entry declares one embedding layer: structured provenance data carried inside the content itself, as distinct from sidecar references (c2pa.manifest_url). Embedded provenance survives operations that break sidecar and file-level bindings: ad-server transcoding, CMS ingestion, copy-paste, reformatting, and CDN re-encoding. For ad-tech pipelines where content passes through multiple intermediaries, embedded provenance is the reliable path for provenance that persists from declaration through delivery. This is a declaration by the embedding party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).', min_length=1, ), + NoExplicitNull, ] = None watermarks: Annotated[ list[Watermark] | None, @@ -2178,12 +2210,14 @@ class Provenance(AdCPBaseModel): description='Content watermarks applied to this asset. Each entry declares one watermarking layer: a content modification that encodes an identifier or fingerprint within the asset. Watermarks differ from embedded provenance: a watermark encodes an identifier (who generated it, who owns it), while embedded provenance carries or references a structured provenance record (the full chain of custody). A single asset may carry both. Aligns with C2PA action taxonomy: c2pa.watermarked.bound (watermark linked to a C2PA manifest) and c2pa.watermarked.unbound (watermark independent of any manifest). This is a declaration by the watermarking party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).', min_length=1, ), + NoExplicitNull, ] = None disclosure: Annotated[ Disclosure | None, Field( description='Regulatory disclosure requirements for this content. Indicates whether AI disclosure is required and under which jurisdictions.' ), + NoExplicitNull, ] = None verification: Annotated[ list[VerificationItem] | None, @@ -2191,8 +2225,9 @@ class Provenance(AdCPBaseModel): description='Third-party verification or detection results for this content. Multiple services may independently evaluate the same content. Provenance is a claim — verification results attached by the declaring party are supplementary. The enforcing party (e.g., seller/publisher) should run its own verification via get_creative_features or calibrate_content.', min_length=1, ), + NoExplicitNull, ] = None - ext: Ext | None = None + ext: Annotated[Ext | None, NoExplicitNull] = None class RightsAgent(AdCPBaseModel): @@ -2436,6 +2471,7 @@ class ImageAsset(AdCPBaseModel): description='Image file size in bytes. Required by agents that advertise a max_file_size_kb constraint.', ge=1, ), + NoExplicitNull, ] = None pixel_ratio: Annotated[ StrictFloat | None, @@ -2443,18 +2479,21 @@ class ImageAsset(AdCPBaseModel): description='Intrinsic pixels per logical render pixel (for example `2` for a 600×500 image intended to render at 300×250). Optional because a validator can infer the ratio when the target format declares logical dimensions. When supplied, it MUST agree with both `width / logical_width` and `height / logical_height`; it is never a substitute for the intrinsic `width` and `height` fields.', gt=0.0, ), + NoExplicitNull, ] = None state_id: Annotated[ str | None, Field( description='Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one declared `states[].state_id` (semantic validators resolve it). Omit for ordinary image slots.' ), + NoExplicitNull, ] = None breakpoint_id: Annotated[ str | None, Field( description='Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one breakpoint declared on the selected state (semantic validators resolve it). Omit for ordinary image slots.' ), + NoExplicitNull, ] = None focal_point: Annotated[ list[FocalPointItem] | None, @@ -2463,16 +2502,22 @@ class ImageAsset(AdCPBaseModel): max_length=2, min_length=2, ), + NoExplicitNull, ] = None format: Annotated[ - str | None, Field(description='Image file format (jpg, png, gif, webp, etc.)') + str | None, + Field(description='Image file format (jpg, png, gif, webp, etc.)'), + NoExplicitNull, + ] = None + alt_text: Annotated[ + str | None, Field(description='Alternative text for accessibility'), NoExplicitNull ] = None - alt_text: Annotated[str | None, Field(description='Alternative text for accessibility')] = None provenance: Annotated[ Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None @@ -2480,17 +2525,20 @@ class BrandKitOverride(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - logo: Annotated[ImageAsset | None, Field(description='Override logo asset.')] = None - colors: Annotated[Colors1 | None, Field(description='Override brand colors (hex strings).')] = ( - None - ) + logo: Annotated[ + ImageAsset | None, Field(description='Override logo asset.'), NoExplicitNull + ] = None + colors: Annotated[ + Colors1 | None, Field(description='Override brand colors (hex strings).'), NoExplicitNull + ] = None voice: Annotated[ str | None, Field( description='Override brand-voice description for surface-composed text/audio output.' ), + NoExplicitNull, ] = None - tagline: Annotated[str | None, Field(description='Override tagline.')] = None + tagline: Annotated[str | None, Field(description='Override tagline.'), NoExplicitNull] = None class BrandRef(AdCPBaseModel): @@ -2509,6 +2557,7 @@ class BrandRef(AdCPBaseModel): Field( description='Brand identifier within the house portfolio. Optional for single-brand domains.' ), + NoExplicitNull, ] = None countries: Annotated[ list[Country1] | None, @@ -2516,24 +2565,28 @@ class BrandRef(AdCPBaseModel): description='Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting.', min_length=1, ), + NoExplicitNull, ] = None industries: Annotated[ list[str] | None, Field( description="Inline override for the brand's industries. Useful when the caller cannot modify the brand's canonical brand.json but needs to declare industries for governance (e.g., Annex III vertical detection). brand.json remains the canonical source; when omitted here, governance agents SHOULD resolve from brand.json." ), + NoExplicitNull, ] = None data_subject_contestation: Annotated[ DataSubjectContestation1 | None, Field( description="Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing." ), + NoExplicitNull, ] = None brand_kit_override: Annotated[ BrandKitOverride | None, Field( description="Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py b/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py index 5ad07370b..9ccc22545 100644 --- a/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py +++ b/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: bundled/protocol/get_adcp_capabilities_response.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -11,7 +11,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Dict, Literal -from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, SchemaInt +from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat, ScalarInt, ScalarStr from pydantic import ( AnyUrl, @@ -102,18 +102,21 @@ class Issue(AdCPBaseModel): Field( description="Optional. JSON Schema tree path of the rejecting keyword (e.g. '#/properties/packages/items/oneOf/1'). 3.1+ consumers SHOULD prefer `schema_id`; `schemaPath` is retained for 3.0.x compatibility (renamed to `schema_path` in a future major). See error-handling.mdx for the validator-internals production-emit rules." ), + NoExplicitNull, ] = None schema_id: Annotated[ str | None, Field( description="Optional. `$id` of the rejecting (sub-)schema (e.g. `/schemas/3.1.0/core/activation-key.json`). MUST resolve to a `$id` published in the spec at the version the seller advertises via `get_adcp_capabilities` — either a deep sub-schema (the typical case) or the response-root `$id` (the bundled-tree fallback for tools served from bundles built before #3868). Sellers MUST NOT emit when the rejection occurred against a private extension, server-only sub-schema, or pre-release element — the public-spec replay rationale only holds when the rejecting element is reachable from the public bundle. Sellers populating `schemaPath` SHOULD also populate `schema_id` when they have it so 3.1+ readers don't get strictly less than 3.0.x readers. See error-handling.mdx for resolution guidance and the bundled-tree caveat." ), + NoExplicitNull, ] = None discriminator: Annotated[ list[DiscriminatorItem] | None, Field( description="Optional. Const-discriminator property/value pair(s) identifying the variant the validator selected from values present in the payload. Sellers MUST populate only when (a) the rejecting schema is a const-discriminated `oneOf` / `anyOf` and (b) the discriminator property is present in the payload — emission on partial-match inference would fingerprint the seller's validator implementation. MUST omit when zero variants survive. Compound discriminators (e.g. `(type, value_type)`) produce multiple entries ordered by declaration in the rejecting schema's `properties` block. Same private-extensions / version-skew carve-out as `schema_id`. See error-handling.mdx." ), + NoExplicitNull, ] = None @@ -146,14 +149,18 @@ class AdcpError(AdCPBaseModel): Field( description='Optional buyer-actionable classification of the failure. Use this when the enclosing error code or message is too coarse or contains producer-internal context that must not cross the buyer trust boundary. `code` reuses the comprehensive standard vocabulary and recovery classifications published in `enums/error-code.json`, including policy, governance, account, commercial, inventory, and creative failures. The wire field remains open for forward compatibility. `message` MUST be safe to show to the buyer and MUST NOT contain vendor identifiers, ad-server type names, internal object names, internal IDs, stack traces, or other producer-private implementation details. When present, the enclosing `error.recovery` MUST classify the buyer-actionable reason. If both the enclosing code and buyer reason are registered, their standard recovery classifications MUST agree with each other and with `error.recovery`; sellers MUST choose the closest compatible enclosing code rather than retain a conflicting upstream transport wrapper. One buyer_reason classifies one error object. If a rejection contains independently actionable failures from different classes, sellers MUST emit separate error objects or omit buyer_reason rather than select a misleading single class.' ), + NoExplicitNull, ] = None field: Annotated[ str | None, Field( description="Field path associated with the error in JSONPath-lite format (e.g., 'packages[0].targeting'). When `issues[]` is also present, sellers MUST set this to `issues[0].pointer` translated from RFC 6901 to JSONPath-lite (e.g., '/packages/0/targeting' → 'packages[0].targeting') so pre-3.1 consumers reading `field` only get deterministic behavior. Will be deprecated in a future major version in favor of `issues[].pointer`." ), + NoExplicitNull, + ] = None + suggestion: Annotated[ + str | None, Field(description='Suggested fix for the error'), NoExplicitNull ] = None - suggestion: Annotated[str | None, Field(description='Suggested fix for the error')] = None retry_after: Annotated[ StrictFloat | None, Field( @@ -161,36 +168,42 @@ class AdcpError(AdCPBaseModel): ge=1.0, le=3600.0, ), + NoExplicitNull, ] = None issues: Annotated[ list[Issue] | None, Field( description='Structured list of validation failures. Primary use is `VALIDATION_ERROR`, where multi-field rejections are common and `field` (singular) cannot carry the full pointer map. MAY appear on other error codes that reject multiple fields at once. When `issues` is present, sellers MUST also populate `field` from `issues[0]` for backward compatibility with pre-3.1 consumers that read `field` only — translating the RFC 6901 `pointer` format to the JSONPath-lite format `field` uses (e.g., `/packages/0/targeting` → `packages[0].targeting`). MUST (not SHOULD) so consumers reading `field` get deterministic behavior across sellers — the cost is one line of dual-write per seller; the cost of SHOULD is a long tail of seller-A-vs-seller-B inconsistency. Future major versions will deprecate `field` in favor of `issues[].pointer`.' ), + NoExplicitNull, ] = None details: Annotated[ dict[str, Any] | None, Field( description='Additional task-specific error details. Sellers MAY mirror `issues[]` here as `details.issues` for backward compatibility with pre-3.1 consumers reading from `details`; new consumers SHOULD prefer the top-level `issues` field.\n\n**Canonical rejection-set shape (3.1+).** When the error reports a rejected value against a closed set of accepted values (e.g., enum mismatch, unsupported pricing option, invalid signal id), sellers SHOULD use the canonical key `accepted_values: ` under `details` rather than seller-specific variants observed in the wild (`available`, `allowed`, `accepted_values` at the error root, etc.). The canonical shape:\n\n```json\n{\n "code": "INVALID_PRICING_OPTION",\n "message": "Pricing option not found: po_prism_abandoner_cpm",\n "field": "pricing_option_id",\n "details": {\n "rejected_value": "po_prism_abandoner_cpm",\n "accepted_values": ["po_prism_cart_cpm", "po_prism_view_cpm"]\n }\n}\n```\n\n- `rejected_value` (optional): the offending value the buyer supplied, echoed for buyer-side diagnostic clarity (especially when the offending field is nested or transformed before validation).\n- `accepted_values` (optional): the closed set the seller would have accepted at this field on this call. Sellers MUST NOT enumerate the full ecosystem-wide accepted set if it differs from what\'s accepted for *this caller in this context* (account, brand, scope) — leaking ecosystem-wide accepted sets to a per-caller rejection turns the error into an enumeration oracle.\n\nThis is **SHOULD-level guidance**, not MUST: `details` remains `additionalProperties: true` and pre-3.1 sellers using `available` / `allowed` / `accepted_values` at the error root remain conformant. The canonical shape lets buyer-side diagnostic tooling (SDK runner hints, dashboards, error classifiers) reliably surface the accepted-set without per-seller pattern matching. SDKs SHOULD accept any of the legacy variants and normalize on read; the canonical shape is what new sellers and 3.1+ adopters should emit going forward.' ), + NoExplicitNull, ] = None recovery: Annotated[ Recovery | None, Field( description='Agent recovery classification. transient: retry after delay (rate limit, service unavailable, timeout). correctable: fix the request and resend (invalid field, budget too low, creative rejected). terminal: requires human action (account suspended, payment required, account not found). AdCP 3.2 producers MUST populate `recovery` on every error; 3.1 producers SHOULD populate it. The shared 3.x schema intentionally does not add `recovery` to `required` so retained and live errors from earlier 3.x producers remain decodable. When `buyer_reason` is present, `recovery` is required and MUST classify that buyer-actionable reason. If the enclosing code and buyer reason are both registered, their standard recovery classifications MUST agree with each other and with this field. A receiver that does not recognize `error.code` (a newer code, or a platform-specific code) MUST still be able to classify the error from `recovery`. When a legacy error omits `recovery`, receivers use the registered classification for a known code and fall back to `transient` for an unknown code, subject to the bounded retry budget. The `enumMetadata.recovery` block in `enums/error-code.json` is the documentary mirror for known top-level and buyer-reason codes; `error.recovery` on the wire is authoritative when present.' ), + NoExplicitNull, ] = None source: Annotated[ Source | None, Field( description='Who emitted this error entry. `producer` (default when absent): emitted by the response\'s authoring agent (the seller for `get_products`, the creative agent for `build_creative`, etc.). `sdk`: augmented by a consuming SDK that detected a non-fatal advisory condition on consumption (e.g., `FORMAT_PROJECTION_FAILED` when the buyer SDK couldn\'t project a v1 format to a canonical, or `FORMAT_DECLARATION_DIVERGENT` when the SDK detected a producer bug on read). SDK-augmented entries SHOULD also set `sdk_id` so downstream consumers can identify which intermediate processor inserted the entry.\n\n**Multi-hop propagation (normative).** AdCP is a federated agent network — responses commonly traverse multiple SDKs (e.g., sales agent → interchange → DSP → buyer). When an SDK augments `errors[]` with a consumption-detected entry, the augmented response carries the entry forward to subsequent hops. Each hop that detects the same condition independently SHOULD deduplicate by `(code, field)` rather than re-emit; the existing entry\'s `sdk_id` identifies which earlier processor saw it first. Producer entries (those without `source: "sdk"`) are authoritative for what the response\'s authoring agent self-detected; SDK entries are observations made on top.\n\n**Replay/audit safety.** Persisted or replayed responses carry `source` and `sdk_id` so the audit trail can distinguish seller-emitted entries from SDK-augmented ones. Without `source`, a downstream consumer can\'t tell whether a code came from the seller or an intermediate SDK, which corrupts attribution.' ), + NoExplicitNull, ] = None sdk_id: Annotated[ str | None, Field( description='Optional identifier for the SDK that augmented this error entry. Format: `@` (e.g., `@adcontextprotocol/adcp@7.3.0`, `adcontextprotocol-adcp-python@1.2.0`). MUST be set when `source: "sdk"`; MUST be absent when `source: "producer"` or absent. Lets downstream consumers identify which intermediate processor inserted the entry, useful for debugging cross-SDK divergence (e.g., one SDK detects a projection failure that another SDK\'s registry version doesn\'t).' ), + NoExplicitNull, ] = None @@ -235,6 +248,7 @@ class PushNotificationConfig(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None token: Annotated[ str | None, @@ -243,6 +257,7 @@ class PushNotificationConfig(AdCPBaseModel): max_length=4096, min_length=16, ), + NoExplicitNull, ] = None authentication: Annotated[ Authentication | None, @@ -250,6 +265,7 @@ class PushNotificationConfig(AdCPBaseModel): deprecated=True, description='Legacy authentication configuration (A2A-compatible). Opts the seller into Bearer or HMAC-SHA256 signing instead of the default RFC 9421 webhook profile. Deprecated; removed in AdCP 4.0. **Precedence is a switch, not a fallback:** presence of this block selects the legacy scheme; absence selects 9421. A seller MUST NOT sign the same webhook both ways, and a buyer MUST NOT attempt \'try 9421 first, fall back to HMAC\' verification — signature mode is determined solely by whether this block was present at registration time. The seller\'s baseline 9421 webhook key is published at its brand.json `agents[]` `jwks_uri` using `adcp_use: "request-signing"` (deprecated `webhook-signing` keys remain accepted during the compatibility window); it does not override this selector and is only used when `authentication` is omitted. See docs/building/by-layer/L1/security.mdx#webhook-callbacks for the full precedence and downgrade-resistance rules (including the `webhook_mode_mismatch` rejection a buyer MUST apply when a received webhook\'s signing mode does not match the registered mode).', ), + NoExplicitNull, ] = None @@ -287,12 +303,14 @@ class Idempotency(AdCPBaseModel): ge=1, le=604800, ), + NoExplicitNull, ] = None account_id_is_opaque: Annotated[ StrictBool | None, Field( description="When true, the seller derives `account_id` via an HKDF-based one-way transform of the buyer's natural account key rather than echoing the natural key on the wire. Buyers MUST NOT attempt to invert the opaque id and MUST treat it as a blind handle scoped to this seller. Absent or false, callers should assume `account_id` is the natural key (or a server-assigned but non-opaque id). This flag does not change the wire shape, but it DOES change buyer behavior — buyers MUST NOT cache, log, or treat `account_id` as a natural-key analog when this flag is true. Migration note for sellers already returning an opaque id without this flag: set it to true at the next capabilities refresh so buyers stop inferring natural-key semantics; until set, new-buyer replay/retry logic will misclassify these ids as natural keys." ), + NoExplicitNull, ] = False @@ -356,6 +374,7 @@ class ReportingDestinationOffering(AdCPBaseModel): description='Physical file formats the seller can produce. Only meaningful for file_transfer.', min_length=1, ), + NoExplicitNull, ] = None access_modes: Annotated[ list[AccessMode] | None, @@ -363,6 +382,7 @@ class ReportingDestinationOffering(AdCPBaseModel): description='Dataset-share access families the seller can provision. Only meaningful for dataset_share.', min_length=1, ), + NoExplicitNull, ] = None verification_profiles: Annotated[ list[VerificationProfile], @@ -462,6 +482,7 @@ class Principal(AdCPBaseModel): ge=1, le=64, ), + NoExplicitNull, ] = None reporting_destination_offerings: Annotated[ list[ReportingDestinationOffering] | None, @@ -470,6 +491,7 @@ class Principal(AdCPBaseModel): max_length=3, min_length=1, ), + NoExplicitNull, ] = None suspension_interval_seconds: Annotated[ SchemaInt | None, @@ -478,6 +500,7 @@ class Principal(AdCPBaseModel): ge=1, le=86400, ), + NoExplicitNull, ] = None caller_event_types: Annotated[ list[CallerEventType] | None, @@ -485,6 +508,7 @@ class Principal(AdCPBaseModel): description='Notification types this seller can emit against caller-level subscribers, so buyers select from the offering instead of probing by rejection. Account-anchored entries fire with fire-time authorization scoping. When absent, only capabilities.changed is assumed. A caller-level subscriber requesting a type outside this set fails the sync request with UNSUPPORTED_FEATURE.', min_length=1, ), + NoExplicitNull, ] = None optimistic_concurrency: Annotated[ StrictBool, @@ -529,6 +553,7 @@ class Notifications(AdCPBaseModel): ge=0, le=86400, ), + NoExplicitNull, ] = None @@ -555,12 +580,14 @@ class CapabilityChanges(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None last_modified: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp when the seller last changed the advertised capability document. This is the scoped replacement for legacy top-level `last_updated` when buyers need cache validation semantics.' ), + NoExplicitNull, ] = None cache_ttl_seconds: Annotated[ SchemaInt | None, @@ -569,12 +596,14 @@ class CapabilityChanges(AdCPBaseModel): ge=60, le=604800, ), + NoExplicitNull, ] = None notifications: Annotated[ Notifications | Notifications5 | None, Field( description='Whether the seller supports agent-level capability-change webhooks. When supported, interested consumers register endpoint subscribers with the declared registration_task; sync_principal is preferred when the broader connection surface is available, while sync_agent_notification_configs remains the specialized compatibility task. Each capabilities.changed fire is a small invalidation payload, and consumers repair by re-reading get_adcp_capabilities.' ), + NoExplicitNull, ] = None @@ -744,6 +773,7 @@ class GovernanceEnforcement(AdCPBaseModel): description='Seller-wide advisory default for governance agents this enforcing service accepts as binding counterparties. A candidate satisfying any matcher is accepted by this declaration. The per-account sync_governance response is authoritative and may apply stricter account-specific criteria. Omission means accept any, preserving legacy behavior.', title='Accepted Governance Agents', ), + NoExplicitNull, ] = None @@ -762,9 +792,9 @@ class DataSubjectContestation(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - url: AnyUrl | None = None - email: EmailStr | None = None - languages: list[str] | None = None + url: Annotated[AnyUrl | None, NoExplicitNull] = None + email: Annotated[EmailStr | None, NoExplicitNull] = None + languages: Annotated[list[str] | None, NoExplicitNull] = None class FocalPointItem(ScalarFloat): @@ -776,9 +806,9 @@ class Colors(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - primary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$')] = None - secondary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$')] = None - accent: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$')] = None + primary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$'), NoExplicitNull] = None + secondary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$'), NoExplicitNull] = None + accent: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$'), NoExplicitNull] = None class Issuer1(AdCPBaseModel): @@ -803,6 +833,7 @@ class Issuer1(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -828,6 +859,7 @@ class Issuer2(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -864,14 +896,15 @@ class AcceptedVerifier(AdCPBaseModel): extra='forbid', ) agent_url: AnyUrl - claim_types: Annotated[list[AnyUrl] | None, Field(min_length=1)] = None - proof_formats: Annotated[list[AnyUrl] | None, Field(min_length=1)] = None + claim_types: Annotated[list[AnyUrl] | None, Field(min_length=1), NoExplicitNull] = None + proof_formats: Annotated[list[AnyUrl] | None, Field(min_length=1), NoExplicitNull] = None ext: Annotated[ dict[str, Any] | None, Field( description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -927,12 +960,14 @@ class Timezone(AdCPBaseModel): description='Seller-wide timezone used by every account. Required only for seller_fixed. Use UTC or an IANA timezone identifier.', min_length=1, ), + NoExplicitNull, ] = None account_selection: Annotated[ AccountSelection | None, Field( description='How an account_fixed timezone is established. seller_assigned covers an existing upstream account or seller onboarding choice; buyer_selected requires timezone in buyer-declared sync_accounts provisioning.' ), + NoExplicitNull, ] = None supported_timezones: Annotated[ list[SupportedTimezone] | None, @@ -940,6 +975,7 @@ class Timezone(AdCPBaseModel): description='Exact timezone values accepted during buyer-selected account provisioning. Required when account_selection is buyer_selected so buyers can validate the choice before sync_accounts.', min_length=1, ), + NoExplicitNull, ] = None @@ -975,6 +1011,7 @@ class Notifications6(AdCPBaseModel): Field( description='Whether `list_accounts` can return recent delivery attempts in `accounts[].webhook_activity[]` when the buyer requests `include_webhook_activity: true`.' ), + NoExplicitNull, ] = False @@ -1068,12 +1105,14 @@ class Account(AdCPBaseModel): Field( description="Whether the seller requires operator-level credentials. This declares who must authenticate; it does not by itself declare whether OAuth is used, whether list_accounts is exposed, or which sync_accounts modes are supported. When true, operators authenticate independently with the seller and account-scoped calls use seller/storefront-assigned account_id values because the seller or upstream platform owns the canonical account namespace. If a credential may access more than one account, the seller MUST expose list_accounts and buyers MUST resolve an explicit account_id before the first account-scoped request. If a credential is bound to exactly one account, the seller SHOULD expose list_accounts returning that singleton; a seller MAY omit list_accounts only when it provides the same explicit account_id through another declared path or out-of-band onboarding. When false (default, buyer-declared accounts), the seller trusts the agent's identity claims and account-scoped calls use the advertiser natural key: brand + operator + optional operator_unit, fixed currency, optional buyer-selected account timezone, and sandbox. operator_unit.id is owned by the operator and is distinct from the seller's account_id. The seller normally provisions through sync_accounts, but MAY lazily provision on the first account-scoped request when billing and other required settings are unambiguous from capabilities or onboarding defaults. A lazy-provisioning seller MUST keep accepting the natural key and MUST expose list_accounts for recovery; if buyer input is needed before use, the seller MUST expose sync_accounts." ), + NoExplicitNull, ] = False authorization_endpoint: Annotated[ AnyUrl | None, Field( description='OAuth authorization endpoint for obtaining operator-level credentials. Present when the seller supports OAuth for operator authentication. The agent directs the operator to this URL to authenticate and obtain a bearer token. If absent and require_operator_auth is true, operators obtain credentials out-of-band (e.g., seller portal, API key).' ), + NoExplicitNull, ] = None supported_billing: Annotated[ list[SupportedBillingEnum], @@ -1088,6 +1127,7 @@ class Account(AdCPBaseModel): description='Required for sellers implementing AdCP 3.2 advertiser-account provisioning, but optional in this shared 3.x response schema so existing 3.0 and 3.1 capability responses remain valid. Declares whether advertiser accounts are bound to one immutable currency (`fixed`), select currency independently per proposal or media buy (`per_media_buy`), or support both models. When only `fixed` is advertised, buyer-declared provisioning entries MUST include `currency`. When only `per_media_buy` is advertised, they MUST omit it. When both are advertised, presence of `currency` selects a fixed-currency account and omission selects per-media-buy currency. Buyers MUST treat absence as an older seller whose currency mode is not discoverable, not as support for either mode.', min_length=1, ), + NoExplicitNull, ] = None timezone: Annotated[ Timezone | None, @@ -1095,42 +1135,49 @@ class Account(AdCPBaseModel): description='Required for sellers implementing AdCP 3.2 advertiser-account provisioning, but optional in the shared 3.x response schema for compatibility. Declares whether the account timezone is seller-wide or fixed per account and whether a buyer must select it during sync_accounts provisioning. Account timezone is the default for account-scoped calendar semantics; feature-specific capability fields explicitly declare exceptions.', title='Account Timezone Capability', ), + NoExplicitNull, ] = None required_for_products: Annotated[ StrictBool | None, Field( description='Whether an account reference is required for get_products. When true, the buyer must establish an account before browsing products. When false (default), the buyer can browse products without an account — useful for price comparison and discovery before committing to a seller. Before an account is provisioned, the buyer omits account rather than sending a natural key that does not resolve yet. See /docs/accounts/overview#account-references-before-provisioning.' ), + NoExplicitNull, ] = False account_financials: Annotated[ StrictBool | None, Field( description='Whether this seller exposes the `get_account_financials` task for querying account-level financial status (spend, credit, invoices). Acts as a **pre-call discriminator** — buyers MUST consult this field before issuing `get_account_financials`; when `false` (or absent), sellers MAY reject the call with an `UNSUPPORTED_FEATURE` / `OPERATION_NOT_SUPPORTED` error. Companion pattern to `creative.bills_through_adcp` (issue #2881) — both fields let buyers gate optional capability calls on a single declared boolean rather than probing for support. Only applicable to operator-billed accounts; sellers using buyer-billed flows omit or set to `false`.' ), + NoExplicitNull, ] = False notifications: Annotated[ Notifications6 | Notifications7 | None, Field( description='Whether the seller supports durable account-lifecycle webhooks through account-level `notification_configs[]`. This capability is specifically for account status changes such as approval, rejection, payment-required, suspension, recovery, and closure. When supported, buyers register subscribers with `sync_accounts.accounts[].notification_configs[]`; each `account.status_changed` fire is an invalidation payload, and buyers repair by re-reading `list_accounts` for the account_id.' ), + NoExplicitNull, ] = None change_feed: Annotated[ ChangeFeed | ChangeFeed3 | None, Field( description='Whether the seller exposes a durable, ordered feed of material changes to authoritative account-scoped state. This is distinct from webhook_activity transport diagnostics and from current-state reads. Sellers claiming support MUST retain changes for at least 90 days after recording and MUST produce records regardless of whether a mutation originated through AdCP, a seller surface, another authorized principal, seller automation, or a connected platform within declared coverage. Experimental in 3.2 (RFC #6810): sellers advertising supported: true MUST list account.change_feed in experimental_features.' ), + NoExplicitNull, ] = None identity_updates: Annotated[ IdentityUpdates | IdentityUpdates3 | None, Field( description='Whether the seller accepts buyer-desired operator identity reconciliation through sync_accounts settings-update entries. Sellers declaring support expose the exact identity transitions they implement, MUST return account revisions from sync_accounts and list_accounts, and MUST return identity_change_preview for dry-run identity updates.' ), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field( description='Whether this seller supports sandbox accounts for testing. Buyer-declared accounts use sandbox: true in sync_accounts or, for an unambiguous lazy-provisioning seller, in the natural-key account reference. Sellers with account_id namespaces expose sandbox accounts as pre-existing test accounts through list_accounts or supply them out-of-band. Requests using a sandbox account perform no real platform calls or spend.' ), + NoExplicitNull, ] = False @@ -1159,6 +1206,7 @@ class AcceptancePolicyDiscovery(AdCPBaseModel): description='Local or registry-referenced catalog profiles that apply seller-wide unless a product adds further profiles. IDs MUST resolve uniquely across profiles and registry_profiles; all referenced profiles compose restrictively.', min_length=1, ), + NoExplicitNull, ] = None @@ -1218,6 +1266,7 @@ class ProposalRefinement(AdCPBaseModel): ge=2, le=10, ), + NoExplicitNull, ] = None @@ -1230,6 +1279,7 @@ class PerformanceFeedback(AdCPBaseModel): Field( description='Whether provide_performance_feedback responses include an honest application_status of accepted, applied, or not_applied. accepted is explicitly not an application claim. Sellers that cannot determine optimizer disposition omit this claim or set it false.' ), + NoExplicitNull, ] = False @@ -1287,6 +1337,7 @@ class ReportingProfile(AdCPBaseModel): max_length=128, min_length=1, ), + NoExplicitNull, ] = None canonicalization_contract_version: Literal['1.0'] = '1.0' canonicalization_media_type: Literal['application/vnd.adcp.reporting-canonicalization+json'] = ( @@ -1297,6 +1348,7 @@ class ReportingProfile(AdCPBaseModel): Field( description='Retrievable exact canonicalization contract on the authenticated seller/provider or AdCP-registry origin. SDKs apply the same bounded, redirect-free SSRF controls as schema_uri and verify canonicalization_sha256 before use.' ), + NoExplicitNull, ] = None canonicalization_sha256: Annotated[ str | None, @@ -1304,6 +1356,7 @@ class ReportingProfile(AdCPBaseModel): description='Digest of the exact canonicalization contract identified by canonicalization_id.', pattern='^[A-Fa-f0-9]{64}$', ), + NoExplicitNull, ] = None @@ -1347,15 +1400,19 @@ class Schedule(AdCPBaseModel): Field( description='For billing_cycle only. fixed requires the advertised anchor and timezone; configurable lets each authorized account configuration select them.' ), + NoExplicitNull, ] = None period_timezone_policy: Annotated[ PeriodTimezonePolicy | None, Field( description="For source_timezone only. fixed advertises one exact upstream IANA timezone; account_resolved requires the seller to resolve and echo the account's upstream reporting timezone during configuration." ), + NoExplicitNull, ] = None - period_anchor: AwareDatetime | None = None - period_timezone: Annotated[str | None, Field(max_length=255, min_length=1)] = None + period_anchor: Annotated[AwareDatetime | None, NoExplicitNull] = None + period_timezone: Annotated[str | None, Field(max_length=255, min_length=1), NoExplicitNull] = ( + None + ) delivery_sla: Annotated[ str, Field( @@ -1405,8 +1462,8 @@ class ProducerIdentity(AdCPBaseModel): ) provider: Provider identity: Annotated[str, Field(max_length=512, min_length=1)] - cloud: Annotated[Cloud | None, Field(title='Reporting Cloud')] = None - region: Annotated[str | None, Field(max_length=128, min_length=1)] = None + cloud: Annotated[Cloud | None, Field(title='Reporting Cloud'), NoExplicitNull] = None + region: Annotated[str | None, Field(max_length=128, min_length=1), NoExplicitNull] = None class ReaderCompatibilityItem(ScalarStr): @@ -1425,6 +1482,7 @@ class OperationsContact(AdCPBaseModel): Field( description='HTTPS page a human uses to open or track a reporting issue, such as a support portal or status page. Same hardened origin shape as the offering document URIs: never an IP literal, userinfo URL, loopback host, AdCP task endpoint, webhook target, or credentialed link.' ), + NoExplicitNull, ] = None email: Annotated[ EmailStr | None, @@ -1432,6 +1490,7 @@ class OperationsContact(AdCPBaseModel): description='Monitored operations mailbox for reporting escalations. A role address, not an individual.', max_length=254, ), + NoExplicitNull, ] = None @@ -1458,8 +1517,10 @@ class Evidence(AdCPBaseModel): ) basis: Basis generated_at: AwareDatetime - attestation_uri: AnyUrl | None = None - attestation_sha256: Annotated[str | None, Field(pattern='^[A-Fa-f0-9]{64}$')] = None + attestation_uri: Annotated[AnyUrl | None, NoExplicitNull] = None + attestation_sha256: Annotated[ + str | None, Field(pattern='^[A-Fa-f0-9]{64}$'), NoExplicitNull + ] = None class PropagationSurface(StrEnum): @@ -1525,12 +1586,14 @@ class RelationshipNotifications(AdCPBaseModel): max_length=1, min_length=1, ), + NoExplicitNull, ] = None supports_webhook_activity: Annotated[ StrictBool | None, Field( description='Whether the declared repair tasks can return recent delivery attempts for these account-level invalidations when include_webhook_activity is true.' ), + NoExplicitNull, ] = False @@ -1549,7 +1612,9 @@ class Surface(StrEnum): class TrustedMatch(AdCPBaseModel): surfaces: Annotated[ - list[Surface] | None, Field(description='Surface types this seller supports via TMP.') + list[Surface] | None, + Field(description='Surface types this seller supports via TMP.'), + NoExplicitNull, ] = None @@ -1648,18 +1713,21 @@ class GeoRegions(AdCPBaseModel): description='Optional opaque ISO subdivision catalog or seller-mapping version that identifies the support snapshot. It is provenance, not part of subdivision identity.', min_length=1, ), + NoExplicitNull, ] = None as_of: Annotated[ date | None, Field( description='Optional date on which the seller last validated this support declaration.' ), + NoExplicitNull, ] = None max_values_per_package: Annotated[ SchemaInt | None, Field( description='Optional maximum number of region values selectable on one package.', ge=1 ), + NoExplicitNull, ] = None max_packages: Annotated[ SchemaInt | None, @@ -1667,6 +1735,7 @@ class GeoRegions(AdCPBaseModel): description='Optional maximum number of independently region-targeted packages created from this configured product.', ge=1, ), + NoExplicitNull, ] = None ext: Annotated[ dict[str, Any] | None, @@ -1674,6 +1743,7 @@ class GeoRegions(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -1702,18 +1772,21 @@ class GeoRegionsExclude(AdCPBaseModel): description='Optional opaque ISO subdivision catalog or seller-mapping version that identifies the support snapshot. It is provenance, not part of subdivision identity.', min_length=1, ), + NoExplicitNull, ] = None as_of: Annotated[ date | None, Field( description='Optional date on which the seller last validated this support declaration.' ), + NoExplicitNull, ] = None max_values_per_package: Annotated[ SchemaInt | None, Field( description='Optional maximum number of region values selectable on one package.', ge=1 ), + NoExplicitNull, ] = None max_packages: Annotated[ SchemaInt | None, @@ -1721,6 +1794,7 @@ class GeoRegionsExclude(AdCPBaseModel): description='Optional maximum number of independently region-targeted packages created from this configured product.', ge=1, ), + NoExplicitNull, ] = None ext: Annotated[ dict[str, Any] | None, @@ -1728,6 +1802,7 @@ class GeoRegionsExclude(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -1735,10 +1810,10 @@ class GeoMetros(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - nielsen_dma: StrictBool | None = None - uk_itl1: StrictBool | None = None - uk_itl2: StrictBool | None = None - eurostat_nuts2: StrictBool | None = None + nielsen_dma: Annotated[StrictBool | None, NoExplicitNull] = None + uk_itl1: Annotated[StrictBool | None, NoExplicitNull] = None + uk_itl2: Annotated[StrictBool | None, NoExplicitNull] = None + eurostat_nuts2: Annotated[StrictBool | None, NoExplicitNull] = None class ME(StrEnum): @@ -1768,28 +1843,28 @@ class GeoPostalAreas(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - US: Annotated[list[ME] | None, Field(min_length=1)] = None - GB: Annotated[list[GBEnum] | None, Field(min_length=1)] = None - CA: Annotated[list[CAEnum] | None, Field(min_length=1)] = None - DE: Annotated[list[Literal['plz']] | None, Field(min_length=1)] = None - CH: Annotated[list[Literal['plz']] | None, Field(min_length=1)] = None - AT: Annotated[list[Literal['plz']] | None, Field(min_length=1)] = None - FR: Annotated[list[Literal['code_postal']] | None, Field(min_length=1)] = None - AU: Annotated[list[Literal['postcode']] | None, Field(min_length=1)] = None - BR: Annotated[list[Literal['cep']] | None, Field(min_length=1)] = None - IN: Annotated[list[Literal['pin']] | None, Field(min_length=1)] = None - ZA: Annotated[list[Literal['postal_code']] | None, Field(min_length=1)] = None - us_zip: Annotated[StrictBool | None, Field(deprecated=True)] = None - us_zip_plus_four: Annotated[StrictBool | None, Field(deprecated=True)] = None - gb_outward: Annotated[StrictBool | None, Field(deprecated=True)] = None - gb_full: Annotated[StrictBool | None, Field(deprecated=True)] = None - ca_fsa: Annotated[StrictBool | None, Field(deprecated=True)] = None - ca_full: Annotated[StrictBool | None, Field(deprecated=True)] = None - de_plz: Annotated[StrictBool | None, Field(deprecated=True)] = None - fr_code_postal: Annotated[StrictBool | None, Field(deprecated=True)] = None - au_postcode: Annotated[StrictBool | None, Field(deprecated=True)] = None - ch_plz: Annotated[StrictBool | None, Field(deprecated=True)] = None - at_plz: Annotated[StrictBool | None, Field(deprecated=True)] = None + US: Annotated[list[ME] | None, Field(min_length=1), NoExplicitNull] = None + GB: Annotated[list[GBEnum] | None, Field(min_length=1), NoExplicitNull] = None + CA: Annotated[list[CAEnum] | None, Field(min_length=1), NoExplicitNull] = None + DE: Annotated[list[Literal['plz']] | None, Field(min_length=1), NoExplicitNull] = None + CH: Annotated[list[Literal['plz']] | None, Field(min_length=1), NoExplicitNull] = None + AT: Annotated[list[Literal['plz']] | None, Field(min_length=1), NoExplicitNull] = None + FR: Annotated[list[Literal['code_postal']] | None, Field(min_length=1), NoExplicitNull] = None + AU: Annotated[list[Literal['postcode']] | None, Field(min_length=1), NoExplicitNull] = None + BR: Annotated[list[Literal['cep']] | None, Field(min_length=1), NoExplicitNull] = None + IN: Annotated[list[Literal['pin']] | None, Field(min_length=1), NoExplicitNull] = None + ZA: Annotated[list[Literal['postal_code']] | None, Field(min_length=1), NoExplicitNull] = None + us_zip: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + us_zip_plus_four: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + gb_outward: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + gb_full: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + ca_fsa: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + ca_full: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + de_plz: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + fr_code_postal: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + au_postcode: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + ch_plz: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + at_plz: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None @@ -1873,6 +1948,7 @@ class Catalog(AdCPBaseModel): Field( description='Optional catalog dataset or derivative-source identifier. This records provenance/coverage and does not change the identifier namespace in system.' ), + NoExplicitNull, ] = None current_version: Annotated[ str, @@ -1949,11 +2025,14 @@ class VerificationMethod(StrEnum): class AgeRestriction(AdCPBaseModel): supported: Annotated[ - StrictBool | None, Field(description='Whether seller supports age restrictions') + StrictBool | None, + Field(description='Whether seller supports age restrictions'), + NoExplicitNull, ] = None verification_methods: Annotated[ list[VerificationMethod] | None, Field(description='Age verification methods this seller supports'), + NoExplicitNull, ] = None @@ -1987,12 +2066,14 @@ class Language(AdCPBaseModel): supported: Annotated[ StrictBool | None, Field(description='Whether seller supports language-preference targeting'), + NoExplicitNull, ] = None supported_languages: Annotated[ list[SupportedLanguage] | None, Field( description='Exact canonical BCP 47 language ranges accepted in targeting.language. Omission means the seller makes no exhaustive language-list declaration.' ), + NoExplicitNull, ] = None @@ -2009,18 +2090,21 @@ class GeoProximity(AdCPBaseModel): Field( description='Whether seller supports simple radius targeting (distance circle from a point)' ), + NoExplicitNull, ] = None travel_time: Annotated[ StrictBool | None, Field( description='Whether seller supports travel time isochrone targeting (requires a routing engine)' ), + NoExplicitNull, ] = None geometry: Annotated[ StrictBool | None, Field( description='Whether seller supports pre-computed GeoJSON geometry (buyer provides the polygon)' ), + NoExplicitNull, ] = None transport_modes: Annotated[ list[TransportMode] | None, @@ -2028,6 +2112,7 @@ class GeoProximity(AdCPBaseModel): description='Transport modes supported for travel_time isochrones. Only relevant when travel_time is true.', min_length=1, ), + NoExplicitNull, ] = None @@ -2123,13 +2208,16 @@ class ConsumerIdentity(AdCPBaseModel): extra='forbid', ) cloud: Annotated[ - Cloud | None, Field(description="Cloud family hosting this identity's account.") + Cloud | None, + Field(description="Cloud family hosting this identity's account."), + NoExplicitNull, ] = None region: Annotated[ str | None, Field( description="Vendor/cloud region identifier. Format follows the cloud family's region naming; not constrained by this schema." ), + NoExplicitNull, ] = None identity: Annotated[ str, @@ -2144,8 +2232,8 @@ class MatchingLatencyHours(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - min: Annotated[SchemaInt | None, Field(ge=0)] = None - max: Annotated[SchemaInt | None, Field(ge=0)] = None + min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None class SupportedOptimizationMetric(StrEnum): @@ -2178,6 +2266,7 @@ class VendorMetricOptimization(AdCPBaseModel): description='Target kinds this seller can support for vendor_metric optimization goals on at least one product. Values match optimization_goals[].target.kind for kind: vendor_metric. A target-less vendor_metric goal maximizes the metric within budget and does not require a target-kind declaration.', min_length=1, ), + NoExplicitNull, ] = None @@ -2263,6 +2352,7 @@ class Features(AdCPBaseModel): deprecated=True, description='DEPRECATED. Legacy wire flag for structured signal_ref references to provider-published signal definitions. New agents SHOULD omit this flag; callers MUST NOT require it before using signal_ref with the Signals protocol.', ), + NoExplicitNull, ] = None @@ -2274,6 +2364,7 @@ class Signals(AdCPBaseModel): Field( description='Whether this agent accepts get_signals discovery without caller credentials in brief or wholesale mode. true means an anonymous discovery request can produce a successful response, but the response may be a public subset and may differ from results for an authenticated principal or selected account. false means get_signals requires an authenticated principal. Absence means unspecified legacy behavior, so callers probe and handle AUTH_MISSING. A valid authenticated request is never rejected merely because credentials were supplied.' ), + NoExplicitNull, ] = None data_provider_domains: Annotated[ list[DataProviderDomain] | None, @@ -2281,6 +2372,7 @@ class Signals(AdCPBaseModel): description="Data provider domains this signals agent is authorized to resell. Buyers should fetch each data provider's adagents.json for published signal definitions and to verify authorization.", min_length=1, ), + NoExplicitNull, ] = None discovery_modes: Annotated[ list[DiscoveryMode] | None, @@ -2288,9 +2380,10 @@ class Signals(AdCPBaseModel): description="Discovery modes this signals agent supports on get_signals. 'brief' (default — every signals agent supports this): semantic discovery driven by signal_spec or signal_refs, with deprecated signal_ids accepted for older clients. 'wholesale': raw wholesale signals feed enumeration — caller omits signal_spec, signal_refs, and signal_ids and the agent returns its full priced signals feed, paginated, scoped by filters/account/destinations/countries. Agents that do not declare 'wholesale' MAY return INVALID_REQUEST for wholesale calls. Absent declaration is treated as ['brief'].", min_length=1, ), + NoExplicitNull, ] = [DiscoveryMode.brief] features: Annotated[ - Features | None, Field(description='Optional signals features supported') + Features | None, Field(description='Optional signals features supported'), NoExplicitNull ] = None @@ -2345,19 +2438,26 @@ class PropertyFeature(AdCPBaseModel): ), ] range: Annotated[ - Range | None, Field(description='For quantitative features, the valid range') + Range | None, + Field(description='For quantitative features, the valid range'), + NoExplicitNull, ] = None categories: Annotated[ - list[str] | None, Field(description='For categorical features, the valid values') + list[str] | None, + Field(description='For categorical features, the valid values'), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Human-readable description of what this feature measures') + str | None, + Field(description='Human-readable description of what this feature measures'), + NoExplicitNull, ] = None methodology_url: Annotated[ AnyUrl | None, Field( description='URL to documentation explaining how this feature is calculated or measured. Helps buyers understand and compare methodologies across vendors.' ), + NoExplicitNull, ] = None @@ -2375,19 +2475,26 @@ class CreativeFeature(AdCPBaseModel): ), ] range: Annotated[ - Range | None, Field(description='For quantitative features, the valid range') + Range | None, + Field(description='For quantitative features, the valid range'), + NoExplicitNull, ] = None categories: Annotated[ - list[str] | None, Field(description='For categorical features, the valid values') + list[str] | None, + Field(description='For categorical features, the valid values'), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Human-readable description of what this feature measures') + str | None, + Field(description='Human-readable description of what this feature measures'), + NoExplicitNull, ] = None methodology_url: Annotated[ AnyUrl | None, Field( description='URL to documentation explaining how this feature is calculated or measured.' ), + NoExplicitNull, ] = None @@ -2397,6 +2504,7 @@ class Governance(AdCPBaseModel): Field( description='Signal-activation policy for the portable attestation support declared in adcp.attestations. Presence means check_governance accepts runtime_attestations[] for purchase_type signal_activation. The claim_types list MUST be a subset of adcp.attestations.accepted_claim_types; issuer, resolver, verifier, proof-format, and delivery allowlists remain authoritative in the shared adcp.attestations block rather than being duplicated here.' ), + NoExplicitNull, ] = None aggregation_window_days: Annotated[ SchemaInt | None, @@ -2405,18 +2513,21 @@ class Governance(AdCPBaseModel): ge=1, le=365, ), + NoExplicitNull, ] = None property_features: Annotated[ list[PropertyFeature] | None, Field( description='Property features this governance agent can evaluate. Each feature describes a score, rating, or certification the agent can provide for properties.' ), + NoExplicitNull, ] = None creative_features: Annotated[ list[CreativeFeature] | None, Field( description='Creative features this governance agent can evaluate. Each feature describes a score, rating, or assessment the agent can provide for creatives (e.g., security scanning, creative quality, content categorization).' ), + NoExplicitNull, ] = None @@ -2442,7 +2553,9 @@ class Endpoint(AdCPBaseModel): ), ] preferred: Annotated[ - Type9 | None, Field(description='Preferred transport when host supports multiple') + Type9 | None, + Field(description='Preferred transport when host supports multiple'), + NoExplicitNull, ] = None @@ -2451,9 +2564,11 @@ class Voice(AdCPBaseModel): extra='allow', ) provider: Annotated[ - str | None, Field(description='TTS provider (elevenlabs, openai, etc.)') + str | None, Field(description='TTS provider (elevenlabs, openai, etc.)'), NoExplicitNull ] = None - voice_id: Annotated[str | None, Field(description='Brand voice identifier')] = None + voice_id: Annotated[str | None, Field(description='Brand voice identifier'), NoExplicitNull] = ( + None + ) class Video(AdCPBaseModel): @@ -2461,10 +2576,12 @@ class Video(AdCPBaseModel): extra='allow', ) formats: Annotated[ - list[str] | None, Field(description='Supported video formats (mp4, webm, etc.)') + list[str] | None, + Field(description='Supported video formats (mp4, webm, etc.)'), + NoExplicitNull, ] = None max_duration_seconds: Annotated[ - SchemaInt | None, Field(description='Maximum video duration') + SchemaInt | None, Field(description='Maximum video duration'), NoExplicitNull ] = None @@ -2473,9 +2590,13 @@ class Avatar(AdCPBaseModel): extra='allow', ) provider: Annotated[ - str | None, Field(description='Avatar provider (d-id, heygen, synthesia, etc.)') + str | None, + Field(description='Avatar provider (d-id, heygen, synthesia, etc.)'), + NoExplicitNull, + ] = None + avatar_id: Annotated[ + str | None, Field(description='Brand avatar identifier'), NoExplicitNull ] = None - avatar_id: Annotated[str | None, Field(description='Brand avatar identifier')] = None class Modalities(AdCPBaseModel): @@ -2483,16 +2604,22 @@ class Modalities(AdCPBaseModel): extra='allow', ) conversational: Annotated[ - StrictBool | None, Field(description='Pure text exchange - the baseline modality') + StrictBool | None, + Field(description='Pure text exchange - the baseline modality'), + NoExplicitNull, ] = True voice: Annotated[ - StrictBool | Voice | None, Field(description='Audio-based interaction using brand voice') + StrictBool | Voice | None, + Field(description='Audio-based interaction using brand voice'), + NoExplicitNull, ] = None video: Annotated[ - StrictBool | Video | None, Field(description='Brand video content playback') + StrictBool | Video | None, Field(description='Brand video content playback'), NoExplicitNull ] = None avatar: Annotated[ - StrictBool | Avatar | None, Field(description='Animated video presence with brand avatar') + StrictBool | Avatar | None, + Field(description='Animated video presence with brand avatar'), + NoExplicitNull, ] = None @@ -2512,10 +2639,12 @@ class Components(AdCPBaseModel): standard: Annotated[ list[StandardEnum] | None, Field(description='Standard components that all SI hosts must render'), + NoExplicitNull, ] = None extensions: Annotated[ dict[str, Any] | None, Field(description='Platform-specific extensions (chatgpt_apps_sdk, maps, forms, etc.)'), + NoExplicitNull, ] = None @@ -2526,6 +2655,7 @@ class Commerce(AdCPBaseModel): acp_checkout: Annotated[ StrictBool | None, Field(description='Supports ACP (Agentic Commerce Protocol) checkout handoff'), + NoExplicitNull, ] = None @@ -2534,11 +2664,12 @@ class A2ui(AdCPBaseModel): extra='allow', ) supported: Annotated[ - StrictBool | None, Field(description='Supports A2UI surface rendering') + StrictBool | None, Field(description='Supports A2UI surface rendering'), NoExplicitNull ] = False catalogs: Annotated[ list[str] | None, Field(description="Supported A2UI component catalogs (e.g., 'si-standard', 'standard')"), + NoExplicitNull, ] = None @@ -2547,16 +2678,21 @@ class Capabilities(AdCPBaseModel): extra='allow', ) modalities: Annotated[ - Modalities | None, Field(description='Interaction modalities supported') + Modalities | None, Field(description='Interaction modalities supported'), NoExplicitNull + ] = None + components: Annotated[ + Components | None, Field(description='Visual components supported'), NoExplicitNull + ] = None + commerce: Annotated[ + Commerce | None, Field(description='Commerce capabilities'), NoExplicitNull + ] = None + a2ui: Annotated[ + A2ui | None, Field(description='A2UI (Agent-to-UI) capabilities'), NoExplicitNull ] = None - components: Annotated[Components | None, Field(description='Visual components supported')] = ( - None - ) - commerce: Annotated[Commerce | None, Field(description='Commerce capabilities')] = None - a2ui: Annotated[A2ui | None, Field(description='A2UI (Agent-to-UI) capabilities')] = None mcp_apps: Annotated[ StrictBool | None, Field(description='Supports MCP Apps for rendering A2UI surfaces in iframes'), + NoExplicitNull, ] = False @@ -2569,7 +2705,9 @@ class SponsoredIntelligence(AdCPBaseModel): ), ] brand_url: Annotated[ - AnyUrl | None, Field(description='URL to brand.json with colors, fonts, logos, tone') + AnyUrl | None, + Field(description='URL to brand.json with colors, fonts, logos, tone'), + NoExplicitNull, ] = None @@ -2605,23 +2743,29 @@ class Brand(AdCPBaseModel): Field( description='Supports get_rights and acquire_rights for rights discovery and clearance' ), + NoExplicitNull, ] = False right_types: Annotated[ - list[RightType] | None, Field(description='Types of rights available through this agent') + list[RightType] | None, + Field(description='Types of rights available through this agent'), + NoExplicitNull, ] = None available_uses: Annotated[ list[AvailableUs] | None, Field(description="Rights uses available across this agent's roster"), + NoExplicitNull, ] = None generation_providers: Annotated[ list[str] | None, Field(description='LLM/generation providers this agent can issue credentials for'), + NoExplicitNull, ] = None description: Annotated[ str | None, Field( description="Description of the agent's brand protocol capabilities", max_length=5000 ), + NoExplicitNull, ] = None @@ -2675,16 +2819,19 @@ class Multiplicity(AdCPBaseModel): Field( description='When true, build_creative accepts max_creatives (one distinct creative per catalog item).' ), + NoExplicitNull, ] = False max_creatives_limit: Annotated[ SchemaInt | None, Field(description='Ceiling on max_creatives. Omitted means no advertised ceiling.', ge=1), + NoExplicitNull, ] = None supports_signal_fanout: Annotated[ StrictBool | None, Field( description='Experimental (x-status: experimental) — agents setting this true MUST also list `creative.signal_fanout` in `experimental_features`; the surface MAY change between 3.x releases with notice (see docs/reference/experimental-status). When true, build_creative accepts signal_conditions[] (one distinct creative group per signal condition, keep-all). Mirrors supports_catalog_fanout.' ), + NoExplicitNull, ] = False max_signal_conditions_limit: Annotated[ SchemaInt | None, @@ -2692,26 +2839,31 @@ class Multiplicity(AdCPBaseModel): description='Ceiling on len(signal_conditions). Over-limit requests are CLAMPED (not rejected), like max_creatives_limit. Omitted means no advertised ceiling.', ge=1, ), + NoExplicitNull, ] = None supports_variants: Annotated[ StrictBool | None, Field( description='When true, build_creative accepts max_variants > 1 / variant_axis (alternatives per creative).' ), + NoExplicitNull, ] = False max_variants_limit: Annotated[ SchemaInt | None, Field(description='Ceiling on max_variants. Omitted means no advertised ceiling.', ge=1), + NoExplicitNull, ] = None variant_dimensions: Annotated[ list[VariantDimension] | None, Field(description='Which variant_axis.dimension values this agent supports.'), + NoExplicitNull, ] = None selection_strategies: Annotated[ list[SelectionStrategy] | None, Field( description='Which selection_strategy values this agent supports when sampling max_creatives < items_total. Sibling to variant_dimensions. Part of the experimental signal-fanout surface (feature id `creative.signal_fanout`).' ), + NoExplicitNull, ] = None @@ -2770,6 +2922,7 @@ class V1FormatRefItem(AdCPBaseModel): description='Width in pixels for visual formats. When specified, height must also be specified. Both fields together create a parameterized format ID for dimension-specific variants.', ge=1, ), + NoExplicitNull, ] = None height: Annotated[ SchemaInt | None, @@ -2777,6 +2930,7 @@ class V1FormatRefItem(AdCPBaseModel): description='Height in pixels for visual formats. When specified, width must also be specified. Both fields together create a parameterized format ID for dimension-specific variants.', ge=1, ), + NoExplicitNull, ] = None duration_ms: Annotated[ StrictFloat | None, @@ -2784,6 +2938,7 @@ class V1FormatRefItem(AdCPBaseModel): description='Duration in milliseconds for time-based formats (video, audio). When specified, creates a parameterized format ID. Omit to reference a template format without parameters.', ge=1.0, ), + NoExplicitNull, ] = None pixel_ratio: Annotated[ StrictFloat | None, @@ -2791,6 +2946,7 @@ class V1FormatRefItem(AdCPBaseModel): description='Required intrinsic-pixel density for a parameterized visual format, expressed as intrinsic pixels per logical pixel. Requires `width` and `height`. Example: `{id: "display_image", width: 300, height: 250, pixel_ratio: 2}` identifies a 300×250 logical render supplied by a 600×500 image. Omit for the backward-compatible 1x variant.', gt=0.0, ), + NoExplicitNull, ] = None @@ -2886,6 +3042,7 @@ class Localization(AdCPBaseModel): ge=0, le=50, ), + NoExplicitNull, ] = None locale_matching: Annotated[ Literal['rfc4647_lookup'], @@ -2952,6 +3109,7 @@ class RequestSigning(AdCPBaseModel): Field( description="Policy for content-digest coverage in request signatures. In AdCP 3.2 and later, an agent with request_signing.supported=true MUST explicitly emit 'required': every accepted signature on a request with a body covers content-digest, and a body-unbound signature is rejected with request_signature_components_incomplete. Omission retains the legacy effective default of 'either' only for a 3.0/3.1 response; 3.2 responses require this field explicitly. 'either' and 'forbidden' are deprecated legacy 3.0/3.1 postures retained only for version negotiation with pre-3.2 peers; they MUST NOT be advertised as a 3.2 signing posture and are removed in 4.0. A shared endpoint MUST select the verifier policy from trusted endpoint configuration and negotiated capabilities before dispatch, never from an unbound request-body version field." ), + NoExplicitNull, ] = None required_for: Annotated[ list[Annotated[str, Field(pattern='^[a-z][a-z0-9_]*$')]] | None, @@ -2959,6 +3117,7 @@ class RequestSigning(AdCPBaseModel): description="AdCP protocol operation names (e.g., 'create_media_buy') for which this agent rejects an unsigned request with request_signature_required unless an independently valid configured fallback authenticator succeeds. A transport-specific rename of an operation is not a valid entry (MCP tools and the AdCP A2A profile use the operation name verbatim, so those names coincide with it) — verifiers MUST NOT accept operation names that are not defined by the AdCP protocol spec. Over MCP the verifier matches the tools/call params.name; over A2A it matches the skill of the sole DataPart of the message (SendMessage, SendStreamingMessage, message/send, message/stream, and the HTTP+JSON and gRPC equivalents). The A2A profile uses the AdCP operation name verbatim as skill, so that is the same name space. A request that does not resolve to exactly one operation is rejected with request_body_malformed; see docs/building/by-layer/L1/security.mdx#operation-resolution-over-a2a. JSON-RPC protocol method names like `tasks/cancel` belong in `protocol_methods_required_for`, not here. Empty in 3.0 by default; sellers populate selectively during per-counterparty pilots. In 4.0 this list MUST include all spend-committing operations the agent supports (create_media_buy, acquire_*, etc.). Every operation listed MUST also appear in `supported_for`; see `x-adcp-validation`.", validate_default=True, ), + NoExplicitNull, ] = [] warn_for: Annotated[ list[Annotated[str, Field(pattern='^[a-z][a-z0-9_]*$')]] | None, @@ -2966,12 +3125,14 @@ class RequestSigning(AdCPBaseModel): description='AdCP protocol operation names for shadow-mode verification. The verifier records missing signatures and well-formed signatures that fail verification or body binding, but MUST NOT establish verified-signer identity from a failed signature; processing continues only when an independent bearer, API-key, or mTLS authenticator succeeds. A partial or malformed Signature / Signature-Input pair always hard-rejects. Used as a bridge between supported_for and required_for. Precedence: required_for > warn_for > supported_for. An operation MUST NOT appear in both warn_for and required_for; see x-adcp-validation.', validate_default=True, ), + NoExplicitNull, ] = [] supported_for: Annotated[ list[Annotated[str, Field(pattern='^[a-z][a-z0-9_]*$')]] | None, Field( description='AdCP protocol operation names for which this agent verifies signatures when present but does not require them. Under the 3.2 profile, a presented signature on a body-bearing request without content-digest coverage rejects even though an unsigned request may use configured fallback authentication. Typically a superset of required_for and warn_for.' ), + NoExplicitNull, ] = None protocol_methods_supported_for: Annotated[ list[ProtocolMethodsSupportedForItem] | None, @@ -2979,6 +3140,7 @@ class RequestSigning(AdCPBaseModel): description="JSON-RPC protocol method names for which this agent verifies signatures when present. Values MUST use exact, case-sensitive equality after JSON decoding: slash-path names such as 'tasks/cancel' and 'tasks/pushNotificationConfig/set' for A2A 0.3, or PascalCase names such as 'CancelTask' and 'CreateTaskPushNotificationConfig' for A2A 1.0. A dual-stack agent lists each supported wire name independently; implementations MUST NOT translate or normalize between protocol versions. The reserved MCP envelope method 'tools/call' is forbidden because its AdCP operation identity is params.name and belongs in supported_for. Under the 3.2 profile, a presented signature on a body-bearing request without content-digest coverage rejects. Disjoint from supported_for, which carries lower_snake_case AdCP operation names only.", validate_default=True, ), + NoExplicitNull, ] = [] protocol_methods_warn_for: Annotated[ list[ProtocolMethodsWarnForItem] | None, @@ -2986,6 +3148,7 @@ class RequestSigning(AdCPBaseModel): description='Exact JSON-RPC protocol method names for shadow-mode verification, mirroring warn_for in the AdCP-operation namespace. Wire-name grammar and exact, case-sensitive matching semantics are identical to protocol_methods_supported_for. Missing signatures and well-formed signatures that fail verification or body binding are recorded but MUST NOT establish verified-signer identity; processing continues only when an independent authenticator succeeds. A partial or malformed Signature / Signature-Input pair always hard-rejects. An item MUST NOT appear in both protocol_methods_warn_for and protocol_methods_required_for; see x-adcp-validation.', validate_default=True, ), + NoExplicitNull, ] = [] protocol_methods_required_for: Annotated[ list[ProtocolMethodsRequiredForItem] | None, @@ -2993,6 +3156,7 @@ class RequestSigning(AdCPBaseModel): description='Exact JSON-RPC protocol method names for which this agent rejects an unsigned request with request_signature_required unless an independently valid configured fallback authenticator succeeds. Wire-name grammar and exact, case-sensitive matching semantics are identical to protocol_methods_supported_for. Separate namespace from required_for: this bucket binds against the JSON-RPC method field, not tools/call params.name. Every listed method MUST also appear in protocol_methods_supported_for; see x-adcp-validation.', validate_default=True, ), + NoExplicitNull, ] = [] @@ -3013,6 +3177,7 @@ class WebhookSigning(AdCPBaseModel): Field( description='Identifier of the webhook-signing profile version the agent emits. Value MUST match the `tag=` parameter emitted in the RFC 9421 `Signature-Input` header (see docs/building/implementation/webhooks.mdx) so receivers can statically validate the declared profile against the on-wire tag. Closed enum; future profile revisions will extend this enum in a follow-up schema bump.' ), + NoExplicitNull, ] = None algorithms: Annotated[ list[Algorithm] | None, @@ -3020,6 +3185,7 @@ class WebhookSigning(AdCPBaseModel): description="Signature algorithms this agent uses on outbound webhooks. 3.0 profile permits 'ed25519' and 'ecdsa-p256-sha256' only; other values are reserved for future profile versions and MUST NOT be emitted under adcp/webhook-signing/v1.", min_length=1, ), + NoExplicitNull, ] = None legacy_hmac_fallback: Annotated[ StrictBool | None, @@ -3027,6 +3193,7 @@ class WebhookSigning(AdCPBaseModel): deprecated=True, description='Whether this agent will fall back to HMAC-SHA256 on the legacy push_notification_config.authentication, accounts[].notification_configs[].authentication, sync_principal.configuration.notification_configs[].authentication, or sync_agent_notification_configs.notification_configs[].authentication paths for receivers that have not adopted RFC 9421. Deprecated; removed in AdCP 4.0.', ), + NoExplicitNull, ] = False delivery_retry_horizon_seconds: Annotated[ SchemaInt | None, @@ -3035,6 +3202,7 @@ class WebhookSigning(AdCPBaseModel): ge=86400, le=604800, ), + NoExplicitNull, ] = None @@ -3045,21 +3213,26 @@ class KeyOrigins(AdCPBaseModel): governance_signing: Annotated[ AnyUrl | None, Field(description='Origin (scheme + host) serving the governance-signing JWKS.'), + NoExplicitNull, ] = None request_signing: Annotated[ - AnyUrl | None, Field(description='Origin (scheme + host) serving the request-signing JWKS.') + AnyUrl | None, + Field(description='Origin (scheme + host) serving the request-signing JWKS.'), + NoExplicitNull, ] = None webhook_signing: Annotated[ AnyUrl | None, Field( description='Origin (scheme + host) serving the JWKS used for webhook delivery. Webhooks are signed with `adcp_use: "request-signing"` keys; the deprecated `adcp_use: "webhook-signing"` value remains accepted during the backward-compatibility window.' ), + NoExplicitNull, ] = None tmp_signing: Annotated[ AnyUrl | None, Field( description='Origin (scheme + host) serving the TMP-signing JWKS, when this operator participates in TMP.' ), + NoExplicitNull, ] = None @@ -3070,12 +3243,14 @@ class CompromiseNotification(AdCPBaseModel): emits: Annotated[ StrictBool | None, Field(description='Whether this agent emits `identity.compromise_notification` events.'), + NoExplicitNull, ] = False accepts: Annotated[ StrictBool | None, Field( description='Whether this agent subscribes to `identity.compromise_notification` events from counterparties it verifies signatures from.' ), + NoExplicitNull, ] = False @@ -3088,24 +3263,28 @@ class Identity(AdCPBaseModel): Field( description="HTTPS URL of the operator's brand.json (typically `https://{operator-domain}/.well-known/brand.json`). Trust-root pointer for this agent's signing keys. See [security.mdx §Discovering an agent's signing keys via `brand_json_url`](https://adcontextprotocol.org/docs/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url) for the verifier algorithm and `x-adcp-validation` for structured constraints. Distinct from `sponsored_intelligence.brand_url`, which is a rendering pointer for SI agent visuals — verifiers MUST use this field for key discovery and MUST NOT fall back to `sponsored_intelligence.brand_url` as a trust-root pointer." ), + NoExplicitNull, ] = None per_principal_key_isolation: Annotated[ StrictBool | None, Field( description="When true, this multi-principal operator scopes signing keys per-principal so a single principal's key compromise does not silently re-scope across principals served by the same operator. `kid` values remain opaque to verifiers per RFC 7517; any operator-side naming convention (e.g., `{operator}:{principal}:{key_version}`) is internal bookkeeping and MUST NOT be parsed by verifiers. See docs/building/understanding/security-model.mdx." ), + NoExplicitNull, ] = False key_origins: Annotated[ KeyOrigins | None, Field( description='Map of signing-key surface/purpose → publishing origin, so counterparties can verify origin separation (e.g., governance keys served from a separate origin than transport/webhook keys) at onboarding. Absent means the operator has not declared a separation scheme; receivers SHOULD assume shared-origin. Every entry listed MUST have a corresponding signing posture declared elsewhere — `request_signing` requires non-empty `request_signing.supported_for`/`required_for`/`protocol_methods_supported_for`/`protocol_methods_required_for`; `webhook_signing` requires `webhook_signing.supported === true` and names the webhook delivery surface, not a required live `adcp_use: "webhook-signing"` key purpose — otherwise the consistency check at signature-verification time has nothing to anchor against. See `x-adcp-validation` and docs/building/by-layer/L1/security.mdx §Origin separation.' ), + NoExplicitNull, ] = None compromise_notification: Annotated[ CompromiseNotification | None, Field( description='Whether this agent emits the `identity.compromise_notification` webhook event on key revocation due to known or suspected compromise (as opposed to scheduled rotation). Subscribers use this to bound the window between compromise detected and verifiers converging on revocation. See docs/building/implementation/webhooks.mdx §identity.compromise_notification.' ), + NoExplicitNull, ] = None @@ -3143,18 +3322,21 @@ class Accreditation(AdCPBaseModel): Field( description="Optional identifier for the certification in the accrediting body's records (when one exists; many bodies do not issue stable IDs)." ), + NoExplicitNull, ] = None valid_until: Annotated[ date | None, Field( description="Optional ISO 8601 date when the current accreditation expires. Buyers MAY treat post-expiry data as un-accredited. Absence means the vendor does not assert an expiry — buyers SHOULD verify currency at the accrediting body's directory." ), + NoExplicitNull, ] = None evidence_url: Annotated[ AnyUrl | None, Field( description="Optional URL pointing at the accrediting body's public listing for this certification (the buyer's path to verify the claim independently)." ), + NoExplicitNull, ] = None @@ -3184,12 +3366,14 @@ class Metric(AdCPBaseModel): Field( description='Optional URI pointing at the published standard this metric IMPLEMENTS (e.g., IAB Attention Measurement Guidelines, MRC Viewable Impression Measurement, GARM emissions framework). Distinct from `accreditations[]` — `standard_reference` is what the metric is built against; `accreditations[]` is third-party certification that the implementation actually conforms. Buyer agents normalizing across vendors SHOULD apply the AdCP URL canonicalization rules before comparing — vendors implementing the same standard MAY use different URL forms for the same canonical document.' ), + NoExplicitNull, ] = None accreditations: Annotated[ list[Accreditation] | None, Field( description="Third-party accreditations this metric holds (MRC, ARF, JIC, ABC, BARB, AGOF, etc.). Distinct from `standard_reference`: a metric can implement a standard without being independently accredited. Buyers asking 'is this MRC-accredited?' SHOULD check this array, not just `standard_reference`. Each entry names the accrediting body and optionally pins a certification ID, validity date, and evidence URL." ), + NoExplicitNull, ] = None unit: Annotated[ str | None, @@ -3197,18 +3381,21 @@ class Metric(AdCPBaseModel): description='Unit of the metric value when reported via `vendor_metric_values.value` (e.g., `score`, `seconds`, `persons`, `gCO2e`, `lift_percent`, `USD`). Buyers SHOULD render the unit alongside the value rather than computing units locally; sellers populating `vendor_metric_values.unit` MUST match this declaration when present.', examples=['score', 'seconds', 'persons', 'gCO2e', 'lift_percent', 'index', 'USD'], ), + NoExplicitNull, ] = None description: Annotated[ str | None, Field( description='Human-readable description of what this metric measures and any relevant methodology notes. Surfaced in buyer-agent UX when explaining the metric to humans.' ), + NoExplicitNull, ] = None methodology_url: Annotated[ AnyUrl | None, Field( description="URL to the vendor's full methodology documentation for this metric. Buyers SHOULD link or fetch this when human review of the methodology is in scope (compliance, RFP review, accreditation audit). Field name mirrors `governance.property_features[].methodology_url`." ), + NoExplicitNull, ] = None methodology_version: Annotated[ str | None, @@ -3216,6 +3403,7 @@ class Metric(AdCPBaseModel): description='Optional version identifier (semver, ISO date, or vendor-defined version string) for the methodology this metric currently implements. When present, buyer agents pin the contracted version via `committed_metrics[].methodology_version` (vendor scope) so silent vendor methodology changes are detectable; absence means the vendor does not version their methodology and buyers MUST treat any change as untracked.', examples=['v2.1', '2026-Q1', '1.0'], ), + NoExplicitNull, ] = None ext: Annotated[ dict[str, Any] | None, @@ -3223,6 +3411,7 @@ class Metric(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -3232,6 +3421,7 @@ class Measurement(AdCPBaseModel): Field( description="Whether this measurement agent produces compact provide_performance_feedback assertions for a buyer-controlled orchestrator gateway. In the first experimental tier the provider reads delivery through the gateway's get_media_buy_delivery task and returns assertions through its provide_performance_feedback task. The gateway decides what to forward to each seller." ), + NoExplicitNull, ] = False metrics: Annotated[ list[Metric], @@ -3323,12 +3513,14 @@ class WholesaleFeedVersioning(AdCPBaseModel): Field( description="Whether the agent tracks pricing_version independently of wholesale_feed_version. When true, the agent returns both tokens and honors if_pricing_version separately — useful for rate-card sweeps that don't change product and signal metadata. When false or absent, the agent collapses both into wholesale_feed_version; callers SHOULD NOT send if_pricing_version (it will be ignored and may produce INVALID_REQUEST when sent without if_wholesale_feed_version per the dependencies rule)." ), + NoExplicitNull, ] = None cache_scope_account: Annotated[ StrictBool | None, Field( description="Whether the agent ever returns cache_scope: 'account' (i.e., publishes per-account overlays distinct from the public rate card). When true, buyers MUST be prepared to maintain account-overlay caches alongside the public layer. When false or absent, all responses are cache_scope: 'public' regardless of whether account was provided — the agent's rate card is universal. Confidentiality note: declaring true advertises that the agent runs custom-pricing deals (low-grade market-posture signal); agents preferring not to disclose this MAY omit the field and let consumers detect-on-call via cache_scope on response." ), + NoExplicitNull, ] = None @@ -3354,14 +3546,18 @@ class Error(AdCPBaseModel): Field( description='Optional buyer-actionable classification of the failure. Use this when the enclosing error code or message is too coarse or contains producer-internal context that must not cross the buyer trust boundary. `code` reuses the comprehensive standard vocabulary and recovery classifications published in `enums/error-code.json`, including policy, governance, account, commercial, inventory, and creative failures. The wire field remains open for forward compatibility. `message` MUST be safe to show to the buyer and MUST NOT contain vendor identifiers, ad-server type names, internal object names, internal IDs, stack traces, or other producer-private implementation details. When present, the enclosing `error.recovery` MUST classify the buyer-actionable reason. If both the enclosing code and buyer reason are registered, their standard recovery classifications MUST agree with each other and with `error.recovery`; sellers MUST choose the closest compatible enclosing code rather than retain a conflicting upstream transport wrapper. One buyer_reason classifies one error object. If a rejection contains independently actionable failures from different classes, sellers MUST emit separate error objects or omit buyer_reason rather than select a misleading single class.' ), + NoExplicitNull, ] = None field: Annotated[ str | None, Field( description="Field path associated with the error in JSONPath-lite format (e.g., 'packages[0].targeting'). When `issues[]` is also present, sellers MUST set this to `issues[0].pointer` translated from RFC 6901 to JSONPath-lite (e.g., '/packages/0/targeting' → 'packages[0].targeting') so pre-3.1 consumers reading `field` only get deterministic behavior. Will be deprecated in a future major version in favor of `issues[].pointer`." ), + NoExplicitNull, + ] = None + suggestion: Annotated[ + str | None, Field(description='Suggested fix for the error'), NoExplicitNull ] = None - suggestion: Annotated[str | None, Field(description='Suggested fix for the error')] = None retry_after: Annotated[ StrictFloat | None, Field( @@ -3369,36 +3565,42 @@ class Error(AdCPBaseModel): ge=1.0, le=3600.0, ), + NoExplicitNull, ] = None issues: Annotated[ list[Issue2] | None, Field( description='Structured list of validation failures. Primary use is `VALIDATION_ERROR`, where multi-field rejections are common and `field` (singular) cannot carry the full pointer map. MAY appear on other error codes that reject multiple fields at once. When `issues` is present, sellers MUST also populate `field` from `issues[0]` for backward compatibility with pre-3.1 consumers that read `field` only — translating the RFC 6901 `pointer` format to the JSONPath-lite format `field` uses (e.g., `/packages/0/targeting` → `packages[0].targeting`). MUST (not SHOULD) so consumers reading `field` get deterministic behavior across sellers — the cost is one line of dual-write per seller; the cost of SHOULD is a long tail of seller-A-vs-seller-B inconsistency. Future major versions will deprecate `field` in favor of `issues[].pointer`.' ), + NoExplicitNull, ] = None details: Annotated[ dict[str, Any] | None, Field( description='Additional task-specific error details. Sellers MAY mirror `issues[]` here as `details.issues` for backward compatibility with pre-3.1 consumers reading from `details`; new consumers SHOULD prefer the top-level `issues` field.\n\n**Canonical rejection-set shape (3.1+).** When the error reports a rejected value against a closed set of accepted values (e.g., enum mismatch, unsupported pricing option, invalid signal id), sellers SHOULD use the canonical key `accepted_values: ` under `details` rather than seller-specific variants observed in the wild (`available`, `allowed`, `accepted_values` at the error root, etc.). The canonical shape:\n\n```json\n{\n "code": "INVALID_PRICING_OPTION",\n "message": "Pricing option not found: po_prism_abandoner_cpm",\n "field": "pricing_option_id",\n "details": {\n "rejected_value": "po_prism_abandoner_cpm",\n "accepted_values": ["po_prism_cart_cpm", "po_prism_view_cpm"]\n }\n}\n```\n\n- `rejected_value` (optional): the offending value the buyer supplied, echoed for buyer-side diagnostic clarity (especially when the offending field is nested or transformed before validation).\n- `accepted_values` (optional): the closed set the seller would have accepted at this field on this call. Sellers MUST NOT enumerate the full ecosystem-wide accepted set if it differs from what\'s accepted for *this caller in this context* (account, brand, scope) — leaking ecosystem-wide accepted sets to a per-caller rejection turns the error into an enumeration oracle.\n\nThis is **SHOULD-level guidance**, not MUST: `details` remains `additionalProperties: true` and pre-3.1 sellers using `available` / `allowed` / `accepted_values` at the error root remain conformant. The canonical shape lets buyer-side diagnostic tooling (SDK runner hints, dashboards, error classifiers) reliably surface the accepted-set without per-seller pattern matching. SDKs SHOULD accept any of the legacy variants and normalize on read; the canonical shape is what new sellers and 3.1+ adopters should emit going forward.' ), + NoExplicitNull, ] = None recovery: Annotated[ Recovery | None, Field( description='Agent recovery classification. transient: retry after delay (rate limit, service unavailable, timeout). correctable: fix the request and resend (invalid field, budget too low, creative rejected). terminal: requires human action (account suspended, payment required, account not found). AdCP 3.2 producers MUST populate `recovery` on every error; 3.1 producers SHOULD populate it. The shared 3.x schema intentionally does not add `recovery` to `required` so retained and live errors from earlier 3.x producers remain decodable. When `buyer_reason` is present, `recovery` is required and MUST classify that buyer-actionable reason. If the enclosing code and buyer reason are both registered, their standard recovery classifications MUST agree with each other and with this field. A receiver that does not recognize `error.code` (a newer code, or a platform-specific code) MUST still be able to classify the error from `recovery`. When a legacy error omits `recovery`, receivers use the registered classification for a known code and fall back to `transient` for an unknown code, subject to the bounded retry budget. The `enumMetadata.recovery` block in `enums/error-code.json` is the documentary mirror for known top-level and buyer-reason codes; `error.recovery` on the wire is authoritative when present.' ), + NoExplicitNull, ] = None source: Annotated[ Source | None, Field( description='Who emitted this error entry. `producer` (default when absent): emitted by the response\'s authoring agent (the seller for `get_products`, the creative agent for `build_creative`, etc.). `sdk`: augmented by a consuming SDK that detected a non-fatal advisory condition on consumption (e.g., `FORMAT_PROJECTION_FAILED` when the buyer SDK couldn\'t project a v1 format to a canonical, or `FORMAT_DECLARATION_DIVERGENT` when the SDK detected a producer bug on read). SDK-augmented entries SHOULD also set `sdk_id` so downstream consumers can identify which intermediate processor inserted the entry.\n\n**Multi-hop propagation (normative).** AdCP is a federated agent network — responses commonly traverse multiple SDKs (e.g., sales agent → interchange → DSP → buyer). When an SDK augments `errors[]` with a consumption-detected entry, the augmented response carries the entry forward to subsequent hops. Each hop that detects the same condition independently SHOULD deduplicate by `(code, field)` rather than re-emit; the existing entry\'s `sdk_id` identifies which earlier processor saw it first. Producer entries (those without `source: "sdk"`) are authoritative for what the response\'s authoring agent self-detected; SDK entries are observations made on top.\n\n**Replay/audit safety.** Persisted or replayed responses carry `source` and `sdk_id` so the audit trail can distinguish seller-emitted entries from SDK-augmented ones. Without `source`, a downstream consumer can\'t tell whether a code came from the seller or an intermediate SDK, which corrupts attribution.' ), + NoExplicitNull, ] = None sdk_id: Annotated[ str | None, Field( description='Optional identifier for the SDK that augmented this error entry. Format: `@` (e.g., `@adcontextprotocol/adcp@7.3.0`, `adcontextprotocol-adcp-python@1.2.0`). MUST be set when `source: "sdk"`; MUST be absent when `source: "producer"` or absent. Lets downstream consumers identify which intermediate processor inserted the entry, useful for debugging cross-SDK divergence (e.g., one SDK detects a projection failure that another SDK\'s registry version doesn\'t).' ), + NoExplicitNull, ] = None @@ -3430,6 +3632,7 @@ class WholesaleFeedWebhooks(AdCPBaseModel): description='Wholesale feed webhook event types this agent can emit. Sales agents emit product.* events and MUST expose list_products or the deprecated 3.x wholesale get_products compatibility path. Signals agents emit signal.* events and MUST support wholesale get_signals. wholesale_feed.bulk_change requires at least one corresponding repair path.', min_length=1, ), + NoExplicitNull, ] = None @@ -3572,15 +3775,16 @@ class MaxImpressionsConstraints(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - minimum: Annotated[SchemaInt | None, Field(ge=1)] = None - maximum: Annotated[SchemaInt | None, Field(ge=1)] = None - allowed_values: Annotated[list[AllowedValue] | None, Field(min_length=1)] = None + minimum: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + maximum: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + allowed_values: Annotated[list[AllowedValue] | None, Field(min_length=1), NoExplicitNull] = None ext: Annotated[ dict[str, Any] | None, Field( description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -3665,12 +3869,14 @@ class AiTool(AdCPBaseModel): Field( description="Version identifier for the AI tool or model (e.g., '25.1', '0125', '2.1'). For generative models, use the model version rather than the API version." ), + NoExplicitNull, ] = None provider: Annotated[ str | None, Field( description="Organization that provides the AI tool (e.g., 'OpenAI', 'Stability AI', 'Google')" ), + NoExplicitNull, ] = None @@ -3694,6 +3900,7 @@ class DeclaredBy(AdCPBaseModel): agent_url: Annotated[ AnyUrl | None, Field(description='URL of the agent or service that declared this provenance'), + NoExplicitNull, ] = None role: Annotated[Role, Field(description='Role of the declaring party in the supply chain')] @@ -3724,6 +3931,7 @@ class VerifyAgent(AdCPBaseModel): Field( description="Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `encypher.markers_present_v2`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." ), + NoExplicitNull, ] = None @@ -3740,6 +3948,7 @@ class EmbeddedProvenanceItem(AdCPBaseModel): Field( description="Standard the embedding conforms to, if any (e.g., 'c2pa' for C2PA Section A.7 text manifest embedding)" ), + NoExplicitNull, ] = None provider: Annotated[ str, @@ -3752,9 +3961,12 @@ class EmbeddedProvenanceItem(AdCPBaseModel): Field( description="Buyer's representation that this embedding can be verified by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist). MAY be omitted for self-verifiable embeddings (e.g., a C2PA text manifest with a public key the seller already trusts)." ), + NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, Field(description='When the provenance data was embedded (ISO 8601)') + AwareDatetime | None, + Field(description='When the provenance data was embedded (ISO 8601)'), + NoExplicitNull, ] = None @@ -3780,6 +3992,7 @@ class VerifyAgent4(AdCPBaseModel): Field( description="Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `imatag.watermark_detected`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." ), + NoExplicitNull, ] = None @@ -3806,6 +4019,7 @@ class Watermark(AdCPBaseModel): Field( description="Buyer's representation that this watermark can be detected by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist)." ), + NoExplicitNull, ] = None c2pa_action: Annotated[ C2paAction | None, @@ -3813,9 +4027,12 @@ class Watermark(AdCPBaseModel): description='C2PA action classification for this watermark', title='C2PA Watermark Action', ), + NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, Field(description='When the watermark was applied (ISO 8601)') + AwareDatetime | None, + Field(description='When the watermark was applied (ISO 8601)'), + NoExplicitNull, ] = None @@ -3843,6 +4060,7 @@ class RenderGuidance(AdCPBaseModel): description='How long the disclosure must persist during content playback or display', title='Disclosure Persistence', ), + NoExplicitNull, ] = None min_duration_ms: Annotated[ SchemaInt | None, @@ -3850,6 +4068,7 @@ class RenderGuidance(AdCPBaseModel): description="Minimum display duration in milliseconds for initial persistence. Recommended when persistence is initial — without it, the duration is at the publisher's discretion. At serve time the publisher reads this from provenance since the brief is not available.", ge=1, ), + NoExplicitNull, ] = None positions: Annotated[ list[Position] | None, @@ -3857,6 +4076,7 @@ class RenderGuidance(AdCPBaseModel): description='Preferred disclosure positions in priority order. The first position a format supports should be used.', min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ dict[str, Any] | None, @@ -3864,6 +4084,7 @@ class RenderGuidance(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -3874,6 +4095,7 @@ class Jurisdiction(AdCPBaseModel): region: Annotated[ str | None, Field(description="Sub-national region code (e.g., 'CA' for California, 'BY' for Bavaria)"), + NoExplicitNull, ] = None regulation: Annotated[ str, @@ -3886,12 +4108,14 @@ class Jurisdiction(AdCPBaseModel): Field( description='Required disclosure label text for this jurisdiction, in the local language' ), + NoExplicitNull, ] = None render_guidance: Annotated[ RenderGuidance | None, Field( description="How the disclosure should be rendered for this jurisdiction. Expresses the declaring party's intent for persistence and position based on regulatory requirements. Publishers control actual rendering but governance agents can audit whether guidance was followed." ), + NoExplicitNull, ] = None @@ -3905,6 +4129,7 @@ class Disclosure(AdCPBaseModel): jurisdictions: Annotated[ list[Jurisdiction] | None, Field(description='Jurisdictions where disclosure obligations apply', min_length=1), + NoExplicitNull, ] = None @@ -3923,7 +4148,9 @@ class VerificationItem(AdCPBaseModel): ), ] verified_time: Annotated[ - AwareDatetime | None, Field(description='When the verification was performed (ISO 8601)') + AwareDatetime | None, + Field(description='When the verification was performed (ISO 8601)'), + NoExplicitNull, ] = None result: Annotated[Result, Field(description='Verification outcome')] confidence: Annotated[ @@ -3931,9 +4158,10 @@ class VerificationItem(AdCPBaseModel): Field( description='Confidence score of the verification result (0.0 to 1.0)', ge=0.0, le=1.0 ), + NoExplicitNull, ] = None details_url: Annotated[ - AnyUrl | None, Field(description='URL to the full verification report') + AnyUrl | None, Field(description='URL to the full verification report'), NoExplicitNull ] = None @@ -3944,46 +4172,54 @@ class Provenance(AdCPBaseModel): description='IPTC-aligned classification of AI involvement in producing this content', title='Digital Source Type', ), + NoExplicitNull, ] = None synthetic_depiction: Annotated[ StrictBool | None, Field( description='Assessed declaration of whether the content synthetically depicts a real or fictional person performing or appearing in a way that was generated or materially manipulated rather than captured as depicted. `true` covers both a fully synthetic performer and material manipulation of a real performer; `false` is an assessed declaration that the content does not contain such a depiction. Absence means the content has not been assessed for synthetic depiction. This field does not claim consent, legality, or independent verification, and receivers MUST NOT derive it solely from `digital_source_type`.' ), + NoExplicitNull, ] = None ai_tool: Annotated[ AiTool | None, Field( description='AI system used to generate or modify this content. Aligns with IPTC 2025.1 AI metadata fields and C2PA claim_generator.' ), + NoExplicitNull, ] = None human_oversight: Annotated[ HumanOversight | None, Field( description='Level of human involvement in the AI-assisted creation process. Independent of `disclosure.required` — the protocol does not derive disclosure obligations from oversight level. Some regulations include carve-outs for human-edited or human-directed AI output, but those carve-outs have factual prerequisites the schema cannot evaluate. Asserting `edited` or `directed` does not by itself justify `disclosure.required: false`.' ), + NoExplicitNull, ] = None declared_by: Annotated[ DeclaredBy | None, Field( description='Party declaring this provenance. Identifies who attached the provenance claim, enabling receiving parties to assess trust.' ), + NoExplicitNull, ] = None declared_at: Annotated[ AwareDatetime | None, Field( description='When this provenance claim was made (ISO 8601). Distinct from created_time, which records when the content itself was produced. A provenance claim may be attached well after content creation, for example when retroactively declaring AI involvement for regulatory compliance.' ), + NoExplicitNull, ] = None created_time: Annotated[ AwareDatetime | None, Field(description='When this content was created or generated (ISO 8601)'), + NoExplicitNull, ] = None c2pa: Annotated[ C2pa | None, Field( description='C2PA sidecar manifest reference. Links to a detached cryptographic provenance manifest for this content. Note: file-level C2PA bindings break when ad servers transcode, resize, or re-encode assets. For pipelines with intermediaries, consider embedded_provenance as the primary provenance mechanism.' ), + NoExplicitNull, ] = None embedded_provenance: Annotated[ list[EmbeddedProvenanceItem] | None, @@ -3991,6 +4227,7 @@ class Provenance(AdCPBaseModel): description='Provenance metadata embedded within the content stream. Each entry declares one embedding layer: structured provenance data carried inside the content itself, as distinct from sidecar references (c2pa.manifest_url). Embedded provenance survives operations that break sidecar and file-level bindings: ad-server transcoding, CMS ingestion, copy-paste, reformatting, and CDN re-encoding. For ad-tech pipelines where content passes through multiple intermediaries, embedded provenance is the reliable path for provenance that persists from declaration through delivery. This is a declaration by the embedding party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).', min_length=1, ), + NoExplicitNull, ] = None watermarks: Annotated[ list[Watermark] | None, @@ -3998,12 +4235,14 @@ class Provenance(AdCPBaseModel): description='Content watermarks applied to this asset. Each entry declares one watermarking layer: a content modification that encodes an identifier or fingerprint within the asset. Watermarks differ from embedded provenance: a watermark encodes an identifier (who generated it, who owns it), while embedded provenance carries or references a structured provenance record (the full chain of custody). A single asset may carry both. Aligns with C2PA action taxonomy: c2pa.watermarked.bound (watermark linked to a C2PA manifest) and c2pa.watermarked.unbound (watermark independent of any manifest). This is a declaration by the watermarking party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).', min_length=1, ), + NoExplicitNull, ] = None disclosure: Annotated[ Disclosure | None, Field( description='Regulatory disclosure requirements for this content. Indicates whether AI disclosure is required and under which jurisdictions.' ), + NoExplicitNull, ] = None verification: Annotated[ list[VerificationItem] | None, @@ -4011,6 +4250,7 @@ class Provenance(AdCPBaseModel): description='Third-party verification or detection results for this content. Multiple services may independently evaluate the same content. Provenance is a claim — verification results attached by the declaring party are supplementary. The enforcing party (e.g., seller/publisher) should run its own verification via get_creative_features or calibrate_content.', min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ dict[str, Any] | None, @@ -4018,6 +4258,7 @@ class Provenance(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -4145,6 +4386,7 @@ class Logo(AdCPBaseModel): description='Image file size in bytes. Required by agents that advertise a max_file_size_kb constraint.', ge=1, ), + NoExplicitNull, ] = None pixel_ratio: Annotated[ StrictFloat | None, @@ -4152,18 +4394,21 @@ class Logo(AdCPBaseModel): description='Intrinsic pixels per logical render pixel (for example `2` for a 600×500 image intended to render at 300×250). Optional because a validator can infer the ratio when the target format declares logical dimensions. When supplied, it MUST agree with both `width / logical_width` and `height / logical_height`; it is never a substitute for the intrinsic `width` and `height` fields.', gt=0.0, ), + NoExplicitNull, ] = None state_id: Annotated[ str | None, Field( description='Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one declared `states[].state_id` (semantic validators resolve it). Omit for ordinary image slots.' ), + NoExplicitNull, ] = None breakpoint_id: Annotated[ str | None, Field( description='Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one breakpoint declared on the selected state (semantic validators resolve it). Omit for ordinary image slots.' ), + NoExplicitNull, ] = None focal_point: Annotated[ list[FocalPointItem] | None, @@ -4172,16 +4417,22 @@ class Logo(AdCPBaseModel): max_length=2, min_length=2, ), + NoExplicitNull, ] = None format: Annotated[ - str | None, Field(description='Image file format (jpg, png, gif, webp, etc.)') + str | None, + Field(description='Image file format (jpg, png, gif, webp, etc.)'), + NoExplicitNull, + ] = None + alt_text: Annotated[ + str | None, Field(description='Alternative text for accessibility'), NoExplicitNull ] = None - alt_text: Annotated[str | None, Field(description='Alternative text for accessibility')] = None provenance: Annotated[ Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None @@ -4189,19 +4440,20 @@ class BrandKitOverride(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - logo: Annotated[Logo | None, Field(description='Override logo asset.', title='Image Asset')] = ( - None - ) - colors: Annotated[Colors | None, Field(description='Override brand colors (hex strings).')] = ( - None - ) + logo: Annotated[ + Logo | None, Field(description='Override logo asset.', title='Image Asset'), NoExplicitNull + ] = None + colors: Annotated[ + Colors | None, Field(description='Override brand colors (hex strings).'), NoExplicitNull + ] = None voice: Annotated[ str | None, Field( description='Override brand-voice description for surface-composed text/audio output.' ), + NoExplicitNull, ] = None - tagline: Annotated[str | None, Field(description='Override tagline.')] = None + tagline: Annotated[str | None, Field(description='Override tagline.'), NoExplicitNull] = None class Brand1(AdCPBaseModel): @@ -4223,6 +4475,7 @@ class Brand1(AdCPBaseModel): pattern='^[a-z0-9_]+$', title='Brand ID', ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -4230,24 +4483,28 @@ class Brand1(AdCPBaseModel): description='Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting.', min_length=1, ), + NoExplicitNull, ] = None industries: Annotated[ list[str] | None, Field( description="Inline override for the brand's industries. Useful when the caller cannot modify the brand's canonical brand.json but needs to declare industries for governance (e.g., Annex III vertical detection). brand.json remains the canonical source; when omitted here, governance agents SHOULD resolve from brand.json." ), + NoExplicitNull, ] = None data_subject_contestation: Annotated[ DataSubjectContestation | None, Field( description="Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing." ), + NoExplicitNull, ] = None brand_kit_override: Annotated[ BrandKitOverride | None, Field( description="Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload." ), + NoExplicitNull, ] = None @@ -4277,6 +4534,7 @@ class Issuer(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -4306,6 +4564,7 @@ class AcceptedIssuer(AdCPBaseModel): description='Optional subset of accepted_claim_types this issuer may assert. Omit to allow any globally accepted claim type for this issuer.', min_length=1, ), + NoExplicitNull, ] = None proof_formats: Annotated[ list[AnyUrl] | None, @@ -4313,6 +4572,7 @@ class AcceptedIssuer(AdCPBaseModel): description='Optional subset of accepted_proof_formats allowed for this issuer. Omit to allow any globally accepted proof format for this issuer.', min_length=1, ), + NoExplicitNull, ] = None credential_origins: Annotated[ list[CredentialOrigin] | None, @@ -4320,6 +4580,7 @@ class AcceptedIssuer(AdCPBaseModel): description='Canonical HTTPS origins from which credential_uri locators may be fetched for this issuer. Exact origin matching happens after URL canonicalization and before DNS resolution. Paths in the credential URI may vary; userinfo is forbidden.', min_length=1, ), + NoExplicitNull, ] = None resolvers: Annotated[ list[Resolver] | None, @@ -4327,6 +4588,7 @@ class AcceptedIssuer(AdCPBaseModel): description='Evaluator-approved resolver endpoints for issuer_credential_id delivery. Presentations carry only resolver_id; they cannot replace url or authentication policy.', min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ dict[str, Any] | None, @@ -4334,6 +4596,7 @@ class AcceptedIssuer(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -4375,6 +4638,7 @@ class Attestations(AdCPBaseModel): description="Verifier agents the evaluator may call. A presenter's verify_agent nomination must match one of these canonicalized URLs, but the evaluator remains verifier-of-record and chooses whether to use the nominated agent, another accepted agent, or local verification.", min_length=1, ), + NoExplicitNull, ] = None max_embedded_credential_bytes: Annotated[ SchemaInt | None, @@ -4383,6 +4647,7 @@ class Attestations(AdCPBaseModel): ge=1024, le=1048576, ), + NoExplicitNull, ] = None ext: Annotated[ dict[str, Any] | None, @@ -4390,6 +4655,7 @@ class Attestations(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -4409,6 +4675,7 @@ class Adcp(AdCPBaseModel): examples=[['3.0'], ['3.0', '3.1'], ['3.0', '3.1-beta']], min_length=1, ), + NoExplicitNull, ] = None build_version: Annotated[ str | None, @@ -4423,6 +4690,7 @@ class Adcp(AdCPBaseModel): ], pattern='^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$', ), + NoExplicitNull, ] = None idempotency: Annotated[ Idempotency | Idempotency3, @@ -4435,18 +4703,21 @@ class Adcp(AdCPBaseModel): Field( description='Caller-scoped durable connection configuration accepted by this agent. This is the buyer-to-seller configuration half of negotiation, not a second seller capability document: the seller advertises its objective offering here, while each authenticated caller submits its desired webhooks and reusable destinations through sync_principal and reads them back through get_principal. Per-account authority and feed selection remain in account/reporting configuration. Sellers exposing this block MUST list protocol.principal in experimental_features.' ), + NoExplicitNull, ] = None capability_changes: Annotated[ CapabilityChanges | None, Field( description='Freshness metadata and optional invalidation webhooks for this `get_adcp_capabilities` document. Buyers and registries MAY cache capabilities for up to `cache_ttl_seconds` when present, SHOULD compare `capabilities_version` across refreshes when present, and SHOULD re-run `get_adcp_capabilities` after receiving a `capabilities.changed` webhook. This block describes the agent-wide capability document, not per-caller authorization or account-scoped settings. A material capability change is any externally advertised contract change that can affect routing, validation, conformance coverage, task availability, auth/account handling, sandbox support, billing support, reporting delivery methods, creative-library support, targeting support, protocol versions, or other buyer-visible feature gates. Non-contract operational changes that do not alter the response body do not require a revision or webhook fire.' ), + NoExplicitNull, ] = None governance_enforcement: Annotated[ GovernanceEnforcement | None, Field( description='Cross-role declaration that this agent enforces buyer-provided governance authorization when performing consequential tasks. This is distinct from the top-level `governance` capability block, which describes an agent that provides governance services. An agent can enforce governance without implementing the governance protocol itself. Absence means the agent makes no governance-enforcement conformance claim.' ), + NoExplicitNull, ] = None attestations: Annotated[ Attestations | None, @@ -4476,6 +4747,7 @@ class Adcp(AdCPBaseModel): ], title='Attestation Capabilities', ), + NoExplicitNull, ] = None @@ -4493,18 +4765,21 @@ class Method3(AdCPBaseModel): ), ] destination_modes: Annotated[list[DestinationMode], Field(min_length=1)] - provider: Provider | None = None - format: Format | None = None + provider: Annotated[Provider | None, NoExplicitNull] = None + format: Annotated[Format | None, NoExplicitNull] = None access_mode: Annotated[ - str | None, Field(max_length=64, min_length=1, pattern='^[a-z][a-z0-9_.-]*$') + str | None, + Field(max_length=64, min_length=1, pattern='^[a-z][a-z0-9_.-]*$'), + NoExplicitNull, ] = None producer_identity: Annotated[ ProducerIdentity | None, Field( description='Seller principal a buyer grants access to for this exact buyer-hosted destination offering.' ), + NoExplicitNull, ] = None - reader_compatibility: list[ReaderCompatibilityItem] | None = None + reader_compatibility: Annotated[list[ReaderCompatibilityItem] | None, NoExplicitNull] = None class Offering(AdCPBaseModel): @@ -4570,6 +4845,7 @@ class Offering(AdCPBaseModel): Field( description='Managed delivery method for this offering. Omit for a Core (API-delivered) offering: rows flow through existing get_media_buy_delivery and reporting_webhook transports and no destination is involved. Present only when the seller advertises managed_delivery.' ), + NoExplicitNull, ] = None @@ -4612,12 +4888,14 @@ class ReliabilityStatistic(AdCPBaseModel): Field( description='Observed seconds from official finalized_at to the first adjustment, required when official_revisions_adjusted is nonzero.' ), + NoExplicitNull, ] = None adjustment_magnitude: Annotated[ list[AdjustmentMagnitudeItem] | None, Field( description='Optional absolute adjustment-magnitude percentiles for comparable control totals such as spend.' ), + NoExplicitNull, ] = None evidence: Evidence @@ -4638,12 +4916,14 @@ class ReportingDelivery(AdCPBaseModel): Field( description='Tier flag: this seller supports managed file, dataset-share, or warehouse delivery. Offerings whose method names a delivery pattern require this tier. When false or absent, every offering is API-delivered and Core-only.' ), + NoExplicitNull, ] = None reconciled_billing: Annotated[ StrictBool | None, Field( description='Tier flag: this seller supports canonical-digest verification and authenticated consumer receipts for both report materializations and post-official adjustments through receipt_task. Offerings with reconciliation_mode consumer_receipt and billing-grade canonicalization require this tier.' ), + NoExplicitNull, ] = None configuration_task: Literal['sync_accounts'] | None = None status_task: Literal['get_reporting_status'] | None = None @@ -4710,18 +4990,21 @@ class ReportingDelivery(AdCPBaseModel): description="Maximum interval after a CONSUMER_STATUS_MISMATCH issue's opened_at during which the seller may keep that issue at a non-escalated recommended_action. After it, the issue MUST be action_required with a contact_ recommended_action naming the diagnosed responsible_party. Declaring it requires operations_contact so the escalation has a destination. Absence means the seller publishes no escalation commitment; it never means an unbounded one.", ge=0, ), + NoExplicitNull, ] = None operations_contact: Annotated[ OperationsContact | None, Field( description='Optional non-secret human escalation path for reporting issues the protocol cannot resolve. It is display metadata for an operator, not an AdCP endpoint: agents MUST NOT dereference, probe, or send protocol traffic to these values, and they carry no authorization. Required when consumer_mismatch_escalation_seconds is advertised.' ), + NoExplicitNull, ] = None reliability_statistics: Annotated[ list[ReliabilityStatistic] | None, Field( description='Optional evidence-scoped observed performance for advertised offerings. offering_id values MUST be unique and name offerings in this capability block.' ), + NoExplicitNull, ] = None resource_retention_days: Annotated[ SchemaInt | None, @@ -4729,14 +5012,16 @@ class ReportingDelivery(AdCPBaseModel): description='Minimum period after publication for which at least one verified exact materialization remains readable to every still-authorized intended consumer.', ge=1, ), + NoExplicitNull, ] = None - supports_webhook_activity: StrictBool | None = None + supports_webhook_activity: Annotated[StrictBool | None, NoExplicitNull] = None authorization_revocation_seconds: Annotated[ SchemaInt | None, Field( description="Maximum delay after caller/account authorization ends before seller-controlled transport access, provider grants, and write credentials are revoked. It cannot revoke a buyer's access to data already written into a buyer-owned destination.", ge=0, ), + NoExplicitNull, ] = None @model_validator(mode='after') @@ -4762,11 +5047,11 @@ class TranslationTarget(AdCPBaseModel): ) token: Annotated[str, Field(min_length=1)] dialect: MacroDialectFamily - dialect_namespace: AnyUrl | None = None - dialect_revision: Annotated[str | None, Field(min_length=1)] = None + dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MacroMappingStatus - universal_semantic: UniversalMacro | None = None + universal_semantic: Annotated[UniversalMacro | None, NoExplicitNull] = None next_operation: Literal['resolve_value'] = 'resolve_value' performed_by: MacroProcessingActor encoding: Annotated[ @@ -4783,11 +5068,11 @@ class MacroResolutionCapability(AdCPBaseModel): extra='forbid', ) dialect: MacroDialectFamily - dialect_namespace: AnyUrl | None = None - dialect_revision: Annotated[str | None, Field(min_length=1)] = None + dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MappingStatus - universal_semantic: UniversalMacro | None = None + universal_semantic: Annotated[UniversalMacro | None, NoExplicitNull] = None operation: Operation performed_by: MacroProcessingActor supported_contexts: Annotated[list[MacroValueContext], Field(min_length=1)] @@ -4797,6 +5082,7 @@ class MacroResolutionCapability(AdCPBaseModel): description='Exact supported encoding profiles, not a maximum depth. Required for value resolution.', min_length=1, ), + NoExplicitNull, ] = None translation_target: Annotated[ TranslationTarget | None, @@ -4804,6 +5090,7 @@ class MacroResolutionCapability(AdCPBaseModel): description='The exact downstream-native token produced by a `translate_to_native` operation. The output asset replaces the source declaration with a declaration for this token and its next operation.', title='Macro Translation Target', ), + NoExplicitNull, ] = None @@ -4814,6 +5101,7 @@ class CreativeSpecs(AdCPBaseModel): description='Seller-wide VAST execution ceiling. Each product format option declares its binding accepted subset in `params.vast_versions`.', min_length=1, ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[MacroResolutionCapability] | None, @@ -4821,22 +5109,29 @@ class CreativeSpecs(AdCPBaseModel): description='Seller-wide ceiling for exact macro processing tuples (dialect identity/revision, semantic mapping, operation, actor, context, and encoding). It never proves a product execution path supports the same tuple and does not claim tracker firing; inspect the selected format option and, when standardized, product tracker capabilities.', min_length=1, ), + NoExplicitNull, ] = None mraid_versions: Annotated[ list[MraidVersion] | None, Field(description='MRAID versions supported for rich media mobile creatives'), + NoExplicitNull, ] = None vpaid: Annotated[ - StrictBool | None, Field(description='VPAID support for interactive video ads') + StrictBool | None, + Field(description='VPAID support for interactive video ads'), + NoExplicitNull, ] = None simid: Annotated[ - StrictBool | None, Field(description='SIMID support for interactive video ads') + StrictBool | None, + Field(description='SIMID support for interactive video ads'), + NoExplicitNull, ] = None vast_validation: Annotated[ VastValidation | None, Field( description="Level of VAST asset validation the seller performs at sync_creatives (including dry_run): 'structural' checks manifest shape and format requirements only and never inspects the VAST document; 'document' additionally parses the VAST document and can return VAST_PARSE_FAILED / VAST_VERSION_MISMATCH; 'wrapper' additionally resolves the wrapper chain and can return VAST_WRAPPER_DEPTH_EXCEEDED. Absent means 'structural'. See the VAST Validation section of the video channel documentation for the normative checks at each level." ), + NoExplicitNull, ] = VastValidation.structural @@ -4864,24 +5159,28 @@ class Targeting(AdCPBaseModel): geo_countries: Annotated[ StrictBool | None, Field(description='Country-level targeting using ISO 3166-1 alpha-2 codes'), + NoExplicitNull, ] = None geo_regions: Annotated[ StrictBool | GeoRegions | None, Field( description='ISO 3166-2 subdivision inclusion targeting. A legacy boolean is a coarse seller-wide declaration. Structured country/value entries are individually supported within the response scope, but do not promise joint composability or availability through the same execution route or account. Only Product.overlay_support supplies the binding set of executable targeting permissions for a configured Product.' ), + NoExplicitNull, ] = None geo_regions_exclude: Annotated[ StrictBool | GeoRegionsExclude | None, Field( description='ISO 3166-2 subdivision exclusion targeting, declared independently from inclusion. Structured country/value entries are individually supported within the response scope, but do not promise joint composability or availability through the same execution route or account. Only Product.overlay_support supplies the binding set of executable targeting permissions for a configured Product; absence means buyers cannot infer exclusion support from geo_regions alone.' ), + NoExplicitNull, ] = None geo_metros: Annotated[ GeoMetros | None, Field( description='Metro area targeting. Properties indicate which classification systems are supported.' ), + NoExplicitNull, ] = None geo_postal_areas: Annotated[ GeoPostalAreas | None, @@ -4889,6 +5188,7 @@ class Targeting(AdCPBaseModel): description='Declares supported postal area systems. Native support is keyed by ISO 3166-1 alpha-2 country with arrays of country-local postal systems. Deprecated country-fused boolean aliases may be included during migration.', title='Postal Area Support', ), + NoExplicitNull, ] = None geo_places: Annotated[ dict[GeoPlaceSystemKey, GeoPlaces] | None, @@ -4896,70 +5196,82 @@ class Targeting(AdCPBaseModel): description='Place targeting support keyed by collision-safe identifier system. Each system declares exact country-to-place-type combinations, accepted catalog versions, and a machine-readable resolver. Sellers MUST reject unsupported systems, country/type pairs, versions, and identifiers rather than silently dropping them.', min_length=1, ), + NoExplicitNull, ] = None age_restriction: Annotated[ AgeRestriction | None, Field(description='Age restriction capabilities for compliance (alcohol, gambling)'), + NoExplicitNull, ] = None demographics: Annotated[ Demographics | None, Field( description='Seller-wide discovery rollup for canonical demographic targeting. supported=true means at least one product implements the demographic targeting contract; it does not authorize demographic targeting on every product. Buyers MUST inspect Product.demographic_targeting for exact execution modes, bounds, intervals, and unknown-age behavior.' ), + NoExplicitNull, ] = None language: Annotated[ StrictBool | Language | None, Field( description="Language-preference targeting support. A legacy boolean is a coarse declaration. The structured form can enumerate the exact canonical BCP 47 targeting ranges the seller accepts. Buyers MUST treat supported_languages as exact selectable values: a declared 'fr' does not by itself authorize a request for 'fr-CA'. Sellers MUST reject unsupported requested values rather than silently widening or dropping them." ), + NoExplicitNull, ] = None keyword_targets: Annotated[ KeywordTargets | None, Field( description='Keyword targeting capabilities. Presence indicates support for targeting_overlay.keyword_targets and keyword_targets_add/remove in update_media_buy.' ), + NoExplicitNull, ] = None negative_keywords: Annotated[ NegativeKeywords | None, Field( description='Negative keyword capabilities. Presence indicates support for targeting_overlay.negative_keywords and negative_keywords_add/remove in update_media_buy.' ), + NoExplicitNull, ] = None placement_selection: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.placement_selection. False or absence makes no product-level promise. Product.overlay_support and Product.placements remain authoritative for a selected product.' ), + NoExplicitNull, ] = None property_list: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.property_list inclusion targeting. False or absence makes no product-level promise. Product.overlay_support is authoritative.' ), + NoExplicitNull, ] = None property_list_exclude: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.property_list_exclude targeting. False or absence makes no product-level promise. Product.overlay_support is authoritative.' ), + NoExplicitNull, ] = None collection_list: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.collection_list inclusion targeting. False or absence makes no product-level promise. Product.overlay_support is authoritative.' ), + NoExplicitNull, ] = None collection_list_exclude: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.collection_list_exclude targeting. False or absence makes no product-level promise. Product.overlay_support is authoritative.' ), + NoExplicitNull, ] = None geo_proximity: Annotated[ GeoProximity | None, Field( description='Proximity targeting capabilities from arbitrary coordinates via targeting_overlay.geo_proximity.' ), + NoExplicitNull, ] = None @@ -4969,6 +5281,7 @@ class Execution(AdCPBaseModel): Field( description='Trusted Match Protocol (TMP) support. Presence of this object indicates the seller has TMP infrastructure deployed. Check individual products via get_products for per-product TMP capabilities.' ), + NoExplicitNull, ] = None axe_integrations: Annotated[ list[AnyUrl] | None, @@ -4976,15 +5289,17 @@ class Execution(AdCPBaseModel): deprecated=True, description='Deprecated. Legacy AXE integrations. Use trusted_match for new integrations.', ), + NoExplicitNull, ] = None creative_specs: Annotated[ - CreativeSpecs | None, Field(description='Creative specification support') + CreativeSpecs | None, Field(description='Creative specification support'), NoExplicitNull ] = None targeting: Annotated[ Targeting | None, Field( description='Targeting capabilities. If declared true/supported, buyer can use these targeting parameters and seller MUST honor them.' ), + NoExplicitNull, ] = None @@ -4997,18 +5312,19 @@ class BrandKitOverride2(AdCPBaseModel): extra='allow', ) logo: Annotated[ - Logo1 | None, Field(description='Override logo asset.', title='Image Asset') + Logo1 | None, Field(description='Override logo asset.', title='Image Asset'), NoExplicitNull + ] = None + colors: Annotated[ + Colors | None, Field(description='Override brand colors (hex strings).'), NoExplicitNull ] = None - colors: Annotated[Colors | None, Field(description='Override brand colors (hex strings).')] = ( - None - ) voice: Annotated[ str | None, Field( description='Override brand-voice description for surface-composed text/audio output.' ), + NoExplicitNull, ] = None - tagline: Annotated[str | None, Field(description='Override tagline.')] = None + tagline: Annotated[str | None, Field(description='Override tagline.'), NoExplicitNull] = None class Vendor(AdCPBaseModel): @@ -5030,6 +5346,7 @@ class Vendor(AdCPBaseModel): pattern='^[a-z0-9_]+$', title='Brand ID', ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -5037,24 +5354,28 @@ class Vendor(AdCPBaseModel): description='Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting.', min_length=1, ), + NoExplicitNull, ] = None industries: Annotated[ list[str] | None, Field( description="Inline override for the brand's industries. Useful when the caller cannot modify the brand's canonical brand.json but needs to declare industries for governance (e.g., Annex III vertical detection). brand.json remains the canonical source; when omitted here, governance agents SHOULD resolve from brand.json." ), + NoExplicitNull, ] = None data_subject_contestation: Annotated[ DataSubjectContestation | None, Field( description="Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing." ), + NoExplicitNull, ] = None brand_kit_override: Annotated[ BrandKitOverride2 | None, Field( description="Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload." ), + NoExplicitNull, ] = None @@ -5070,6 +5391,7 @@ class SupportedActivationMethods2(AdCPBaseModel): description='Supported transfer directions. buyer_to_seller: buyer writes to a seller-hosted bucket. seller_to_buyer: seller reads from a buyer-hosted bucket. Absent means unspecified (resolve during account setup), not neither.', min_length=1, ), + NoExplicitNull, ] = None vendor: Annotated[ Vendor, @@ -5095,18 +5417,19 @@ class BrandKitOverride3(AdCPBaseModel): extra='allow', ) logo: Annotated[ - Logo2 | None, Field(description='Override logo asset.', title='Image Asset') + Logo2 | None, Field(description='Override logo asset.', title='Image Asset'), NoExplicitNull + ] = None + colors: Annotated[ + Colors | None, Field(description='Override brand colors (hex strings).'), NoExplicitNull ] = None - colors: Annotated[Colors | None, Field(description='Override brand colors (hex strings).')] = ( - None - ) voice: Annotated[ str | None, Field( description='Override brand-voice description for surface-composed text/audio output.' ), + NoExplicitNull, ] = None - tagline: Annotated[str | None, Field(description='Override tagline.')] = None + tagline: Annotated[str | None, Field(description='Override tagline.'), NoExplicitNull] = None class Vendor1(AdCPBaseModel): @@ -5128,6 +5451,7 @@ class Vendor1(AdCPBaseModel): pattern='^[a-z0-9_]+$', title='Brand ID', ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -5135,24 +5459,28 @@ class Vendor1(AdCPBaseModel): description='Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting.', min_length=1, ), + NoExplicitNull, ] = None industries: Annotated[ list[str] | None, Field( description="Inline override for the brand's industries. Useful when the caller cannot modify the brand's canonical brand.json but needs to declare industries for governance (e.g., Annex III vertical detection). brand.json remains the canonical source; when omitted here, governance agents SHOULD resolve from brand.json." ), + NoExplicitNull, ] = None data_subject_contestation: Annotated[ DataSubjectContestation | None, Field( description="Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing." ), + NoExplicitNull, ] = None brand_kit_override: Annotated[ BrandKitOverride3 | None, Field( description="Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload." ), + NoExplicitNull, ] = None @@ -5180,6 +5508,7 @@ class SupportedActivationMethods3(AdCPBaseModel): description="Principals the buyer grants access to in the vendor's system. identity is always required. cloud and region are optional, paired deployment metadata: omit both for global principals (for example, an IAM principal or federated identity). Their operational meaning is vendor-specific — they may constrain direct-share reachability or select a fulfillment route, or may be routing and cost hints only. They are not compliance boundaries; data-transfer assessments key on the recipient entity's jurisdiction, not the grantee account's region. Optional: sellers MAY instead communicate identities during account setup.", min_length=1, ), + NoExplicitNull, ] = None @@ -5192,18 +5521,19 @@ class BrandKitOverride4(AdCPBaseModel): extra='allow', ) logo: Annotated[ - Logo3 | None, Field(description='Override logo asset.', title='Image Asset') + Logo3 | None, Field(description='Override logo asset.', title='Image Asset'), NoExplicitNull + ] = None + colors: Annotated[ + Colors | None, Field(description='Override brand colors (hex strings).'), NoExplicitNull ] = None - colors: Annotated[Colors | None, Field(description='Override brand colors (hex strings).')] = ( - None - ) voice: Annotated[ str | None, Field( description='Override brand-voice description for surface-composed text/audio output.' ), + NoExplicitNull, ] = None - tagline: Annotated[str | None, Field(description='Override tagline.')] = None + tagline: Annotated[str | None, Field(description='Override tagline.'), NoExplicitNull] = None class Vendor2(AdCPBaseModel): @@ -5225,6 +5555,7 @@ class Vendor2(AdCPBaseModel): pattern='^[a-z0-9_]+$', title='Brand ID', ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -5232,24 +5563,28 @@ class Vendor2(AdCPBaseModel): description='Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting.', min_length=1, ), + NoExplicitNull, ] = None industries: Annotated[ list[str] | None, Field( description="Inline override for the brand's industries. Useful when the caller cannot modify the brand's canonical brand.json but needs to declare industries for governance (e.g., Annex III vertical detection). brand.json remains the canonical source; when omitted here, governance agents SHOULD resolve from brand.json." ), + NoExplicitNull, ] = None data_subject_contestation: Annotated[ DataSubjectContestation | None, Field( description="Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing." ), + NoExplicitNull, ] = None brand_kit_override: Annotated[ BrandKitOverride4 | None, Field( description="Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload." ), + NoExplicitNull, ] = None @@ -5282,18 +5617,19 @@ class BrandKitOverride5(AdCPBaseModel): extra='allow', ) logo: Annotated[ - Logo4 | None, Field(description='Override logo asset.', title='Image Asset') + Logo4 | None, Field(description='Override logo asset.', title='Image Asset'), NoExplicitNull + ] = None + colors: Annotated[ + Colors | None, Field(description='Override brand colors (hex strings).'), NoExplicitNull ] = None - colors: Annotated[Colors | None, Field(description='Override brand colors (hex strings).')] = ( - None - ) voice: Annotated[ str | None, Field( description='Override brand-voice description for surface-composed text/audio output.' ), + NoExplicitNull, ] = None - tagline: Annotated[str | None, Field(description='Override tagline.')] = None + tagline: Annotated[str | None, Field(description='Override tagline.'), NoExplicitNull] = None class Vendor3(AdCPBaseModel): @@ -5315,6 +5651,7 @@ class Vendor3(AdCPBaseModel): pattern='^[a-z0-9_]+$', title='Brand ID', ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -5322,24 +5659,28 @@ class Vendor3(AdCPBaseModel): description='Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting.', min_length=1, ), + NoExplicitNull, ] = None industries: Annotated[ list[str] | None, Field( description="Inline override for the brand's industries. Useful when the caller cannot modify the brand's canonical brand.json but needs to declare industries for governance (e.g., Annex III vertical detection). brand.json remains the canonical source; when omitted here, governance agents SHOULD resolve from brand.json." ), + NoExplicitNull, ] = None data_subject_contestation: Annotated[ DataSubjectContestation | None, Field( description="Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing." ), + NoExplicitNull, ] = None brand_kit_override: Annotated[ BrandKitOverride5 | None, Field( description="Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload." ), + NoExplicitNull, ] = None @@ -5368,6 +5709,7 @@ class SupportedActivationMethods5(AdCPBaseModel): max_length=256, min_length=1, ), + NoExplicitNull, ] = None bind_expiry_days: Annotated[ SchemaInt | None, @@ -5375,6 +5717,7 @@ class SupportedActivationMethods5(AdCPBaseModel): description="Window after which the seller MAY expire an unfulfilled platform_segment bind (per-audience action: failed on sync_audiences). Initial vendor distribution is days-scale; windows shorter than the vendor's documented distribution latency are non-conformant.", ge=1, ), + NoExplicitNull, ] = None @@ -5394,6 +5737,7 @@ class AudienceTargeting(AdCPBaseModel): Field( description="Whether the seller accepts the buyer's CRM/loyalty ID as a matchable identifier. Only applicable when the seller operates a closed ecosystem with a shared ID namespace (e.g., a retailer matching against their loyalty program). When true, buyers can include platform_customer_id values in AudienceMember.identifiers for matching against the seller's identity graph. Reporting on matched platform_customer_ids typically requires a clean room or the seller's own reporting surface." ), + NoExplicitNull, ] = None supported_uid_types: Annotated[ list[UIDType] | None, @@ -5401,6 +5745,7 @@ class AudienceTargeting(AdCPBaseModel): description='Universal ID types accepted for audience matching (MAIDs, RampID, UID2, etc.). MAID support varies significantly by platform — check this field before sending uids with type: maid.', min_length=1, ), + NoExplicitNull, ] = None minimum_audience_size: Annotated[ SchemaInt, @@ -5423,12 +5768,14 @@ class AudienceTargeting(AdCPBaseModel): description="Union of audience_activation.methods across the seller's products. Fast-fail discovery: a buyer reads this once and skips the catalog walk when nothing overlaps its pipeline. Per-product declarations are the source of truth; sellers MUST keep this consistent with the catalog. Operational coordinates are account-scoped: the union MAY omit consumer_identities and destination_ref until bilateral account setup establishes them, and MUST NOT expose another account's coordinates. Absence of this field with media_buy.audience_activation listed in experimental_features means walk the catalog; only a present, non-overlapping union is a fast-fail signal. Experimental (x-status: experimental): sellers implementing audience activation declarations MUST list media_buy.audience_activation in experimental_features. Per docs/reference/experimental-status, this surface MAY change between 3.x releases with notice.", min_length=1, ), + NoExplicitNull, ] = None matching_latency_hours: Annotated[ MatchingLatencyHours | None, Field( description='Expected matching latency range in hours after upload. Use to calibrate polling cadence and set appropriate expectations before configuring push_notification_config.' ), + NoExplicitNull, ] = None @@ -5436,7 +5783,7 @@ class AttributionWindow(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - event_type: EventType | None = None + event_type: Annotated[EventType | None, NoExplicitNull] = None post_click: Annotated[ list[PostClickItem], Field( @@ -5450,6 +5797,7 @@ class AttributionWindow(AdCPBaseModel): description='Available post-view attribution windows (e.g. [{"interval": 1, "unit": "days"}])', min_length=1, ), + NoExplicitNull, ] = None @@ -5462,12 +5810,14 @@ class ConversionTracking(AdCPBaseModel): Field( description='Whether this seller can deduplicate conversion events across multiple event sources within a single goal. When true, the seller honors the deduplication semantics in optimization_goals event_sources arrays — the same event_id from multiple sources counts once. When false or absent, buyers should use a single event source per goal; multi-source arrays will be treated as first-source-wins. Most social platforms cannot deduplicate across independently-managed pixel and CAPI sources.' ), + NoExplicitNull, ] = None per_creative_attribution: Annotated[ StrictBool | None, Field( description='Whether the seller can attribute conversions to specific creatives within a package and surface that breakdown via media_buy_deliveries[].by_package[].by_creative[].conversions in get_media_buy_delivery. Defaults to false when omitted. Sellers that report conversions only at the line / package / placement / campaign granularity (retail-media, MMP-mediated mobile, CTV performance) declare false (or omit) and the per-creative scenario grades not_applicable for them. Sellers that surface ad-level conversion attribution (most social platforms) declare true and the scenario asserts the breakdown is populated end-to-end. Defaults to false to preserve backward compatibility.' ), + NoExplicitNull, ] = None supported_event_types: Annotated[ list[EventType] | None, @@ -5475,6 +5825,7 @@ class ConversionTracking(AdCPBaseModel): description='Event types this seller can track and attribute. If omitted, all standard event types are supported.', min_length=1, ), + NoExplicitNull, ] = None supported_targets: Annotated[ list[SupportedTarget3] | None, @@ -5482,10 +5833,12 @@ class ConversionTracking(AdCPBaseModel): description='Event-goal target kinds this seller can compute against. Buyers should only submit event-kind optimization goals whose target.kind is listed here — sellers MUST reject goals with unlisted target kinds. When omitted, only target-less event goals (maximize conversion count within budget) are guaranteed; sellers MAY accept specific target kinds but buyers should not rely on it. Named to parallel `metric_optimization.supported_targets` at the product level — same concept (which target kinds are supported), one at seller-capability granularity and one at product granularity.', min_length=1, ), + NoExplicitNull, ] = None supported_uid_types: Annotated[ list[UIDType] | None, Field(description='Universal ID types accepted for user matching', min_length=1), + NoExplicitNull, ] = None supported_hashed_identifiers: Annotated[ list[SupportedIdentifierType] | None, @@ -5493,16 +5846,19 @@ class ConversionTracking(AdCPBaseModel): description='Hashed PII types accepted for user matching. Buyers must hash before sending (SHA-256, normalized).', min_length=1, ), + NoExplicitNull, ] = None supported_action_sources: Annotated[ list[SupportedActionSource] | None, Field(description='Action sources this seller accepts events from', min_length=1), + NoExplicitNull, ] = None attribution_windows: Annotated[ list[AttributionWindow] | None, Field( description='Attribution windows available from this seller. Single-element arrays indicate fixed windows; multi-element arrays indicate configurable options the buyer can choose from via attribution_window on optimization goals.' ), + NoExplicitNull, ] = None @@ -5516,6 +5872,7 @@ class FrequencyCapping(AdCPBaseModel): description='Entity granularities the seller can enforce caps against. Values from the reach-unit enum. Omit to indicate all reach-unit values are supported.', min_length=1, ), + NoExplicitNull, ] = None supported_window_units: Annotated[ list[str] | None, @@ -5523,6 +5880,7 @@ class FrequencyCapping(AdCPBaseModel): description="Duration units the seller supports for frequency cap windows. Values must match the duration.json unit enum (e.g., 'hours', 'days', 'campaign'). Omit to indicate all duration units are supported.", min_length=1, ), + NoExplicitNull, ] = None @@ -5556,12 +5914,14 @@ class BudgetCapping(AdCPBaseModel): description='Feature-specific default cap-day timezone used when timezone_basis is fixed. Use UTC or an IANA timezone identifier.', min_length=1, ), + NoExplicitNull, ] = None buyer_timezone_override: Annotated[ StrictBool | None, Field( description='When true, buyers MAY override the default cap-day boundary via media-buy-level budget_cap_timezone. The accepted timezone applies to aggregate and package caps alike. When false or absent, a submitted override is rejected with UNSUPPORTED_FEATURE.' ), + NoExplicitNull, ] = None @@ -5571,6 +5931,7 @@ class ContentStandards(AdCPBaseModel): Field( description="Whether the seller runs a local evaluation model. When false, all artifacts will have local_verdict: 'unevaluated' and the failures_only filter on get_media_buy_artifacts is not useful." ), + NoExplicitNull, ] = None supported_channels: Annotated[ list[MediaChannel] | None, @@ -5578,12 +5939,14 @@ class ContentStandards(AdCPBaseModel): description='Channels for which the seller can provide content artifacts. Helps buyers understand which parts of a mixed-channel buy will have content standards coverage.', min_length=1, ), + NoExplicitNull, ] = None supports_webhook_delivery: Annotated[ StrictBool | None, Field( description='Whether the seller supports push-based artifact delivery via artifact_webhook configured at buy creation time.' ), + NoExplicitNull, ] = None @@ -5600,18 +5963,21 @@ class Portfolio(AdCPBaseModel): Field( description="Complete list of AdCP media channels for which this sales agent accepts and can meaningfully answer product-discovery briefs. When present, this is an exhaustive brief-routing allowlist: buyers MAY skip the agent when a brief's requested channels do not intersect it. Omission means channel scope is unknown and MUST NOT be interpreted as support for every channel. This is a routing pre-filter, not a promise of current product availability." ), + NoExplicitNull, ] = None primary_countries: Annotated[ list[PrimaryCountry] | None, Field( description="Complete list of ISO 3166-1 alpha-2 countries for which this sales agent accepts and can meaningfully answer product-discovery briefs. When present, this is an exhaustive brief-routing allowlist: buyers MAY skip the agent when a brief's requested countries do not intersect it. Omission means country scope is unknown and MUST NOT be interpreted as global coverage. This is a routing pre-filter, not a promise of current product availability and not an executable geo-targeting declaration; media_buy.execution.targeting geo capabilities and each product's overlay_support remain authoritative for targeting execution." ), + NoExplicitNull, ] = None description: Annotated[ str | None, Field( description='Markdown-formatted description of the inventory portfolio', max_length=5000 ), + NoExplicitNull, ] = None advertising_policies: Annotated[ str | None, @@ -5619,6 +5985,7 @@ class Portfolio(AdCPBaseModel): description='Advertising content policies, restrictions, and guidelines', max_length=10000, ), + NoExplicitNull, ] = None @@ -5628,11 +5995,11 @@ class TranslationTarget1(AdCPBaseModel): ) token: Annotated[str, Field(min_length=1)] dialect: MacroDialectFamily - dialect_namespace: AnyUrl | None = None - dialect_revision: Annotated[str | None, Field(min_length=1)] = None + dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MacroMappingStatus - universal_semantic: UniversalMacro | None = None + universal_semantic: Annotated[UniversalMacro | None, NoExplicitNull] = None next_operation: Literal['resolve_value'] = 'resolve_value' performed_by: MacroProcessingActor encoding: Annotated[ @@ -5649,11 +6016,11 @@ class MacroResolutionCapability2(AdCPBaseModel): extra='forbid', ) dialect: MacroDialectFamily - dialect_namespace: AnyUrl | None = None - dialect_revision: Annotated[str | None, Field(min_length=1)] = None + dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MappingStatus - universal_semantic: UniversalMacro | None = None + universal_semantic: Annotated[UniversalMacro | None, NoExplicitNull] = None operation: Operation performed_by: MacroProcessingActor supported_contexts: Annotated[list[MacroValueContext], Field(min_length=1)] @@ -5663,6 +6030,7 @@ class MacroResolutionCapability2(AdCPBaseModel): description='Exact supported encoding profiles, not a maximum depth. Required for value resolution.', min_length=1, ), + NoExplicitNull, ] = None translation_target: Annotated[ TranslationTarget1 | None, @@ -5670,6 +6038,7 @@ class MacroResolutionCapability2(AdCPBaseModel): description='The exact downstream-native token produced by a `translate_to_native` operation. The output asset replaces the source declaration with a declaration for this token and its next operation.', title='Macro Translation Target', ), + NoExplicitNull, ] = None @@ -5679,6 +6048,7 @@ class Format5(AdCPBaseModel): Field( description='Stable publisher- or product-declaration identity that this creative operation can satisfy. When publisher_domain is present, this field is required.' ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -5686,6 +6056,7 @@ class Format5(AdCPBaseModel): description='Publisher namespace for format_option_id when this operation claims compatibility with a publisher declaration.', pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[MacroResolutionCapability2] | None, @@ -5693,23 +6064,27 @@ class Format5(AdCPBaseModel): description='Creative-route processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. These capabilities describe build, validation, or preview processing and do not grant seller production authority.', min_length=1, ), + NoExplicitNull, ] = None - technical_requirements_complete: StrictBool | None = None - display_name: str | None = None - sample_render_url: AnyUrl | None = None - applies_to_channels: list[MediaChannel] | None = None - seller_preference: SellerPreference | None = None + technical_requirements_complete: Annotated[StrictBool | None, NoExplicitNull] = None + display_name: Annotated[str | None, NoExplicitNull] = None + sample_render_url: Annotated[AnyUrl | None, NoExplicitNull] = None + applies_to_channels: Annotated[list[MediaChannel] | None, NoExplicitNull] = None + seller_preference: Annotated[SellerPreference | None, NoExplicitNull] = None locale_policy: Annotated[ LocalePolicy | None, Field( description='Seller-declared locale eligibility for one product or placement format declaration. Each accepted_language_ranges entry is a concrete canonical BCP 47 language range evaluated with RFC 4647 section 3.3.1 Basic Filtering: fr accepts fr, fr-CA, and fr-FR, while fr-CA accepts only fr-CA and its more-specific descendants. Ranges are ORed and wildcards are not supported. Presence is a hard assignment constraint, not a preference: at least one materialized creative locale variant must match. The seller filters the creative to eligible variants before applying buyer-declared strict Lookup, locale_fallbacks, or default behavior; buyer policy can never select a seller-ineligible variant.', title='Creative Locale Policy', ), + NoExplicitNull, ] = None - canonical_formats_only: StrictBool | None = False - experimental: StrictBool | None = False - format_shape: str | None = None - v1_format_ref: Annotated[list[V1FormatRefItem] | None, Field(min_length=1)] = None + canonical_formats_only: Annotated[StrictBool | None, NoExplicitNull] = False + experimental: Annotated[StrictBool | None, NoExplicitNull] = False + format_shape: Annotated[str | None, NoExplicitNull] = None + v1_format_ref: Annotated[list[V1FormatRefItem] | None, Field(min_length=1), NoExplicitNull] = ( + None + ) format_schema: Annotated[ FormatSchema | None, Field( @@ -5722,6 +6097,7 @@ class Format5(AdCPBaseModel): ], title='Platform Extension Reference', ), + NoExplicitNull, ] = None format_kind: Annotated[ FormatKind, @@ -5748,6 +6124,7 @@ class SupportedFormat(AdCPBaseModel): description="Stable identifier for this format capability within the creative agent (e.g., 'audio_narration_30s'). The value MUST be unique across this agent's supported_formats catalog. Route the advertised operation with this ID: build_creative.target_capability_id(s) for build, preview_creative.target_capability_id for preview, or validate_input targets[] kind capability for validate. Capability IDs are agent-local routes and do not appear on portable manifests or products.", pattern='^[a-zA-Z0-9_-]+$', ), + NoExplicitNull, ] = None format: Annotated[ Format5, @@ -5762,6 +6139,7 @@ class SupportedFormat(AdCPBaseModel): description='Creative operations this capability supports. `build` means the agent can produce a conforming manifest via build_creative; `validate` means it can evaluate inputs against the declaration; `preview` means it can render a preview. New 3.2 producers MUST emit this field so buyers and registries can distinguish producers, validators, and renderers without probing tasks. Consumers interpret omission from a legacy 3.x entry as `["build"]`.', min_length=1, ), + NoExplicitNull, ] = [Operation4.build] @@ -5774,60 +6152,70 @@ class Creative(AdCPBaseModel): Field( description='When true, this creative agent can process briefs with compliance requirements (required_disclosures, prohibited_claims) and will validate that disclosures can be satisfied by the target format.' ), + NoExplicitNull, ] = None has_creative_library: Annotated[ StrictBool | None, Field( description='When true, this agent hosts a creative library and supports list_creatives and creative_id references in build_creative. Creative agents with a library should also implement the accounts protocol (sync_accounts / list_accounts) so buyers can establish access.' ), + NoExplicitNull, ] = False supports_revisions: Annotated[ StrictBool | None, Field( description='When true, this agent accepts buyer-assigned revision_id on sync_creatives, enforces immutable revision content, echoes accepted revision identity, returns it from list_creatives, and attributes delivered executions to it. Revision support does not imply revision history, rollback, or staged activation.' ), + NoExplicitNull, ] = False supports_generation: Annotated[ StrictBool | None, Field( description='When true, this agent can generate creatives from natural language briefs via build_creative. The buyer provides a message with creative direction, and the agent produces a manifest with generated assets. When false, build_creative only supports transformation or library retrieval.' ), + NoExplicitNull, ] = False supports_transformation: Annotated[ StrictBool | None, Field( description='When true, this agent can transform or resize existing canonical manifests via build_creative. The buyer supplies a creative_manifest and an advertised target_capability_id.' ), + NoExplicitNull, ] = False representation_resolution: Annotated[ RepresentationResolution | None, Field( description='Explicit opt-in for deterministic seller-bound selection from `build_creative.creative_representation_set`. Only the destination sales agent may advertise and exercise this capability because resolution requires its current product, placement/publisher narrowings, and seller-wide execution ceilings. A standalone creative agent may help a buyer select locally but MUST NOT advertise this capability or claim seller deliverability. Absence means the caller selects a representation before sending a seller-bound manifest; the agent MUST NOT guess silently.' ), + NoExplicitNull, ] = None supports_transformers: Annotated[ StrictBool | None, Field( description='When true, this agent exposes account-scoped creative transformers via list_transformers (the creative analog of media-buy products) and accepts transformer_id + config on build_creative. Buyers SHOULD call list_transformers to discover available transformers, their typed config params (and account-scoped enumerable option values via expand_params), and pricing. When false or absent, the agent does not offer the transformer surface.' ), + NoExplicitNull, ] = False supports_refinement: Annotated[ StrictBool | None, Field( description="When true, this agent retains produced build_variant leaves for an agent-defined retention window and can re-build from one via build_creative's refine_from_build_variant_id — applying a natural-language instruction in message plus an optional config delta, returning new lineage-linked variants. A build-time agent capability independent of generation/transformation. When false or absent, refine_from_build_variant_id is rejected with UNSUPPORTED_FEATURE; buyers refine instead via the transform path (creative_manifest + message)." ), + NoExplicitNull, ] = False supports_spend_controls: Annotated[ StrictBool | None, Field( description='When true, build_creative honors a per-call `max_spend` ceiling (producing partial paid results and returning budget_status:"capped" + a BUDGET_CAP_REACHED advisory rather than overspending) AND supports mode:"estimate" dry-runs (a projected cost band, producing/billing nothing). When false or absent, max_spend / mode:estimate are rejected with UNSUPPORTED_FEATURE. Out-of-band billers (bills_through_adcp:false) have no AdCP cost truth to cap against, so this is meaningful only alongside bills_through_adcp:true.' ), + NoExplicitNull, ] = False supports_evaluator: Annotated[ StrictBool | None, Field( description="Experimental (x-status: experimental) — agents setting this true MUST also list `creative.evaluator` in `experimental_features`; the surface MAY change between 3.x releases with notice (see docs/reference/experimental-status). When true, build_creative accepts an advisory `evaluator` input (exemplars / account-arranged evaluator_id / agent_url, plus an optional `feature_requirement[]` gate, a `rank_by` ordering, and an allowlisted `feature_agent` pointer). Feature discovery uses this response's governance.creative_features catalog: rank_by, feature_requirement, and eval.features[] all share the same creative-feature vocabulary as get_creative_features. evaluator_id is not discovered from this catalog; it is a pre-provisioned account preset whose emitted feature_ids still come from it. The evaluator populates a per-leaf `eval` block of creative-feature values (creative-feature-result[], the same shape get_creative_features returns) on BuildCreativeVariantSuccess leaves, which is what the recommended/rank it sets on the best_of_n axis are computed over. The agent runs a gate-then-rank pipeline over its best_of_n exploration: it evaluates each leaf, DROPS leaves failing `feature_requirement[]` from its recommended survivors, then orders survivors by `rank_by`. The gate is internal pruning of which leaves the agent recommends/returns from its own exploration — it never blocks an already-produced billable leaf: what is produced and billed is governed by max_variants/max_creatives/max_spend, not the evaluator. When the evaluator names an external agent, it MUST appear in `creative_policy.accepted_verifiers[]` (off-list → EVALUATOR_AGENT_NOT_ACCEPTED), and the producing agent authenticates the outbound evaluator call on the transport. Evaluator credentials and caller-supplied trust material MUST NOT be passed in the build_creative payload; credential- or trust-material payload keys should be rejected with CREDENTIAL_IN_ARGS. When false or absent, the `evaluator` input is ignored and no `eval` block is emitted." ), + NoExplicitNull, ] = False refinable_retention_seconds: Annotated[ SchemaInt | None, @@ -5835,36 +6223,42 @@ class Creative(AdCPBaseModel): description='When supports_refinement is true, the GUARANTEED-MINIMUM window (a floor, not a ceiling) during which a produced build_variant_id remains refinable via refine_from_build_variant_id: a ref within this window from production SHOULD resolve; the agent MAY retain longer. Omit when the retention window is agent-defined and not advertised — buyers then treat refinability as best-effort and handle REFERENCE_NOT_FOUND.', ge=0, ), + NoExplicitNull, ] = None multiplicity: Annotated[ Multiplicity | None, Field( description="Pre-call discriminators for build_creative fan-out, so a buyer knows BEFORE sending max_creatives / max_variants whether this agent supports them and the ceilings. Over-limit requests are CLAMPED to these ceilings (the agent produces up to the limit and signals the shortfall via items_returned < items_total on BuildCreativeVariantSuccess), not rejected — consistent with item_limit's 'use the lesser' rule. Absent means no fan-out: build_creative produces a single creative and max_creatives/max_variants>1 are not supported." ), + NoExplicitNull, ] = None supported_formats: Annotated[ list[SupportedFormat] | None, Field( description='Canonical-format capability catalog for this creative agent. This is the 3.2 source of truth for discovering which format contracts the agent can build, validate, or preview; it replaces the deprecated `list_creative_formats` task. Each entry uses the authority-free `CreativeOperationFormatDeclaration` projection of a product format declaration: canonical shape and creative-route macro processing are preserved, while seller production commitments are excluded. New 3.2 producers MUST publish a stable agent-local `capability_id` and explicit `operations` for task routing. Every emitted `capability_id` MUST be unique within this catalog so a route selects exactly one entry. During the 3.x compatibility window, consumers MUST also accept legacy entries that omit either field; absent `operations` means `["build"]`, while an absent `capability_id` means the entry is discoverable by canonical contract but cannot be selected through a capability-ID route.\n\n**Publisher-specific support.** To claim exact support for a publisher declaration, `format` carries the declaration\'s `{publisher_domain, format_option_id}` pair plus its canonical `format_kind` and narrowed `params`. Generic creative agents MAY instead advertise a canonical parameter envelope without publisher identity. A generic capability matches a target declaration only when the capability can satisfy every target constraint; matching canonical names alone is insufficient. Registries MAY reverse-index these entries by `format.format_kind`, `format.publisher_domain`, and `format.format_option_id`.\n\nThis catalog describes creative operations, not sales-agent inventory deliverability. Sales agents publish the purchasable closed set on each `Product.format_options[]`; publisher acceptance lives in `adagents.json.formats[]`.' ), + NoExplicitNull, ] = None preview: Annotated[ Preview | None, Field( description='Per-route preview_creative capability metadata. New 3.2 producers whose supported_formats[] explicitly advertises a routable preview operation MUST emit this block. rendering_origin describes how each route is implemented but is informational and never grants presentation authority: only a matching publisher-origin placement preview_provider delegation can do that. routes[].capability_id MUST equal the set of capability IDs on supported_formats[] entries whose operations contains preview.' ), + NoExplicitNull, ] = None localization: Annotated[ Localization | None, Field( description='Materialized creative-localization support for sync_creatives/list_creatives, including source-only monolingual topology. Presence opts the agent into exact locale-variant round-trip, strict RFC 4647 Lookup, optional buyer-declared language-family fallback rules, explicit final default/unmatched behavior, creative-wide review, transactional replacement, seller product-format locale-policy enforcement, and delivery attribution. This is a coarse structural capability, not a promise that every locale/format/account combination is accepted; sellers publish accepted ranges on product format declarations and validate each write before mutation. Omit this object when localization is unsupported.' ), + NoExplicitNull, ] = None bills_through_adcp: Annotated[ StrictBool | None, Field( description='When true, this creative agent bills through the AdCP rate-card surface: list_creatives returns pricing_options when include_pricing=true with an authenticated account, build_creative populates pricing_option_id and vendor_cost on the response, and report_usage accepts records against the rate card. When false or absent, the agent bills out of band (flat license, SaaS contract, bundled enterprise agreement) and buyers should skip pricing fields and tolerate report_usage returning accepted: 0 with errors carrying BILLING_OUT_OF_BAND. A pre-call discriminator so buyer agents can route across many creative agents without first establishing an account to probe pricing.' ), + NoExplicitNull, ] = False canonical_catalog_version: Annotated[ str | None, @@ -5872,6 +6266,7 @@ class Creative(AdCPBaseModel): description="Optional. The AdCP canonical-formats catalog version this agent's runtime is built against (e.g., `3.1`, `3.2.0`). Lets buyer SDKs detect canonical-catalog skew between their generated types and the seller's actual support. SDKs MAY declare the version they were generated against (typically the AdCP version they ship for); when seller and SDK versions disagree, SDKs SHOULD soft-warn rather than fail (the open-enum semantics on `canonical-format-kind.json` make unknown canonicals safe to retain, so skew is not a hard error — it just means the older side might not understand newer canonical values). Omitted by sellers who haven't yet generated against a versioned catalog; absence is interpreted as the AdCP version advertised by the broader capabilities response.", pattern='^\\d+\\.\\d+(\\.\\d+)?$', ), + NoExplicitNull, ] = None @@ -5885,6 +6280,7 @@ class PolicyProfile(AdCPBaseModel): description='Standalone policy modes accepted in this exact scope and allocation context. Combination-only components belong only in supported_combinations and need not appear here. `automatic` means the seller accepts and preserves an explicit `{automatic:true}` authored block; omission only invokes inheritance/default behavior.', min_length=1, ), + NoExplicitNull, ] = None cost_per_strengths: Annotated[ list[CostPerStrength] | None, @@ -5892,6 +6288,7 @@ class PolicyProfile(AdCPBaseModel): description='Supported standalone cost_per strengths in this profile. Required exactly when modes includes cost_per.', min_length=1, ), + NoExplicitNull, ] = None roas_strengths: Annotated[ list[RoasStrength] | None, @@ -5899,6 +6296,7 @@ class PolicyProfile(AdCPBaseModel): description='Supported standalone roas strengths in this profile. Required exactly when modes includes roas.', min_length=1, ), + NoExplicitNull, ] = None supported_combinations: Annotated[ list[MaxBidWithCostPer | MaxBidWithRoas] | None, @@ -5906,6 +6304,7 @@ class PolicyProfile(AdCPBaseModel): description='Multi-field policies supported in this exact scope and allocation context. Each entry independently qualifies the strengths supported in that combination; standalone strength support does not imply combination support. Absence means no multi-field combination is claimed.', min_length=1, ), + NoExplicitNull, ] = None @@ -5914,15 +6313,18 @@ class FrequencyCapIntervalConstraints(AdCPBaseModel): extra='forbid', ) unit: Unit - minimum_interval: Annotated[SchemaInt | None, Field(ge=1)] = None - maximum_interval: Annotated[SchemaInt | None, Field(ge=1)] = None - allowed_intervals: Annotated[list[AllowedInterval] | None, Field(min_length=1)] = None + minimum_interval: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + maximum_interval: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + allowed_intervals: Annotated[ + list[AllowedInterval] | None, Field(min_length=1), NoExplicitNull + ] = None ext: Annotated[ dict[str, Any] | None, Field( description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -5933,10 +6335,12 @@ class ScopeCapability(AdCPBaseModel): fixed: Annotated[ PolicyProfile | None, Field(description='Policies supported when budget allocation is fixed or omitted.'), + NoExplicitNull, ] = None seller_optimized: Annotated[ PolicyProfile | None, Field(description='Policies supported when budget_allocation.mode is seller_optimized.'), + NoExplicitNull, ] = None @@ -5949,12 +6353,14 @@ class BiddingPolicyCapability(AdCPBaseModel): Field( description='Policies accepted at media-buy scope and inherited by packages that omit a package override.' ), + NoExplicitNull, ] = None package: Annotated[ ScopeCapability | None, Field( description='Policies accepted as package-authored policies, including explicit automatic overrides.' ), + NoExplicitNull, ] = None @@ -5967,6 +6373,7 @@ class FrequencyCapConstraints(AdCPBaseModel): Field( description='Logical cap fields that can change after creation. An empty array means create-only. For example, [max_impressions] permits changing the count while keeping per and window fixed. Omission means update support is undeclared and the legacy broad meaning applies.' ), + NoExplicitNull, ] = None supported_control_modes: Annotated[ list[SupportedControlMode] | None, @@ -5974,10 +6381,12 @@ class FrequencyCapConstraints(AdCPBaseModel): description='FrequencyCap shapes accepted by the product. max_impressions_and_suppress means both controls may appear together and are enforced with AND semantics.', min_length=1, ), + NoExplicitNull, ] = None supported_per_units: Annotated[ list[ReachUnit] | None, Field(description='Entity granularities accepted by max_impressions caps.', min_length=1), + NoExplicitNull, ] = None max_impressions_constraints: Annotated[ MaxImpressionsConstraints | None, @@ -5985,6 +6394,7 @@ class FrequencyCapConstraints(AdCPBaseModel): description='Exact supported max_impressions presets or range.', title='Frequency Cap Impression Constraints', ), + NoExplicitNull, ] = None window_constraints: Annotated[ list[FrequencyCapIntervalConstraints] | None, @@ -5992,6 +6402,7 @@ class FrequencyCapConstraints(AdCPBaseModel): description='Exact supported max_impressions.window intervals, one entry per duration unit.', min_length=1, ), + NoExplicitNull, ] = None suppression_constraints: Annotated[ list[FrequencyCapIntervalConstraints] | None, @@ -5999,6 +6410,7 @@ class FrequencyCapConstraints(AdCPBaseModel): description='Exact supported suppress cooldown intervals, one entry per duration unit.', min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ dict[str, Any] | None, @@ -6006,6 +6418,7 @@ class FrequencyCapConstraints(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None @@ -6016,60 +6429,70 @@ class Features1(AdCPBaseModel): deprecated=True, description='Deprecated 3.x compatibility capability for creatives provided inline in create_media_buy and update_media_buy package payloads. buy_products, accept_proposal, and control_media_buy never accept inline creatives. New integrations use the dedicated creative lifecycle. Removed in 4.0.', ), + NoExplicitNull, ] = None property_list_filtering: Annotated[ StrictBool | None, Field( description='Honors property_list parameter in get_products to filter results to buyer-approved properties' ), + NoExplicitNull, ] = None catalog_management: Annotated[ StrictBool | None, Field( description='Supports sync_catalogs task for catalog feed management with platform review and approval' ), + NoExplicitNull, ] = None catalog_item_availability_updates: Annotated[ StrictBool | None, Field( description='Supports buyer-pushed item_availability_updates and item_availability_queries on sync_catalogs for immediate suppression/restoration and current-state readback in buyer-managed catalogs. Seller declarations may be true only with catalog_management: true; buyer required_features filters may request this feature alone. Requests containing availability operations are synchronous and accept at most 1,000 combined update and query entries. A successful suppress covers selection, dynamic rendering, and cached or pre-generated creatives materialized from the item. Internal lineage MUST retain resolved_account_id, catalog_id, catalog_generation, and item_id. Static creatives supplied or promoted by the buyer without catalog lineage remain outside this automatic guarantee. Suppression persists across feed refreshes and ordinary upserts until restore, expires_at, or catalog deletion. A seller that does not declare true MUST reject availability operations with UNSUPPORTED_FEATURE before lookup or mutation and MUST NOT interpret them as discovery. This does not control seller-owned wholesale inventory or let restore bypass seller controls.' ), + NoExplicitNull, ] = None committed_metrics_supported: Annotated[ StrictBool | None, Field( description="Seller has per-package snapshot infrastructure for the reporting contract. When true, the seller MUST populate `package.committed_metrics` on committed `create_media_buy` responses where `confirmed_at` is non-null, MUST omit `package.committed_metrics` while `confirmed_at` is null for a provisional buy, and MUST honor append-only mid-flight metric additions via `update_media_buy`. The unified `committed_metrics` array (per the metric-accountability design) covers both standard and vendor-defined metric entries, so a single flag is load-bearing. Buyers filtering on this flag are detecting 'this seller can stamp the reporting contract,' which closes the audit gap from PR #3510 where absence of `committed_metrics` was indistinguishable between 'didn't snapshot' and 'snapshot infrastructure not implemented.'" ), + NoExplicitNull, ] = None seller_optimized_budget: Annotated[ StrictBool | None, Field( description="Supports the core seller-optimized shared-budget contract for budget_allocation.mode `seller_optimized`: one hard shared total_budget, seller allocation of that total across the buy's packages against budget_allocation.optimization_goals, media-buy-level pacing, and echo of the allocation configuration on buy read surfaces. Sellers declaring true MUST accept eligible explicit-package and proposal executions that use only these core controls and MUST enforce the aggregate budget. Core media-buy pacing: sellers declaring true MUST accept omitted media-buy pacing (which defaults to even when total_budget is present) and pacing `even` on seller-optimized buys; they MAY reject `asap` or `front_loaded` with `UNSUPPORTED_FEATURE` (error.field `pacing`) before any provider mutation, and MUST NOT silently coerce them to `even`. Package-level controls inside a seller-optimized buy are separate capabilities: package budget caps (seller_optimized_package_budgets), package minimum-spend targets (seller_optimized_min_spend_targets), and package pacing (seller_optimized_package_pacing). A seller declaring this feature but not one of those sub-capabilities MUST reject any request that would leave that package control on a seller-optimized buy with `UNSUPPORTED_FEATURE` before any over-subscription validation or provider mutation, and MUST NOT silently drop, soften, or coerce it. Over-subscription validation (`INVALID_REQUEST`) applies only to package controls the seller has declared; see seller_optimized_min_spend_targets. Product combinations may still be rejected when their currencies, optimization capabilities, pricing terms, or delivery constraints are incompatible. Sellers that do not declare this feature MUST reject any request carrying `budget_allocation.mode: 'seller_optimized'` with `UNSUPPORTED_FEATURE` before any provider mutation, and MUST NOT coerce the request to fixed allocation." ), + NoExplicitNull, ] = None seller_optimized_package_budgets: Annotated[ StrictBool | None, Field( description='Honors package `budget` as an optional hard lifetime package spend cap inside a seller-optimized buy: packages[].budget and new_packages[].budget on create_media_buy and update_media_buy, purchases[].budget on buy_products, package budget controls on control_media_buy, and max_spend_percentage on seller-optimized proposal allocations. The cap is a ceiling, not a reserved or current allocation, and package caps may sum above total_budget. Meaningful only with seller_optimized_budget: true; seller declarations may be true only with seller_optimized_budget: true, while buyer required_features filters may request this feature alone. A seller that declares seller_optimized_budget without this feature MUST reject a request that would leave a package budget on a seller-optimized buy, including an allocation-mode switch that retains fixed-mode package budgets (the buyer clears them with null in the same atomic update), with `UNSUPPORTED_FEATURE` before any over-subscription validation or provider mutation, and MUST NOT issue seller-optimized proposals carrying max_spend_percentage. Does not govern fixed allocation, where package budgets remain required.' ), + NoExplicitNull, ] = None seller_optimized_min_spend_targets: Annotated[ StrictBool | None, Field( description='Honors package `min_spend_target` as a soft lifetime minimum-spend target inside a seller-optimized buy: packages[].min_spend_target and new_packages[].min_spend_target on create_media_buy and update_media_buy, purchases[].min_spend_target on buy_products, package min_spend_target controls on control_media_buy, and min_spend_target_percentage on seller-optimized proposal allocations. The seller SHOULD attempt to deliver at least the target before allocating incremental spend elsewhere; it is not a billing or delivery guarantee. Meaningful only with seller_optimized_budget: true; seller declarations may be true only with seller_optimized_budget: true, while buyer required_features filters may request this feature alone. Sellers declaring this feature MUST reject package minimum-spend targets summing above total_budget with `INVALID_REQUEST` before mutation, and, when they also declare seller_optimized_package_budgets, MUST likewise reject a min_spend_target above its own package budget. A seller that declares seller_optimized_budget without this feature MUST reject a request carrying a numeric min_spend_target with `UNSUPPORTED_FEATURE` before any over-subscription validation or provider mutation, so an over-subscribed target sent to such a seller yields `UNSUPPORTED_FEATURE`, and MUST NOT issue seller-optimized proposals carrying min_spend_target_percentage.' ), + NoExplicitNull, ] = None seller_optimized_package_pacing: Annotated[ StrictBool | None, Field( description='Honors package `pacing` as subordinate per-package pacing inside a seller-optimized buy, in addition to the media-buy-level pacing covered by seller_optimized_budget: packages[].pacing and new_packages[].pacing on create_media_buy and update_media_buy, purchases[].pacing on buy_products, package pacing controls on control_media_buy, and allocation pacing on seller-optimized proposals. Package pacing MUST NOT cause delivery to exceed aggregate media-buy pacing. Meaningful only with seller_optimized_budget: true; seller declarations may be true only with seller_optimized_budget: true, while buyer required_features filters may request this feature alone. Package pacing equal to the effective media-buy pacing adds no subordinate constraint and does not require this feature; buyers SHOULD omit package pacing on seller-optimized buys unless this feature is advertised. A seller that declares seller_optimized_budget without this feature MUST reject a request that would leave package pacing differing from media-buy pacing on a seller-optimized buy, including an allocation-mode switch that retains such fixed-mode package pacing (the buyer can align it in the same update), with `UNSUPPORTED_FEATURE` before any provider mutation, and MUST NOT issue seller-optimized proposals carrying allocation pacing. Does not govern package pacing in fixed allocation.' ), + NoExplicitNull, ] = None bidding_policy: Annotated[ BiddingPolicyCapability | None, Field( description='Structured support for canonical bidding by authored scope, allocation context, mode, strength, and strength-qualified multi-field combination. Presence does not imply support for both scopes, both allocation modes, or every policy shape. Sellers MUST preserve every advertised semantic exactly and reject unadvertised policies rather than translating them.' ), + NoExplicitNull, ] = None @@ -6092,12 +6515,14 @@ class MediaBuy(AdCPBaseModel): Field( description='Whether this seller accepts product discovery without caller credentials. This applies to list_products and to get_products in brief or wholesale mode; it does not apply to proposal refinement or finalization, purchasing, account-scoped reads, or mutations. true means an anonymous discovery request can produce a successful response, but the response may be a public subset and may differ from results for an authenticated principal or selected account. false means these discovery calls require an authenticated principal. Absence means unspecified legacy behavior, so callers probe and handle AUTH_MISSING. A valid authenticated request is never rejected merely because credentials were supplied. true is inconsistent with account.required_for_products=true because an anonymous caller cannot select protected account context.' ), + NoExplicitNull, ] = None acceptance_policy_discovery: Annotated[ AcceptancePolicyDiscovery | None, Field( description='Registry-backed seller acceptance-policy discovery. Presence means the seller publishes a versioned catalog; it does not claim that the seller evaluates acceptance_context during discovery. Discovery is advisory, exact task responses remain authoritative, and absent capability means support is unknown rather than unrestricted acceptance.' ), + NoExplicitNull, ] = None supported_pricing_models: Annotated[ list[SupportedPricingModel] | None, @@ -6105,6 +6530,7 @@ class MediaBuy(AdCPBaseModel): description='Pricing models this seller supports across its product portfolio. Buyers can use this for pre-flight filtering before querying individual products. Individual products may support a subset of these models.', min_length=1, ), + NoExplicitNull, ] = None buying_modes: Annotated[ list[BuyingMode] | None, @@ -6112,18 +6538,21 @@ class MediaBuy(AdCPBaseModel): description="Buying modes this seller supports on get_products. 'brief' (semantic discovery driven by the brief) is universally supported and implicit. 'wholesale' (raw wholesale product feed enumeration — caller omits brief and the seller returns the full priced product feed, paginated) is opt-in and SHOULD be declared explicitly so buyers can probe before issuing wholesale calls. 'refine' lets buyers iterate on prior products/proposals and is also the vehicle for finalizing draft proposals when the seller returns them. Sellers MAY declare ['brief', 'wholesale'] to signal wholesale support; absent declaration is treated as ['brief'] for wholesale-feed probing purposes and sellers MAY return INVALID_REQUEST for wholesale calls they do not support. Symmetric with signals.discovery_modes.", min_length=1, ), + NoExplicitNull, ] = [BuyingMode.brief] measurement_terms_acceptance: Annotated[ StrictBool | None, Field( description="Whether this seller can accept the default measurement_terms it advertises on a product. A value of true means the seller can return a product carrying measurement_terms for a measurement-specific brief and accept those terms unchanged on a package for that product. This opts the seller into conformance scenarios that discover and replay the product's own terms. False or absent means acceptance is outside the seller's advertised scope, but the seller must still reject unsupported terms with TERMS_REJECTED rather than being graded on an acceptance path it did not claim." ), + NoExplicitNull, ] = False availability_horizon: Annotated[ StrictBool | None, Field( description='Whether this seller supports flexible-window availability discovery: parsing offer_filters.availability_horizon and answering with time-dimensioned forecast points that carry availability_status. Sellers declaring true MUST apply the full window contract — half-open non-overlapping windows that partition the requested horizon (or signal gaps via incomplete[]), with availability_status computed from all booking eligibility constraints, not only competing holds. false or absent means flexible-window support is unknown: buyers SHOULD use exact start_date/end_date filtering, and sellers MAY ignore the field or reject it. Conformance storyboards gate flexible-window checks on this declaration.' ), + NoExplicitNull, ] = False lifecycle_tools: Annotated[ list[LifecycleTool] | None, @@ -6131,12 +6560,14 @@ class MediaBuy(AdCPBaseModel): description='Compact product and MediaBuy lifecycle operation names this seller supports. Added in AdCP 3.2 as task-specific contracts that form the 4.0 lifecycle foundation. Sellers may advertise any supported subset while retaining the deprecated get_products/create_media_buy/update_media_buy facades throughout 3.x. Each stateful split task has its own idempotency identity; callers MUST retry with the same tool name.', min_length=1, ), + NoExplicitNull, ] = None proposal_refinement: Annotated[ ProposalRefinement | None, Field( description='Pre-flight support for typed refine_proposals revision dimensions. These declarations mean the seller can parse and mechanically validate a dimension; they never promise that the seller will commercially concede it. Absence means typed-dimension support is unknown and buyers must handle per-result partial or unable outcomes. A dimension omitted from an explicit supported_dimensions list MUST be rejected at task level with UNSUPPORTED_FEATURE before any proposal is created.' ), + NoExplicitNull, ] = None reporting_delivery_methods: Annotated[ list[CapabilityReportingDeliveryMethod] | None, @@ -6144,12 +6575,14 @@ class MediaBuy(AdCPBaseModel): description="How this seller delivers reporting data to buyers. Polling via get_media_buy_delivery is always available as a baseline regardless of this field. This array declares additional push-based delivery methods the seller supports. 'webhook': seller pushes to buyer-provided URL (configured per buy via reporting_webhook). 'offline': seller pushes batch files to a cloud storage bucket (seller-provisioned per account via reporting_bucket on the account object). When absent, only polling is available.", min_length=1, ), + NoExplicitNull, ] = None performance_feedback: Annotated[ PerformanceFeedback | None, Field( description='Structured seller performance-feedback support beyond the legacy scalar contract. Presence means the seller accepts compact baseline/metric/provenance fields from a buyer orchestrator and returns a feedback_id.' ), + NoExplicitNull, ] = None offline_delivery_protocols: Annotated[ list[CloudStorageProtocol] | None, @@ -6157,6 +6590,7 @@ class MediaBuy(AdCPBaseModel): description="Cloud storage protocols this seller supports for offline file delivery. Only meaningful when reporting_delivery_methods includes 'offline'. Buyers express a protocol preference in sync_accounts; the seller provisions the account's reporting_bucket using a supported protocol.", min_length=1, ), + NoExplicitNull, ] = None reporting_delivery: Annotated[ ReportingDelivery | None, @@ -6164,24 +6598,28 @@ class MediaBuy(AdCPBaseModel): description='AdCP 3.2 Reliable Reporting capability. The affirmative machine answer to ‘Do you support Reliable Reporting?’ requires this block with supported: true and reliable_reporting_version: 1.0 plus media_buy.reporting_delivery in experimental_features. Core exposes seller get_reporting_status; during the published migration window, consumer_status_task separately advertises opt-in buyer-to-seller sync_reporting_status and becomes required Core only in the next eligible minor. managed_delivery and reconciled_billing identify optional tiers. This generalizes, but does not remove, the legacy reporting_delivery_methods/offline_delivery_protocols surface; those legacy fields and declarations from other protocols do not imply Reliable Reporting support.', title='Reporting Delivery Capabilities', ), + NoExplicitNull, ] = None supports_proposals: Annotated[ StrictBool | None, Field( description='Conformance declaration that this seller supports proposals through either the compact request/refine/finalize lifecycle or the legacy get_products facade. accept_proposal, or the create_media_buy compatibility facade, consumes a finalized committed proposal_id before expires_at.' ), + NoExplicitNull, ] = False outcome_target: Annotated[ StrictBool | None, Field( description="Whether this seller supports reverse-forecast planning: parsing criteria.outcome_target (a compact goal — a forecastable-metric delivery metric or an event-type conversion event — plus a desired volume, a cost_per target, or both) and planning against it, answering with total_budget_guidance and forecasts whose points carry the goal's key in metrics; see outcome-target.json for cost_per answers and rejections. This flag does not distinguish sellers that plan cost_per; buyers rely on the negotiated adcp_version together with features.bidding_policy. false or absent means support is unknown: buyers SHOULD express outcome goals in brief prose instead, and sellers reject a structured outcome_target on proposal requests with UNSUPPORTED_FEATURE rather than silently ignoring it (list_products ignores it for every seller)." ), + NoExplicitNull, ] = False governance_aware: Annotated[ StrictBool | None, Field( description='Compatibility claim used by existing media-buy conformance runners. A value of true corresponds only to online governance consultation for create_media_buy, the historically graded surface. Agents use adcp.governance_enforcement for explicit task-scoped claims, including update_media_buy and cross-role signed-context enforcement.' ), + NoExplicitNull, ] = False propagation_surfaces: Annotated[ list[PropagationSurface] | None, @@ -6189,12 +6627,14 @@ class MediaBuy(AdCPBaseModel): description='Where this seller surfaces dependency-resource impairments (creative suspended/rejected post-approval, audience suspended, catalog item withdrawn, event source insufficient, property depublished) to buyers. Non-exclusive: a seller mirroring impairments on both the buy snapshot AND firing webhooks declares `["snapshot", "webhook"]` (the common case for premium guaranteed sellers). Each value names one surface where buyers can observe an impairment:\n\n- **`snapshot`** — seller propagates resource transitions into `media_buy.health` and `media_buy.impairments[]` on the next `get_media_buys` read. The `impairment.coherence` compliance assertion grades this surface; storyboards that exercise it (`media_buy_seller/dependency_impairment`, `media_buy_seller/dependency_impairment_cardinality`) require `"snapshot"` to be declared, else they grade `not_applicable`.\n- **`webhook`** — seller fires `notification-type: impairment` webhooks (configured via `push_notification_config`). Sellers declaring `"webhook"` MUST satisfy the persistent-channel webhook contract for the impairment event type. A seller declaring `["webhook"]` without `"snapshot"` is webhook-only — buyers reconcile state from the push channel alone, and snapshot-coherence storyboards grade `not_applicable`.\n- **`out_of_band`** — seller propagates via channels outside the AdCP protocol surface entirely (email to trafficker, separate dashboard, partner-specific notification feed). Long-tail and enterprise-bundled platforms commonly use this when impairment workflows are managed in human channels. Sellers declaring only `["out_of_band"]` are not graded by snapshot or webhook compliance — their bar is the offline agreement, not a protocol assertion. If a seller has impairment data in their API under a non-AdCP field name (a mapping gap, not truly out-of-band), they SHOULD document the mapping rather than declare `out_of_band` — the spec\'s gap, not the seller\'s posture, is what `out_of_band` legitimately covers.\n\nDefault: `["snapshot"]` when absent (preserves the existing snapshot-coherence contract for sellers that don\'t declare). Empty array `[]` is invalid (`minItems: 1`) — omit the field to inherit the default rather than declaring no surfaces. Pick the surfaces that honestly describe where buyers will see impairments on this agent. Mixing is normative — `["snapshot", "webhook"]` is the documented common case; `["snapshot", "webhook", "out_of_band"]` is valid for sellers that ship all three surfaces (rare but legal). See lifecycle.mdx § Compliance for the per-surface contract.', min_length=1, ), + NoExplicitNull, ] = [PropagationSurface.snapshot] creative_approval_mode: Annotated[ CreativeApprovalMode | None, Field( description="Tenant-wide applicability signal for media-buy creative approval behavior. This is not a notification or new approval workflow. `auto_approve` means human review does not block serving eligibility after creatives are assigned and automated validation passes. `require_human` means one or more products/accounts may require manual review before creatives become eligible to serve; buyers and compliance runners MUST treat this as a worst-case ceiling across this seller's portfolio unless a future product-level override says otherwise. Compliance runners use this mainly to decide whether auto-approval-dependent storyboards apply. When absent, approval behavior is legacy-unspecified; runners SHOULD NOT treat omission as an affirmative auto-approval claim. `ai_assisted` is intentionally not part of the enum until a behavioral contract is defined." ), + NoExplicitNull, ] = None supported_indicator_types: Annotated[ list[SupportedIndicatorType] | None, @@ -6202,34 +6642,41 @@ class MediaBuy(AdCPBaseModel): description="Indicator types this seller can expose on get_media_buys media-buy/package/creative-assignment snapshots. Each type's meaning is defined by the negotiated AdCP release; indicator types do not carry independent sub-versions. This is availability, not complete upstream coverage. Poll-only sellers may declare this field without relationship_notifications. If relationship_notifications includes indicators.changed, this field is required so receivers know which durable indicator types can be repaired.", min_length=1, ), + NoExplicitNull, ] = None relationship_notifications: Annotated[ RelationshipNotifications | None, Field( description='Optional durable account-level invalidations for indicator, creative-assignment, and assignment-approval changes. A seller may expose indicators only through polling and omit this block. A seller without an indicator catalog may declare creative.assignment_changed alone. get_media_buys is the complete authoritative repair read. creative.assignment_changed is independent of the optional bounded list_creatives reverse projection, so inline-only sellers can advertise approval and assignment invalidations. Presence means the seller accepts the declared subscriptions through sync_accounts notification_configs. Timestamp-only reevaluation does not fire. Poll-based upstream integrations fire when they detect a change; this declaration does not promise upstream detection latency.' ), + NoExplicitNull, ] = None - features: Features1 | None = None + features: Annotated[Features1 | None, NoExplicitNull] = None execution: Annotated[ - Execution | None, Field(description='Technical execution capabilities for media buying') + Execution | None, + Field(description='Technical execution capabilities for media buying'), + NoExplicitNull, ] = None audience_evidence: Annotated[ AudienceEvidence | None, Field( description='Support for structured product audience evidence and buyer-authored evidence policy. Presence means the seller can publish Product.audience_evidence, preserve immutable snapshots through package readback, and evaluate the declared policy modes. It does not declare audience-targeting capability.' ), + NoExplicitNull, ] = None rights_attestations: Annotated[ RightsAttestations | None, Field( description='Seller evaluation policy for portable rights-grant attestations carried on creative rights constraints. Presence requires adcp.attestations and the rights-grant claim URI in accepted_claim_types. The seller remains verifier-of-record and never treats verification_url or a buyer-authored evaluation as authorization.' ), + NoExplicitNull, ] = None audience_targeting: Annotated[ AudienceTargeting | None, Field( description='Audience targeting capabilities. Presence of this object indicates the seller supports audience targeting, including sync_audiences and audience_include/audience_exclude in targeting overlays.' ), + NoExplicitNull, ] = None supported_optimization_metrics: Annotated[ list[SupportedOptimizationMetric] | None, @@ -6237,24 +6684,28 @@ class MediaBuy(AdCPBaseModel): description='Optimization metrics this seller can support on at least one of their products. Seller-level rollup of product-level metric_optimization.supported_metrics declarations (core/product.json). Buyers SHOULD filter their requested optimization goals against this list before submitting briefs. Sellers MUST keep this in sync with their product catalog — if no products support a metric, it must not appear here. Omitting this field means the seller declares no specific guarantees about which metrics they support; buyers should fall back to per-product inspection of metric_optimization.supported_metrics.', min_length=1, ), + NoExplicitNull, ] = None vendor_metric_optimization: Annotated[ VendorMetricOptimization | None, Field( description='Seller-level rollup of vendor-metric optimization capabilities supported by at least one product. Product-level vendor_metric_optimization.supported_metrics[] remains authoritative for the specific (vendor, metric_id) pairs and target kinds a buyer may bind on a package; this seller-level object exists so buyers and compliance runners can discover whether vendor_metric goals are in scope before walking the catalog. Sellers MUST keep this in sync with product-level vendor_metric_optimization declarations.' ), + NoExplicitNull, ] = None conversion_tracking: Annotated[ ConversionTracking | None, Field( description='Seller-level conversion tracking capabilities. Presence of this object indicates the seller supports sync_event_sources and log_event for conversion event tracking.' ), + NoExplicitNull, ] = None frequency_capping: Annotated[ FrequencyCapping | None, Field( description='Seller-wide package frequency-capping infrastructure. Presence means the seller honors targeting_overlay.frequency_cap on packages, with an independent counter per package, and MUST reject caps it cannot enforce rather than silently dropping them. Product.overlay_support is the binding per-product declaration. A cap whose single counter spans every package in a MediaBuy is advertised separately through aggregate_frequency_capping.' ), + NoExplicitNull, ] = None aggregate_frequency_capping: Annotated[ AggregateFrequencyCapping | None, @@ -6262,24 +6713,28 @@ class MediaBuy(AdCPBaseModel): description='Seller-wide support for a maximum-impression cap whose single counter is shared across every package in a MediaBuy. Presence is required before a buyer sends a root frequency_cap. Products additionally declare media_buy_support.frequency_cap and any narrower constraints.', title='Media Buy Frequency Cap Capability', ), + NoExplicitNull, ] = None budget_capping: Annotated[ BudgetCapping | None, Field( description='Hard daily budget-cap capabilities. Presence declares only the scopes listed in supported_scopes; sellers MUST reject a daily_budget_cap at an undeclared scope with UNSUPPORTED_FEATURE before mutation and MUST NOT silently drop or soften it. A cap is always a hard ceiling. Sellers that offer a best-effort pacing target must expose that under a separately named feature rather than interpreting daily_budget_cap as soft. Daily caps are orthogonal to pacing.' ), + NoExplicitNull, ] = None content_standards: Annotated[ ContentStandards | None, Field( description='Content standards implementation details. Presence of this object indicates the seller supports content_standards configuration including sampling rates and category filtering. Gives buyers pre-buy visibility into local evaluation and artifact delivery capabilities. This is a seller-side media-buy capability; governance agents providing content standards services declare `specialisms: ["content-standards"]` instead.' ), + NoExplicitNull, ] = None portfolio: Annotated[ Portfolio | None, Field( description="Information about the seller's media inventory portfolio. Media-buy sellers SHOULD publish primary_channels and primary_countries as their complete brief-routing scope. Buyers use publisher_domains to verify authorization via adagents.json. Omitted routing arrays mean unknown scope and MUST NOT be interpreted as global coverage." ), + NoExplicitNull, ] = None @@ -6294,6 +6749,7 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -6303,12 +6759,14 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): ge=1, le=99, ), + NoExplicitNull, ] = None context_id: Annotated[ str | None, Field( description='Transport-managed conversation identifier. On A2A, this maps to the native Message/Task `contextId` used to associate messages with a conversation; it is not carried inside the AdCP DataPart. On MCP, a request-body `context_id`, where admitted by the selected request schema, is a compatibility-only field: servers MUST ignore it, callers MUST NOT rely on it for continuity, and it MUST NOT select session state, identity, account, authorization, task continuation, or idempotency scope. MCP continuity, if provided, comes from the transport session. Distinct from `context` (per-request opaque echo, see below) and from `task_id` (AdCP operation tracking).' ), + NoExplicitNull, ] = None context: Annotated[ dict[str, Any] | None, @@ -6316,12 +6774,14 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): description='Per-request opaque caller-supplied correlation object echoed unchanged in the response. Used for buyer-side tracking (UI session IDs, trace IDs, custom metadata) that the agent MUST preserve byte-for-byte without parsing. Distinct from `context_id` (transport-managed A2A conversation correlation or MCP compatibility metadata) — `context` is caller-owned echo and never selects transport state. Both MAY appear on the same response.\n\n**Relationship to per-task body-level `context` declarations.** Many task request/response schemas (147 as of 3.1) already declare a body-level `context` field that `$ref`s `/schemas/core/context.json` at the body root. Under the flat-on-the-wire MCP serialization (see `notes` below), envelope-level `context` and body-level `context` occupy the same key on the response root — they are NOT separate fields, they MUST share the same value, and they MUST both `$ref` `core/context.json`. The envelope declaration is **authoritative** for the schema definition; per-task body declarations are mirrors retained for tooling reasons (SDK codegen completeness, per-task validation against the response schema in isolation). Future versions MAY drop body-level `context` declarations from per-task schemas; conformance does not require either declaration to be present, only that the wire value `$ref`s `core/context.json`.', title='Context Object', ), + NoExplicitNull, ] = None task_id: Annotated[ str | None, Field( description='Unique identifier for tracking asynchronous operations. Present when a task requires extended processing time. Used to query task status and retrieve results when complete.' ), + NoExplicitNull, ] = None status: Annotated[ Status, @@ -6335,18 +6795,21 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): Field( description='Human-readable summary of the task result. Provides natural language explanation of what happened, suitable for display to end users or for AI agent comprehension. Generated by the protocol layer based on the task response.' ), + NoExplicitNull, ] = None timestamp: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp when the response was generated. Useful for debugging, logging, cache validation, and tracking async operation progress.' ), + NoExplicitNull, ] = None replayed: Annotated[ StrictBool | None, Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False adcp_error: Annotated[ AdcpError | None, @@ -6354,6 +6817,7 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): description="Transport-envelope error signal for fatal task failures. Per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`, a fatal task failure SHOULD populate both this envelope-level field AND the payload's `errors[]` array — the envelope carries a typed, extractable error so MCP/A2A clients can dispatch without re-parsing the payload, while the payload's structured `errors[]` remains the canonical normative shape. Non-fatal warnings populate ONLY `payload.errors[]` with `severity: warning` — the envelope MUST NOT carry `adcp_error` for non-failures.", title='Error', ), + NoExplicitNull, ] = None push_notification_config: Annotated[ PushNotificationConfig | None, @@ -6361,6 +6825,7 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): description='AdCP application-layer webhook configuration for async task updates over MCP, A2A, or REST. Echoed from the request to confirm webhook settings. It is distinct from transport-native progress or A2A TaskPushNotificationConfig delivery and can outlive the originating transport session.', title='Push Notification Config', ), + NoExplicitNull, ] = None governance_context: Annotated[ str | None, @@ -6370,12 +6835,14 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None payload: Annotated[ dict[str, Any] | None, Field( description='Conceptual grouping for the task-specific response data defined by individual task response schemas (e.g., get-products-response.json, create-media-buy-response.json). `payload` is a documentary construct — it is NOT a required wire field, and its on-the-wire shape depends on transport (see Transport serialization below). Task response schemas declare body fields without wrapping them in a `payload` object; the wire representation places those body fields per transport convention. On MCP the body fields appear as siblings of envelope fields at the root of the tool response; on A2A they appear inside `task.artifacts[0].parts[].DataPart`; on REST they appear at the root of the JSON body.' ), + NoExplicitNull, ] = None adcp: Annotated[Adcp, Field(description='Core AdCP protocol information')] supported_protocols: Annotated[ @@ -6390,117 +6857,136 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): Field( description='Account management capabilities. Describes how accounts are established, what billing models are supported, and whether an account is required before browsing products.' ), + NoExplicitNull, ] = None media_buy: Annotated[ MediaBuy | None, Field( description='Media-buy protocol capabilities. Expected when media_buy is in supported_protocols. Sellers declaring media_buy should also include account with supported_billing.' ), + NoExplicitNull, ] = None signals: Annotated[ Signals | None, Field( description='Signals protocol capabilities. Only present if signals is in supported_protocols.' ), + NoExplicitNull, ] = None governance: Annotated[ Governance | None, Field( description='Governance protocol capabilities. Only present if governance is in supported_protocols. Governance agents provide property and creative data like compliance scores, brand safety ratings, sustainability metrics, and creative quality assessments.' ), + NoExplicitNull, ] = None sponsored_intelligence: Annotated[ SponsoredIntelligence | None, Field( description='Sponsored Intelligence protocol capabilities. Only present if sponsored_intelligence is in supported_protocols. SI agents handle conversational brand experiences.' ), + NoExplicitNull, ] = None brand: Annotated[ Brand | None, Field( description='Brand protocol capabilities. Only present if brand is in supported_protocols. Brand agents provide identity data (logos, colors, tone, assets) and optionally rights clearance for licensable content (talent, music, stock media).' ), + NoExplicitNull, ] = None creative: Annotated[ Creative | None, Field( description='Creative protocol capabilities. Only present if creative is in supported_protocols.' ), + NoExplicitNull, ] = None oauth: Annotated[ Oauth | None, Field( description='Introduced in AdCP 3.2. OAuth 2.0 protected-resource support for inbound transport authentication. This is a capability claim, not a requirement that every caller use OAuth: `supported: true` means the agent publishes RFC 9728 protected-resource metadata for its advertised endpoint and RFC 8414 metadata for every referenced authorization server, and opts into the universal `oauth_setup` conformance storyboard. Agents that use only static Bearer, Basic, mTLS, or RFC 9421 authentication omit this block or declare `supported: false`. Operator credential acquisition remains separately described by `account.authorization_endpoint` when applicable.' ), + NoExplicitNull, ] = None request_signing: Annotated[ RequestSigning | None, Field( description='RFC 9421 HTTP Signatures support for incoming requests. Signing remains optional through 3.2, but every accepted 3.2 signature on a request with a body MUST cover content-digest. Request signing becomes required for spend-committing operations in 4.0. The full profile is defined in docs/building/by-layer/L1/security.mdx (Signed Requests (Transport Layer)).' ), + NoExplicitNull, ] = None webhook_signing: Annotated[ WebhookSigning | None, Field( description='RFC 9421 webhook-signature and delivery-retry support for outbound webhook callbacks (top-level peer of request_signing). Declares which AdCP webhook-signing profile version and algorithms this agent produces on delivery, whether it supports the legacy HMAC-SHA256 fallback for receivers that have not yet adopted RFC 9421, and the maximum retry horizon receivers use to retain immutable delivery evidence. See docs/building/by-layer/L3/webhooks.mdx.' ), + NoExplicitNull, ] = None identity: Annotated[ Identity | None, Field( description='Operator identity posture — trust-root pointer (`brand_json_url`) plus key-scoping and compromise-response controls the agent operates. `brand_json_url` is **load-bearing** for signature verification: when the agent declares any signing posture (`request_signing.supported_for`/`required_for` non-empty, `webhook_signing.supported === true`, or any `key_origins` subfield), `brand_json_url` MUST be present (storyboard-enforced in 3.x; schema-required in 4.0). Verifiers use it to bootstrap from the agent URL to the operator\'s brand.json (and from there to signing keys); see [security.mdx §Discovering an agent\'s signing keys](https://adcontextprotocol.org/docs/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url). The remaining fields (`per_principal_key_isolation`, `key_origins`, `compromise_notification`) are advisory and receivers use them to reason about blast radius and revocation latency at onboarding. Empty-object semantics: `identity: {}` means "posture block present but no posture claimed" — schema-valid but advisory-neutral and receivers MUST treat it as equivalent to omitting the block, **except** that an agent declaring a signing posture elsewhere in the response with an empty `identity` MUST be rejected by storyboard runners as missing `brand_json_url`.' ), + NoExplicitNull, ] = None measurement_gateway: Annotated[ MeasurementGateway | None, Field( description='Buyer-controlled task gateway between an orchestrator and measurement providers. In the first experimental tier, providers read buyer-approved cross-seller delivery through get_media_buy_delivery and return compact assertions through provide_performance_feedback, without receiving seller credentials. Orchestrators implementing this block MUST include measurement in supported_protocols and measurement.gateway in experimental_features; this gateway role does not claim the media_buy seller protocol.' ), + NoExplicitNull, ] = None measurement: Annotated[ Measurement | None, Field( description="Experimental measurement capability block. Presence indicates this agent computes one or more quantitative metrics about ad delivery, exposure, or effect, and is willing to be discovered as a measurement vendor. Agents implementing this block MUST list `measurement.core` in experimental_features. Returns metric definitions and whether the provider produces compact performance feedback, not pricing/coverage (negotiated via `measurement_terms` on `create_media_buy`) or raw/live datasets. Per-buy vendor values remain on delivery reports; optimizer-ready projections use provide_performance_feedback. AgenticAdvertising.org crawls each measurement agent's `metrics[]` on a TTL to populate the federated cross-vendor index." ), + NoExplicitNull, ] = None compliance_testing: Annotated[ ComplianceTesting | None, Field( description="Compliance testing capabilities. The presence of this block declares that the agent supports deterministic testing via comply_test_controller for lifecycle state machine validation. Omit the block entirely if the agent does not support compliance testing. Sellers SHOULD list every canonical controller scenario they implement so buyers and runners can distinguish full deterministic coverage from partial coverage without probing each scenario one by one; the runtime source of truth remains comply_test_controller with scenario: 'list_scenarios'." ), + NoExplicitNull, ] = None specialisms: Annotated[ list[Specialism] | None, Field( description="Optional — specialized compliance claims this agent supports. Values MUST be kebab-case enum IDs (e.g., 'creative-generative', 'sales-non-guaranteed'). An agent that implements a specialism's tools but omits its ID from this array will receive 'No applicable tracks found' from the compliance runner — tracks for that specialism are not evaluated even if every tool works. Omitting the field means the agent declares no specialism claims (it still passes the universal + domain-baseline storyboards implied by supported_protocols). Each specialism maps to a storyboard bundle at /compliance/{version}/specialisms/{id}/ that the AAO compliance runner executes to verify the claim. Each specialism rolls up to one of the protocols in supported_protocols — the runner rejects a specialism claim whose parent protocol is missing. Only list specialisms your agent actually implements — the AAO Verified badge enumerates which specialisms were demonstrably passed." ), + NoExplicitNull, ] = None extensions_supported: Annotated[ list[ExtensionsSupportedItem] | None, Field( description='Extension namespaces this agent supports. Buyers can expect meaningful data in ext.{namespace} fields on responses from this agent. Extension schemas are published in the AdCP extension registry.' ), + NoExplicitNull, ] = None experimental_features: Annotated[ list[ExperimentalFeature] | None, Field( description='Experimental AdCP surfaces this agent implements. A surface is experimental when its schema carries x-status: experimental and the working group has not yet frozen it. Sellers that implement any experimental surface MUST list its feature id here. Buyers inspect this array before relying on experimental surfaces — a seller that does not list a surface is asserting it does not implement it. Experimental surfaces MAY break between any two 3.x releases with at least 6 weeks notice; the full contract is in docs/reference/experimental-status.' ), + NoExplicitNull, ] = None wholesale_feed_versioning: Annotated[ WholesaleFeedVersioning | None, Field( description="Conditional-fetch token capabilities for get_products and get_signals. Independent of wholesale feed webhooks: an agent MAY support cheap version probes via if_wholesale_feed_version without pushing change payloads (and vice versa). When supported is true, the agent returns wholesale_feed_version on every get_products / get_signals response and honors if_wholesale_feed_version on subsequent requests. When absent or supported is false, callers MAY still send if_wholesale_feed_version — pre-3.1 agents that ignore it just return the full payload (correct, just inefficient). Pre-flight declaration here lets buyers fast-path which agents to bother caching versions for. See get_products / get_signals 'Wholesale feed versioning' sections." ), + NoExplicitNull, ] = None last_updated: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp of when capabilities were last updated. Buyers can use this for cache invalidation.' ), + NoExplicitNull, ] = None errors: Annotated[ - list[Error] | None, Field(description='Task-specific errors and warnings') + list[Error] | None, Field(description='Task-specific errors and warnings'), NoExplicitNull ] = None ext: Annotated[ dict[str, Any] | None, @@ -6508,10 +6994,12 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): description='Extension object for platform-specific, vendor-namespaced parameters. Extensions are always optional and must be namespaced under a vendor/platform key (e.g., ext.gam, ext.roku). Used for custom capabilities, partner-specific configuration, and features being proposed for standardization.', title='Extension Object', ), + NoExplicitNull, ] = None wholesale_feed_webhooks: Annotated[ WholesaleFeedWebhooks | None, Field( description='Per-agent wholesale product-feed and wholesale signals-feed webhook capabilities. Consumers register durable sync_accounts notification subscribers and receive actual product.*, signal.*, or wholesale_feed.bulk_change payloads without polling. Product mirrors bootstrap and repair through list_products(if_feed_version); signal mirrors use get_signals(if_wholesale_feed_version). Deprecated wholesale get_products remains the 3.x product compatibility path. Webhook emission MUST apply the same caller/account authorization and cache-scope predicate as the corresponding read.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/collection/collection_list.py b/src/adcp/types/domains/collection/collection_list.py index cc082bc3b..38da09cb0 100644 --- a/src/adcp/types/domains/collection/collection_list.py +++ b/src/adcp/types/domains/collection/collection_list.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: collection/collection_list.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from ..core import account_ref, brand_ref @@ -19,34 +19,39 @@ class CollectionList(AdCPBaseModel): ) list_id: Annotated[str, Field(description='Unique identifier for this collection list')] name: Annotated[str, Field(description='Human-readable name for the list')] - description: Annotated[str | None, Field(description="Description of the list's purpose")] = ( - None - ) + description: Annotated[ + str | None, Field(description="Description of the list's purpose"), NoExplicitNull + ] = None account: Annotated[ account_ref.AccountReference | None, Field( description='Account that owns this list. Returned as account_id form (seller-assigned identifier).' ), + NoExplicitNull, ] = None base_collections: Annotated[ list[base_collection_source.BaseCollectionSource] | None, Field( description="Array of collection sources to evaluate. Each entry is a discriminated union: distribution_ids (platform-independent identifiers), publisher_collections (publisher_domain + collection_ids), or publisher_genres (publisher_domain + genres). If omitted, queries the agent's entire collection database." ), + NoExplicitNull, ] = None filters: Annotated[ collection_list_filters.CollectionListFilters | None, Field(description='Dynamic filters applied when resolving the list'), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Brand reference used to automatically apply appropriate rules. Resolved to full brand identity at execution time.' ), + NoExplicitNull, ] = None webhook_url: Annotated[ AnyUrl | None, Field(description='URL to receive notifications when the resolved list changes'), + NoExplicitNull, ] = None cache_duration_hours: Annotated[ SchemaInt | None, @@ -54,16 +59,18 @@ class CollectionList(AdCPBaseModel): description='Recommended cache duration for resolved list. Consumers should re-fetch after this period. Defaults to 168 (one week) because collection metadata changes less frequently than property metadata.', ge=1, ), + NoExplicitNull, ] = 168 - created_at: Annotated[AwareDatetime | None, Field(description='When the list was created')] = ( - None - ) + created_at: Annotated[ + AwareDatetime | None, Field(description='When the list was created'), NoExplicitNull + ] = None updated_at: Annotated[ - AwareDatetime | None, Field(description='When the list was last modified') + AwareDatetime | None, Field(description='When the list was last modified'), NoExplicitNull ] = None collection_count: Annotated[ SchemaInt | None, Field( description='Number of collections in the resolved list (at time of last resolution)' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/collection/collection_list_changed_webhook.py b/src/adcp/types/domains/collection/collection_list_changed_webhook.py index 0d3fb9154..7199c70a3 100644 --- a/src/adcp/types/domains/collection/collection_list_changed_webhook.py +++ b/src/adcp/types/domains/collection/collection_list_changed_webhook.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: collection/collection_list_changed_webhook.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from ..core import ext as ext_1 @@ -17,13 +17,19 @@ class ChangeSummary(AdCPBaseModel): extra='forbid', ) collections_added: Annotated[ - SchemaInt | None, Field(description='Number of collections added since last resolution') + SchemaInt | None, + Field(description='Number of collections added since last resolution'), + NoExplicitNull, ] = None collections_removed: Annotated[ - SchemaInt | None, Field(description='Number of collections removed since last resolution') + SchemaInt | None, + Field(description='Number of collections removed since last resolution'), + NoExplicitNull, ] = None total_collections: Annotated[ - SchemaInt | None, Field(description='Total collections in the resolved list') + SchemaInt | None, + Field(description='Total collections in the resolved list'), + NoExplicitNull, ] = None @@ -42,14 +48,19 @@ class CollectionListChangedWebhook(AdCPBaseModel): ] event: Annotated[Literal['collection_list_changed'], Field(description='The event type')] = 'collection_list_changed' list_id: Annotated[str, Field(description='ID of the collection list that changed')] - list_name: Annotated[str | None, Field(description='Name of the collection list')] = None + list_name: Annotated[ + str | None, Field(description='Name of the collection list'), NoExplicitNull + ] = None change_summary: Annotated[ - ChangeSummary | None, Field(description='Summary of changes to the resolved list') + ChangeSummary | None, + Field(description='Summary of changes to the resolved list'), + NoExplicitNull, ] = None resolved_at: Annotated[AwareDatetime, Field(description='When the list was re-resolved')] cache_valid_until: Annotated[ AwareDatetime | None, Field(description='When the consumer should refresh from the governance agent'), + NoExplicitNull, ] = None signature: Annotated[ str, @@ -57,4 +68,4 @@ class CollectionListChangedWebhook(AdCPBaseModel): description='HMAC-SHA256 webhook signature over {unix_timestamp}.{raw_http_body_bytes} using the secret exchanged out-of-band when the seller registered with the governance agent. Recipients MUST verify against the X-ADCP-Signature and X-ADCP-Timestamp headers using timing-safe comparison and MUST reject requests where |now - timestamp| > 300 seconds. The body copy of this field is a convenience only — the headers are authoritative. See docs/building/by-layer/L1/security#webhook-security.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/collection/collection_list_filters.py b/src/adcp/types/domains/collection/collection_list_filters.py index a56006edd..e643fafe4 100644 --- a/src/adcp/types/domains/collection/collection_list_filters.py +++ b/src/adcp/types/domains/collection/collection_list_filters.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: collection/collection_list_filters.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import content_rating @@ -36,6 +36,7 @@ class CollectionListFilters(AdCPBaseModel): description="Exclude collections with any of these content ratings (OR logic). This is a metadata filter on the collection's declared content_rating field — it does not evaluate episode content.", min_length=1, ), + NoExplicitNull, ] = None content_ratings_include: Annotated[ list[content_rating.ContentRating] | None, @@ -43,6 +44,7 @@ class CollectionListFilters(AdCPBaseModel): description='Include only collections with any of these content ratings (OR logic). Collections without a declared content_rating are excluded.', min_length=1, ), + NoExplicitNull, ] = None genres_exclude: Annotated[ list[str] | None, @@ -50,6 +52,7 @@ class CollectionListFilters(AdCPBaseModel): description='Exclude collections tagged with any of these genres (OR logic). Values are interpreted against genre_taxonomy when present.', min_length=1, ), + NoExplicitNull, ] = None genres_include: Annotated[ list[str] | None, @@ -57,16 +60,19 @@ class CollectionListFilters(AdCPBaseModel): description='Include only collections with any of these genres (OR logic). Collections without genre metadata are excluded. Values are interpreted against genre_taxonomy when present.', min_length=1, ), + NoExplicitNull, ] = None genre_taxonomy: Annotated[ genre_taxonomy_1.GenreTaxonomy | None, Field( description='Taxonomy for genre filter values. When present, genres_include and genres_exclude values are interpreted as taxonomy IDs.' ), + NoExplicitNull, ] = None kinds: Annotated[ list[collection_kind.CollectionKind] | None, Field(description='Filter to these collection kinds', min_length=1), + NoExplicitNull, ] = None exclude_distribution_ids: Annotated[ list[ExcludeDistributionId] | None, @@ -74,8 +80,10 @@ class CollectionListFilters(AdCPBaseModel): description='Always exclude collections with these distribution identifiers', min_length=1, ), + NoExplicitNull, ] = None production_quality: Annotated[ list[production_quality_1.ProductionQuality] | None, Field(description='Filter by production quality tier', min_length=1), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/collection/create_collection_list_request.py b/src/adcp/types/domains/collection/create_collection_list_request.py index c0807e20d..91a6b516d 100644 --- a/src/adcp/types/domains/collection/create_collection_list_request.py +++ b/src/adcp/types/domains/collection/create_collection_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/create_collection_list_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref, brand_ref @@ -26,27 +27,31 @@ class CreateCollectionListRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account that will own the list. Pass a natural key (brand, operator, optional sandbox) or a seller-assigned account_id from list_accounts. When omitted, this task applies its task-local single-account shortcut: if exactly one account is accessible to the authenticated caller, the seller may assign the list to that account; otherwise it MUST return an account-required or ambiguous-account error. Omission MUST NOT mean an undocumented credential-local default account.' ), + NoExplicitNull, ] = None name: Annotated[str, Field(description='Human-readable name for the list')] - description: Annotated[str | None, Field(description="Description of the list's purpose")] = ( - None - ) + description: Annotated[ + str | None, Field(description="Description of the list's purpose"), NoExplicitNull + ] = None base_collections: Annotated[ list[base_collection_source.BaseCollectionSource] | None, Field( description="Array of collection sources to evaluate. Each entry is a discriminated union: distribution_ids (platform-independent identifiers), publisher_collections (publisher_domain + collection_ids), or publisher_genres (publisher_domain + genres). If omitted, queries the agent's entire collection database.", min_length=1, ), + NoExplicitNull, ] = None filters: Annotated[ collection_list_filters.CollectionListFilters | None, Field(description='Dynamic filters to apply when resolving the list'), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Brand reference. When provided, the agent automatically applies appropriate rules based on brand characteristics (industry, target_audience, etc.). Resolved at execution time.' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -57,5 +62,5 @@ class CreateCollectionListRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/collection/create_collection_list_response.py b/src/adcp/types/domains/collection/create_collection_list_response.py index 2c6cd87aa..fe0deed94 100644 --- a/src/adcp/types/domains/collection/create_collection_list_response.py +++ b/src/adcp/types/domains/collection/create_collection_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/create_collection_list_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -35,6 +36,7 @@ class CreateCollectionListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEn Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/collection/delete_collection_list_request.py b/src/adcp/types/domains/collection/delete_collection_list_request.py index bba2ff419..843b45e15 100644 --- a/src/adcp/types/domains/collection/delete_collection_list_request.py +++ b/src/adcp/types/domains/collection/delete_collection_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/delete_collection_list_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref @@ -26,9 +27,10 @@ class DeleteCollectionListRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account that owns the list. Required when the authenticated agent has access to multiple accounts; optional otherwise.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None idempotency_key: Annotated[ str, Field( diff --git a/src/adcp/types/domains/collection/delete_collection_list_response.py b/src/adcp/types/domains/collection/delete_collection_list_response.py index ebb56a177..a700dade7 100644 --- a/src/adcp/types/domains/collection/delete_collection_list_response.py +++ b/src/adcp/types/domains/collection/delete_collection_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/delete_collection_list_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -27,6 +28,7 @@ class DeleteCollectionListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEn Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/collection/get_collection_list_request.py b/src/adcp/types/domains/collection/get_collection_list_request.py index bebff05ef..668048a89 100644 --- a/src/adcp/types/domains/collection/get_collection_list_request.py +++ b/src/adcp/types/domains/collection/get_collection_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/get_collection_list_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref @@ -24,10 +24,12 @@ class Pagination(AdCPBaseModel): max_results: Annotated[ SchemaInt | None, Field(description='Maximum number of collections to return per page', ge=1, le=10000), + NoExplicitNull, ] = 1000 cursor: Annotated[ str | None, Field(description='Opaque cursor from a previous response to fetch the next page'), + NoExplicitNull, ] = None @@ -41,18 +43,21 @@ class GetCollectionListRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account that owns the list. Required when the authenticated agent has access to multiple accounts and the list_id is not globally unique within that scope; optional otherwise.' ), + NoExplicitNull, ] = None resolve: Annotated[ StrictBool | None, Field( description='Whether to apply filters and return resolved collections (default: true)' ), + NoExplicitNull, ] = True pagination: Annotated[ Pagination | None, Field( description='Pagination parameters. Uses higher limits than standard pagination because collection lists can contain thousands of entries.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/collection/get_collection_list_response.py b/src/adcp/types/domains/collection/get_collection_list_response.py index 8dc7c23c4..3957e7b8f 100644 --- a/src/adcp/types/domains/collection/get_collection_list_response.py +++ b/src/adcp/types/domains/collection/get_collection_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/get_collection_list_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import content_rating as content_rating_1 @@ -46,33 +46,42 @@ class Collection(AdCPBaseModel): Field( description='Registry-assigned stable identifier for this collection. Present when the collection has been registered in the collection registry.' ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, Field( description='Domain of the adagents.json that declares this collection — the canonical namespace for collection_id. SHOULD be returned whenever known: together with collection_id it is the domain-qualified identity that makes a resolved row matchable when no platform-independent distribution identifier exists (e.g. a channel known only by a platform_channel_id).' ), + NoExplicitNull, ] = None collection_id: Annotated[ str | None, Field( description="Publisher-assigned collection ID within publisher_domain's adagents.json. SHOULD be returned whenever known; identity is the tuple (publisher_domain, collection_id)." ), + NoExplicitNull, ] = None name: Annotated[str, Field(description='Human-readable collection name')] distribution_ids: Annotated[ list[DistributionId] | None, Field(description='Platform-independent identifiers for cross-publisher matching'), + NoExplicitNull, ] = None content_rating: Annotated[ content_rating_1.ContentRating | None, Field(description='Baseline content rating for this collection'), + NoExplicitNull, + ] = None + genre: Annotated[ + list[str] | None, Field(description='Genre tags for this collection'), NoExplicitNull ] = None - genre: Annotated[list[str] | None, Field(description='Genre tags for this collection')] = None genre_taxonomy: Annotated[ - genre_taxonomy_1.GenreTaxonomy | None, Field(description='Taxonomy system for genre values') + genre_taxonomy_1.GenreTaxonomy | None, + Field(description='Taxonomy system for genre values'), + NoExplicitNull, ] = None - kind: collection_kind.CollectionKind | None = None + kind: Annotated[collection_kind.CollectionKind | None, NoExplicitNull] = None class GetCollectionListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): @@ -88,22 +97,25 @@ class GetCollectionListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvel Field( description='Resolved collections that passed filters (if resolve=true). Each entry contains identification and key metadata for seller matching.' ), + NoExplicitNull, ] = None - pagination: pagination_response.PaginationResponse | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None resolved_at: Annotated[ - AwareDatetime | None, Field(description='When the list was resolved') + AwareDatetime | None, Field(description='When the list was resolved'), NoExplicitNull ] = None cache_valid_until: Annotated[ AwareDatetime | None, Field( description='Cache expiration timestamp. Re-fetch the list after this time to get updated collections.' ), + NoExplicitNull, ] = None coverage_gaps: Annotated[ dict[str, list[CoverageGap]] | None, Field( description="Collections included in the list despite missing metadata for a filtered dimension. Maps dimension name (e.g., 'genre', 'content_rating') to arrays of distribution identifiers for collections not covered. Only present when filters are applied and some collections lack the filtered metadata." ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/collection/list_collection_lists_request.py b/src/adcp/types/domains/collection/list_collection_lists_request.py index 24f14a365..3f660a62b 100644 --- a/src/adcp/types/domains/collection/list_collection_lists_request.py +++ b/src/adcp/types/domains/collection/list_collection_lists_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/list_collection_lists_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref @@ -26,10 +27,13 @@ class ListCollectionListsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Filter to lists owned by this account. When omitted, returns lists across all accounts accessible to the authenticated agent.' ), + NoExplicitNull, ] = None name_contains: Annotated[ - str | None, Field(description='Filter to lists whose name contains this string') + str | None, + Field(description='Filter to lists whose name contains this string'), + NoExplicitNull, ] = None - pagination: pagination_request.PaginationRequest | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/collection/list_collection_lists_response.py b/src/adcp/types/domains/collection/list_collection_lists_response.py index 908fa19bc..0f45dd4bb 100644 --- a/src/adcp/types/domains/collection/list_collection_lists_response.py +++ b/src/adcp/types/domains/collection/list_collection_lists_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/list_collection_lists_response.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -26,6 +27,6 @@ class ListCollectionListsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv list[collection_list.CollectionList], Field(description='Array of collection lists (metadata only, not resolved collections)'), ] - pagination: pagination_response.PaginationResponse | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/collection/update_collection_list_request.py b/src/adcp/types/domains/collection/update_collection_list_request.py index 3d80c62c0..a220c12e1 100644 --- a/src/adcp/types/domains/collection/update_collection_list_request.py +++ b/src/adcp/types/domains/collection/update_collection_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/update_collection_list_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..core import account_ref, brand_ref @@ -27,33 +28,38 @@ class UpdateCollectionListRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account that owns the list. Required when the authenticated agent has access to multiple accounts; optional otherwise.' ), + NoExplicitNull, ] = None - name: Annotated[str | None, Field(description='New name for the list')] = None - description: Annotated[str | None, Field(description='New description')] = None + name: Annotated[str | None, Field(description='New name for the list'), NoExplicitNull] = None + description: Annotated[str | None, Field(description='New description'), NoExplicitNull] = None base_collections: Annotated[ list[base_collection_source.BaseCollectionSource] | None, Field( description='Complete replacement for the base collections list (not a patch). Each entry is a discriminated union: distribution_ids (platform-independent identifiers), publisher_collections (publisher_domain + collection_ids), or publisher_genres (publisher_domain + genres).' ), + NoExplicitNull, ] = None filters: Annotated[ collection_list_filters.CollectionListFilters | None, Field(description='Complete replacement for the filters (not a patch)'), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Update brand reference. Resolved to full brand identity at execution time.' ), + NoExplicitNull, ] = None webhook_url: Annotated[ AnyUrl | None, Field( description='Update the webhook URL for list change notifications (set to empty string to remove). Governance agents MUST validate this URL against SSRF per docs/building/by-layer/L1/security#webhook-url-validation-ssrf.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None idempotency_key: Annotated[ str, Field( diff --git a/src/adcp/types/domains/collection/update_collection_list_response.py b/src/adcp/types/domains/collection/update_collection_list_response.py index 5f3545445..2495a5fdd 100644 --- a/src/adcp/types/domains/collection/update_collection_list_response.py +++ b/src/adcp/types/domains/collection/update_collection_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/update_collection_list_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -29,6 +30,7 @@ class UpdateCollectionListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEn Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/compliance/comply_test_controller_request.py b/src/adcp/types/domains/compliance/comply_test_controller_request.py index e5dc81d9f..67fad2e4e 100644 --- a/src/adcp/types/domains/compliance/comply_test_controller_request.py +++ b/src/adcp/types/domains/compliance/comply_test_controller_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: compliance/comply_test_controller_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat @@ -112,6 +112,7 @@ class ReachWindow(AdCPBaseModel): Field( description="Duration of the measurement window. REQUIRED when kind is `period` or `rolling`. Matches the `period` field in delivery-metrics.json's reach_window block." ), + NoExplicitNull, ] = None @@ -133,6 +134,7 @@ class NotYetMeasurableVendorMetric(AdCPBaseModel): Field( description='Qualifier for the exact committed vendor-metric row whose measurement is deferred.' ), + NoExplicitNull, ] = None @@ -149,27 +151,33 @@ class Params(AdCPBaseModel): Field( description='Creative to transition (force_creative_status) or seed (seed_creative).' ), + NoExplicitNull, ] = None account_id: Annotated[ str | None, Field( description='Account to transition, simulate, or seed. Used by force_account_status, simulate_budget_spend, and seed_account.' ), + NoExplicitNull, ] = None media_buy_id: Annotated[ str | None, Field( description='Media buy to transition or purge (force_media_buy_status, force_media_buy_purge), simulate (simulate_delivery, simulate_budget_spend), or seed (seed_media_buy).' ), + NoExplicitNull, ] = None session_id: Annotated[ - str | None, Field(description='Session to transition. Used by force_session_status.') + str | None, + Field(description='Session to transition. Used by force_session_status.'), + NoExplicitNull, ] = None product_id: Annotated[ str | None, Field( description='Product to seed or prepare for deterministic compact lifecycle testing. Used by seed_product, seed_pricing_option, compact_product_lifecycle_probe prepare, and compact_direct_buy_lifecycle_probe prepare.' ), + NoExplicitNull, ] = None proposal_id: Annotated[ str | None, @@ -177,34 +185,43 @@ class Params(AdCPBaseModel): description='Committed proposal whose hold the compact_product_lifecycle_probe expires.', min_length=1, ), + NoExplicitNull, ] = None pricing_option_id: Annotated[ str | None, Field( description='Pricing option to seed, scoped to a product. Used by seed_pricing_option.' ), + NoExplicitNull, + ] = None + plan_id: Annotated[ + str | None, Field(description='Plan to seed. Used by seed_plan.'), NoExplicitNull ] = None - plan_id: Annotated[str | None, Field(description='Plan to seed. Used by seed_plan.')] = None rights_id: Annotated[ - str | None, Field(description='Rights grant to seed. Used by seed_rights_grant.') + str | None, + Field(description='Rights grant to seed. Used by seed_rights_grant.'), + NoExplicitNull, ] = None fixture: Annotated[ dict[str, Any] | None, Field( description='Arbitrary fixture payload carried by seed_* scenarios. Shape matches the domain object the seed scenario creates (account, product, creative, plan, media buy, pricing option, rights grant). Seller MAY reject malformed fixtures with INVALID_PARAMS. Kept permissive so storyboard authors can declare the minimum shape each test needs without the spec locking down every field.' ), + NoExplicitNull, ] = None operation: Annotated[ Operation | None, Field( description="Scenario-specific probe operation. catalog_item_availability_probe uses seed_inaccessible_item, query_eligibility, advance_time, and recreate_catalog. compact_product_lifecycle_probe uses prepare to make one seeded product's compact proposal, acceptance, operational-control, and MediaBuy readback path deterministic and expire_proposal to advance strictly beyond a committed proposal's stored expires_at and process the hold lapse. compact_direct_buy_lifecycle_probe uses prepare to make one seeded product's list, direct-purchase, operational-control, and readback path deterministic. reporting_core_lifecycle_probe uses prepare, advance_time, publish_zero_row, publish_nonempty, restate_snapshot, restate_after_received, omit_obligation, advance_past_status_deadline, and advance_past_escalation to exercise obligation availability, health deadlines, explicit reporting, provisional restatement, stale-received grace, buyer-side missing-obligation detection, counted consumer-status silence, and consumer-mismatch escalation. advance_past_status_deadline moves the clock strictly past expected_at plus the advertised automated_recovery_window_seconds without recording any consumer status, so obligation_counts.consumer_status_pending is gradable; advance_past_escalation moves it strictly past the open CONSUMER_STATUS_MISMATCH issue opened_at plus the advertised consumer_mismatch_escalation_seconds and returns both that boundary and any still-future stale_received_grace_deadline, so the escalation precedence rule is gradable. restate_snapshot publishes a new snapshot revision that immediately supersedes the current snapshot for the same logical slice and is invalid when the current revision is official. restate_after_received does the same restatement but only against the revision named in received_reporting_revision_id, so the stale-received grace projection can be graded live; it returns the grace deadline and advance_to positions the virtual clock inside or past it. Reliable Reporting tier probes use prepare plus publish_official_adjustment, probe_scheduler_dst, suppress_readiness, advance_within_retention, revoke_access, or publish_adjustment to seed deterministic Core-integrity, Managed Delivery, and Reconciled Billing lifecycle evidence." ), + NoExplicitNull, ] = None target_health: Annotated[ TargetHealth | None, Field( description='Clock-derived health boundary selected by reporting_core_lifecycle_probe advance_time. The controller chooses the corresponding deterministic target time; callers do not supply timestamps.' ), + NoExplicitNull, ] = None received_reporting_revision_id: Annotated[ str | None, @@ -214,12 +231,14 @@ class Params(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None advance_to: Annotated[ AdvanceTo | None, Field( description='Where reporting_core_lifecycle_probe restate_after_received leaves the virtual clock relative to the returned stale_received_grace_deadline. within_grace grades the delayed projection; past_grace grades the escalation to action_required. Repeating the operation is convergent: it reuses the committed restatement and only moves the clock.' ), + NoExplicitNull, ] = AdvanceTo.within_grace catalog_id: Annotated[ str | None, @@ -228,6 +247,7 @@ class Params(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None catalog_generation: Annotated[ str | None, @@ -236,6 +256,7 @@ class Params(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None item_id: Annotated[ str | None, @@ -244,55 +265,66 @@ class Params(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None target_time: Annotated[ AwareDatetime | None, Field( description='Deterministic sandbox clock target used by catalog_item_availability_probe advance_time.' ), + NoExplicitNull, ] = None status: Annotated[ str | None, Field( description="Target status for the resource. Type depends on scenario: creative-status for force_creative_status, account-status for force_account_status, media-buy-status for force_media_buy_status. For force_session_status, must be 'complete' or 'terminated'." ), + NoExplicitNull, ] = None rejection_reason: Annotated[ str | None, Field( description='Reason for rejection. Used by force_creative_status and force_media_buy_status when status = rejected.' ), + NoExplicitNull, ] = None purge_kind: Annotated[ PurgeKind | None, Field( description='Purge mode for force_creative_purge. soft retains a list_creatives tombstone; hard removes the creative entirely.' ), + NoExplicitNull, ] = None reason_code: Annotated[ creative_event_reason_code.CreativeEventReasonCode | None, Field( description='Creative lifecycle reason code for force_creative_status and force_creative_purge.' ), + NoExplicitNull, ] = None reason_detail: Annotated[ str | None, Field( description='Human-readable detail for force_creative_status and force_creative_purge.' ), + NoExplicitNull, ] = None termination_reason: Annotated[ str | None, Field( description='Reason for termination (e.g., session_timeout, host_terminated, policy_violation). Used by force_session_status when status = terminated.' ), + NoExplicitNull, ] = None impressions: Annotated[ SchemaInt | None, Field(description='Impressions to simulate. Used by simulate_delivery.', ge=0), + NoExplicitNull, ] = None clicks: Annotated[ - SchemaInt | None, Field(description='Clicks to simulate. Used by simulate_delivery.', ge=0) + SchemaInt | None, + Field(description='Clicks to simulate. Used by simulate_delivery.', ge=0), + NoExplicitNull, ] = None plays: Annotated[ SchemaInt | None, @@ -300,22 +332,26 @@ class Params(AdCPBaseModel): description='Raw DOOH or broadcast plays to add to delivery. Used by simulate_delivery for single-package buys; sellers MUST surface the cumulative value at totals.plays and by_package[0].plays in the next get_media_buy_delivery response. Multi-package simulations require a future package-scoped form and MUST reject this media-buy-scoped field.', ge=0, ), + NoExplicitNull, ] = None dooh_metrics: Annotated[ delivery_metrics.DoohMetrics | None, Field( description='DOOH delivery detail to inject. Used by simulate_delivery for single-package buys; the latest injected block replaces the previous simulated DOOH detail and MUST surface at totals.dooh_metrics and by_package[0].dooh_metrics. Multi-package simulations require a future package-scoped form and MUST reject this media-buy-scoped field.' ), + NoExplicitNull, ] = None conversions: Annotated[ SchemaInt | None, Field(description='Conversions to simulate. Used by simulate_delivery.', ge=0), + NoExplicitNull, ] = None delivery_date: Annotated[ date | None, Field( description="Calendar date, in the product's reporting_capabilities.timezone, attributable to this simulated delivery batch. Used by simulate_delivery to seed deterministic date-range tests. When get_media_buy_delivery supplies start_date or end_date, dated batches are included when delivery_date is greater than or equal to start_date and strictly less than end_date. Omit to preserve cumulative, unfiltered simulation behavior." ), + NoExplicitNull, ] = None conversion_value: Annotated[ StrictFloat | None, @@ -323,6 +359,7 @@ class Params(AdCPBaseModel): description='Total attributed conversion value to simulate in the delivery reporting currency. Used by simulate_delivery.', ge=0.0, ), + NoExplicitNull, ] = None commissionable_value: Annotated[ StrictFloat | None, @@ -330,12 +367,14 @@ class Params(AdCPBaseModel): description='Settled attributed value eligible for revenue-share commission to simulate in the delivery reporting currency. Used by simulate_delivery.', ge=0.0, ), + NoExplicitNull, ] = None reported_spend: Annotated[ ReportedSpend | None, Field( description='Spend as reported in delivery data. Does not affect budget. Used by simulate_delivery.' ), + NoExplicitNull, ] = None reach: Annotated[ StrictFloat | None, @@ -343,6 +382,7 @@ class Params(AdCPBaseModel): description="Unique reach count to inject into the simulated delivery row. Used by simulate_delivery. The unit of measurement matches the reach_unit declared on the media buy's optimization goal. When supplied, sellers MUST surface this value at `totals.reach` in the next get_media_buy_delivery response.", ge=0.0, ), + NoExplicitNull, ] = None frequency: Annotated[ StrictFloat | None, @@ -350,48 +390,56 @@ class Params(AdCPBaseModel): description='Average frequency per reach unit to inject. Used by simulate_delivery. When supplied, sellers MUST surface this value at `totals.frequency`, including frequency-only delivery. The measurement window for this frequency value is declared in `reach_window` when also present.', ge=0.0, ), + NoExplicitNull, ] = None reach_unit: Annotated[ reach_unit_1.ReachUnit | None, Field( description='Unit for an injected reach or frequency value. Used by simulate_delivery. When supplied with reach or frequency, sellers MUST surface it on the corresponding delivery row.' ), + NoExplicitNull, ] = None reach_window: Annotated[ ReachWindow | None, Field( description='Measurement window semantics to attach to the simulated reach and frequency values. Used by simulate_delivery. When present, sellers MUST populate `reach_window` on the delivery row so buyers can determine the window semantics (cumulative vs. period vs. rolling). Omitting this param produces a row with no `reach_window` — valid per schema but sum-unsafe. Mirrors the `reach_window` shape in delivery-metrics.json.' ), + NoExplicitNull, ] = None viewability: Annotated[ delivery_metrics.Viewability | None, Field( description='Viewability metrics to inject into simulated delivery. Uses the canonical delivery viewability shape, including viewed-seconds distributions. This media-buy-scoped form is valid only for a single-package buy, where the reference seller surfaces the same values at package and media-buy totals grain.' ), + NoExplicitNull, ] = None vendor_metric_values: Annotated[ list[vendor_metric_value.VendorMetricValue] | None, Field( description="Vendor-defined metric values to inject into the next delivery report. Used by simulate_delivery. The reference seller reconciles these rows against each package's seller-stamped committed_metrics contract." ), + NoExplicitNull, ] = None vendor_metric_values_by_package: Annotated[ dict[str, list[vendor_metric_value.VendorMetricValue]] | None, Field( description='Package-scoped vendor-defined values to inject, keyed by package_id. Used by simulate_delivery for multi-package buys. Sellers MUST use this form whenever more than one package could carry the same vendor/metric_id key; the legacy media-buy-scoped vendor_metric_values form is unambiguous only for a single-package buy.' ), + NoExplicitNull, ] = None not_yet_measurable_vendor_metrics: Annotated[ list[NotYetMeasurableVendorMetric] | None, Field( description='Committed vendor metrics that are not yet measurable for the simulated measurement window. Used by simulate_delivery to prove that a future metric is omitted from missing_metrics until its measurement window matures.' ), + NoExplicitNull, ] = None not_yet_measurable_vendor_metrics_by_package: Annotated[ dict[str, list[NotYetMeasurableVendorMetricsByPackageItem]] | None, Field( description="Package-scoped committed vendor metrics that are not yet measurable for the simulated window, keyed by package_id. Used by simulate_delivery for multi-package buys so a deferral on one package never suppresses another package's overdue gap." ), + NoExplicitNull, ] = None spend_percentage: Annotated[ StrictFloat | None, @@ -400,12 +448,14 @@ class Params(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None arm: Annotated[ Arm | None, Field( description="Response arm for the next forced operation call. Used by force_create_media_buy_arm, force_get_products_arm, force_get_signals_arm, and force_get_creative_features_arm. 'submitted' is supported for all four operations; get_creative_features also supports 'completed' with a deterministic result; create_media_buy also supports 'input-required'; get_products also supports 'rejected'. Async completion after a submitted task is covered by force_task_completion; 'working' is an out-of-band progress signal, not an initial response arm." ), + NoExplicitNull, ] = None task_id: Annotated[ str | None, @@ -413,6 +463,7 @@ class Params(AdCPBaseModel): description="Deterministic task handle the seller MUST emit verbatim on the next forced operation response when arm is 'submitted'. The seller MUST accept this exact value on subsequent tasks/get or get_task_status calls within the same authenticated sandbox account + principal pair and MUST return REFERENCE_NOT_FOUND for the same task_id under any other account or principal. Sandbox task_ids are caller-opaque strings - the seller's production task-id format rules do not apply.", max_length=128, ), + NoExplicitNull, ] = None evaluation_id: Annotated[ str | None, @@ -421,6 +472,7 @@ class Params(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None message: Annotated[ str | None, @@ -428,6 +480,7 @@ class Params(AdCPBaseModel): description='Optional human-readable explanation surfaced on the next forced operation response. Used by force_create_media_buy_arm, force_get_products_arm, force_get_signals_arm, and force_get_creative_features_arm for the submitted arm. Plain text only.', max_length=2000, ), + NoExplicitNull, ] = None reason: Annotated[ str | None, @@ -436,6 +489,7 @@ class Params(AdCPBaseModel): max_length=2000, min_length=1, ), + NoExplicitNull, ] = None suggestions: Annotated[ list[Suggestion] | None, @@ -444,6 +498,7 @@ class Params(AdCPBaseModel): max_length=20, min_length=1, ), + NoExplicitNull, ] = None format_id: Annotated[ str | None, @@ -451,12 +506,14 @@ class Params(AdCPBaseModel): deprecated=True, description='**DEPRECATED 3.x compatibility fixture.** Legacy named-format ID to seed for seed_creative_format scenarios. The seller MUST expose it through the deprecated list_creative_formats projection for the duration of the compliance session. Canonical 3.2 scenarios seed creative.supported_formats capabilities instead.', ), + NoExplicitNull, ] = None vendor: Annotated[ brand_ref.BrandReference | None, Field( description="Measurement vendor whose catalog is being seeded. Used by seed_measurement_catalog. The seller MUST treat `metrics[]` as this vendor's `get_adcp_capabilities.measurement.metrics[]` snapshot for the duration of the compliance session." ), + NoExplicitNull, ] = None metrics: Annotated[ list[Metric] | None, @@ -464,18 +521,21 @@ class Params(AdCPBaseModel): description='Measurement catalog entries to seed for the vendor. Used by seed_measurement_catalog. Shape mirrors `get_adcp_capabilities.measurement.metrics[]`; each metric_id is unique within the seeded vendor catalog.', min_length=1, ), + NoExplicitNull, ] = None tool: Annotated[ str | None, Field( description="Tool name whose upstream dependency to affect. Used by force_upstream_unavailable. The seller marks the named upstream as unreachable for subsequent calls to this tool within the compliance session. Sellers MAY accept a wildcard ('*') to affect all tools." ), + NoExplicitNull, ] = None upstream_name: Annotated[ str | None, Field( description="Human-readable identifier for the upstream dependency to force unavailable (e.g., 'inventory-service', 'creative-agent'). Used by force_upstream_unavailable. When omitted, the seller marks its default upstream for the specified tool as unavailable. Sellers MUST include the same name in STALE_RESPONSE error.details.upstream.name on the affected response." ), + NoExplicitNull, ] = None cache_age_seconds: Annotated[ SchemaInt | None, @@ -483,12 +543,14 @@ class Params(AdCPBaseModel): description='Deterministic age of the stale cache entry used by force_upstream_unavailable. When supplied, the seller MUST emit this exact value in the matching STALE_RESPONSE error.details.cache_age_seconds on the next affected response. This lets the conformance runner bind the advisory code and its required detail fields to the same errors[] entry without assuming array order.', ge=0, ), + NoExplicitNull, ] = None result: Annotated[ task_completion_data.ComplianceTaskCompletionData | None, Field( description="Deterministic result payload. force_get_creative_features_arm uses it when arm is completed and returns it on the next matching operation call. force_task_completion records it against an existing task and supports the bounded get_products, get_signals, create_media_buy, and get_creative_features completion union; polling and production SDKs resolve all task results through the originating task's manifest response mapping instead of embedding a global result union. The seller MUST preserve caller-supplied fields. Sellers MUST emit INVALID_PARAMS when the payload does not match the required response branch and MAY reject payloads exceeding 256 KB." ), + NoExplicitNull, ] = None @@ -507,9 +569,10 @@ class ComplyTestControllerRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Scenario-specific parameters. Required for all scenarios except list_scenarios.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None account: Annotated[ Account | None, Field( diff --git a/src/adcp/types/domains/compliance/comply_test_controller_response.py b/src/adcp/types/domains/compliance/comply_test_controller_response.py index b4a45b863..24481ec89 100644 --- a/src/adcp/types/domains/compliance/comply_test_controller_response.py +++ b/src/adcp/types/domains/compliance/comply_test_controller_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: compliance/comply_test_controller_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -11,7 +11,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool @@ -47,6 +47,7 @@ class Forced(AdCPBaseModel): description="Echo of the registered task_id. Present only when arm is 'submitted' (the arm that emits a task envelope).", max_length=128, ), + NoExplicitNull, ] = None evaluation_id: Annotated[ str | None, @@ -55,6 +56,7 @@ class Forced(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None reason: Annotated[ str | None, @@ -63,6 +65,7 @@ class Forced(AdCPBaseModel): max_length=2000, min_length=1, ), + NoExplicitNull, ] = None suggestions: Annotated[ list[Suggestion] | None, @@ -71,6 +74,7 @@ class Forced(AdCPBaseModel): max_length=20, min_length=1, ), + NoExplicitNull, ] = None @@ -139,9 +143,12 @@ class RecordedCalls1(AdCPBaseModel): Field( description='Host portion of the URL, useful for grouping calls by upstream platform.' ), + NoExplicitNull, ] = None path: Annotated[ - str | None, Field(description='Path portion of the URL (without query string).') + str | None, + Field(description='Path portion of the URL (without query string).'), + NoExplicitNull, ] = None content_type: Annotated[ str, @@ -160,6 +167,7 @@ class RecordedCalls1(AdCPBaseModel): Field( description="Optional adopter-supplied semantic tag for the call's role. Values: `platform_primary` for the primary upstream platform the adapter is integrating with (e.g., a TikTok audience-upload call from a sales-social adapter); `measurement` for ancillary calls to measurement vendors (DV, IAS, Nielsen, MOAT); `attribution` for server-side conversion APIs (TTD Trans-API, Meta CAPI, AppsFlyer/Branch postbacks) that flow alongside primary platform calls in a buy-step; `creative_serving` for ad-server / CDN / tag-build calls (GAM tag generation, VAST/CDN fetches, creative trafficking); `identity` for ID-graph / hashing-service calls (LiveRamp, ID5, UID2); `other` for everything else (config fetches, internal telemetry, consent signal exchange). Lets storyboards scope `upstream_traffic` assertions via `purpose_filter` so a buyer-agent adapter that legitimately calls measurement vendors during a single buy step doesn't muddy the platform-primary assertion. Calls without a `purpose` field are treated as `purpose: other` for `purpose_filter` matching — adopters who haven't classified are matched only by storyboards filtering on `other` (or by storyboards with no `purpose_filter`). Self-reported, not adversarially trustworthy — same trust model as the rest of recorded_calls; misclassification by a façade is bounded by the runner's reporting of unclassified-call counts in `actual` when filters match zero." ), + NoExplicitNull, ] = None payload: Annotated[ Any, @@ -174,6 +182,7 @@ class RecordedCalls1(AdCPBaseModel): description='SHA-256 digest of the canonicalized outbound request body, lowercase hex (64 chars). Required when `attestation_mode` is `digest`; MUST be absent when `attestation_mode` is `raw`. Canonicalization order is normative: (1) controllers MUST apply the recursive secret-key redaction (same pattern as the raw-mode payload field) BEFORE computing the digest; (2) for `application/json` and `*+json` content types, controllers MUST then serialize the redacted body to RFC 8785 (JCS) canonical form — sorted keys, no extraneous whitespace — and digest the resulting bytes. Storyboard-supplied identifiers (hashed PII, request correlation values) MUST NOT be redacted before digest computation; they are the load-bearing match target for `identifier_match_proofs` and digesting them away makes echo verification impossible. Both `payload_digest_sha256` AND `identifier_match_proofs` MUST be computed against the same post-redaction canonical bytes — diverging the two surfaces breaks coherence between digest replay and identifier echo. JCS edge cases: when a digest-mode `query_upstream_traffic` response cannot be produced because the parsed JSON-like value tree contains a non-finite numeric value (`NaN`, `+Infinity`, or `-Infinity`), controllers MUST NOT coerce that value to `null`, a string, or any other placeholder for digest computation; they MUST return the typed ControllerError code `JCS_NON_FINITE_NUMBER`. Runners MUST grade the affected upstream_traffic digest validation `not_applicable` because RFC 8785 forbids non-finite numbers. Payloads carrying numeric identifiers MUST serialize them as JSON strings before digest computation (adtech bid payloads regularly carry IDs outside ±2^53 where I-JSON / RFC 7493 number round-tripping diverges across implementations). For non-JSON content types the digest is computed over the post-redaction raw body bytes.', pattern='^[a-f0-9]{64}$', ), + NoExplicitNull, ] = None payload_length: Annotated[ SchemaInt, @@ -188,6 +197,7 @@ class RecordedCalls1(AdCPBaseModel): description='Per-identifier echo proofs for digest-mode calls. Required when `attestation_mode` is `digest` AND the request supplied `params.identifier_value_digests`; MUST be absent or empty otherwise. Each entry corresponds to one digest from the request. Capped at 64 to match the request-side `params.identifier_value_digests` cap. Lets storyboards verify `identifier_paths` echo in digest mode without ever transmitting plaintext identifiers to the controller. SHA-256 is a privacy mechanism here, not a trust mechanism — controllers self-report `found` and a determined façade can return any boolean; consumers MUST NOT treat digest-mode passing as cryptographically more trustworthy than raw mode. Tokenization is normative: for `application/json` and `*+json` content types, controllers MUST scan exactly the JSON string-typed leaf values of the post-redaction canonicalized body — no substring matching, no word splitting, no case folding, no Unicode normalization. A token matches when its SHA-256 hash equals one of the requested digests byte-for-byte. For non-JSON content types (form-urlencoded, multipart, plain text), `identifier_match_proofs` MUST be empty and runner-side `identifier_paths` assertions targeting those calls grade `not_applicable` — token boundaries are not portably defined across non-JSON shapes.', max_length=64, ), + NoExplicitNull, ] = None timestamp: Annotated[ AwareDatetime, @@ -202,6 +212,7 @@ class RecordedCalls1(AdCPBaseModel): ge=100, le=599, ), + NoExplicitNull, ] = None @@ -227,9 +238,12 @@ class RecordedCalls2(AdCPBaseModel): Field( description='Host portion of the URL, useful for grouping calls by upstream platform.' ), + NoExplicitNull, ] = None path: Annotated[ - str | None, Field(description='Path portion of the URL (without query string).') + str | None, + Field(description='Path portion of the URL (without query string).'), + NoExplicitNull, ] = None content_type: Annotated[ str, @@ -248,6 +262,7 @@ class RecordedCalls2(AdCPBaseModel): Field( description="Optional adopter-supplied semantic tag for the call's role. Values: `platform_primary` for the primary upstream platform the adapter is integrating with (e.g., a TikTok audience-upload call from a sales-social adapter); `measurement` for ancillary calls to measurement vendors (DV, IAS, Nielsen, MOAT); `attribution` for server-side conversion APIs (TTD Trans-API, Meta CAPI, AppsFlyer/Branch postbacks) that flow alongside primary platform calls in a buy-step; `creative_serving` for ad-server / CDN / tag-build calls (GAM tag generation, VAST/CDN fetches, creative trafficking); `identity` for ID-graph / hashing-service calls (LiveRamp, ID5, UID2); `other` for everything else (config fetches, internal telemetry, consent signal exchange). Lets storyboards scope `upstream_traffic` assertions via `purpose_filter` so a buyer-agent adapter that legitimately calls measurement vendors during a single buy step doesn't muddy the platform-primary assertion. Calls without a `purpose` field are treated as `purpose: other` for `purpose_filter` matching — adopters who haven't classified are matched only by storyboards filtering on `other` (or by storyboards with no `purpose_filter`). Self-reported, not adversarially trustworthy — same trust model as the rest of recorded_calls; misclassification by a façade is bounded by the runner's reporting of unclassified-call counts in `actual` when filters match zero." ), + NoExplicitNull, ] = None payload: Annotated[ Any | None, @@ -276,6 +291,7 @@ class RecordedCalls2(AdCPBaseModel): description='Per-identifier echo proofs for digest-mode calls. Required when `attestation_mode` is `digest` AND the request supplied `params.identifier_value_digests`; MUST be absent or empty otherwise. Each entry corresponds to one digest from the request. Capped at 64 to match the request-side `params.identifier_value_digests` cap. Lets storyboards verify `identifier_paths` echo in digest mode without ever transmitting plaintext identifiers to the controller. SHA-256 is a privacy mechanism here, not a trust mechanism — controllers self-report `found` and a determined façade can return any boolean; consumers MUST NOT treat digest-mode passing as cryptographically more trustworthy than raw mode. Tokenization is normative: for `application/json` and `*+json` content types, controllers MUST scan exactly the JSON string-typed leaf values of the post-redaction canonicalized body — no substring matching, no word splitting, no case folding, no Unicode normalization. A token matches when its SHA-256 hash equals one of the requested digests byte-for-byte. For non-JSON content types (form-urlencoded, multipart, plain text), `identifier_match_proofs` MUST be empty and runner-side `identifier_paths` assertions targeting those calls grade `not_applicable` — token boundaries are not portably defined across non-JSON shapes.', max_length=64, ), + NoExplicitNull, ] = None timestamp: Annotated[ AwareDatetime, @@ -290,6 +306,7 @@ class RecordedCalls2(AdCPBaseModel): ge=100, le=599, ), + NoExplicitNull, ] = None @@ -338,8 +355,8 @@ class ComplyTestControllerResponse1(ComplyTestControllerResponse): description='Scenarios this seller has implemented. Runners and sellers MUST accept unknown scenario strings (open-for-extension) — new scenarios may be added in additive releases. Adopters who advertise `catalog_item_availability_probe` support deterministic cross-principal reference, eligibility-gate, expiry-clock, and catalog-generation tests for the catalog availability storyboard. Adopters who advertise `compact_product_lifecycle_probe` support deterministic synchronous list/request/finalize/decline/accept/control/readback behavior for a prepared product and strict post-deadline expiry of a committed proposal. Adopters who advertise `compact_direct_buy_lifecycle_probe` support deterministic synchronous list/buy/control/readback behavior for a prepared product. Adopters who advertise `reporting_core_lifecycle_probe` support deterministic obligation-before-report, clock-health, zero-row reporting, provisional-restatement, and post-received restatement-grace tests without wall-clock waits. `reliable_reporting_core_integrity_probe`, `reliable_reporting_managed_delivery_probe`, and `reliable_reporting_reconciled_billing_probe` seed the source-calendar/checkpoint, managed-resource, and receipt/adjustment workflows used by the Reliable Reporting tier storyboards. Adopters who advertise `force_creative_purge` opt in to deterministic creative purge coverage for account-level lifecycle webhooks. Adopters who advertise `force_media_buy_purge` opt in to deterministic deletion-independent idempotency replay coverage. Adopters who advertise `seed_measurement_catalog` opt in to deterministic measurement-catalog fixtures used by vendor_metric precondition storyboards. Adopters who advertise `query_upstream_traffic` opt in to the upstream-traffic conformance contract; storyboards that declare `check: upstream_traffic` grade not_applicable against adopters who do not advertise it. Adopters who advertise `query_provenance_audit_observations` opt in to sandbox-only audit-observation assertions for accepted creatives. Adopters who advertise `force_upstream_unavailable` opt in to stale-cache conformance testing via the `stale_response_advisory` storyboard.' ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ComplyTestControllerResponse2(ComplyTestControllerResponse): @@ -350,10 +367,12 @@ class ComplyTestControllerResponse2(ComplyTestControllerResponse): previous_state: Annotated[str, Field(description='State before this transition')] current_state: Annotated[str, Field(description='State after this transition')] message: Annotated[ - str | None, Field(description='Human-readable description of the transition') + str | None, + Field(description='Human-readable description of the transition'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ComplyTestControllerResponse3(ComplyTestControllerResponse): @@ -368,10 +387,11 @@ class ComplyTestControllerResponse3(ComplyTestControllerResponse): cumulative: Annotated[ dict[str, Any] | None, Field(description='Running totals across all simulation calls (simulate_delivery only)'), + NoExplicitNull, ] = None - message: str | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + message: Annotated[str | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ComplyTestControllerResponse4(ComplyTestControllerResponse): @@ -385,9 +405,11 @@ class ComplyTestControllerResponse4(ComplyTestControllerResponse): description='Echo of the registered directive. The next matching operation call from this sandbox account will return the named arm.' ), ] - message: Annotated[str | None, Field(description='Human-readable acknowledgement.')] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + message: Annotated[ + str | None, Field(description='Human-readable acknowledgement.'), NoExplicitNull + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ComplyTestControllerResponse5(ComplyTestControllerResponse): @@ -395,9 +417,11 @@ class ComplyTestControllerResponse5(ComplyTestControllerResponse): extra='allow', ) success: Literal[True] - message: Annotated[str | None, Field(description='Human-readable acknowledgement.')] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + message: Annotated[ + str | None, Field(description='Human-readable acknowledgement.'), NoExplicitNull + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ComplyTestControllerResponse6(ComplyTestControllerResponse): @@ -412,8 +436,8 @@ class ComplyTestControllerResponse6(ComplyTestControllerResponse): list[audit_observation.CreativeAuditObservation], Field(description='Audit observations recorded for the creative in the sandbox session.'), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ComplyTestControllerResponse7(ComplyTestControllerResponse): @@ -439,6 +463,7 @@ class ComplyTestControllerResponse7(ComplyTestControllerResponse): Field( description='True when `total_count > recorded_calls.length`. Runners MAY raise the `limit` and re-query, but storyboards SHOULD declare assertions that fit within the default 100-call window — a truncated response is a signal that the storyboard step is causing more upstream activity than expected.' ), + NoExplicitNull, ] = None since_timestamp: Annotated[ AwareDatetime, @@ -446,8 +471,8 @@ class ComplyTestControllerResponse7(ComplyTestControllerResponse): description="Echo of the `since_timestamp` the runner requested (or the session-start timestamp the adopter substituted when the runner omitted it). Informational — the runner SHOULD use its own clock-bracket of when it issued the AdCP step request, not this echo, when attributing recorded_calls to a specific step. The controller is part of the adopter's claimed conformance; the echo is not adversarially trustworthy." ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ComplyTestControllerResponse8(ComplyTestControllerResponse): @@ -462,10 +487,10 @@ class ComplyTestControllerResponse8(ComplyTestControllerResponse): ), ] error_detail: Annotated[ - str | None, Field(description='Human-readable explanation of the failure') + str | None, Field(description='Human-readable explanation of the failure'), NoExplicitNull ] = None current_state: Annotated[ str | None, Field(description='Current state of the entity, or null if not found') ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/compliance/get_creative_features_completion.py b/src/adcp/types/domains/compliance/get_creative_features_completion.py index a7fd506c2..2206975c7 100644 --- a/src/adcp/types/domains/compliance/get_creative_features_completion.py +++ b/src/adcp/types/domains/compliance/get_creative_features_completion.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: compliance/get_creative_features_completion.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..core import creative_consumption @@ -20,7 +20,7 @@ class GetCreativeFeaturesComplianceCompletion(AdCPBaseModel): status: Literal['completed'] = 'completed' evaluation_id: Annotated[str, Field(min_length=1)] results: list[creative_feature_result.CreativeFeatureResult] - pricing_option_id: str | None = None - vendor_cost: Annotated[StrictFloat | None, Field(ge=0.0)] = None - currency: Annotated[str | None, Field(pattern='^[A-Z]{3}$')] = None - consumption: creative_consumption.CreativeConsumption | None = None + pricing_option_id: Annotated[str | None, NoExplicitNull] = None + vendor_cost: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + currency: Annotated[str | None, Field(pattern='^[A-Z]{3}$'), NoExplicitNull] = None + consumption: Annotated[creative_consumption.CreativeConsumption | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/artifact.py b/src/adcp/types/domains/content_standards/artifact.py index 82e314ab3..a74834062 100644 --- a/src/adcp/types/domains/content_standards/artifact.py +++ b/src/adcp/types/domains/content_standards/artifact.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: content_standards/artifact.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel from ..core import format_id as format_id_1 @@ -55,17 +55,19 @@ class Metadata(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - canonical: Annotated[AnyUrl | None, Field(description='Canonical URL')] = None - author: Annotated[str | None, Field(description='Artifact author name')] = None - keywords: Annotated[str | None, Field(description='Artifact keywords')] = None + canonical: Annotated[AnyUrl | None, Field(description='Canonical URL'), NoExplicitNull] = None + author: Annotated[str | None, Field(description='Artifact author name'), NoExplicitNull] = None + keywords: Annotated[str | None, Field(description='Artifact keywords'), NoExplicitNull] = None open_graph: Annotated[ - dict[str, Any] | None, Field(description='Open Graph protocol metadata') + dict[str, Any] | None, Field(description='Open Graph protocol metadata'), NoExplicitNull + ] = None + twitter_card: Annotated[ + dict[str, Any] | None, Field(description='Twitter Card metadata'), NoExplicitNull ] = None - twitter_card: Annotated[dict[str, Any] | None, Field(description='Twitter Card metadata')] = ( - None - ) json_ld: Annotated[ - list[dict[str, Any]] | None, Field(description='JSON-LD structured data (schema.org)') + list[dict[str, Any]] | None, + Field(description='JSON-LD structured data (schema.org)'), + NoExplicitNull, ] = None @@ -73,11 +75,19 @@ class Identifiers(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - apple_podcast_id: Annotated[str | None, Field(description='Apple Podcasts ID')] = None - spotify_collection_id: Annotated[str | None, Field(description='Spotify collection ID')] = None - podcast_guid: Annotated[str | None, Field(description='Podcast GUID (from RSS feed)')] = None - youtube_video_id: Annotated[str | None, Field(description='YouTube video ID')] = None - rss_url: Annotated[AnyUrl | None, Field(description='RSS feed URL')] = None + apple_podcast_id: Annotated[ + str | None, Field(description='Apple Podcasts ID'), NoExplicitNull + ] = None + spotify_collection_id: Annotated[ + str | None, Field(description='Spotify collection ID'), NoExplicitNull + ] = None + podcast_guid: Annotated[ + str | None, Field(description='Podcast GUID (from RSS feed)'), NoExplicitNull + ] = None + youtube_video_id: Annotated[ + str | None, Field(description='YouTube video ID'), NoExplicitNull + ] = None + rss_url: Annotated[AnyUrl | None, Field(description='RSS feed URL'), NoExplicitNull] = None class AssetAccess1(AdCPBaseModel): @@ -104,6 +114,7 @@ class AssetAccess2(AdCPBaseModel): deprecated=True, description="Deprecated in AdCP 3.2 and eligible for removal in 4.0 or later only after the deprecation-policy notice and release-cycle gates are satisfied. Presence denotes the legacy inline-credential form, not workload-identity authorization. New producers MUST omit this field. Consumers MUST NOT activate received credentials automatically; they may process them only for an explicitly allowlisted legacy peer and otherwise MUST reject or quarantine them. Consumers MUST redact the complete value from logs, traces, errors, metrics, analytics, model context, and durable storage. Use the consumer's pre-authorized workload identity or `signed_url` instead.", ), + NoExplicitNull, ] = None @@ -134,6 +145,7 @@ class Assets1(AdCPBaseModel): Field( description="Role of this text in the document. Use 'title' for the main artifact title, 'description' for summaries." ), + NoExplicitNull, ] = None content: Annotated[ str, @@ -147,47 +159,58 @@ class Assets1(AdCPBaseModel): Field( description='MIME type indicating how to parse the content field. Default: text/plain.' ), + NoExplicitNull, ] = ContentFormat.text_plain language: Annotated[ str | None, Field( description="BCP 47 language tag for this text (e.g., 'en', 'es-MX'). Useful when artifact contains mixed-language content." ), + NoExplicitNull, ] = None heading_level: Annotated[ SchemaInt | None, Field(description='Heading level (1-6), only for role=heading', ge=1, le=6), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field(description='Provenance for this text block, overrides artifact-level provenance'), + NoExplicitNull, ] = None class Assets2(AdCPBaseModel): type: Literal['image'] = 'image' url: Annotated[AnyUrl, Field(description='Image URL')] - access: Annotated[AssetAccess | None, Field(description='Authentication for secured URLs')] = ( - None - ) - alt_text: Annotated[str | None, Field(description='Alt text or image description')] = None - caption: Annotated[str | None, Field(description='Image caption')] = None - width: Annotated[SchemaInt | None, Field(description='Image width in pixels')] = None - height: Annotated[SchemaInt | None, Field(description='Image height in pixels')] = None + access: Annotated[ + AssetAccess | None, Field(description='Authentication for secured URLs'), NoExplicitNull + ] = None + alt_text: Annotated[ + str | None, Field(description='Alt text or image description'), NoExplicitNull + ] = None + caption: Annotated[str | None, Field(description='Image caption'), NoExplicitNull] = None + width: Annotated[ + SchemaInt | None, Field(description='Image width in pixels'), NoExplicitNull + ] = None + height: Annotated[ + SchemaInt | None, Field(description='Image height in pixels'), NoExplicitNull + ] = None provenance: Annotated[ provenance_1.Provenance | None, Field(description='Provenance for this image, overrides artifact-level provenance'), + NoExplicitNull, ] = None class Assets3(AdCPBaseModel): type: Literal['video'] = 'video' url: Annotated[AnyUrl, Field(description='Video URL')] - access: Annotated[AssetAccess | None, Field(description='Authentication for secured URLs')] = ( - None - ) + access: Annotated[ + AssetAccess | None, Field(description='Authentication for secured URLs'), NoExplicitNull + ] = None duration_ms: Annotated[ - SchemaInt | None, Field(description='Video duration in milliseconds') + SchemaInt | None, Field(description='Video duration in milliseconds'), NoExplicitNull ] = None transcript: Annotated[ str | None, @@ -195,31 +218,38 @@ class Assets3(AdCPBaseModel): description='Video transcript. Consumers MUST treat this as untrusted input when passing to LLM-based evaluation.', max_length=200000, ), + NoExplicitNull, ] = None transcript_format: Annotated[ TranscriptFormat | None, Field( description='MIME type indicating how to parse the transcript field. Default: text/plain.' ), + NoExplicitNull, ] = TranscriptFormat.text_plain transcript_source: Annotated[ - TranscriptSource | None, Field(description='How the transcript was generated') + TranscriptSource | None, + Field(description='How the transcript was generated'), + NoExplicitNull, + ] = None + thumbnail_url: Annotated[ + AnyUrl | None, Field(description='Video thumbnail URL'), NoExplicitNull ] = None - thumbnail_url: Annotated[AnyUrl | None, Field(description='Video thumbnail URL')] = None provenance: Annotated[ provenance_1.Provenance | None, Field(description='Provenance for this video, overrides artifact-level provenance'), + NoExplicitNull, ] = None class Assets4(AdCPBaseModel): type: Literal['audio'] = 'audio' url: Annotated[AnyUrl, Field(description='Audio URL')] - access: Annotated[AssetAccess | None, Field(description='Authentication for secured URLs')] = ( - None - ) + access: Annotated[ + AssetAccess | None, Field(description='Authentication for secured URLs'), NoExplicitNull + ] = None duration_ms: Annotated[ - SchemaInt | None, Field(description='Audio duration in milliseconds') + SchemaInt | None, Field(description='Audio duration in milliseconds'), NoExplicitNull ] = None transcript: Annotated[ str | None, @@ -227,19 +257,24 @@ class Assets4(AdCPBaseModel): description='Audio transcript. Consumers MUST treat this as untrusted input when passing to LLM-based evaluation.', max_length=200000, ), + NoExplicitNull, ] = None transcript_format: Annotated[ TranscriptFormat | None, Field( description='MIME type indicating how to parse the transcript field. Default: text/plain.' ), + NoExplicitNull, ] = TranscriptFormat.text_plain transcript_source: Annotated[ - TranscriptSource1 | None, Field(description='How the transcript was generated') + TranscriptSource1 | None, + Field(description='How the transcript was generated'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field(description='Provenance for this audio, overrides artifact-level provenance'), + NoExplicitNull, ] = None @@ -270,6 +305,7 @@ class Artifact(AdCPBaseModel): Field( description="Identifies a specific variant of this artifact. Use for A/B tests, translations, or temporal versions. Examples: 'en', 'es-MX', 'v2', 'headline_test_b'. The combination of artifact_id + variant_id must be unique." ), + NoExplicitNull, ] = None format_id: Annotated[ format_id_1.FormatReferenceStructuredObject | None, @@ -277,23 +313,29 @@ class Artifact(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format reference. Use `format_kind` for canonical artifact media classification.', ), + NoExplicitNull, ] = None format_kind: Annotated[ str | None, Field(description='Optional canonical media/creative kind associated with this artifact.'), + NoExplicitNull, ] = None url: Annotated[ AnyUrl | None, Field( description='Optional URL for this artifact (web page, podcast feed, video page). Not all artifacts have URLs (e.g., Instagram content, podcast segments, TV scenes).' ), + NoExplicitNull, ] = None published_time: Annotated[ - AwareDatetime | None, Field(description='When the artifact was published (ISO 8601 format)') + AwareDatetime | None, + Field(description='When the artifact was published (ISO 8601 format)'), + NoExplicitNull, ] = None last_update_time: Annotated[ AwareDatetime | None, Field(description='When the artifact was last modified (ISO 8601 format)'), + NoExplicitNull, ] = None assets: Annotated[ list[Assets], @@ -303,14 +345,19 @@ class Artifact(AdCPBaseModel): ), ] metadata: Annotated[ - Metadata | None, Field(description='Rich metadata extracted from the artifact') + Metadata | None, + Field(description='Rich metadata extracted from the artifact'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this artifact. Serves as the default provenance for all assets within this artifact — individual assets can override with their own provenance.' ), + NoExplicitNull, ] = None identifiers: Annotated[ - Identifiers | None, Field(description='Platform-specific identifiers for this artifact') + Identifiers | None, + Field(description='Platform-specific identifiers for this artifact'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/content_standards/artifact_webhook_payload.py b/src/adcp/types/domains/content_standards/artifact_webhook_payload.py index 39c89399d..668cc3e35 100644 --- a/src/adcp/types/domains/content_standards/artifact_webhook_payload.py +++ b/src/adcp/types/domains/content_standards/artifact_webhook_payload.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: content_standards/artifact_webhook_payload.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from ..core import ext as ext_1 @@ -15,13 +15,17 @@ class Pagination(AdCPBaseModel): total_artifacts: Annotated[ - SchemaInt | None, Field(description='Total artifacts in the delivery period') + SchemaInt | None, + Field(description='Total artifacts in the delivery period'), + NoExplicitNull, ] = None batch_number: Annotated[ - SchemaInt | None, Field(description='Current batch number (1-indexed)') + SchemaInt | None, Field(description='Current batch number (1-indexed)'), NoExplicitNull ] = None total_batches: Annotated[ - SchemaInt | None, Field(description='Total batches for this delivery period') + SchemaInt | None, + Field(description='Total batches for this delivery period'), + NoExplicitNull, ] = None @@ -33,9 +37,12 @@ class Artifact(AdCPBaseModel): impression_id: Annotated[ str | None, Field(description='Optional impression identifier for correlation with delivery reports'), + NoExplicitNull, ] = None package_id: Annotated[ - str | None, Field(description='Package within the media buy this artifact relates to') + str | None, + Field(description='Package within the media buy this artifact relates to'), + NoExplicitNull, ] = None @@ -68,6 +75,8 @@ class ArtifactWebhookPayload(AdCPBaseModel): list[Artifact], Field(description='Content artifacts from delivered impressions') ] pagination: Annotated[ - Pagination | None, Field(description='Pagination info when batching large artifact sets') + Pagination | None, + Field(description='Pagination info when batching large artifact sets'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/calibrate_content_request.py b/src/adcp/types/domains/content_standards/calibrate_content_request.py index f22e59cc6..e13d9a84c 100644 --- a/src/adcp/types/domains/content_standards/calibrate_content_request.py +++ b/src/adcp/types/domains/content_standards/calibrate_content_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/calibrate_content_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import Field from ..core import context as context_1 @@ -28,5 +29,5 @@ class CalibrateContentRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/content_standards.py b/src/adcp/types/domains/content_standards/content_standards.py index 18137b446..66390fa00 100644 --- a/src/adcp/types/domains/content_standards/content_standards.py +++ b/src/adcp/types/domains/content_standards/content_standards.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: content_standards/content_standards.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import Field from ..core import ext as ext_1 @@ -20,10 +20,12 @@ class CalibrationExemplars(AdCPBaseModel): pass_: Annotated[ list[artifact.Artifact] | None, Field(alias='pass', description='Artifacts that pass the content standards'), + NoExplicitNull, ] = None fail: Annotated[ list[artifact.Artifact] | None, Field(description='Artifacts that fail the content standards'), + NoExplicitNull, ] = None @@ -32,7 +34,9 @@ class ContentStandards(AdCPBaseModel): str, Field(description='Unique identifier for this standards configuration') ] name: Annotated[ - str | None, Field(description='Human-readable name for this standards configuration') + str | None, + Field(description='Human-readable name for this standards configuration'), + NoExplicitNull, ] = None countries_all: Annotated[ list[str] | None, @@ -40,6 +44,7 @@ class ContentStandards(AdCPBaseModel): description='ISO 3166-1 alpha-2 country codes. Standards apply in ALL listed countries (AND logic).', min_length=1, ), + NoExplicitNull, ] = None channels_any: Annotated[ list[channels.MediaChannel] | None, @@ -47,6 +52,7 @@ class ContentStandards(AdCPBaseModel): description='Advertising channels. Standards apply to ANY of the listed channels (OR logic).', min_length=1, ), + NoExplicitNull, ] = None languages_any: Annotated[ list[str] | None, @@ -54,6 +60,7 @@ class ContentStandards(AdCPBaseModel): description="BCP 47 language tags (e.g., 'en', 'de', 'fr'). Standards apply to content in ANY of these languages (OR logic). Content in unlisted languages is not covered by these standards.", min_length=1, ), + NoExplicitNull, ] = None policies: Annotated[ list[policy_entry.PolicyEntry] | None, @@ -61,12 +68,14 @@ class ContentStandards(AdCPBaseModel): description='Bespoke policies for this content-standards configuration, using the same shape as registry entries. Each policy is addressable by policy_id; governance findings reference the policy_id that triggered them.', min_length=1, ), + NoExplicitNull, ] = None calibration_exemplars: Annotated[ CalibrationExemplars | None, Field( description='Training/test set to calibrate policy interpretation. Provides concrete examples of pass/fail decisions.' ), + NoExplicitNull, ] = None pricing_options: Annotated[ list[vendor_pricing_option.VendorPricingOption] | None, @@ -74,5 +83,6 @@ class ContentStandards(AdCPBaseModel): description='Pricing options for this content standards service. The buyer passes the selected pricing_option_id in report_usage for billing verification.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/create_content_standards_request.py b/src/adcp/types/domains/content_standards/create_content_standards_request.py index e4b269173..4f6cc84e3 100644 --- a/src/adcp/types/domains/content_standards/create_content_standards_request.py +++ b/src/adcp/types/domains/content_standards/create_content_standards_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/create_content_standards_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, model_validator from ..core import context as context_1 @@ -29,6 +29,7 @@ class Scope(AdCPBaseModel): description='ISO 3166-1 alpha-2 country codes. Standards apply in ALL listed countries (AND logic).', min_length=1, ), + NoExplicitNull, ] = None channels_any: Annotated[ list[channels.MediaChannel] | None, @@ -36,6 +37,7 @@ class Scope(AdCPBaseModel): description='Advertising channels. Standards apply to ANY of the listed channels (OR logic).', min_length=1, ), + NoExplicitNull, ] = None languages_any: Annotated[ list[str], @@ -45,7 +47,7 @@ class Scope(AdCPBaseModel): ), ] description: Annotated[ - str | None, Field(description='Human-readable description of this scope') + str | None, Field(description='Human-readable description of this scope'), NoExplicitNull ] = None @@ -58,7 +60,7 @@ class Pass(AdCPBaseModel): ), ] language: Annotated[ - str | None, Field(description='BCP 47 language tag for content at this URL') + str | None, Field(description='BCP 47 language tag for content at this URL'), NoExplicitNull ] = None @@ -71,7 +73,7 @@ class Fail(AdCPBaseModel): ), ] language: Annotated[ - str | None, Field(description='BCP 47 language tag for content at this URL') + str | None, Field(description='BCP 47 language tag for content at this URL'), NoExplicitNull ] = None @@ -79,9 +81,12 @@ class CalibrationExemplars(AdCPBaseModel): pass_: Annotated[ list[Pass | artifact.Artifact] | None, Field(alias='pass', description='Content that passes the standards'), + NoExplicitNull, ] = None fail: Annotated[ - list[Fail | artifact.Artifact] | None, Field(description='Content that fails the standards') + list[Fail | artifact.Artifact] | None, + Field(description='Content that fails the standards'), + NoExplicitNull, ] = None @@ -95,6 +100,7 @@ class CreateContentStandardsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="Registry policy IDs to use as the evaluation basis for this content standard. When provided, the agent resolves policies from the registry and uses their policy text and exemplars as the evaluation criteria. The 'policy' field becomes optional when registry_policy_ids is provided." ), + NoExplicitNull, ] = None policies: Annotated[ list[policy_entry.PolicyEntry] | None, @@ -102,12 +108,14 @@ class CreateContentStandardsRequest(AdcpRequest, AdcpVersionEnvelope): description='Bespoke policies for this content-standards configuration, using the same shape as registry entries. Each policy is addressable by policy_id and carries its own enforcement (must|should); governance findings reference the policy_id that triggered them. Inline bespoke policies can omit version/name/category (defaulted by the server). Combines with registry_policy_ids — registry policies and bespoke policies are both evaluated. Bespoke policy_ids MUST be flat (no colons/slashes) to avoid collision with namespaced registry ids.', min_length=1, ), + NoExplicitNull, ] = None calibration_exemplars: Annotated[ CalibrationExemplars | None, Field( description='Training/test set to calibrate policy interpretation. Use URL references for pages to be fetched and analyzed, or full artifacts for pre-extracted content.' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -118,8 +126,8 @@ class CreateContentStandardsRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> CreateContentStandardsRequest: diff --git a/src/adcp/types/domains/content_standards/create_content_standards_response.py b/src/adcp/types/domains/content_standards/create_content_standards_response.py index bab6dbf3c..e407ccce2 100644 --- a/src/adcp/types/domains/content_standards/create_content_standards_response.py +++ b/src/adcp/types/domains/content_standards/create_content_standards_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/create_content_standards_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -10,6 +10,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import Field from ..core import context as context_1 @@ -34,8 +35,8 @@ class CreateContentStandardsResponse1(CreateContentStandardsResponse): standards_id: Annotated[ str, Field(description='Unique identifier for the created standards configuration') ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class CreateContentStandardsResponse2(CreateContentStandardsResponse): @@ -45,6 +46,7 @@ class CreateContentStandardsResponse2(CreateContentStandardsResponse): Field( description='If the error is a scope conflict, the ID of the existing standards that conflict' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/get_content_standards_request.py b/src/adcp/types/domains/content_standards/get_content_standards_request.py index f9a21cf14..d6df04f8c 100644 --- a/src/adcp/types/domains/content_standards/get_content_standards_request.py +++ b/src/adcp/types/domains/content_standards/get_content_standards_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/get_content_standards_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import Field from ..core import context as context_1 @@ -19,5 +20,5 @@ class GetContentStandardsRequest(AdcpRequest, AdcpVersionEnvelope): standards_id: Annotated[ str, Field(description='Identifier for the standards configuration to retrieve') ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/get_media_buy_artifacts_request.py b/src/adcp/types/domains/content_standards/get_media_buy_artifacts_request.py index e2128bcea..032313abd 100644 --- a/src/adcp/types/domains/content_standards/get_media_buy_artifacts_request.py +++ b/src/adcp/types/domains/content_standards/get_media_buy_artifacts_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/get_media_buy_artifacts_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..core import account_ref @@ -18,10 +18,12 @@ class TimeRange(AdCPBaseModel): - start: Annotated[AwareDatetime | None, Field(description='Start of time range (inclusive)')] = ( - None - ) - end: Annotated[AwareDatetime | None, Field(description='End of time range (exclusive)')] = None + start: Annotated[ + AwareDatetime | None, Field(description='Start of time range (inclusive)'), NoExplicitNull + ] = None + end: Annotated[ + AwareDatetime | None, Field(description='End of time range (exclusive)'), NoExplicitNull + ] = None class Pagination(AdCPBaseModel): @@ -31,10 +33,12 @@ class Pagination(AdCPBaseModel): max_results: Annotated[ SchemaInt | None, Field(description='Maximum number of artifacts to return per page', ge=1, le=10000), + NoExplicitNull, ] = 1000 cursor: Annotated[ str | None, Field(description='Opaque cursor from a previous response to fetch the next page'), + NoExplicitNull, ] = None @@ -44,26 +48,30 @@ class GetMediaBuyArtifactsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Filter artifacts to a specific account. When omitted, returns artifacts across all accessible accounts.' ), + NoExplicitNull, ] = None media_buy_id: Annotated[str, Field(description='Media buy to get artifacts from')] package_ids: Annotated[ list[str] | None, Field(description='Filter to specific packages within the media buy', min_length=1), + NoExplicitNull, ] = None failures_only: Annotated[ StrictBool | None, Field( description="When true, only return artifacts where the seller's local model returned local_verdict: 'fail'. Useful for auditing false positives. Not useful when the seller does not run a local evaluation model (all verdicts are 'unevaluated')." ), + NoExplicitNull, ] = False - time_range: Annotated[TimeRange | None, Field(description='Filter to specific time period')] = ( - None - ) + time_range: Annotated[ + TimeRange | None, Field(description='Filter to specific time period'), NoExplicitNull + ] = None pagination: Annotated[ Pagination | None, Field( description='Pagination parameters. Uses higher limits than standard pagination because artifact result sets can be very large.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/list_content_standards_request.py b/src/adcp/types/domains/content_standards/list_content_standards_request.py index 0669ef86a..632eff837 100644 --- a/src/adcp/types/domains/content_standards/list_content_standards_request.py +++ b/src/adcp/types/domains/content_standards/list_content_standards_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/list_content_standards_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -22,15 +23,20 @@ class ListContentStandardsRequest(AdcpRequest, AdcpVersionEnvelope): extra='allow', ) channels: Annotated[ - list[channels_1.MediaChannel] | None, Field(description='Filter by channel', min_length=1) + list[channels_1.MediaChannel] | None, + Field(description='Filter by channel', min_length=1), + NoExplicitNull, ] = None languages: Annotated[ - list[str] | None, Field(description='Filter by BCP 47 language tags', min_length=1) + list[str] | None, + Field(description='Filter by BCP 47 language tags', min_length=1), + NoExplicitNull, ] = None countries: Annotated[ list[str] | None, Field(description='Filter by ISO 3166-1 alpha-2 country codes', min_length=1), + NoExplicitNull, ] = None - pagination: pagination_request.PaginationRequest | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/list_content_standards_response.py b/src/adcp/types/domains/content_standards/list_content_standards_response.py index 15d935636..2705a74d9 100644 --- a/src/adcp/types/domains/content_standards/list_content_standards_response.py +++ b/src/adcp/types/domains/content_standards/list_content_standards_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/list_content_standards_response.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -10,6 +10,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import Field from ..core import context as context_1 @@ -34,8 +35,8 @@ def _response_arm_models(cls) -> tuple[type[ListContentStandardsResponse], ...]: class ListContentStandardsResponse2(ListContentStandardsResponse): errors: list[error.Error] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ListContentStandardsResponse1(ListContentStandardsResponse): @@ -43,6 +44,6 @@ class ListContentStandardsResponse1(ListContentStandardsResponse): list[content_standards.ContentStandards], Field(description='Array of content standards configurations matching the filter criteria'), ] - pagination: pagination_response.PaginationResponse | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/update_content_standards_request.py b/src/adcp/types/domains/content_standards/update_content_standards_request.py index 9c142430f..7a5d215f1 100644 --- a/src/adcp/types/domains/content_standards/update_content_standards_request.py +++ b/src/adcp/types/domains/content_standards/update_content_standards_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/update_content_standards_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..core import context as context_1 @@ -26,6 +26,7 @@ class Scope(AdCPBaseModel): description='ISO 3166-1 alpha-2 country codes. Standards apply in ALL listed countries (AND logic).', min_length=1, ), + NoExplicitNull, ] = None channels_any: Annotated[ list[channels.MediaChannel] | None, @@ -33,6 +34,7 @@ class Scope(AdCPBaseModel): description='Advertising channels. Standards apply to ANY of the listed channels (OR logic).', min_length=1, ), + NoExplicitNull, ] = None languages_any: Annotated[ list[str] | None, @@ -40,9 +42,10 @@ class Scope(AdCPBaseModel): description="BCP 47 language tags (e.g., 'en', 'de', 'fr'). Standards apply to content in ANY of these languages (OR logic). Content in unlisted languages is not covered by these standards.", min_length=1, ), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Human-readable description of this scope') + str | None, Field(description='Human-readable description of this scope'), NoExplicitNull ] = None @@ -55,7 +58,7 @@ class Pass(AdCPBaseModel): ), ] language: Annotated[ - str | None, Field(description='BCP 47 language tag for content at this URL') + str | None, Field(description='BCP 47 language tag for content at this URL'), NoExplicitNull ] = None @@ -68,7 +71,7 @@ class Fail(AdCPBaseModel): ), ] language: Annotated[ - str | None, Field(description='BCP 47 language tag for content at this URL') + str | None, Field(description='BCP 47 language tag for content at this URL'), NoExplicitNull ] = None @@ -76,9 +79,12 @@ class CalibrationExemplars(AdCPBaseModel): pass_: Annotated[ list[Pass | artifact.Artifact] | None, Field(alias='pass', description='Content that passes the standards'), + NoExplicitNull, ] = None fail: Annotated[ - list[Fail | artifact.Artifact] | None, Field(description='Content that fails the standards') + list[Fail | artifact.Artifact] | None, + Field(description='Content that fails the standards'), + NoExplicitNull, ] = None @@ -90,12 +96,14 @@ class UpdateContentStandardsRequest(AdcpRequest, AdcpVersionEnvelope): scope: Annotated[ Scope | None, Field(description='Updated scope for where this standards configuration applies'), + NoExplicitNull, ] = None registry_policy_ids: Annotated[ list[str] | None, Field( description='Registry policy IDs to use as the evaluation basis. When provided, the agent resolves policies from the registry and uses their policy text and exemplars as the evaluation criteria.' ), + NoExplicitNull, ] = None policies: Annotated[ list[policy_entry.PolicyEntry] | None, @@ -103,15 +111,17 @@ class UpdateContentStandardsRequest(AdcpRequest, AdcpVersionEnvelope): description='Updated bespoke policies for this content-standards configuration, using the same shape as registry entries. Replaces the existing policies array; use stable policy_ids to track policies across versions. Combines with registry_policy_ids. Bespoke policy_ids MUST be flat (no colons/slashes).', min_length=1, ), + NoExplicitNull, ] = None calibration_exemplars: Annotated[ CalibrationExemplars | None, Field( description='Updated training/test set to calibrate policy interpretation. Use URL references for pages to be fetched and analyzed, or full artifacts for pre-extracted content.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None idempotency_key: Annotated[ str, Field( diff --git a/src/adcp/types/domains/content_standards/update_content_standards_response.py b/src/adcp/types/domains/content_standards/update_content_standards_response.py index 1b68f3c58..1765569e7 100644 --- a/src/adcp/types/domains/content_standards/update_content_standards_response.py +++ b/src/adcp/types/domains/content_standards/update_content_standards_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/update_content_standards_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -10,6 +10,7 @@ from typing import Annotated, Literal +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -38,8 +39,8 @@ class UpdateContentStandardsResponse1(UpdateContentStandardsResponse): Literal[True], Field(description='Indicates the update was applied successfully') ] standards_id: Annotated[str, Field(description='ID of the updated standards configuration')] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class UpdateContentStandardsResponse2(UpdateContentStandardsResponse): @@ -55,6 +56,7 @@ class UpdateContentStandardsResponse2(UpdateContentStandardsResponse): Field( description='If scope change conflicts with another configuration, the ID of the conflicting standards' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/content_standards/validate_content_delivery_request.py b/src/adcp/types/domains/content_standards/validate_content_delivery_request.py index 0c1019bdb..3ab3a7476 100644 --- a/src/adcp/types/domains/content_standards/validate_content_delivery_request.py +++ b/src/adcp/types/domains/content_standards/validate_content_delivery_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/validate_content_delivery_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, Field, StrictBool from ..core import context as context_1 @@ -18,8 +18,10 @@ class BrandContext(AdCPBaseModel): - brand_id: Annotated[str | None, Field(description='Brand identifier')] = None - sku_id: Annotated[str | None, Field(description='Product/SKU identifier if applicable')] = None + brand_id: Annotated[str | None, Field(description='Brand identifier'), NoExplicitNull] = None + sku_id: Annotated[ + str | None, Field(description='Product/SKU identifier if applicable'), NoExplicitNull + ] = None class Record(AdCPBaseModel): @@ -27,22 +29,28 @@ class Record(AdCPBaseModel): media_buy_id: Annotated[ str | None, Field(description='Media buy this record belongs to (when batching across multiple buys)'), + NoExplicitNull, + ] = None + timestamp: Annotated[ + AwareDatetime | None, Field(description='When the delivery occurred'), NoExplicitNull ] = None - timestamp: Annotated[AwareDatetime | None, Field(description='When the delivery occurred')] = ( - None - ) artifact: Annotated[artifact_1.Artifact, Field(description='Artifact where ad was delivered')] country: Annotated[ - str | None, Field(description='ISO 3166-1 alpha-2 country code where delivery occurred') + str | None, + Field(description='ISO 3166-1 alpha-2 country code where delivery occurred'), + NoExplicitNull, ] = None channel: Annotated[ - str | None, Field(description='Channel type (e.g., display, video, audio, social)') + str | None, + Field(description='Channel type (e.g., display, video, audio, social)'), + NoExplicitNull, ] = None brand_context: Annotated[ BrandContext | None, Field( description='Brand information for policy evaluation. Schema TBD - placeholder for brand identifiers.' ), + NoExplicitNull, ] = None @@ -57,9 +65,10 @@ class ValidateContentDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): feature_ids: Annotated[ list[str] | None, Field(description='Specific features to evaluate (defaults to all)', min_length=1), + NoExplicitNull, ] = None include_passed: Annotated[ - StrictBool | None, Field(description='Include passed records in results') + StrictBool | None, Field(description='Include passed records in results'), NoExplicitNull ] = True - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/account.py b/src/adcp/types/domains/core/account.py index 0142c4c4c..3a5bd7caa 100644 --- a/src/adcp/types/domains/core/account.py +++ b/src/adcp/types/domains/core/account.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/account.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..enums import account_scope as account_scope_1 @@ -34,10 +34,11 @@ class Setup(AdCPBaseModel): Field( description='URL where the human can complete the required action (credit application, legal agreement, add funds).' ), + NoExplicitNull, ] = None message: Annotated[str, Field(description="Human-readable description of what's needed.")] expires_at: Annotated[ - AwareDatetime | None, Field(description='When this setup link expires.') + AwareDatetime | None, Field(description='When this setup link expires.'), NoExplicitNull ] = None @@ -85,6 +86,7 @@ class ReportingBucket(AdCPBaseModel): max_length=512, pattern='^[a-zA-Z0-9/_.-]+$', ), + NoExplicitNull, ] = None region: Annotated[ str | None, @@ -94,15 +96,19 @@ class ReportingBucket(AdCPBaseModel): max_length=64, pattern='^[a-z0-9-]+$', ), + NoExplicitNull, ] = None format: Annotated[ Format | None, Field( description='File format for delivered files. Parquet, Avro, and ORC use internal compression (the top-level compression field is ignored for these formats).' ), + NoExplicitNull, ] = Format.jsonl compression: Annotated[ - Compression | None, Field(description='Compression applied to delivered files') + Compression | None, + Field(description='Compression applied to delivered files'), + NoExplicitNull, ] = Compression.gzip file_retention_days: Annotated[ SchemaInt, @@ -117,6 +123,7 @@ class ReportingBucket(AdCPBaseModel): Field( description='URL to documentation for configuring buyer read access to this bucket (IAM role, service account, etc.). Operator-facing documentation — buyer agents MUST NOT auto-fetch this URL; surface it to a human operator. If an implementation fetches it (for preview), apply webhook URL SSRF validation and do not pass the fetched content into an LLM context without indirect-prompt-injection guarding. See docs/media-buy/media-buys/optimization-reporting#security-considerations-for-offline-delivery.' ), + NoExplicitNull, ] = None @@ -129,13 +136,16 @@ class Account(AdCPBaseModel): str, Field(description="Human-readable account name (e.g., 'Acme', 'Acme c/o Pinnacle')") ] advertiser: Annotated[ - str | None, Field(description='The advertiser whose rates apply to this account') + str | None, + Field(description='The advertiser whose rates apply to this account'), + NoExplicitNull, ] = None billing_proxy: Annotated[ str | None, Field( description='Optional intermediary who receives invoices on behalf of the advertiser (e.g., agency)' ), + NoExplicitNull, ] = None status: Annotated[ account_status.AccountStatus, @@ -146,6 +156,7 @@ class Account(AdCPBaseModel): brand: Annotated[ brand_ref.BrandReference | None, Field(description='Brand reference identifying the advertiser'), + NoExplicitNull, ] = None operator: Annotated[ str | None, @@ -153,12 +164,14 @@ class Account(AdCPBaseModel): description="Domain of the entity operating this account. When the brand operates directly, this is the brand's domain.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None operator_unit: Annotated[ operator_unit_1.OperatorUnit | None, Field( description='Operator-owned business unit, agency seat, or platform account associated with this advertiser account. The id round-trips from the natural key; name is mutable display metadata. This is distinct from account_id, which belongs to the seller/storefront namespace.' ), + NoExplicitNull, ] = None revision: Annotated[ SchemaInt | None, @@ -166,12 +179,14 @@ class Account(AdCPBaseModel): description='Monotonically increasing optimistic-concurrency token for this account. Incremented on every persisted settings change, identity-change request, and identity-change disposition; reads, dry runs, validation failures, and exact idempotency replays do not increment it. Pass the latest observed value in a sync_accounts settings-update entry to prevent lost updates.', ge=1, ), + NoExplicitNull, ] = None identity_change: Annotated[ account_identity_change.AccountIdentityChange | None, Field( description='Pending or rejected operator-identity transition. While present, the top-level operator and operator_unit remain the current canonical identity. Re-read list_accounts until the request is applied (canonical fields change and this object disappears) or rejected.' ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -179,6 +194,7 @@ class Account(AdCPBaseModel): description="Immutable transaction currency when the seller's advertiser object is currency-bound. Media buys on this account MUST use this currency. Omit when the account selects currency independently per media buy.", pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None timezone: Annotated[ str | None, @@ -186,42 +202,50 @@ class Account(AdCPBaseModel): description='Immutable operational timezone for this account, expressed as UTC or an IANA timezone identifier. AdCP 3.2 sellers return it on every account. It is the default calendar-day boundary for account-scoped behavior unless a feature explicitly declares another timezone basis. For buyer-selected account_fixed provisioning it participates in the natural account key.', min_length=1, ), + NoExplicitNull, ] = None billing: Annotated[ billing_party.BillingParty | None, Field( description="Who is invoiced on this account. See billing_entity for the invoiced party's business details." ), + NoExplicitNull, ] = None billing_entity: Annotated[ business_entity.BusinessEntity | None, Field( description='Current canonical business entity for the party responsible for payment. Contains the legal name, tax IDs, and address needed for formal B2B invoicing. Corresponds to whoever billing points to (operator, agent, or advertiser). When this account appears in a response, bank details MUST be omitted and the request-only destination_billing_entity MUST NOT be exposed.' ), + NoExplicitNull, ] = None destination_billing_entity: Annotated[ Any | None, Field(description='Request-only staging field. It MUST NOT appear in account read models.'), + NoExplicitNull, ] = None rate_card: Annotated[ - str | None, Field(description='Identifier for the rate card applied to this account') + str | None, + Field(description='Identifier for the rate card applied to this account'), + NoExplicitNull, ] = None payment_terms: Annotated[ payment_terms_1.PaymentTerms | None, Field( description='Payment terms agreed for this account. Binding for all invoices when the account is active.' ), + NoExplicitNull, ] = None credit_limit: Annotated[ - CreditLimit | None, Field(description='Maximum outstanding balance allowed') + CreditLimit | None, Field(description='Maximum outstanding balance allowed'), NoExplicitNull ] = None setup: Annotated[ Setup | None, Field( description="Present when status is 'pending_approval'. Contains next steps for completing account activation." ), + NoExplicitNull, ] = None - account_scope: account_scope_1.AccountScope | None = None + account_scope: Annotated[account_scope_1.AccountScope | None, NoExplicitNull] = None governance_agents: Annotated[ list[GovernanceAgent] | None, Field( @@ -229,18 +253,21 @@ class Account(AdCPBaseModel): max_length=1, min_length=1, ), + NoExplicitNull, ] = None reporting_bucket: Annotated[ ReportingBucket | None, Field( description="Cloud storage bucket where the seller delivers offline reporting files for this account. Seller provisions a dedicated bucket or a per-account prefix within a shared bucket, and grants the buyer read access out-of-band. Access MUST be scoped at the IAM layer so each account can only read its own prefix — bucket-wide grants are non-compliant even with per-account prefixes. Seller MUST revoke access when the account's status transitions to inactive, suspended, or closed. See security considerations for offline delivery in docs/media-buy/media-buys/optimization-reporting. Only present when the seller supports offline delivery (reporting_delivery_methods includes 'offline' in capabilities)." ), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field( description='When true, this is a sandbox account — no real platform calls, no real spend. For account-id namespaces, sandbox accounts are pre-existing test accounts on the platform discovered via list_accounts or supplied out-of-band. For buyer-declared accounts, sandbox is part of the natural key: the same brand/operator pair can have both a production and sandbox account.' ), + NoExplicitNull, ] = None notification_configs: Annotated[ list[notification_config.NotificationConfig] | None, @@ -248,6 +275,7 @@ class Account(AdCPBaseModel): description='Account-level webhook subscriptions for creative lifecycle/assignment changes, indicators.changed, account status, durable account-change wake-ups, wholesale feed changes, and reporting.delivery_ready. Buyers manage entries via sync_accounts and verify persisted state on list_accounts. account.change_recorded wakes receivers to drain list_account_changes; reporting.delivery_ready is repaired through get_reporting_status; indicator and assignment payloads are invalidations repaired completely through get_media_buys; list_creatives may provide a bounded reverse projection. Distinct from per-resource push_notification_config. Entries are keyed by account-scoped subscriber_id; credentials are write-only.', max_length=16, ), + NoExplicitNull, ] = None reporting_delivery_configs: Annotated[ list[reporting_delivery_config_state.ReportingDeliveryConfigurationState] | None, @@ -255,6 +283,7 @@ class Account(AdCPBaseModel): description="Resolved durable reporting delivery configurations owned by the authenticated caller for this account. list_accounts MUST expose only the calling principal's set. State and seller-issued destination_ref are returned; credentials and bearer profiles MUST NOT appear. Any setup URL is a secret-free authenticated entry point, not a bearer credential.", max_length=16, ), + NoExplicitNull, ] = None webhook_activity: Annotated[ list[webhook_activity_record.WebhookActivityRecord] | None, @@ -262,5 +291,6 @@ class Account(AdCPBaseModel): description='Recent webhook delivery attempts scoped to this account when the caller requested webhook activity on list_accounts and the seller surfaces the log. Includes account-anchored notifications such as account.status_changed and MAY include other account-level fires relevant to this account. Three-state presence follows the shared webhook_activity[] contract: omitted means unsupported or not requested, [] means supported but no retained fires, non-empty lists recent attempts most-recent-first.', max_length=200, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/account_authorization.py b/src/adcp/types/domains/core/account_authorization.py index f4acc12c1..2b35f018d 100644 --- a/src/adcp/types/domains/core/account_authorization.py +++ b/src/adcp/types/domains/core/account_authorization.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/account_authorization.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool @@ -40,16 +40,19 @@ class AccountAuthorization(AdCPBaseModel): Field( description='Optional per-task allowlist of request fields the caller may set. Keys are task names (which MUST also appear in allowed_tasks). Values are arrays of top-level request-field paths permitted for that task. When a task appears in field_scopes, requests to that task with any field outside the allowlist MUST be rejected with FIELD_NOT_PERMITTED. Compact product tools use their own task names and top-level fields such as criteria and refinements. Implicit framing fields are always permitted and do NOT need to appear in the allowlist — they identify the resource or shape the call rather than mutating business state. Tasks absent from field_scopes have no field-level restriction beyond what the task schema already enforces.' ), + NoExplicitNull, ] = None scope_name: Annotated[ Literal['attestation_verifier'] | ScopeName | None, Field( description='Optional named scope identifier. When present, callers and the vendor agent can reason about the grant by name rather than by enumerating allowed_tasks and field_scopes. Modeled as a discriminated union so code generators produce a literal type for the standard scope(s) and a distinct type for agent-defined values — this prevents a typo of `attestation_verifier` from being silently accepted as a custom scope. Agent-defined scope names MUST use a `custom:` prefix to avoid collision with future standard scopes. The prefix is protocol-neutral: a signals agent, a governance agent, or a creative agent defines custom scopes the same way a media-buy sales agent does.' ), + NoExplicitNull, ] = None read_only: Annotated[ StrictBool | None, Field( description='Convenience flag. When true, the caller is permitted only non-mutating tasks. Sellers MUST reject any mutation from a read-only caller with READ_ONLY_SCOPE. For the AdCP 3.2 product split, read-only permits list_products but rejects request_proposals, refine_proposals, and decline_proposals; a legacy get_products call is permitted only when the seller can guarantee the selected arm is a synchronous side-effect-free read. Sellers MAY omit this field; omission is equivalent to `false`. Callers MUST NOT infer read-only from `allowed_tasks` alone — the seller MUST set this explicitly when it applies.' ), + NoExplicitNull, ] = False diff --git a/src/adcp/types/domains/core/account_change.py b/src/adcp/types/domains/core/account_change.py index 2150fedd6..4d3570ff1 100644 --- a/src/adcp/types/domains/core/account_change.py +++ b/src/adcp/types/domains/core/account_change.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/account_change.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field, StrictBool @@ -49,6 +49,7 @@ class Resource(AdCPBaseModel): description='Additional canonical parent identifiers needed to repair a nested resource, for example media_buy_id and package_id for a creative assignment. Keys and values MUST NOT contain sensitive payload data.', max_length=12, ), + NoExplicitNull, ] = None @@ -71,6 +72,7 @@ class Origin(AdCPBaseModel): description='Opaque, non-secret reference to the connected source when safe for this caller.', max_length=255, ), + NoExplicitNull, ] = None @@ -115,8 +117,9 @@ class Repair(AdCPBaseModel): Field( description='False when deletion or compelled erasure makes the resource unavailable on the repair read.' ), + NoExplicitNull, ] = True - unavailable_reason: UnavailableReason | None = None + unavailable_reason: Annotated[UnavailableReason | None, NoExplicitNull] = None class Type(StrEnum): @@ -133,7 +136,9 @@ class Actor(AdCPBaseModel): ) type: Type id: Annotated[ - str | None, Field(description='Opaque, redaction-safe actor reference.', max_length=255) + str | None, + Field(description='Opaque, redaction-safe actor reference.', max_length=255), + NoExplicitNull, ] = None @@ -161,6 +166,7 @@ class AccountChange(AdCPBaseModel): Field( description='Upstream business time when the change occurred, only when the seller can establish it reliably.' ), + NoExplicitNull, ] = None batch_id: Annotated[ str | None, @@ -170,6 +176,7 @@ class AccountChange(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None resource: Annotated[ Resource, @@ -197,6 +204,7 @@ class AccountChange(AdCPBaseModel): Field( description='Post-change revision exposed by the repair read, when that resource family defines one.' ), + NoExplicitNull, ] = None changed_paths: Annotated[ list[ChangedPath] | None, @@ -204,6 +212,7 @@ class AccountChange(AdCPBaseModel): description='Bounded set of RFC 6901 JSON Pointers naming material fields that changed. Values are intentionally omitted.', max_length=64, ), + NoExplicitNull, ] = None repair: Annotated[ Repair, @@ -216,6 +225,7 @@ class AccountChange(AdCPBaseModel): Field( description='Optional privacy-safe actor classification. Sellers MUST omit direct personal identifiers unless the authenticated caller is authorized for them.' ), + NoExplicitNull, ] = None reason: Annotated[ str | None, @@ -224,6 +234,7 @@ class AccountChange(AdCPBaseModel): max_length=100, pattern='^[a-z][a-z0-9_.-]{0,99}$', ), + NoExplicitNull, ] = None summary: Annotated[ str | None, @@ -231,10 +242,12 @@ class AccountChange(AdCPBaseModel): description='Optional brief, untrusted human-readable summary. MUST NOT contain secrets or sensitive payload data.', max_length=500, ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Bounded vendor extensions. The entire encoded change record, including extensions, MUST NOT exceed 64 KiB and remains subject to the same secret/PII prohibitions.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/account_change_recorded_webhook.py b/src/adcp/types/domains/core/account_change_recorded_webhook.py index 8cea2f4b8..d4570f8c7 100644 --- a/src/adcp/types/domains/core/account_change_recorded_webhook.py +++ b/src/adcp/types/domains/core/account_change_recorded_webhook.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/account_change_recorded_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from . import ext as ext_1 @@ -18,7 +18,7 @@ class Resource(AdCPBaseModel): ) type: Annotated[str, Field(max_length=100, min_length=1, pattern='^[a-z][a-z0-9_.-]{0,99}$')] resource_id: Annotated[str, Field(max_length=255, min_length=1)] - parent_ids: Annotated[dict[str, str] | None, Field(max_length=12)] = None + parent_ids: Annotated[dict[str, str] | None, Field(max_length=12), NoExplicitNull] = None class AccountChangeRecordedWebhook(AdCPBaseModel): @@ -65,10 +65,12 @@ class AccountChangeRecordedWebhook(AdCPBaseModel): max_length=4096, min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Bounded vendor extensions subject to the same secret/PII prohibitions as the base payload.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/account_identity_change.py b/src/adcp/types/domains/core/account_identity_change.py index dea0f8d79..69ea474ae 100644 --- a/src/adcp/types/domains/core/account_identity_change.py +++ b/src/adcp/types/domains/core/account_identity_change.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/account_identity_change.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from . import operator_identity @@ -25,7 +25,9 @@ class AccountIdentityChange1(AdCPBaseModel): Field(description='Complete desired operator identity submitted by the buyer.'), ] requested_at: Annotated[ - AwareDatetime | None, Field(description='When the seller recorded the request.') + AwareDatetime | None, + Field(description='When the seller recorded the request.'), + NoExplicitNull, ] = None @@ -44,7 +46,9 @@ class AccountIdentityChange2(AdCPBaseModel): Field(description='Complete desired operator identity submitted by the buyer.'), ] requested_at: Annotated[ - AwareDatetime | None, Field(description='When the seller recorded the request.') + AwareDatetime | None, + Field(description='When the seller recorded the request.'), + NoExplicitNull, ] = None reason: Annotated[ str, Field(description='Human-readable rejection reason.', max_length=1000, min_length=1) diff --git a/src/adcp/types/domains/core/account_identity_change_preview.py b/src/adcp/types/domains/core/account_identity_change_preview.py index e614355c3..d1a32b9a1 100644 --- a/src/adcp/types/domains/core/account_identity_change_preview.py +++ b/src/adcp/types/domains/core/account_identity_change_preview.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/account_identity_change_preview.json -# timestamp: 2026-10-04T00:53:27+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, RootModel @@ -42,7 +42,7 @@ class Impact(AdCPBaseModel): ) area: Area effect: Effect - reason: Annotated[str | None, Field(max_length=1000, min_length=1)] = None + reason: Annotated[str | None, Field(max_length=1000, min_length=1), NoExplicitNull] = None class Effect1(StrEnum): @@ -52,7 +52,7 @@ class Effect1(StrEnum): class NonblockingImpact(Impact): - effect: Effect1 | None = None + effect: Annotated[Effect1 | None, NoExplicitNull] = None class NonblockingImpacts(RootModel[list[NonblockingImpact]]): diff --git a/src/adcp/types/domains/core/account_ref.py b/src/adcp/types/domains/core/account_ref.py index 03e2e74ea..fe4e1f462 100644 --- a/src/adcp/types/domains/core/account_ref.py +++ b/src/adcp/types/domains/core/account_ref.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/account_ref.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from . import brand_ref @@ -44,6 +44,7 @@ class AccountReference2(AdCPBaseModel): Field( description='Optional operator-owned business unit, agency seat, or platform account. Only id participates in the natural account key; name is mutable display metadata.' ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -51,6 +52,7 @@ class AccountReference2(AdCPBaseModel): description="Immutable ISO 4217 transaction currency when the seller's advertiser object is currency-bound. When present, this is part of the natural account key and media buys on the account MUST use it. Omit when currency is selected independently per media buy.", pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None timezone: Annotated[ str | None, @@ -58,12 +60,14 @@ class AccountReference2(AdCPBaseModel): description='Immutable account timezone. Include it in the natural key when get_adcp_capabilities.account.timezone declares account_fixed with buyer_selected; omit it for seller_fixed or seller_assigned accounts.', min_length=1, ), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field( description='When true, references the sandbox account for this brand/operator pair. Defaults to false (production account).' ), + NoExplicitNull, ] = False diff --git a/src/adcp/types/domains/core/account_status_changed_webhook.py b/src/adcp/types/domains/core/account_status_changed_webhook.py index 4e3fbf2e9..7e9504653 100644 --- a/src/adcp/types/domains/core/account_status_changed_webhook.py +++ b/src/adcp/types/domains/core/account_status_changed_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/account_status_changed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import account_status @@ -41,7 +41,9 @@ class Setup(AdCPBaseModel): ), ] expires_at: Annotated[ - AwareDatetime | None, Field(description='When the setup action or hint expires, if known.') + AwareDatetime | None, + Field(description='When the setup action or hint expires, if known.'), + NoExplicitNull, ] = None @@ -122,11 +124,13 @@ class AccountStatusChangedWebhook(AdCPBaseModel): description='Optional short human-readable detail. Treat as untrusted text. Sellers MUST NOT include secrets, setup tokens, internal stack traces, or regulated financial details.', max_length=500, ), + NoExplicitNull, ] = None setup: Annotated[ Setup | None, Field( description='Reduced setup hint when the new status requires human action. This block intentionally omits setup.url; receivers fetch the current setup URL from list_accounts if the caller is authorized to see it.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/account_timezone_capability.py b/src/adcp/types/domains/core/account_timezone_capability.py index bf7b14481..de7841c1d 100644 --- a/src/adcp/types/domains/core/account_timezone_capability.py +++ b/src/adcp/types/domains/core/account_timezone_capability.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/account_timezone_capability.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -43,12 +43,14 @@ class AccountTimezoneCapability(AdCPBaseModel): description='Seller-wide timezone used by every account. Required only for seller_fixed. Use UTC or an IANA timezone identifier.', min_length=1, ), + NoExplicitNull, ] = None account_selection: Annotated[ AccountSelection | None, Field( description='How an account_fixed timezone is established. seller_assigned covers an existing upstream account or seller onboarding choice; buyer_selected requires timezone in buyer-declared sync_accounts provisioning.' ), + NoExplicitNull, ] = None supported_timezones: Annotated[ list[SupportedTimezone] | None, @@ -56,4 +58,5 @@ class AccountTimezoneCapability(AdCPBaseModel): description='Exact timezone values accepted during buyer-selected account provisioning. Required when account_selection is buyer_selected so buyers can validate the choice before sync_accounts.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/account_with_authorization.py b/src/adcp/types/domains/core/account_with_authorization.py index 7a56c8547..2e6e91e75 100644 --- a/src/adcp/types/domains/core/account_with_authorization.py +++ b/src/adcp/types/domains/core/account_with_authorization.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/account_with_authorization.json -# timestamp: 2026-06-05T16:54:33+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import Field from . import account_authorization @@ -18,4 +19,5 @@ class AccountWithAuthorization(Account): Field( description="Optional. The caller's scope grant against this account. Vendor agents of any type (media-buy, signals, governance, creative, brand) that support scope introspection SHOULD populate this so callers can preempt SCOPE_INSUFFICIENT / FIELD_NOT_PERMITTED errors rather than discovering scope by trial and error. Media-buy sales agents claiming the `attestation_verifier` standard scope MUST populate it. Absence means the vendor agent does not advertise introspectable scope for this account — callers MUST NOT infer access from absence, and fall back to error-driven discovery via the RBAC error codes." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/ad_inventory_config.py b/src/adcp/types/domains/core/ad_inventory_config.py index 40c8df5df..e7a2d2052 100644 --- a/src/adcp/types/domains/core/ad_inventory_config.py +++ b/src/adcp/types/domains/core/ad_inventory_config.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/ad_inventory_config.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -18,7 +18,9 @@ class AdInventoryConfiguration(AdCPBaseModel): SchemaInt, Field(description='Number of planned ad breaks in the installment', ge=0) ] total_ad_seconds: Annotated[ - SchemaInt | None, Field(description='Total seconds of ad time across all breaks', ge=0) + SchemaInt | None, + Field(description='Total seconds of ad time across all breaks', ge=0), + NoExplicitNull, ] = None max_ad_duration_seconds: Annotated[ SchemaInt | None, @@ -26,16 +28,19 @@ class AdInventoryConfiguration(AdCPBaseModel): description='Maximum duration in seconds for a single ad within a break. Buyers need this to know whether their creative fits.', ge=1, ), + NoExplicitNull, ] = None unplanned_breaks: Annotated[ StrictBool | None, Field( description='Whether ad breaks are dynamic and driven by live conditions (sports timeouts, election coverage). When false, all breaks are pre-defined.' ), + NoExplicitNull, ] = None supported_formats: Annotated[ list[str] | None, Field( description="Ad format types supported in breaks (e.g., 'video', 'audio', 'display')" ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/agent_notification_config.py b/src/adcp/types/domains/core/agent_notification_config.py index af2f567ad..a800e3bec 100644 --- a/src/adcp/types/domains/core/agent_notification_config.py +++ b/src/adcp/types/domains/core/agent_notification_config.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/agent_notification_config.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool from ..enums import auth_scheme, notification_type @@ -24,6 +24,7 @@ class Authentication(AdCPBaseModel): description='Credentials for the legacy scheme. Bearer: token. HMAC-SHA256: shared secret. Minimum 32 characters. Exchanged out-of-band during onboarding. Write-only.', min_length=32, ), + NoExplicitNull, ] = None @@ -58,12 +59,14 @@ class AgentNotificationConfig(AdCPBaseModel): Field( description="Explicit scope acknowledgment required whenever event_types includes any account-anchored type: true states that this subscriber intentionally receives those events for every account the principal is authorized for at each delivery attempt. Subscribing an endpoint to all accounts is never implicit. Authorization is evaluated per delivery attempt, including retries: losing authority for an account suppresses queued retries carrying that account's data." ), + NoExplicitNull, ] = None include_future_event_types: Annotated[ StrictBool | None, Field( description='When true, the seller also fires caller-eligible notification types added to the enum by later AdCP versions — but only types classified invalidation-only, whose payloads carry identifiers and a repair pointer rather than domain data. Payload-bearing types always require explicit enumeration in event_types; this flag never silently opts a caller into more-sensitive payloads. There is deliberately no wildcard event type. Receivers setting this MUST tolerate unknown notification_type values.' ), + NoExplicitNull, ] = False authentication: Annotated[ Authentication | None, @@ -71,11 +74,13 @@ class AgentNotificationConfig(AdCPBaseModel): deprecated=True, description="Legacy authentication selector. Same precedence and semantics as `push-notification-config.authentication` and account-level `notification-config.authentication`: presence opts the seller into Bearer or HMAC-SHA256 signing; absence selects the default RFC 9421 webhook profile keyed off the seller's brand.json `agents[]` JWKS. Deprecated; removed in AdCP 4.0. Credentials are write-only and MUST NOT be echoed on reads.", ), + NoExplicitNull, ] = None active: Annotated[ StrictBool | None, Field( description='When false, the seller persists the configuration but suppresses fires. Use to pause a subscriber without losing the registration. Paused configs may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time. Reactivation requires full SSRF validation with connect pinning plus proof-of-control for any tuple without current valid proof.' ), + NoExplicitNull, ] = True - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/agent_notification_config_state.py b/src/adcp/types/domains/core/agent_notification_config_state.py index 68353b331..6eda58e1c 100644 --- a/src/adcp/types/domains/core/agent_notification_config_state.py +++ b/src/adcp/types/domains/core/agent_notification_config_state.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/agent_notification_config_state.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool from ..enums import auth_scheme, notification_type @@ -32,10 +32,13 @@ class AgentNotificationConfigState(AdCPBaseModel): all_authorized_accounts: Annotated[ StrictBool | None, Field(description='Echoed scope acknowledgment for account-anchored event types.'), + NoExplicitNull, ] = None include_future_event_types: Annotated[ - StrictBool | None, Field(description='Echoed from the desired configuration when set.') + StrictBool | None, + Field(description='Echoed from the desired configuration when set.'), + NoExplicitNull, ] = None - authentication: Annotated[Authentication | None, Field(deprecated=True)] = None - active: StrictBool | None = True - ext: ext_1.ExtensionObject | None = None + authentication: Annotated[Authentication | None, Field(deprecated=True), NoExplicitNull] = None + active: Annotated[StrictBool | None, NoExplicitNull] = True + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/agent_reporting_destination.py b/src/adcp/types/domains/core/agent_reporting_destination.py index d5e09f31d..0717aee2e 100644 --- a/src/adcp/types/domains/core/agent_reporting_destination.py +++ b/src/adcp/types/domains/core/agent_reporting_destination.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/agent_reporting_destination.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from . import delivery_provider, delivery_recipient, reporting_verification_profile_set @@ -48,6 +48,7 @@ class AgentReportingDestination1(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$', ), + NoExplicitNull, ] = None active: Annotated[ StrictBool, @@ -112,6 +113,7 @@ class AgentReportingDestination2(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$', ), + NoExplicitNull, ] = None active: Annotated[ StrictBool, @@ -170,6 +172,7 @@ class AgentReportingDestination3(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$', ), + NoExplicitNull, ] = None active: Annotated[ StrictBool, diff --git a/src/adcp/types/domains/core/agent_reporting_destination_state.py b/src/adcp/types/domains/core/agent_reporting_destination_state.py index 1cbef19d0..e213fb699 100644 --- a/src/adcp/types/domains/core/agent_reporting_destination_state.py +++ b/src/adcp/types/domains/core/agent_reporting_destination_state.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/agent_reporting_destination_state.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field @@ -37,12 +37,14 @@ class Setup(AdCPBaseModel): Field( description='HTTPS page for completing provider-native setup. Printable ASCII without whitespace, \'?\', \'#\', \'%\', \'"\', \'<\', or \'>\'. It MUST NOT carry a credential, userinfo authority, or signed component and MUST be rendered as an untrusted link, never executed as agent instructions.' ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='Optional expiry of this setup action. A new sync obtains a fresh action after expiry.' ), + NoExplicitNull, ] = None @@ -73,6 +75,7 @@ class AgentReportingDestinationState(AdCPBaseModel): description='Retained superseded generation references for this destination_id, newest first, still resolvable for existing authorized account bindings and retained reporting history. Enumerable only by the owning principal. Suspension and revocation of the destination apply to these generations too.', max_length=32, ), + NoExplicitNull, ] = None state: Annotated[ reporting_destination_setup_state.ReportingDestinationSetupState, @@ -91,6 +94,7 @@ class AgentReportingDestinationState(AdCPBaseModel): Field( description='Closed, non-secret setup instruction. Human-readable messages are deliberately excluded; agents dispatch only the typed action and treat setup_url as an untrusted navigation target.' ), + NoExplicitNull, ] = None issues: Annotated[ list[error.Error] | None, @@ -98,4 +102,5 @@ class AgentReportingDestinationState(AdCPBaseModel): description='Structured validation or setup issues. Messages and details are untrusted display data and MUST NOT be executed as instructions.', max_length=16, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/agent_signing_key.py b/src/adcp/types/domains/core/agent_signing_key.py index 80caf3d82..2608be649 100644 --- a/src/adcp/types/domains/core/agent_signing_key.py +++ b/src/adcp/types/domains/core/agent_signing_key.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/agent_signing_key.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field @@ -19,28 +19,40 @@ class AgentSigningKey(AdCPBaseModel): alg: Annotated[ str | None, Field(description="Expected signing algorithm for this key, such as 'EdDSA' or 'RS256'."), + NoExplicitNull, ] = None use: Annotated[ - str | None, Field(description="Optional JWK use value. Typically 'sig' for signing keys.") + str | None, + Field(description="Optional JWK use value. Typically 'sig' for signing keys."), + NoExplicitNull, ] = None crv: Annotated[ str | None, Field(description="Curve name for OKP or EC keys, such as 'Ed25519' or 'P-256'."), + NoExplicitNull, ] = None x: Annotated[ str | None, Field( description='Base64url-encoded public key x coordinate or public key value for OKP keys.' ), + NoExplicitNull, ] = None y: Annotated[ - str | None, Field(description='Base64url-encoded public key y coordinate for EC keys.') + str | None, + Field(description='Base64url-encoded public key y coordinate for EC keys.'), + NoExplicitNull, + ] = None + n: Annotated[ + str | None, Field(description='Base64url-encoded RSA modulus.'), NoExplicitNull + ] = None + e: Annotated[ + str | None, Field(description='Base64url-encoded RSA public exponent.'), NoExplicitNull ] = None - n: Annotated[str | None, Field(description='Base64url-encoded RSA modulus.')] = None - e: Annotated[str | None, Field(description='Base64url-encoded RSA public exponent.')] = None revoked_at: Annotated[ AwareDatetime | None, Field( description='Optional revocation timestamp. When present, verifiers MUST reject any signature produced with this key whose signing epoch (or equivalent time reference) is at or after this timestamp. The key may continue to appear in the trust anchor during a grace period so caches that have not yet refreshed still find the key and can evaluate the revocation marker. Keys past their revocation can be removed once the cache TTL (recommended: 5 minutes) has elapsed across all verifiers.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/agent_webhook_challenge.py b/src/adcp/types/domains/core/agent_webhook_challenge.py index 00e893df2..49ae89e97 100644 --- a/src/adcp/types/domains/core/agent_webhook_challenge.py +++ b/src/adcp/types/domains/core/agent_webhook_challenge.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/agent_webhook_challenge.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -33,6 +33,7 @@ class DeliveryAuth(AdCPBaseModel): description='SHA-256 hex fingerprint of the exact legacy credential string supplied in notification_configs[].authentication.credentials. Required when mode is Bearer or HMAC-SHA256; absent for rfc9421.', pattern='^[a-f0-9]{64}$', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/app_item.py b/src/adcp/types/domains/core/app_item.py index ae754a723..126a56b5b 100644 --- a/src/adcp/types/domains/core/app_item.py +++ b/src/adcp/types/domains/core/app_item.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/app_item.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -47,6 +47,7 @@ class AppItem(AdCPBaseModel): Field( description="Reverse-domain bundle identifier (e.g., 'com.acmegames.puzzlequest'). The universal store identifier: required for Android (Google Play), also used for iOS MMP attribution, SKAN matching, and app-ads.txt verification. Distinct from app_id, which is a buyer-assigned catalog key." ), + NoExplicitNull, ] = None apple_id: Annotated[ str | None, @@ -54,27 +55,33 @@ class AppItem(AdCPBaseModel): description="Numeric Apple App Store ID (e.g., '389801252'). Required for Apple Search Ads and iOS platforms that use the numeric ID rather than bundle_id.", pattern='^[0-9]+$', ), + NoExplicitNull, ] = None description: Annotated[ str | None, Field( description='App description. Platforms typically pull this from the store listing automatically; supply here to override or for platforms that require it in the request.' ), + NoExplicitNull, ] = None category: Annotated[ str | None, Field( description="Primary store category (e.g., 'games', 'productivity', 'finance', 'health_fitness', 'social_networking')." ), + NoExplicitNull, ] = None genre: Annotated[ str | None, Field( description="Sub-genre within the category. Particularly relevant for games (e.g., 'puzzle', 'strategy', 'rpg', 'casual', 'simulation', 'action')." ), + NoExplicitNull, ] = None icon_url: Annotated[ - AnyUrl | None, Field(description='App icon image URL. Typically 1024×1024 px.') + AnyUrl | None, + Field(description='App icon image URL. Typically 1024×1024 px.'), + NoExplicitNull, ] = None screenshots: Annotated[ list[AnyUrl] | None, @@ -82,26 +89,31 @@ class AppItem(AdCPBaseModel): description='App store screenshot URLs. Used by platforms for creative generation when native store assets are not available.', min_length=1, ), + NoExplicitNull, ] = None preview_video_url: Annotated[ AnyUrl | None, Field(description='App preview or gameplay video URL for use in video ad creatives.'), + NoExplicitNull, ] = None store_url: Annotated[ AnyUrl | None, Field( description="Direct link to the app's store listing (Apple App Store or Google Play)." ), + NoExplicitNull, ] = None deep_link_url: Annotated[ AnyUrl | None, Field( description="Deep link URI for re-engagement campaigns targeting existing users. Use Universal Links (iOS) or App Links (Android) where available (e.g., 'https://acmegames.com/app/level/5'). Falls back to URI scheme (e.g., 'acmegames://level/5') when universal links are not configured." ), + NoExplicitNull, ] = None price: Annotated[ price_1.Price | None, Field(description='App download price. Set amount to 0 for free apps.'), + NoExplicitNull, ] = None rating: Annotated[ StrictFloat | None, @@ -110,15 +122,17 @@ class AppItem(AdCPBaseModel): ge=0.0, le=5.0, ), + NoExplicitNull, ] = None rating_count: Annotated[ - SchemaInt | None, Field(description='Total number of store ratings.', ge=0) + SchemaInt | None, Field(description='Total number of store ratings.', ge=0), NoExplicitNull ] = None content_rating: Annotated[ str | None, Field( description="Age or content rating (e.g., '4+', '12+', 'Everyone', 'Teen', 'PEGI 12'). Format depends on store and region." ), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, @@ -126,6 +140,7 @@ class AppItem(AdCPBaseModel): description="Tags for filtering and targeting (e.g., 'multiplayer', 'offline', 'no-ads', 'subscription').", min_length=1, ), + NoExplicitNull, ] = None assets: Annotated[ list[offering_asset_group.OfferingAssetGroup] | None, @@ -133,5 +148,6 @@ class AppItem(AdCPBaseModel): description="Typed creative asset pools for this app. Uses the same OfferingAssetGroup structure as offering-type catalogs. Standard group IDs: 'images_landscape' (promotional hero), 'images_vertical' (9:16 for Snap, Stories), 'images_square' (1:1 for display), 'video' (gameplay or demo video). Supplements icon_url and screenshots for platform-specific format requirements.", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/assets/audio_asset.py b/src/adcp/types/domains/core/assets/audio_asset.py index 8cb913633..01ca0c875 100644 --- a/src/adcp/types/domains/core/assets/audio_asset.py +++ b/src/adcp/types/domains/core/assets/audio_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/audio_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from enum import IntEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from ...enums import audio_channel_layout @@ -32,43 +32,52 @@ class AudioAsset(AdCPBaseModel): ] = 'audio' url: Annotated[AnyUrl, Field(description='URL to the audio asset')] duration_ms: Annotated[ - SchemaInt | None, Field(description='Audio duration in milliseconds', ge=0) + SchemaInt | None, Field(description='Audio duration in milliseconds', ge=0), NoExplicitNull + ] = None + file_size_bytes: Annotated[ + SchemaInt | None, Field(description='File size in bytes', ge=1), NoExplicitNull ] = None - file_size_bytes: Annotated[SchemaInt | None, Field(description='File size in bytes', ge=1)] = ( - None - ) container_format: Annotated[ str | None, Field(description='Audio container/file format (mp3, m4a, aac, wav, ogg, flac, etc.)'), + NoExplicitNull, ] = None codec: Annotated[ str | None, Field( description='Audio codec used (aac, aac_lc, he_aac, pcm, mp3, vorbis, opus, flac, ac3, eac3, etc.)' ), + NoExplicitNull, ] = None sampling_rate_hz: Annotated[ - SchemaInt | None, Field(description='Sampling rate in Hz (e.g., 44100, 48000, 96000)') + SchemaInt | None, + Field(description='Sampling rate in Hz (e.g., 44100, 48000, 96000)'), + NoExplicitNull, ] = None channels: Annotated[ - audio_channel_layout.AudioChannelLayout | None, Field(description='Channel configuration') + audio_channel_layout.AudioChannelLayout | None, + Field(description='Channel configuration'), + NoExplicitNull, ] = None - bit_depth: Annotated[BitDepth | None, Field(description='Bit depth')] = None + bit_depth: Annotated[BitDepth | None, Field(description='Bit depth'), NoExplicitNull] = None bitrate_kbps: Annotated[ - SchemaInt | None, Field(description='Bitrate in kilobits per second', ge=1) + SchemaInt | None, Field(description='Bitrate in kilobits per second', ge=1), NoExplicitNull ] = None loudness_lufs: Annotated[ - StrictFloat | None, Field(description='Integrated loudness in LUFS') + StrictFloat | None, Field(description='Integrated loudness in LUFS'), NoExplicitNull + ] = None + true_peak_dbfs: Annotated[ + StrictFloat | None, Field(description='True peak level in dBFS'), NoExplicitNull ] = None - true_peak_dbfs: Annotated[StrictFloat | None, Field(description='True peak level in dBFS')] = ( - None - ) transcript_url: Annotated[ - AnyUrl | None, Field(description='URL to text transcript of the audio content') + AnyUrl | None, + Field(description='URL to text transcript of the audio content'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/card_asset.py b/src/adcp/types/domains/core/assets/card_asset.py index 5d202c99b..8a1bbe58f 100644 --- a/src/adcp/types/domains/core/assets/card_asset.py +++ b/src/adcp/types/domains/core/assets/card_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/assets/card_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import asset_union @@ -34,34 +34,40 @@ class CardAsset(AdCPBaseModel): Field( description='Optional per-card short text label (typically 25-40 chars). Length governed by `card_headline_max_chars` on the format declaration. Meta carousel headline, Pinterest pin title, Snap Collection sticker text, TikTok caption-short.' ), + NoExplicitNull, ] = None description: Annotated[ str | None, Field( description='Optional per-card longer text (typically 100-500 chars). Distinct from `headline`: `description` is body copy, `headline` is the label. Length governed by `card_description_max_chars` on the format declaration. Meta carousel description, Pinterest pin description, AI-surface result body text, TikTok long caption.' ), + NoExplicitNull, ] = None cta: Annotated[ str | None, Field( description="Optional per-card call-to-action label (e.g., 'SHOP_NOW', 'LEARN_MORE'). When the parent format declares `cta_values` (allowed CTA labels), the per-card `cta` MUST be one of those values. Lets a Meta or TikTok carousel show different CTAs per card." ), + NoExplicitNull, ] = None landing_page_url: Annotated[ asset_union.UrlAsset | None, Field( description='Optional per-card click-through URL. URL asset with `url_type: "clickthrough"`.' ), + NoExplicitNull, ] = None platform_extensions: Annotated[ list[asset_union.PlatformExtensionRef] | None, Field( description='Per-card platform-specific extensions (URI+digest references). Same hosting model as format-level platform_extensions. Use this for Meta carousel-card attributes, Pinterest pin overrides, etc. — NEVER inline non-canonical keys on the card object directly.' ), + NoExplicitNull, ] = None provenance: Annotated[ asset_union.Provenance | None, Field( description='Provenance metadata for this card, overrides manifest-level provenance.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/css_asset.py b/src/adcp/types/domains/core/assets/css_asset.py index 41e41b5ad..4e60db723 100644 --- a/src/adcp/types/domains/core/assets/css_asset.py +++ b/src/adcp/types/domains/core/assets/css_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/assets/css_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from .. import provenance as provenance_1 @@ -24,11 +24,14 @@ class CssAsset(AdCPBaseModel): ] = 'css' content: Annotated[str, Field(description='CSS content')] media: Annotated[ - str | None, Field(description="CSS media query context (e.g., 'screen', 'print')") + str | None, + Field(description="CSS media query context (e.g., 'screen', 'print')"), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/daast_asset.py b/src/adcp/types/domains/core/assets/daast_asset.py index cf4a9c9b9..5823c9807 100644 --- a/src/adcp/types/domains/core/assets/daast_asset.py +++ b/src/adcp/types/domains/core/assets/daast_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/daast_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool from ...enums import daast_tracking_event @@ -87,6 +87,7 @@ class MacroDeclaration(AdCPBaseModel): Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), + NoExplicitNull, ] = None dialect_revision: Annotated[ str | None, @@ -94,6 +95,7 @@ class MacroDeclaration(AdCPBaseModel): description='Immutable registry/mapping revision, release, commit, or digest. Required for IAB VAST, IAB DAAST, and vendor dialects.', min_length=1, ), + NoExplicitNull, ] = None dialect_semantic: Annotated[ str, @@ -108,6 +110,7 @@ class MacroDeclaration(AdCPBaseModel): Field( description='Verified AdCP universal meaning. Present only when mapping_status is `verified_universal`.' ), + NoExplicitNull, ] = None operation: asset_union.MacroProcessingOperation performed_by: Annotated[ @@ -115,12 +118,14 @@ class MacroDeclaration(AdCPBaseModel): Field( description='Actor authorized to perform the declared translation or value resolution. Omitted for preservation.' ), + NoExplicitNull, ] = None translation_target: Annotated[ asset_union.MacroTranslationTarget | None, Field( description='Required only for `translate_to_native`. The output token contract that replaces this declaration after translation.' ), + NoExplicitNull, ] = None location: Location encoding: Annotated[ @@ -168,6 +173,7 @@ class MacroDeclaration12(AdCPBaseModel): Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), + NoExplicitNull, ] = None dialect_revision: Annotated[ str | None, @@ -175,6 +181,7 @@ class MacroDeclaration12(AdCPBaseModel): description='Immutable registry/mapping revision, release, commit, or digest. Required for IAB VAST, IAB DAAST, and vendor dialects.', min_length=1, ), + NoExplicitNull, ] = None dialect_semantic: Annotated[ str, @@ -189,6 +196,7 @@ class MacroDeclaration12(AdCPBaseModel): Field( description='Verified AdCP universal meaning. Present only when mapping_status is `verified_universal`.' ), + NoExplicitNull, ] = None operation: asset_union.MacroProcessingOperation performed_by: Annotated[ @@ -196,12 +204,14 @@ class MacroDeclaration12(AdCPBaseModel): Field( description='Actor authorized to perform the declared translation or value resolution. Omitted for preservation.' ), + NoExplicitNull, ] = None translation_target: Annotated[ asset_union.MacroTranslationTarget | None, Field( description='Required only for `translate_to_native`. The output token contract that replaces this declaration after translation.' ), + NoExplicitNull, ] = None location: Location13 encoding: Annotated[ @@ -235,27 +245,36 @@ class DaastAsset3(AdCPBaseModel): ), ] = 'daast' daast_version: Annotated[ - daast_version_1.DaastVersion | None, Field(description='DAAST specification version') + daast_version_1.DaastVersion | None, + Field(description='DAAST specification version'), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Expected audio duration in milliseconds (if known)', ge=0), + NoExplicitNull, ] = None tracking_events: Annotated[ list[daast_tracking_event.DaastTrackingEvent] | None, Field(description='Tracking events supported by this DAAST tag'), + NoExplicitNull, ] = None companion_ads: Annotated[ - StrictBool | None, Field(description='Whether companion display ads are included') + StrictBool | None, + Field(description='Whether companion display ads are included'), + NoExplicitNull, ] = None transcript_url: Annotated[ - AnyUrl | None, Field(description='URL to text transcript of the audio content') + AnyUrl | None, + Field(description='URL to text transcript of the audio content'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None macro_declarations: Annotated[ list[MacroDeclaration] | None, @@ -263,6 +282,7 @@ class DaastAsset3(AdCPBaseModel): description='One declaration per occurrence in a field carried by this asset. URL-delivered assets declare only locator-URL occurrences; receivers do not infer declarations for tokens discovered later in a fetched document.', min_length=1, ), + NoExplicitNull, ] = None delivery_type: Annotated[ Literal['url'], @@ -287,27 +307,36 @@ class DaastAsset4(AdCPBaseModel): ), ] = 'daast' daast_version: Annotated[ - daast_version_1.DaastVersion | None, Field(description='DAAST specification version') + daast_version_1.DaastVersion | None, + Field(description='DAAST specification version'), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Expected audio duration in milliseconds (if known)', ge=0), + NoExplicitNull, ] = None tracking_events: Annotated[ list[daast_tracking_event.DaastTrackingEvent] | None, Field(description='Tracking events supported by this DAAST tag'), + NoExplicitNull, ] = None companion_ads: Annotated[ - StrictBool | None, Field(description='Whether companion display ads are included') + StrictBool | None, + Field(description='Whether companion display ads are included'), + NoExplicitNull, ] = None transcript_url: Annotated[ - AnyUrl | None, Field(description='URL to text transcript of the audio content') + AnyUrl | None, + Field(description='URL to text transcript of the audio content'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None macro_declarations: Annotated[ list[MacroDeclaration12] | None, @@ -315,6 +344,7 @@ class DaastAsset4(AdCPBaseModel): description='One declaration per occurrence in a field carried by this asset. URL-delivered assets declare only locator-URL occurrences; receivers do not infer declarations for tokens discovered later in a fetched document.', min_length=1, ), + NoExplicitNull, ] = None delivery_type: Annotated[ Literal['inline'], diff --git a/src/adcp/types/domains/core/assets/daast_tracker_asset.py b/src/adcp/types/domains/core/assets/daast_tracker_asset.py index 065ec6771..81bb26308 100644 --- a/src/adcp/types/domains/core/assets/daast_tracker_asset.py +++ b/src/adcp/types/domains/core/assets/daast_tracker_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/daast_tracker_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from ...enums import daast_tracking_event @@ -75,6 +75,7 @@ class DaastTrackerAsset(AdCPBaseModel): Field( description='Exact tokens in `url` and their resolver/encoding contracts.', min_length=1 ), + NoExplicitNull, ] = None offset: Annotated[ str | None, @@ -82,16 +83,19 @@ class DaastTrackerAsset(AdCPBaseModel): description='DAAST `offset` attribute. Required when `daast_event` is `progress` (DAAST 1.1 §3.2.4.3); ignored otherwise for compatibility with existing 3.x manifests. Same format as VAST 4.2 `Tracking@offset`: `HH:MM:SS` or `HH:MM:SS.mmm` for absolute time (two-digit hours, minutes 00–59, seconds 00–59), or an integer percentage 0–100 suffixed with `%`. Negative offsets are NOT permitted.', pattern='^(\\d{2}:[0-5]\\d:[0-5]\\d(\\.\\d{3})?|(100|\\d{1,2})%)$', ), + NoExplicitNull, ] = None target: Annotated[ Target | None, Field( description='Which DAAST creative element this tracker scopes to — `linear` for `/` (DAAST 1.1 §3.2.1.7), `companion` for `//` (DAAST 1.1 §3.2.2.7). DAAST has no `` element. Defaults to `linear`. Existing 3.x assets remain structurally permissive; a tracker execution contract applies the standards-valid event/target matrix when matching a creative to a product.' ), + NoExplicitNull, ] = Target.linear provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/display_tag_asset.py b/src/adcp/types/domains/core/assets/display_tag_asset.py index d5ceb0365..4423f1a7f 100644 --- a/src/adcp/types/domains/core/assets/display_tag_asset.py +++ b/src/adcp/types/domains/core/assets/display_tag_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/display_tag_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool from .. import macro_bearing_url @@ -85,6 +85,7 @@ class MacroDeclaration(MacroDeclaration_1): Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), + NoExplicitNull, ] = None dialect_revision: Annotated[ str | None, @@ -92,6 +93,7 @@ class MacroDeclaration(MacroDeclaration_1): description='Immutable registry/mapping revision, release, commit, or digest. Required for IAB VAST, IAB DAAST, and vendor dialects.', min_length=1, ), + NoExplicitNull, ] = None dialect_semantic: Annotated[ str, @@ -106,6 +108,7 @@ class MacroDeclaration(MacroDeclaration_1): Field( description='Verified AdCP universal meaning. Present only when mapping_status is `verified_universal`.' ), + NoExplicitNull, ] = None operation: asset_union.MacroProcessingOperation performed_by: Annotated[ @@ -113,12 +116,14 @@ class MacroDeclaration(MacroDeclaration_1): Field( description='Actor authorized to perform the declared translation or value resolution. Omitted for preservation.' ), + NoExplicitNull, ] = None translation_target: Annotated[ asset_union.MacroTranslationTarget | None, Field( description='Required only for `translate_to_native`. The output token contract that replaces this declaration after translation.' ), + NoExplicitNull, ] = None encoding: Annotated[ asset_union.MacroEncoding, @@ -156,12 +161,14 @@ class DisplayTagAsset4(AdCPBaseModel): description='Exact macro tokens present anywhere in this representation. Tokens remain byte-preserved until the resolver named by each declaration substitutes them.', min_length=1, ), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overriding manifest-level provenance.' ), + NoExplicitNull, ] = None delivery_type: Literal['tag_url'] = 'tag_url' url: Annotated[ @@ -198,6 +205,7 @@ class MacroDeclaration15(MacroDeclaration_1): Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), + NoExplicitNull, ] = None dialect_revision: Annotated[ str | None, @@ -205,6 +213,7 @@ class MacroDeclaration15(MacroDeclaration_1): description='Immutable registry/mapping revision, release, commit, or digest. Required for IAB VAST, IAB DAAST, and vendor dialects.', min_length=1, ), + NoExplicitNull, ] = None dialect_semantic: Annotated[ str, @@ -219,6 +228,7 @@ class MacroDeclaration15(MacroDeclaration_1): Field( description='Verified AdCP universal meaning. Present only when mapping_status is `verified_universal`.' ), + NoExplicitNull, ] = None operation: asset_union.MacroProcessingOperation performed_by: Annotated[ @@ -226,12 +236,14 @@ class MacroDeclaration15(MacroDeclaration_1): Field( description='Actor authorized to perform the declared translation or value resolution. Omitted for preservation.' ), + NoExplicitNull, ] = None translation_target: Annotated[ asset_union.MacroTranslationTarget | None, Field( description='Required only for `translate_to_native`. The output token contract that replaces this declaration after translation.' ), + NoExplicitNull, ] = None encoding: Annotated[ asset_union.MacroEncoding, @@ -269,12 +281,14 @@ class DisplayTagAsset5(AdCPBaseModel): description='Exact macro tokens present anywhere in this representation. Tokens remain byte-preserved until the resolver named by each declaration substitutes them.', min_length=1, ), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overriding manifest-level provenance.' ), + NoExplicitNull, ] = None delivery_type: Literal['inline_markup'] = 'inline_markup' markup_type: Annotated[ @@ -316,6 +330,7 @@ class MacroDeclaration16(MacroDeclaration_1): Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), + NoExplicitNull, ] = None dialect_revision: Annotated[ str | None, @@ -323,6 +338,7 @@ class MacroDeclaration16(MacroDeclaration_1): description='Immutable registry/mapping revision, release, commit, or digest. Required for IAB VAST, IAB DAAST, and vendor dialects.', min_length=1, ), + NoExplicitNull, ] = None dialect_semantic: Annotated[ str, @@ -337,6 +353,7 @@ class MacroDeclaration16(MacroDeclaration_1): Field( description='Verified AdCP universal meaning. Present only when mapping_status is `verified_universal`.' ), + NoExplicitNull, ] = None operation: asset_union.MacroProcessingOperation performed_by: Annotated[ @@ -344,12 +361,14 @@ class MacroDeclaration16(MacroDeclaration_1): Field( description='Actor authorized to perform the declared translation or value resolution. Omitted for preservation.' ), + NoExplicitNull, ] = None translation_target: Annotated[ asset_union.MacroTranslationTarget | None, Field( description='Required only for `translate_to_native`. The output token contract that replaces this declaration after translation.' ), + NoExplicitNull, ] = None encoding: Annotated[ asset_union.MacroEncoding, @@ -387,12 +406,14 @@ class DisplayTagAsset6(AdCPBaseModel): description='Exact macro tokens present anywhere in this representation. Tokens remain byte-preserved until the resolver named by each declaration substitutes them.', min_length=1, ), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overriding manifest-level provenance.' ), + NoExplicitNull, ] = None delivery_type: Literal['paired_redirect'] = 'paired_redirect' ad_request_url: Annotated[ diff --git a/src/adcp/types/domains/core/assets/html_asset.py b/src/adcp/types/domains/core/assets/html_asset.py index 9ad378fc1..b5b92a758 100644 --- a/src/adcp/types/domains/core/assets/html_asset.py +++ b/src/adcp/types/domains/core/assets/html_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/assets/html_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from .. import provenance as provenance_1 @@ -14,21 +14,26 @@ class Accessibility(AdCPBaseModel): alt_text: Annotated[ - str | None, Field(description='Text alternative describing the creative content') + str | None, + Field(description='Text alternative describing the creative content'), + NoExplicitNull, ] = None keyboard_navigable: Annotated[ StrictBool | None, Field(description='Whether the creative can be fully operated via keyboard'), + NoExplicitNull, ] = None motion_control: Annotated[ StrictBool | None, Field( description='Whether the creative respects prefers-reduced-motion or provides pause/stop controls' ), + NoExplicitNull, ] = None screen_reader_tested: Annotated[ StrictBool | None, Field(description='Whether the creative has been tested with screen readers'), + NoExplicitNull, ] = None @@ -43,14 +48,18 @@ class HtmlAsset(AdCPBaseModel): ), ] = 'html' content: Annotated[str, Field(description='HTML content')] - version: Annotated[str | None, Field(description="HTML version (e.g., 'HTML5')")] = None + version: Annotated[ + str | None, Field(description="HTML version (e.g., 'HTML5')"), NoExplicitNull + ] = None accessibility: Annotated[ Accessibility | None, Field(description='Self-declared accessibility properties for this opaque creative'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/image_asset.py b/src/adcp/types/domains/core/assets/image_asset.py index 9a5de6b11..15130540d 100644 --- a/src/adcp/types/domains/core/assets/image_asset.py +++ b/src/adcp/types/domains/core/assets/image_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/assets/image_asset.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat from pydantic import AnyUrl, ConfigDict, Field, StrictFloat @@ -37,6 +37,7 @@ class ImageAsset(AdCPBaseModel): description='Image file size in bytes. Required by agents that advertise a max_file_size_kb constraint.', ge=1, ), + NoExplicitNull, ] = None pixel_ratio: Annotated[ StrictFloat | None, @@ -44,18 +45,21 @@ class ImageAsset(AdCPBaseModel): description='Intrinsic pixels per logical render pixel (for example `2` for a 600×500 image intended to render at 300×250). Optional because a validator can infer the ratio when the target format declares logical dimensions. When supplied, it MUST agree with both `width / logical_width` and `height / logical_height`; it is never a substitute for the intrinsic `width` and `height` fields.', gt=0.0, ), + NoExplicitNull, ] = None state_id: Annotated[ str | None, Field( description='Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one declared `states[].state_id` (semantic validators resolve it). Omit for ordinary image slots.' ), + NoExplicitNull, ] = None breakpoint_id: Annotated[ str | None, Field( description='Binding used only when this image populates a `seller_rendered_stateful_display` `state_canvases` slot. It MUST match one breakpoint declared on the selected state (semantic validators resolve it). Omit for ordinary image slots.' ), + NoExplicitNull, ] = None focal_point: Annotated[ list[FocalPointItem] | None, @@ -64,14 +68,20 @@ class ImageAsset(AdCPBaseModel): max_length=2, min_length=2, ), + NoExplicitNull, ] = None format: Annotated[ - str | None, Field(description='Image file format (jpg, png, gif, webp, etc.)') + str | None, + Field(description='Image file format (jpg, png, gif, webp, etc.)'), + NoExplicitNull, + ] = None + alt_text: Annotated[ + str | None, Field(description='Alternative text for accessibility'), NoExplicitNull ] = None - alt_text: Annotated[str | None, Field(description='Alternative text for accessibility')] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/javascript_asset.py b/src/adcp/types/domains/core/assets/javascript_asset.py index 81b2d19fa..d77ae9325 100644 --- a/src/adcp/types/domains/core/assets/javascript_asset.py +++ b/src/adcp/types/domains/core/assets/javascript_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/assets/javascript_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ...enums import javascript_module_type @@ -15,21 +15,26 @@ class Accessibility(AdCPBaseModel): alt_text: Annotated[ - str | None, Field(description='Text alternative describing the creative content') + str | None, + Field(description='Text alternative describing the creative content'), + NoExplicitNull, ] = None keyboard_navigable: Annotated[ StrictBool | None, Field(description='Whether the creative can be fully operated via keyboard'), + NoExplicitNull, ] = None motion_control: Annotated[ StrictBool | None, Field( description='Whether the creative respects prefers-reduced-motion or provides pause/stop controls' ), + NoExplicitNull, ] = None screen_reader_tested: Annotated[ StrictBool | None, Field(description='Whether the creative has been tested with screen readers'), + NoExplicitNull, ] = None @@ -47,14 +52,17 @@ class JavascriptAsset(AdCPBaseModel): module_type: Annotated[ javascript_module_type.JavascriptModuleType | None, Field(description='JavaScript module type'), + NoExplicitNull, ] = None accessibility: Annotated[ Accessibility | None, Field(description='Self-declared accessibility properties for this opaque creative'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/markdown_asset.py b/src/adcp/types/domains/core/assets/markdown_asset.py index af288a283..a9cbe5c53 100644 --- a/src/adcp/types/domains/core/assets/markdown_asset.py +++ b/src/adcp/types/domains/core/assets/markdown_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/assets/markdown_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ...enums import markdown_flavor as markdown_flavor_1 @@ -33,16 +33,19 @@ class MarkdownAsset(AdCPBaseModel): Field( description='Optional language claim for this markdown. In a materialized creative localization variant, localized-creative-asset.json requires this value to use /schemas/core/locale-tag.json and conformance requires exact equality with the enclosing variant locale. General non-localized assets retain the legacy unconstrained string for compatibility.' ), + NoExplicitNull, ] = None markdown_flavor: Annotated[ markdown_flavor_1.MarkdownFlavor | None, Field( description='Markdown flavor used. CommonMark for strict compatibility, GFM for tables/task lists/strikethrough.' ), + NoExplicitNull, ] = markdown_flavor_1.MarkdownFlavor.commonmark allow_raw_html: Annotated[ StrictBool | None, Field( description='Whether raw HTML blocks are allowed in the markdown. False recommended for security.' ), + NoExplicitNull, ] = False diff --git a/src/adcp/types/domains/core/assets/pixel_tracker_asset.py b/src/adcp/types/domains/core/assets/pixel_tracker_asset.py index e7c9ff657..7ad5f981d 100644 --- a/src/adcp/types/domains/core/assets/pixel_tracker_asset.py +++ b/src/adcp/types/domains/core/assets/pixel_tracker_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/pixel_tracker_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from ...enums import pixel_tracking_event @@ -69,6 +69,7 @@ class PixelTrackerAsset(AdCPBaseModel): Field( description="How the tracker URL is invoked at serve time:\n- `img` — fired as an image pixel (HTTP GET with ``-like semantics; no JS execution)\n- `js` — fired as a script include (renderer evaluates the URL's response as JavaScript)\n\nMatches IAB OpenRTB Native 1.2 method enum (1=img, 2=js). `js` MUST only be used by sellers whose renderer supports JavaScript trackers; sellers without JS-tracker support MUST reject `method: js` declarations at sync_creatives time with `CREATIVE_REJECTED` carrying the reason." ), + NoExplicitNull, ] = Method.img url: Annotated[ macro_bearing_url.MacroBearingUrl, @@ -82,16 +83,19 @@ class PixelTrackerAsset(AdCPBaseModel): description='One declaration per token occurrence in `url`; declaration_id values MUST be unique and locations MUST resolve. When omitted, legacy behavior applies.', min_length=1, ), + NoExplicitNull, ] = None custom_event_name: Annotated[ str | None, Field( description='REQUIRED when `event` is `custom`; otherwise MUST be absent. Adopter-defined event name. When tracker execution is undeclared, an unknown custom event is a forward-compatible probe and the seller silently no-ops instead of rejecting the creative. An effective tracker_execution_contract overrides that legacy fallback: with complete:true an unlisted custom selector is unsupported and rejects compatibility with tracker_contract_mismatch; a listed custom selector is an affirmative accept-and-initiate commitment.' ), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/published_post_asset.py b/src/adcp/types/domains/core/assets/published_post_asset.py index a9236e5a0..20e9df09f 100644 --- a/src/adcp/types/domains/core/assets/published_post_asset.py +++ b/src/adcp/types/domains/core/assets/published_post_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/published_post_asset.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator from .. import provenance as provenance_1 @@ -21,13 +21,17 @@ class IdentityRef(AdCPBaseModel): extra='allow', ) handle: Annotated[ - str | None, Field(description='Human-readable handle or username when available.') + str | None, + Field(description='Human-readable handle or username when available.'), + NoExplicitNull, ] = None profile_url: Annotated[ - AnyUrl | None, Field(description='URL for the authoring profile, page, or channel.') + AnyUrl | None, + Field(description='URL for the authoring profile, page, or channel.'), + NoExplicitNull, ] = None platform_identity_id: Annotated[ - str | None, Field(description='Opaque platform-native identity identifier.') + str | None, Field(description='Opaque platform-native identity identifier.'), NoExplicitNull ] = None @@ -53,18 +57,22 @@ class ReferenceAuthorization(AdCPBaseModel): checked_at: Annotated[ AwareDatetime | None, Field(description='When the seller last checked the authorization state.'), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field(description='When the authorization expires, if the platform exposes an expiry.'), + NoExplicitNull, ] = None authorization_url: Annotated[ AnyUrl | None, Field(description='Optional URL the buyer or creator can use to complete authorization.'), + NoExplicitNull, ] = None authorization_instructions: Annotated[ str | None, Field(description='Human-readable instructions for completing or restoring authorization.'), + NoExplicitNull, ] = None @@ -83,40 +91,47 @@ class PublishedPostAsset(AdCPBaseModel): Field( description='Canonical URL for the published post. Preferred when the platform exposes a stable public or authenticated URL.' ), + NoExplicitNull, ] = None platform: Annotated[ str | None, Field( description="Optional platform or publisher namespace for the referenced post. Informational unless the seller's product declaration or platform extension narrows the accepted values." ), + NoExplicitNull, ] = None platform_post_id: Annotated[ str | None, Field( description='Optional platform-native post identifier when a URL alone is not stable or not available. Buyers SHOULD include `platform` when using `platform_post_id` without `post_url`, unless the product or format declaration already narrows the platform. Platform-specific identifier semantics belong in platform_extensions; this field is only an opaque reference.' ), + NoExplicitNull, ] = None identity_ref: Annotated[ IdentityRef | None, Field( description='Optional identity hint for the authoring handle/page/channel that owns the post. Sellers MUST verify authorization from platform state; buyers MUST NOT use this object as proof of authorization.' ), + NoExplicitNull, ] = None published_at: Annotated[ AwareDatetime | None, Field(description='When the referenced post was originally published, if known.'), + NoExplicitNull, ] = None reference_authorization: Annotated[ ReferenceAuthorization | None, Field( description='Server-emitted, seller-observed authorization state for the referenced post or identity. Sellers MAY return this object on read surfaces. On write requests, sellers MUST ignore buyer-supplied `reference_authorization.status` and other authorization-state claims unless a platform extension explicitly defines a signed proof shape and the seller verifies that proof.' ), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this reference asset, overrides manifest-level provenance.' ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/assets/text_asset.py b/src/adcp/types/domains/core/assets/text_asset.py index 11f1ea791..8f544ee95 100644 --- a/src/adcp/types/domains/core/assets/text_asset.py +++ b/src/adcp/types/domains/core/assets/text_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/assets/text_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from .. import provenance as provenance_1 @@ -28,10 +28,12 @@ class TextAsset(AdCPBaseModel): Field( description='Optional language claim for this text. In a materialized creative localization variant, localized-creative-asset.json requires this value to use /schemas/core/locale-tag.json and conformance requires exact equality with the enclosing variant locale. General non-localized assets retain the legacy unconstrained string for compatibility.' ), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/url_asset.py b/src/adcp/types/domains/core/assets/url_asset.py index 0e312fafa..6d8bd47af 100644 --- a/src/adcp/types/domains/core/assets/url_asset.py +++ b/src/adcp/types/domains/core/assets/url_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/url_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from ...enums import url_asset_type @@ -64,6 +64,7 @@ class UrlAsset(AdCPBaseModel): Field( description='Mechanism a receiver uses to invoke this URL: `clickthrough` for a user destination, `ad_request` for a third-party display creative request, `tracker_pixel` for an event HTTP request, or `tracker_script` for a script include. SHOULD be present on every URL asset.' ), + NoExplicitNull, ] = None macro_declarations: Annotated[ list[MacroDeclaration] | None, @@ -71,19 +72,22 @@ class UrlAsset(AdCPBaseModel): description='One declaration per token occurrence in `url`; declaration_id values MUST be unique and every location MUST identify an existing occurrence. Absence retains legacy opaque transport behavior.', min_length=1, ), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Description of what this URL points to') + str | None, Field(description='Description of what this URL points to'), NoExplicitNull ] = None state_id: Annotated[ str | None, Field( description='Binding used only when this URL populates a `seller_rendered_stateful_display` `state_click_urls` slot. It MUST match one declared `states[].state_id` (semantic validators resolve it); at most one entry per state. Omit for ordinary URL slots.' ), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/vast_asset.py b/src/adcp/types/domains/core/assets/vast_asset.py index 4f9ea6726..6f85ec7a9 100644 --- a/src/adcp/types/domains/core/assets/vast_asset.py +++ b/src/adcp/types/domains/core/assets/vast_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/vast_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool from ...enums import vast_tracking_event @@ -87,6 +87,7 @@ class MacroDeclaration(AdCPBaseModel): Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), + NoExplicitNull, ] = None dialect_revision: Annotated[ str | None, @@ -94,6 +95,7 @@ class MacroDeclaration(AdCPBaseModel): description='Immutable registry/mapping revision, release, commit, or digest. Required for IAB VAST, IAB DAAST, and vendor dialects.', min_length=1, ), + NoExplicitNull, ] = None dialect_semantic: Annotated[ str, @@ -108,6 +110,7 @@ class MacroDeclaration(AdCPBaseModel): Field( description='Verified AdCP universal meaning. Present only when mapping_status is `verified_universal`.' ), + NoExplicitNull, ] = None operation: asset_union.MacroProcessingOperation performed_by: Annotated[ @@ -115,12 +118,14 @@ class MacroDeclaration(AdCPBaseModel): Field( description='Actor authorized to perform the declared translation or value resolution. Omitted for preservation.' ), + NoExplicitNull, ] = None translation_target: Annotated[ asset_union.MacroTranslationTarget | None, Field( description='Required only for `translate_to_native`. The output token contract that replaces this declaration after translation.' ), + NoExplicitNull, ] = None location: Location encoding: Annotated[ @@ -158,6 +163,7 @@ class VastAsset3(AdCPBaseModel): Field( description='Exact VAST version declared by the supplied URL response or inline document. Required by the 3.2 canonical `video_vast` and `audio_vast` manifest paths; optional only on the deprecated named-format compatibility path. Receivers MUST NOT relabel or synthesize a newer version merely because the destination accepts it.' ), + NoExplicitNull, ] = None macro_declarations: Annotated[ list[MacroDeclaration] | None, @@ -165,31 +171,37 @@ class VastAsset3(AdCPBaseModel): description='One declaration per exact occurrence in a field carried by this asset. A URL-delivered asset can declare only occurrences in its locator `url`; tokens discovered later in a fetched VAST response require document validation evidence or an inline/snapshotted asset and MUST NOT be guessed from the locator. IAB tokens cite a registry namespace and revision rather than copying the live registry into AdCP.', min_length=1, ), + NoExplicitNull, ] = None vpaid_enabled: Annotated[ StrictBool | None, Field(description='Whether VPAID (Video Player-Ad Interface Definition) is supported'), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Expected media duration in milliseconds (if known)', ge=0), + NoExplicitNull, ] = None tracking_events: Annotated[ list[vast_tracking_event.VastTrackingEvent] | None, Field(description='Tracking events supported by this VAST tag'), + NoExplicitNull, ] = None captions_url: Annotated[ - AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)') + AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)'), NoExplicitNull ] = None audio_description_url: Annotated[ AnyUrl | None, Field(description='URL to audio description track for visually impaired users'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None delivery_type: Annotated[ Literal['url'], @@ -228,6 +240,7 @@ class MacroDeclaration20(AdCPBaseModel): Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), + NoExplicitNull, ] = None dialect_revision: Annotated[ str | None, @@ -235,6 +248,7 @@ class MacroDeclaration20(AdCPBaseModel): description='Immutable registry/mapping revision, release, commit, or digest. Required for IAB VAST, IAB DAAST, and vendor dialects.', min_length=1, ), + NoExplicitNull, ] = None dialect_semantic: Annotated[ str, @@ -249,6 +263,7 @@ class MacroDeclaration20(AdCPBaseModel): Field( description='Verified AdCP universal meaning. Present only when mapping_status is `verified_universal`.' ), + NoExplicitNull, ] = None operation: asset_union.MacroProcessingOperation performed_by: Annotated[ @@ -256,12 +271,14 @@ class MacroDeclaration20(AdCPBaseModel): Field( description='Actor authorized to perform the declared translation or value resolution. Omitted for preservation.' ), + NoExplicitNull, ] = None translation_target: Annotated[ asset_union.MacroTranslationTarget | None, Field( description='Required only for `translate_to_native`. The output token contract that replaces this declaration after translation.' ), + NoExplicitNull, ] = None location: Location26 encoding: Annotated[ @@ -299,6 +316,7 @@ class VastAsset4(AdCPBaseModel): Field( description='Exact VAST version declared by the supplied URL response or inline document. Required by the 3.2 canonical `video_vast` and `audio_vast` manifest paths; optional only on the deprecated named-format compatibility path. Receivers MUST NOT relabel or synthesize a newer version merely because the destination accepts it.' ), + NoExplicitNull, ] = None macro_declarations: Annotated[ list[MacroDeclaration20] | None, @@ -306,31 +324,37 @@ class VastAsset4(AdCPBaseModel): description='One declaration per exact occurrence in a field carried by this asset. A URL-delivered asset can declare only occurrences in its locator `url`; tokens discovered later in a fetched VAST response require document validation evidence or an inline/snapshotted asset and MUST NOT be guessed from the locator. IAB tokens cite a registry namespace and revision rather than copying the live registry into AdCP.', min_length=1, ), + NoExplicitNull, ] = None vpaid_enabled: Annotated[ StrictBool | None, Field(description='Whether VPAID (Video Player-Ad Interface Definition) is supported'), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Expected media duration in milliseconds (if known)', ge=0), + NoExplicitNull, ] = None tracking_events: Annotated[ list[vast_tracking_event.VastTrackingEvent] | None, Field(description='Tracking events supported by this VAST tag'), + NoExplicitNull, ] = None captions_url: Annotated[ - AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)') + AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)'), NoExplicitNull ] = None audio_description_url: Annotated[ AnyUrl | None, Field(description='URL to audio description track for visually impaired users'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None delivery_type: Annotated[ Literal['inline'], diff --git a/src/adcp/types/domains/core/assets/vast_tracker_asset.py b/src/adcp/types/domains/core/assets/vast_tracker_asset.py index d6395ac3a..b8b1169cb 100644 --- a/src/adcp/types/domains/core/assets/vast_tracker_asset.py +++ b/src/adcp/types/domains/core/assets/vast_tracker_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/vast_tracker_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from ...enums import vast_tracking_event @@ -76,6 +76,7 @@ class VastTrackerAsset(AdCPBaseModel): Field( description='Exact tokens in `url` and their resolver/encoding contracts.', min_length=1 ), + NoExplicitNull, ] = None offset: Annotated[ str | None, @@ -83,16 +84,19 @@ class VastTrackerAsset(AdCPBaseModel): description='VAST `offset` attribute. Required when `vast_event` is `progress`; ignored otherwise for compatibility with existing 3.x manifests. Format matches the VAST 4.2 XSD `Tracking@offset` pattern: `HH:MM:SS` or `HH:MM:SS.mmm` for absolute time (two-digit hours, minutes 00–59, seconds 00–59), or an integer percentage 0–100 suffixed with `%`. Negative offsets are NOT permitted — the VAST 4.2 XSD pattern does not allow a leading minus.', pattern='^(\\d{2}:[0-5]\\d:[0-5]\\d(\\.\\d{3})?|(100|\\d{1,2})%)$', ), + NoExplicitNull, ] = None target: Annotated[ Target | None, Field( description='Which VAST creative element this tracker scopes to — `linear` for `/`, `non_linear` for `/`, `companion` for `//`. Defaults to `linear`. Existing 3.x assets remain structurally permissive; a tracker execution contract applies the standards-valid event/target matrix when matching a creative to a product.' ), + NoExplicitNull, ] = Target.linear provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/video_asset.py b/src/adcp/types/domains/core/assets/video_asset.py index 0612ddb2d..eba2a43cc 100644 --- a/src/adcp/types/domains/core/assets/video_asset.py +++ b/src/adcp/types/domains/core/assets/video_asset.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/assets/video_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat from ...enums import audio_channel_layout @@ -79,79 +79,107 @@ class VideoAsset(AdCPBaseModel): ), ] duration_ms: Annotated[ - SchemaInt | None, Field(description='Video duration in milliseconds', ge=1) + SchemaInt | None, Field(description='Video duration in milliseconds', ge=1), NoExplicitNull + ] = None + file_size_bytes: Annotated[ + SchemaInt | None, Field(description='File size in bytes', ge=1), NoExplicitNull ] = None - file_size_bytes: Annotated[SchemaInt | None, Field(description='File size in bytes', ge=1)] = ( - None - ) container_format: Annotated[ - str | None, Field(description='Video container format (mp4, webm, mov, etc.)') + str | None, + Field(description='Video container format (mp4, webm, mov, etc.)'), + NoExplicitNull, ] = None video_codec: Annotated[ - str | None, Field(description='Video codec used (h264, h265, vp9, av1, prores, etc.)') + str | None, + Field(description='Video codec used (h264, h265, vp9, av1, prores, etc.)'), + NoExplicitNull, ] = None video_bitrate_kbps: Annotated[ - SchemaInt | None, Field(description='Video stream bitrate in kilobits per second', ge=1) + SchemaInt | None, + Field(description='Video stream bitrate in kilobits per second', ge=1), + NoExplicitNull, ] = None frame_rate: Annotated[ str | None, Field( description="Frame rate as string to preserve precision (e.g., '23.976', '29.97', '30')" ), + NoExplicitNull, + ] = None + frame_rate_type: Annotated[frame_rate_type_1.FrameRateType | None, NoExplicitNull] = None + scan_type: Annotated[scan_type_1.ScanType | None, NoExplicitNull] = None + color_space: Annotated[ + ColorSpace | None, Field(description='Color space of the video'), NoExplicitNull ] = None - frame_rate_type: frame_rate_type_1.FrameRateType | None = None - scan_type: scan_type_1.ScanType | None = None - color_space: Annotated[ColorSpace | None, Field(description='Color space of the video')] = None hdr_format: Annotated[ HdrFormat | None, Field(description="HDR format if applicable, or 'sdr' for standard dynamic range"), + NoExplicitNull, ] = None chroma_subsampling: Annotated[ - ChromaSubsampling | None, Field(description='Chroma subsampling format') + ChromaSubsampling | None, Field(description='Chroma subsampling format'), NoExplicitNull + ] = None + video_bit_depth: Annotated[ + VideoBitDepth | None, Field(description='Video bit depth'), NoExplicitNull ] = None - video_bit_depth: Annotated[VideoBitDepth | None, Field(description='Video bit depth')] = None gop_interval_seconds: Annotated[ - StrictFloat | None, Field(description='GOP/keyframe interval in seconds') + StrictFloat | None, Field(description='GOP/keyframe interval in seconds'), NoExplicitNull ] = None - gop_type: gop_type_1.GopType | None = None - moov_atom_position: moov_atom_position_1.MoovAtomPosition | None = None + gop_type: Annotated[gop_type_1.GopType | None, NoExplicitNull] = None + moov_atom_position: Annotated[moov_atom_position_1.MoovAtomPosition | None, NoExplicitNull] = ( + None + ) has_audio: Annotated[ - StrictBool | None, Field(description='Whether the video contains an audio track') + StrictBool | None, + Field(description='Whether the video contains an audio track'), + NoExplicitNull, ] = None audio_codec: Annotated[ str | None, Field(description='Audio codec used (aac, aac_lc, he_aac, pcm, mp3, ac3, eac3, etc.)'), + NoExplicitNull, ] = None audio_sampling_rate_hz: Annotated[ - SchemaInt | None, Field(description='Audio sampling rate in Hz (e.g., 44100, 48000)') + SchemaInt | None, + Field(description='Audio sampling rate in Hz (e.g., 44100, 48000)'), + NoExplicitNull, ] = None audio_channels: Annotated[ audio_channel_layout.AudioChannelLayout | None, Field(description='Audio channel configuration'), + NoExplicitNull, + ] = None + audio_bit_depth: Annotated[ + AudioBitDepth | None, Field(description='Audio bit depth'), NoExplicitNull ] = None - audio_bit_depth: Annotated[AudioBitDepth | None, Field(description='Audio bit depth')] = None audio_bitrate_kbps: Annotated[ - SchemaInt | None, Field(description='Audio bitrate in kilobits per second', ge=1) + SchemaInt | None, + Field(description='Audio bitrate in kilobits per second', ge=1), + NoExplicitNull, ] = None audio_loudness_lufs: Annotated[ - StrictFloat | None, Field(description='Integrated loudness in LUFS') + StrictFloat | None, Field(description='Integrated loudness in LUFS'), NoExplicitNull ] = None audio_true_peak_dbfs: Annotated[ - StrictFloat | None, Field(description='True peak level in dBFS') + StrictFloat | None, Field(description='True peak level in dBFS'), NoExplicitNull ] = None captions_url: Annotated[ - AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)') + AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)'), NoExplicitNull ] = None transcript_url: Annotated[ - AnyUrl | None, Field(description='URL to text transcript of the video content') + AnyUrl | None, + Field(description='URL to text transcript of the video content'), + NoExplicitNull, ] = None audio_description_url: Annotated[ AnyUrl | None, Field(description='URL to audio description track for visually impaired users'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/webhook_asset.py b/src/adcp/types/domains/core/assets/webhook_asset.py index a1dc08d0c..e30b32d79 100644 --- a/src/adcp/types/domains/core/assets/webhook_asset.py +++ b/src/adcp/types/domains/core/assets/webhook_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/assets/webhook_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field from ...enums import http_method, universal_macro, webhook_response_type, webhook_security_method @@ -18,10 +18,12 @@ class Security(AdCPBaseModel): webhook_security_method.WebhookSecurityMethod, Field(description='Authentication method') ] hmac_header: Annotated[ - str | None, Field(description="Header name for HMAC signature (e.g., 'X-Signature')") + str | None, + Field(description="Header name for HMAC signature (e.g., 'X-Signature')"), + NoExplicitNull, ] = None api_key_header: Annotated[ - str | None, Field(description="Header name for API key (e.g., 'X-API-Key')") + str | None, Field(description="Header name for API key (e.g., 'X-API-Key')"), NoExplicitNull ] = None @@ -36,22 +38,25 @@ class WebhookAsset(AdCPBaseModel): ), ] = 'webhook' url: Annotated[AnyUrl, Field(description='Webhook URL to call for dynamic content')] - method: Annotated[http_method.HttpMethod | None, Field(description='HTTP method')] = ( - http_method.HttpMethod.POST - ) + method: Annotated[ + http_method.HttpMethod | None, Field(description='HTTP method'), NoExplicitNull + ] = http_method.HttpMethod.POST timeout_ms: Annotated[ SchemaInt | None, Field(description='Maximum time to wait for response in milliseconds', ge=10, le=5000), + NoExplicitNull, ] = 500 supported_macros: Annotated[ list[universal_macro.UniversalMacro | str] | None, Field( description='Universal macros that can be passed to webhook (e.g., DEVICE_TYPE, COUNTRY). See docs/creative/universal-macros.mdx for full list.' ), + NoExplicitNull, ] = None required_macros: Annotated[ list[universal_macro.UniversalMacro | str] | None, Field(description='Universal macros that must be provided for webhook to function'), + NoExplicitNull, ] = None response_type: Annotated[ webhook_response_type.WebhookResponseType, @@ -63,4 +68,5 @@ class WebhookAsset(AdCPBaseModel): Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/zip_asset.py b/src/adcp/types/domains/core/assets/zip_asset.py index c6f97d094..a3c7fe7cf 100644 --- a/src/adcp/types/domains/core/assets/zip_asset.py +++ b/src/adcp/types/domains/core/assets/zip_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/assets/zip_asset.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, StrictBool from .. import provenance as provenance_1 @@ -14,21 +14,26 @@ class Accessibility(AdCPBaseModel): alt_text: Annotated[ - str | None, Field(description='Text alternative describing the creative content') + str | None, + Field(description='Text alternative describing the creative content'), + NoExplicitNull, ] = None keyboard_navigable: Annotated[ StrictBool | None, Field(description='Whether the creative can be fully operated via keyboard'), + NoExplicitNull, ] = None motion_control: Annotated[ StrictBool | None, Field( description='Whether the creative respects prefers-reduced-motion or provides pause/stop controls' ), + NoExplicitNull, ] = None screen_reader_tested: Annotated[ StrictBool | None, Field(description='Whether the creative has been tested with screen readers'), + NoExplicitNull, ] = None @@ -49,24 +54,28 @@ class ZipAsset(AdCPBaseModel): description='Maximum file size in kilobytes. Receivers should reject zips exceeding this.', ge=0, ), + NoExplicitNull, ] = None entry_point: Annotated[ str | None, Field( description="Relative path to the entry file within the zip (typically 'index.html'). Receivers default to 'index.html' if absent." ), + NoExplicitNull, ] = None allowed_inner_extensions: Annotated[ list[str] | None, Field( description="File extensions permitted inside the zip (e.g., ['html', 'css', 'js', 'png', 'jpg', 'svg', 'webp', 'json', 'woff2']). Receivers may reject zips containing other extensions." ), + NoExplicitNull, ] = None backup_image_url: Annotated[ AnyUrl | None, Field( description='Fallback image URL for environments that cannot render the bundled creative (e.g., non-HTML5 endpoints, ad blockers). Recommended for HTML5 banners.' ), + NoExplicitNull, ] = None digest: Annotated[ str | None, @@ -74,14 +83,17 @@ class ZipAsset(AdCPBaseModel): description='Optional SHA-256 content digest of the zip archive (sha256:) for integrity verification. Lets receivers detect tampered or stale archives.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None accessibility: Annotated[ Accessibility | None, Field(description='Self-declared accessibility properties for this opaque creative'), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this asset, overrides manifest-level provenance' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/async_response_refs/creative/get_creative_features_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/creative/get_creative_features_async_response_submitted.py index 6b0bd0095..fa1ce8bcf 100644 --- a/src/adcp/types/domains/core/async_response_refs/creative/get_creative_features_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/creative/get_creative_features_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/creative/get_creative_features_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -36,6 +36,7 @@ class GetCreativeFeaturesSubmitted(AdCPBaseModel): description='Provider-generated identity allocated when the evaluation is accepted. Optional in AdCP 3.x and required in AdCP 4.0. Providers SHOULD emit it in 3.x; when present, exact replays and the terminal result MUST carry this same value. Distinct from task_id and idempotency_key.', min_length=1, ), + NoExplicitNull, ] = None message: Annotated[ str | None, @@ -43,6 +44,7 @@ class GetCreativeFeaturesSubmitted(AdCPBaseModel): description='Optional human-readable explanation of why the evaluation is submitted. Plain text only; callers treat it as untrusted provider input.', max_length=2000, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_input_required.py b/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_input_required.py index abe340e98..01c43e48c 100644 --- a/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_input_required.py +++ b/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/creative/sync_creatives_async_response_input_required.json -# timestamp: 2026-05-29T23:56:01+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -25,7 +25,9 @@ class SyncCreativesInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why buyer input is needed') + Reason | None, + Field(description='Reason code indicating why buyer input is needed'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_submitted.py index 9c87f5d91..6343ab683 100644 --- a/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/creative/sync_creatives_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -36,12 +36,14 @@ class SyncCreativesSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Batch ingestion queued; typical turnaround 15-30 minutes.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_working.py b/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_working.py index e306bbcdf..ee4511dd9 100644 --- a/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_working.py +++ b/src/adcp/types/domains/core/async_response_refs/creative/sync_creatives_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/creative/sync_creatives_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ... import context as context_1 @@ -18,20 +18,30 @@ class SyncCreativesWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ - str | None, Field(description='Current step or phase of the operation') + str | None, Field(description='Current step or phase of the operation'), NoExplicitNull ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, + ] = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None creatives_processed: Annotated[ - SchemaInt | None, Field(description='Number of creatives processed so far', ge=0) + SchemaInt | None, + Field(description='Number of creatives processed so far', ge=0), + NoExplicitNull, ] = None creatives_total: Annotated[ - SchemaInt | None, Field(description='Total number of creatives to process', ge=0) + SchemaInt | None, + Field(description='Total number of creatives to process', ge=0), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_input_required.py b/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_input_required.py index a322fbc3e..756c42a3a 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_input_required.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/build_creative_async_response_input_required.json -# timestamp: 2026-05-29T23:56:01+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -26,13 +26,16 @@ class BuildCreativeInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why input is needed') + Reason | None, + Field(description='Reason code indicating why input is needed'), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional validation errors or warnings explaining why input is required.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_submitted.py index 2fb45a85a..81c3d461e 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/build_creative_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -36,12 +36,14 @@ class BuildCreativeSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Generative build queued; typical turnaround 3–5 minutes.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_working.py b/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_working.py index 653e18b6b..2c34d23c6 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_working.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/build_creative_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/build_creative_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ... import context as context_1 @@ -18,17 +18,24 @@ class BuildCreativeWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ str | None, Field( description="Current step or phase of the operation (e.g., 'generating_assets', 'resolving_macros', 'rendering_preview')" ), + NoExplicitNull, ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_input_required.py b/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_input_required.py index bb7da427a..a46c2575f 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_input_required.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/create_media_buy_async_response_input_required.json -# timestamp: 2026-05-29T23:56:01+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -25,13 +25,16 @@ class CreateMediaBuyInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why input is needed') + Reason | None, + Field(description='Reason code indicating why input is needed'), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional validation errors or warnings for debugging purposes. Helps explain why input is required.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_submitted.py index 19fa10e0a..a91a1329e 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/create_media_buy_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -36,12 +36,14 @@ class CreateMediaBuySubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Awaiting IO signature from sales team; typical turnaround 2–4 hours.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_working.py b/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_working.py index b09e8be39..84b2a4e2c 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_working.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/create_media_buy_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/create_media_buy_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ... import context as context_1 @@ -18,14 +18,20 @@ class CreateMediaBuyWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ - str | None, Field(description='Current step or phase of the operation') + str | None, Field(description='Current step or phase of the operation'), NoExplicitNull ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_input_required.py b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_input_required.py index 33e346407..885d729ba 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_input_required.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/get_products_async_response_input_required.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -25,14 +25,19 @@ class GetProductsInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why input is needed') + Reason | None, + Field(description='Reason code indicating why input is needed'), + NoExplicitNull, ] = None partial_results: Annotated[ list[product.Product] | None, Field(description='Partial product results that may help inform the clarification'), + NoExplicitNull, ] = None suggestions: Annotated[ - list[str] | None, Field(description='Suggested values or options for the required input') + list[str] | None, + Field(description='Suggested values or options for the required input'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py index 5e4be1c86..b76ef7ad8 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/get_products_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ... import context as context_1 @@ -36,15 +36,19 @@ class GetProductsSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Custom curation queued; typical turnaround 10–30 minutes.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None estimated_completion: Annotated[ - AwareDatetime | None, Field(description='Estimated completion time for the search') + AwareDatetime | None, + Field(description='Estimated completion time for the search'), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_working.py b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_working.py index 49a7e2e4c..718c99c54 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_working.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/get_products_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ... import context as context_1 @@ -20,18 +20,22 @@ class GetProductsWorking(AdCPBaseModel): percentage: Annotated[ StrictFloat | None, Field(description='Progress percentage of the search operation', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ str | None, Field( description="Current step in the search process (e.g., 'searching_inventory', 'validating_availability')" ), + NoExplicitNull, ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the search process') + SchemaInt | None, + Field(description='Total number of steps in the search process'), + NoExplicitNull, ] = None step_number: Annotated[ - SchemaInt | None, Field(description='Current step number (1-indexed)') + SchemaInt | None, Field(description='Current step number (1-indexed)'), NoExplicitNull ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_input_required.py b/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_input_required.py index 332b83101..258025591 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_input_required.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/sync_catalogs_async_response_input_required.json -# timestamp: 2026-05-29T23:56:01+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -30,6 +30,7 @@ class SyncCatalogsInputRequired(AdCPBaseModel): Field( description='Reason code indicating why buyer input is needed. APPROVAL_REQUIRED: platform requires explicit approval before activating the catalog. FEED_VALIDATION: feed URL returned unexpected format or schema errors. ITEM_REVIEW: platform flagged items for manual review. FEED_ACCESS: platform cannot access the feed URL (authentication, CORS, etc.).' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_submitted.py index e50407f29..f63ab2260 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/sync_catalogs_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -36,12 +36,14 @@ class SyncCatalogsSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Catalog ingestion queued; typical turnaround 5–15 minutes.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_working.py b/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_working.py index ee29491da..c8c54b0d5 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_working.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/sync_catalogs_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/sync_catalogs_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ... import context as context_1 @@ -18,31 +18,44 @@ class SyncCatalogsWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ str | None, Field( description="Current step or phase of the operation (e.g., 'Fetching product feed', 'Validating items', 'Platform review')" ), + NoExplicitNull, ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, + ] = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None catalogs_processed: Annotated[ - SchemaInt | None, Field(description='Number of catalogs processed so far', ge=0) + SchemaInt | None, + Field(description='Number of catalogs processed so far', ge=0), + NoExplicitNull, ] = None catalogs_total: Annotated[ - SchemaInt | None, Field(description='Total number of catalogs to process', ge=0) + SchemaInt | None, + Field(description='Total number of catalogs to process', ge=0), + NoExplicitNull, ] = None items_processed: Annotated[ SchemaInt | None, Field(description='Total number of catalog items processed across all catalogs', ge=0), + NoExplicitNull, ] = None items_total: Annotated[ SchemaInt | None, Field(description='Total number of catalog items to process across all catalogs', ge=0), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_input_required.py b/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_input_required.py index 570fd47d6..b32fddc3b 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_input_required.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/update_media_buy_async_response_input_required.json -# timestamp: 2026-05-29T23:56:01+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -24,7 +24,9 @@ class UpdateMediaBuyInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why input is needed') + Reason | None, + Field(description='Reason code indicating why input is needed'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_submitted.py index ef9261393..ce3498f0a 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/update_media_buy_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ... import context as context_1 @@ -36,12 +36,14 @@ class UpdateMediaBuySubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Awaiting operator re-approval; typical turnaround 2–4 hours.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_working.py b/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_working.py index dbe6e7e2c..6be340535 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_working.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/update_media_buy_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/update_media_buy_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ... import context as context_1 @@ -18,14 +18,20 @@ class UpdateMediaBuyWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ - str | None, Field(description='Current step or phase of the operation') + str | None, Field(description='Current step or phase of the operation'), NoExplicitNull ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py index 35ec0661d..de2e72d89 100644 --- a/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/signals/get_signals_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ... import context as context_1 @@ -36,16 +36,19 @@ class GetSignalsSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Provider discovery queued; typical turnaround 10-30 minutes.' Plain text only. Callers MUST treat this as untrusted agent input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile agent may inject prompt-injection payloads aimed at the caller's agent.", max_length=2000, ), + NoExplicitNull, ] = None estimated_completion: Annotated[ AwareDatetime | None, Field(description='Estimated completion time for the signal discovery task.'), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories or partial provider unavailability). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_working.py b/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_working.py index 51c7e5023..1da0051ed 100644 --- a/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_working.py +++ b/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/signals/get_signals_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ... import context as context_1 @@ -22,19 +22,22 @@ class GetSignalsWorking(AdCPBaseModel): Field( description='Progress percentage of the signal discovery operation.', ge=0.0, le=100.0 ), + NoExplicitNull, ] = None current_step: Annotated[ str | None, Field( description='Current step in the signal discovery process, such as `querying_providers`, `ranking_signals`, or `checking_deployments`.' ), + NoExplicitNull, ] = None total_steps: Annotated[ SchemaInt | None, Field(description='Total number of steps in the signal discovery process.'), + NoExplicitNull, ] = None step_number: Annotated[ - SchemaInt | None, Field(description='Current step number (1-indexed).') + SchemaInt | None, Field(description='Current step number (1-indexed).'), NoExplicitNull ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/attestation_capabilities.py b/src/adcp/types/domains/core/attestation_capabilities.py index 9cd9b6537..e72a95e99 100644 --- a/src/adcp/types/domains/core/attestation_capabilities.py +++ b/src/adcp/types/domains/core/attestation_capabilities.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/attestation_capabilities.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, RootModel from . import attestation_issuer @@ -53,9 +53,9 @@ class AcceptedVerifier(AdCPBaseModel): extra='forbid', ) agent_url: AnyUrl - claim_types: Annotated[list[AnyUrl] | None, Field(min_length=1)] = None - proof_formats: Annotated[list[AnyUrl] | None, Field(min_length=1)] = None - ext: ext_1.ExtensionObject | None = None + claim_types: Annotated[list[AnyUrl] | None, Field(min_length=1), NoExplicitNull] = None + proof_formats: Annotated[list[AnyUrl] | None, Field(min_length=1), NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AcceptedIssuer(AdCPBaseModel): @@ -69,6 +69,7 @@ class AcceptedIssuer(AdCPBaseModel): description='Optional subset of accepted_claim_types this issuer may assert. Omit to allow any globally accepted claim type for this issuer.', min_length=1, ), + NoExplicitNull, ] = None proof_formats: Annotated[ list[AnyUrl] | None, @@ -76,6 +77,7 @@ class AcceptedIssuer(AdCPBaseModel): description='Optional subset of accepted_proof_formats allowed for this issuer. Omit to allow any globally accepted proof format for this issuer.', min_length=1, ), + NoExplicitNull, ] = None credential_origins: Annotated[ list[CredentialOrigin] | None, @@ -83,6 +85,7 @@ class AcceptedIssuer(AdCPBaseModel): description='Canonical HTTPS origins from which credential_uri locators may be fetched for this issuer. Exact origin matching happens after URL canonicalization and before DNS resolution. Paths in the credential URI may vary; userinfo is forbidden.', min_length=1, ), + NoExplicitNull, ] = None resolvers: Annotated[ list[Resolver] | None, @@ -90,8 +93,9 @@ class AcceptedIssuer(AdCPBaseModel): description='Evaluator-approved resolver endpoints for issuer_credential_id delivery. Presentations carry only resolver_id; they cannot replace url or authentication policy.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AttestationCapabilities(AdCPBaseModel): @@ -132,6 +136,7 @@ class AttestationCapabilities(AdCPBaseModel): description="Verifier agents the evaluator may call. A presenter's verify_agent nomination must match one of these canonicalized URLs, but the evaluator remains verifier-of-record and chooses whether to use the nominated agent, another accepted agent, or local verification.", min_length=1, ), + NoExplicitNull, ] = None max_embedded_credential_bytes: Annotated[ SchemaInt | None, @@ -140,5 +145,6 @@ class AttestationCapabilities(AdCPBaseModel): ge=1024, le=1048576, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/attestation_evaluation.py b/src/adcp/types/domains/core/attestation_evaluation.py index 9a0536ab7..5521a0fa8 100644 --- a/src/adcp/types/domains/core/attestation_evaluation.py +++ b/src/adcp/types/domains/core/attestation_evaluation.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/attestation_evaluation.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat @@ -51,6 +51,7 @@ class ActionBinding(AdCPBaseModel): max_length=1024, min_length=1, ), + NoExplicitNull, ] = None action_digest: Annotated[ str | None, @@ -58,6 +59,7 @@ class ActionBinding(AdCPBaseModel): description='SHA-256 digest of the domain-defined canonical action preimage.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None @@ -78,12 +80,14 @@ class AttestationEvaluation(AdCPBaseModel): description='SHA-256 of the exact resolved or embedded credential bytes according to proof_format. REQUIRED by the normative contract when outcome is verified, expired, revoked, invalid, subject_mismatch, or digest_mismatch; omitted when no credential bytes were obtained. For dual delivery, this is the resolved credential digest; reference_digest independently binds the embedded bytes.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None proof_format: Annotated[ AnyUrl | None, Field( description='Absolute URI identifying the credential/proof format used to interpret and hash obtained credential bytes. REQUIRED whenever credential_digest is present.' ), + NoExplicitNull, ] = None outcome: Annotated[ Outcome, @@ -103,6 +107,7 @@ class AttestationEvaluation(AdCPBaseModel): Field( description='Accepted verifier agent actually used, when verification was delegated. Omit for local verification or when no verifier was called.' ), + NoExplicitNull, ] = None reason_codes: Annotated[ list[ReasonCode] | None, @@ -110,6 +115,7 @@ class AttestationEvaluation(AdCPBaseModel): description='Open, machine-readable reason identifiers supplied by the evaluator. Consumers MUST branch on outcome, not on free-form or evaluator-specific reason codes.', min_length=1, ), + NoExplicitNull, ] = None confidence: Annotated[ StrictFloat | None, @@ -118,23 +124,27 @@ class AttestationEvaluation(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None revocation_checked_at: Annotated[ AwareDatetime | None, Field( description='When the evaluator last checked authoritative revocation state. Its freshness is governed by proof format and evaluator policy.' ), + NoExplicitNull, ] = None valid_until: Annotated[ AwareDatetime | None, Field( description='Upper bound on reuse of this evaluation, considering credential expiry, revocation-cache TTL, and evaluator policy. Consumers MUST re-evaluate after this time.' ), + NoExplicitNull, ] = None action_binding: Annotated[ ActionBinding | None, Field( description='Optional binding to the consuming action or readback. Domain consumers that rely on an evaluation MUST carry either an action id or an action digest so the result cannot be transplanted to an unrelated decision.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/attestation_issuer.py b/src/adcp/types/domains/core/attestation_issuer.py index f1e8bd57b..2009ac580 100644 --- a/src/adcp/types/domains/core/attestation_issuer.py +++ b/src/adcp/types/domains/core/attestation_issuer.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/attestation_issuer.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from . import brand_ref @@ -29,7 +29,7 @@ class AttestationIssuer2(AdCPBaseModel): description='Canonical HTTPS endpoint of the issuing agent. Evaluators compare it using AdCP URL canonicalization rules.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AttestationIssuer3(AdCPBaseModel): @@ -48,7 +48,7 @@ class AttestationIssuer3(AdCPBaseModel): description='Canonical HTTPS origin with no path, query, fragment, or userinfo. This identifies the issuer; it does not authorize a fetch.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AttestationIssuer1(AdCPBaseModel): @@ -59,7 +59,7 @@ class AttestationIssuer1(AdCPBaseModel): Literal['brand'], Field(description='The issuer is identified by an AdCP BrandRef.') ] = 'brand' brand: brand_ref.BrandReference - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None AttestationIssuer = Annotated[ diff --git a/src/adcp/types/domains/core/attestation_reference.py b/src/adcp/types/domains/core/attestation_reference.py index 1a90530f7..74760a7a7 100644 --- a/src/adcp/types/domains/core/attestation_reference.py +++ b/src/adcp/types/domains/core/attestation_reference.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/attestation_reference.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator from . import attestation_issuer, attestation_subject @@ -20,8 +20,8 @@ class ValidityHint(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - not_before: AwareDatetime | None = None - expires_at: AwareDatetime | None = None + not_before: Annotated[AwareDatetime | None, NoExplicitNull] = None + expires_at: Annotated[AwareDatetime | None, NoExplicitNull] = None class VerifyAgent(AdCPBaseModel): @@ -42,7 +42,7 @@ class Locator(AdCPBaseModel): description='HTTPS URI of the credential. Before any fetch, evaluators MUST match its canonical origin to credential_origins on the accepted issuer capability and then apply the attestation fetch contract.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Locator1(AdCPBaseModel): @@ -67,7 +67,7 @@ class Locator1(AdCPBaseModel): pattern='^[A-Za-z0-9._:-]+$', ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class EmbeddedCredential(AdCPBaseModel): @@ -87,7 +87,7 @@ class EmbeddedCredential(AdCPBaseModel): min_length=1, ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AttestationReference(AdCPBaseModel): @@ -108,12 +108,14 @@ class AttestationReference(AdCPBaseModel): description='Stable locator for resolving the credential. Credential URLs are permitted only when their canonical origin is allowlisted for the matched issuer. Issuer-scoped IDs name a resolver_id already published by the evaluator; the presentation cannot introduce a resolver URL.', discriminator='type', ), + NoExplicitNull, ] = None embedded_credential: Annotated[ EmbeddedCredential | None, Field( description='Optional inline credential for private, authenticated, or offline delivery. It may accompany locator or be the only delivery path. Its format must be supported by the evaluator, and the evaluator MUST verify the credential exactly as it would a resolved credential.' ), + NoExplicitNull, ] = None content_digest: Annotated[ str | None, @@ -121,6 +123,7 @@ class AttestationReference(AdCPBaseModel): description='Optional SHA-256 digest pin for the exact credential bytes, formatted as sha256:. The credential format defines its canonical byte representation. A mismatch is invalid and MUST NOT fall back to the unpinned credential. REQUIRED when both locator and embedded_credential are present; both byte representations MUST match this digest.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None credential_version: Annotated[ str | None, @@ -129,20 +132,23 @@ class AttestationReference(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None validity_hint: Annotated[ ValidityHint | None, Field( description="Optional planning-time validity hint copied from issuer metadata. Evaluators MUST use the resolved or embedded credential's signed validity and revocation state as authoritative." ), + NoExplicitNull, ] = None verify_agent: Annotated[ VerifyAgent | None, Field( description='Optional presenter nomination of a verifier already published by the evaluator. This is a representation, not routing authority: agent_url MUST match an accepted_verifiers[] entry after canonicalization, and the evaluator may choose another accepted verifier or verify locally.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> AttestationReference: diff --git a/src/adcp/types/domains/core/attestation_subject.py b/src/adcp/types/domains/core/attestation_subject.py index ff4620b51..1519bf545 100644 --- a/src/adcp/types/domains/core/attestation_subject.py +++ b/src/adcp/types/domains/core/attestation_subject.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/attestation_subject.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from . import brand_ref @@ -21,7 +21,7 @@ class AttestationSubject2(AdCPBaseModel): agent_url: Annotated[ AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AttestationSubject3(AdCPBaseModel): @@ -55,8 +55,9 @@ class AttestationSubject3(AdCPBaseModel): description='Optional SHA-256 pin for the exact content or immutable snapshot identified by this resource subject. This is part of the complete typed subject identity and is distinct from AttestationReference.content_digest, which pins credential bytes.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AttestationSubject1(AdCPBaseModel): @@ -65,7 +66,7 @@ class AttestationSubject1(AdCPBaseModel): ) type: Literal['brand'] = 'brand' brand: brand_ref.BrandReference - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None AttestationSubject = Annotated[ diff --git a/src/adcp/types/domains/core/attribution_window.py b/src/adcp/types/domains/core/attribution_window.py index a78e4ecbf..ed602aa29 100644 --- a/src/adcp/types/domains/core/attribution_window.py +++ b/src/adcp/types/domains/core/attribution_window.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/attribution_window.json -# timestamp: 2026-05-22T13:15:12+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import attribution_model @@ -22,16 +22,19 @@ class AttributionWindow(AdCPBaseModel): Field( description='Post-click attribution window. Conversions occurring within this duration after a click are attributed to the ad.' ), + NoExplicitNull, ] = None post_view: Annotated[ duration.Duration | None, Field( description='Post-view attribution window. Conversions occurring within this duration after an ad impression (without click) are attributed to the ad.' ), + NoExplicitNull, ] = None model: Annotated[ attribution_model.AttributionModel | None, Field( description="Attribution model used to assign credit when multiple touchpoints exist. SHOULD be populated when committing to a specific model; when absent, the seller's default applies." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/audience_activation_method.py b/src/adcp/types/domains/core/audience_activation_method.py index 8b1aae52c..35393c94e 100644 --- a/src/adcp/types/domains/core/audience_activation_method.py +++ b/src/adcp/types/domains/core/audience_activation_method.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/audience_activation_method.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field from ..enums import cloud_storage_protocol @@ -57,13 +57,16 @@ class ConsumerIdentity(AdCPBaseModel): extra='forbid', ) cloud: Annotated[ - Cloud | None, Field(description="Cloud family hosting this identity's account.") + Cloud | None, + Field(description="Cloud family hosting this identity's account."), + NoExplicitNull, ] = None region: Annotated[ str | None, Field( description="Vendor/cloud region identifier. Format follows the cloud family's region naming; not constrained by this schema." ), + NoExplicitNull, ] = None identity: Annotated[ str, @@ -89,6 +92,7 @@ class AudienceActivationMethod3(AdCPBaseModel): description='Supported transfer directions. buyer_to_seller: buyer writes to a seller-hosted bucket. seller_to_buyer: seller reads from a buyer-hosted bucket. Absent means unspecified (resolve during account setup), not neither.', min_length=1, ), + NoExplicitNull, ] = None vendor: Annotated[ brand_ref.BrandReference, @@ -113,6 +117,7 @@ class AudienceActivationMethod4(AdCPBaseModel): description="Principals the buyer grants access to in the vendor's system. identity is always required. cloud and region are optional, paired deployment metadata: omit both for global principals (for example, an IAM principal or federated identity). Their operational meaning is vendor-specific — they may constrain direct-share reachability or select a fulfillment route, or may be routing and cost hints only. They are not compliance boundaries; data-transfer assessments key on the recipient entity's jurisdiction, not the grantee account's region. Optional: sellers MAY instead communicate identities during account setup.", min_length=1, ), + NoExplicitNull, ] = None @@ -141,6 +146,7 @@ class AudienceActivationMethod6(AdCPBaseModel): max_length=256, min_length=1, ), + NoExplicitNull, ] = None bind_expiry_days: Annotated[ SchemaInt | None, @@ -148,6 +154,7 @@ class AudienceActivationMethod6(AdCPBaseModel): description="Window after which the seller MAY expire an unfulfilled platform_segment bind (per-audience action: failed on sync_audiences). Initial vendor distribution is days-scale; windows shorter than the vendor's documented distribution latency are non-conformant.", ge=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/audience_characteristic.py b/src/adcp/types/domains/core/audience_characteristic.py index a6be90ca3..e2ca29480 100644 --- a/src/adcp/types/domains/core/audience_characteristic.py +++ b/src/adcp/types/domains/core/audience_characteristic.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/audience_characteristic.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat, model_validator @@ -39,8 +39,8 @@ class Range(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - min: StrictFloat | None = None - max: StrictFloat | None = None + min: Annotated[StrictFloat | None, NoExplicitNull] = None + max: Annotated[StrictFloat | None, NoExplicitNull] = None class Taxonomy(AdCPBaseModel): @@ -48,8 +48,8 @@ class Taxonomy(AdCPBaseModel): extra='forbid', ) system: AnyUrl - version: Annotated[str | None, Field(min_length=1)] = None - value_id: Annotated[str | None, Field(min_length=1)] = None + version: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + value_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AudienceCharacteristic(AdCPBaseModel): @@ -65,21 +65,26 @@ class AudienceCharacteristic(AdCPBaseModel): value: Annotated[ str | StrictFloat | StrictBool | Value | None, Field(description='Scalar or set value for the dimension.'), + NoExplicitNull, ] = None range: Annotated[ Range | None, Field( description='Inclusive numeric interval. For dimension `age`, values are completed years and MUST be non-negative integers, and min MUST be less than or equal to max. JSON Schema enforces the age value and bound types; implementations enforce the relational min <= max constraint.' ), + NoExplicitNull, ] = None taxonomy: Annotated[ Taxonomy | None, Field( description='External system that defines the dimension or value. Taxonomy metadata is descriptive and does not create exact demographic targeting semantics.' ), + NoExplicitNull, ] = None label: Annotated[ - str | None, Field(description='Human-readable display label; never the comparison key.') + str | None, + Field(description='Human-readable display label; never the comparison key.'), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/audience_evidence.py b/src/adcp/types/domains/core/audience_evidence.py index 826e8a853..a6a263e09 100644 --- a/src/adcp/types/domains/core/audience_evidence.py +++ b/src/adcp/types/domains/core/audience_evidence.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/audience_evidence.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel, StrictFloat from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type @@ -35,7 +35,7 @@ class Baseline(AdCPBaseModel): system: AnyUrl population_id: Annotated[str, Field(min_length=1)] version: Annotated[str, Field(min_length=1)] - description: str | None = None + description: Annotated[str | None, NoExplicitNull] = None class EvidenceType(StrEnum): @@ -52,7 +52,7 @@ class Subject12(AdCPBaseModel): agent_url: Annotated[ AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject13(AdCPBaseModel): @@ -86,8 +86,9 @@ class Subject13(AdCPBaseModel): description='Optional SHA-256 pin for the exact content or immutable snapshot identified by this resource subject. This is part of the complete typed subject identity and is distinct from AttestationReference.content_digest, which pins credential bytes.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject22(Subject12): @@ -108,7 +109,7 @@ class Subject24(AdCPBaseModel): agent_url: Annotated[ AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject26(Subject22): @@ -152,7 +153,7 @@ class Subject34(AdCPBaseModel): min_length=1, ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject36(Subject32): @@ -185,6 +186,7 @@ class Subject36(Subject32): description='Optional SHA-256 pin for the exact content or immutable snapshot identified by this resource subject. This is part of the complete typed subject identity and is distinct from AttestationReference.content_digest, which pins credential bytes.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None @@ -200,7 +202,7 @@ class Subject11(AdCPBaseModel): ) type: Literal['brand'] = 'brand' brand: brand_ref.BrandReference - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject14(AdCPBaseModel): @@ -211,7 +213,7 @@ class Subject14(AdCPBaseModel): resource_type: Literal['https://adcontextprotocol.org/claims/subjects/audience-evidence'] = 'https://adcontextprotocol.org/claims/subjects/audience-evidence' content_digest: Annotated[str, Field(pattern='^sha256:[a-f0-9]{64}$')] brand: brand_ref.BrandReference - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject15(Subject11): @@ -338,6 +340,7 @@ class Subject35(Subject31): description='Optional SHA-256 pin for the exact content or immutable snapshot identified by this resource subject. This is part of the complete typed subject identity and is distinct from AttestationReference.content_digest, which pins credential bytes.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None @@ -435,6 +438,7 @@ class AudienceEvidence(AdCPBaseModel): Field( description='Optional subject-resolution method used by the study or estimate. This describes population methodology, not a promise that the product can execute that resolution at serving time.' ), + NoExplicitNull, ] = None provider: Annotated[ brand_ref.BrandReference, Field(description='Brand responsible for producing the evidence.') @@ -446,6 +450,7 @@ class AudienceEvidence(AdCPBaseModel): description='Number of observations or respondents underlying the estimate, when applicable.', ge=1, ), + NoExplicitNull, ] = None confidence: Annotated[ StrictFloat | None, @@ -454,6 +459,7 @@ class AudienceEvidence(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None last_updated: Annotated[ AwareDatetime, @@ -464,6 +470,7 @@ class AudienceEvidence(AdCPBaseModel): Field( description='Documentation for interpreting the methodology. It is disclosure, not executable routing.' ), + NoExplicitNull, ] = None attestation_refs: Annotated[ list[AttestationRef] | None, @@ -472,5 +479,6 @@ class AudienceEvidence(AdCPBaseModel): max_length=10, min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/audience_evidence_pin.py b/src/adcp/types/domains/core/audience_evidence_pin.py index cc35d766b..f9fa94cab 100644 --- a/src/adcp/types/domains/core/audience_evidence_pin.py +++ b/src/adcp/types/domains/core/audience_evidence_pin.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/audience_evidence_pin.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -20,4 +20,4 @@ class AudienceEvidencePin(AdCPBaseModel): snapshot_id: Annotated[str, Field(min_length=1)] version: Annotated[str, Field(min_length=1)] content_digest: Annotated[str, Field(pattern='^sha256:[a-f0-9]{64}$')] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/audience_evidence_requirements.py b/src/adcp/types/domains/core/audience_evidence_requirements.py index fb55a146c..d06b227d8 100644 --- a/src/adcp/types/domains/core/audience_evidence_requirements.py +++ b/src/adcp/types/domains/core/audience_evidence_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/audience_evidence_requirements.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type @@ -45,6 +45,7 @@ class MaximumAge(Duration): Field( description="Time unit. 'seconds' for sub-minute precision. 'campaign' spans the full campaign flight." ), + NoExplicitNull, ] = None @@ -70,6 +71,7 @@ class AudienceEvidenceRequirements(AdCPBaseModel): description='Allowed methodologies. excluded_methodologies always wins if a value appears in both lists.', min_length=1, ), + NoExplicitNull, ] = None excluded_methodologies: Annotated[ list[audience_evidence_methodology.AudienceEvidenceMethodology] | None, @@ -77,16 +79,18 @@ class AudienceEvidenceRequirements(AdCPBaseModel): description='Disallowed methodologies. Exclusion wins over accepted_methodologies when the lists overlap.', min_length=1, ), + NoExplicitNull, + ] = None + accepted_evidence_types: Annotated[ + list[AcceptedEvidenceType] | None, Field(min_length=1), NoExplicitNull ] = None - accepted_evidence_types: Annotated[list[AcceptedEvidenceType] | None, Field(min_length=1)] = ( - None - ) accepted_providers: Annotated[ list[brand_ref.BrandReference] | None, Field( description='Allowed evidence providers. excluded_providers always wins if the same canonical BrandRef appears in both lists.', min_length=1, ), + NoExplicitNull, ] = None excluded_providers: Annotated[ list[brand_ref.BrandReference] | None, @@ -94,29 +98,35 @@ class AudienceEvidenceRequirements(AdCPBaseModel): description='Disallowed evidence providers. Exclusion wins over accepted_providers when the lists overlap.', min_length=1, ), + NoExplicitNull, ] = None accepted_subject_types: Annotated[ - list[audience_subject_type.AudienceSubjectType] | None, Field(min_length=1) + list[audience_subject_type.AudienceSubjectType] | None, Field(min_length=1), NoExplicitNull ] = None accepted_resolution_methods: Annotated[ - list[audience_resolution_method.AudienceResolutionMethod] | None, Field(min_length=1) + list[audience_resolution_method.AudienceResolutionMethod] | None, + Field(min_length=1), + NoExplicitNull, ] = None - minimum_confidence: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] = None + minimum_confidence: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0), NoExplicitNull] = None maximum_age: Annotated[ MaximumAge | None, Field( description='Maximum age at evaluation time measured from last_updated. Campaign-relative duration is not valid for evidence age.' ), + NoExplicitNull, ] = None methodology_documentation_required: Annotated[ StrictBool | None, Field(description='Whether an admissible item must carry methodology_url.'), + NoExplicitNull, ] = False independent_attestation_required: Annotated[ StrictBool | None, Field( description="Whether an admissible item must have at least one exact reference/evaluation pair whose outcome is verified, whose reference is published in the evidence, and whose claim type and issuer match the buyer's accepted lists as well as the seller's issuer and resolver policy. Setting true requires accepted_attestation_issuers." ), + NoExplicitNull, ] = False accepted_attestation_issuers: Annotated[ list[attestation_issuer.AttestationIssuer] | None, @@ -124,6 +134,7 @@ class AudienceEvidenceRequirements(AdCPBaseModel): description="Buyer allowlist for independent attestation issuers. An exact reference/evaluation pair satisfies the requirement only when reference.issuer matches an entry here. This list narrows and never broadens the seller's accepted issuer policy.", min_length=1, ), + NoExplicitNull, ] = None accepted_attestation_claim_types: Annotated[ list[AnyUrl] | None, @@ -131,5 +142,6 @@ class AudienceEvidenceRequirements(AdCPBaseModel): description='Optional acceptable claim types when attestation is used. Buyer-supplied claim types further constrain but never broaden seller policy.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/audience_evidence_selection.py b/src/adcp/types/domains/core/audience_evidence_selection.py index 06ba87388..50bd26a0b 100644 --- a/src/adcp/types/domains/core/audience_evidence_selection.py +++ b/src/adcp/types/domains/core/audience_evidence_selection.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/audience_evidence_selection.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import attestation_reference, audience_evidence @@ -84,6 +84,7 @@ class AudienceEvidenceSelection(AdCPBaseModel): Field( description='Optional inline immutable snapshot. Its identity, version, and digest MUST equal the surrounding fields.' ), + NoExplicitNull, ] = None attestation_evaluations: Annotated[ list[AttestationEvaluation] | None, @@ -92,5 +93,6 @@ class AudienceEvidenceSelection(AdCPBaseModel): max_length=10, min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/audience_member.py b/src/adcp/types/domains/core/audience_member.py index 022ae6742..eb7f05c12 100644 --- a/src/adcp/types/domains/core/audience_member.py +++ b/src/adcp/types/domains/core/audience_member.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/audience_member.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, model_validator from ..enums import uid_type @@ -40,6 +40,7 @@ class AudienceMember(AdCPBaseModel): description='SHA-256 hash of lowercase, trimmed email address. Pseudonymous PII, not anonymous — the email namespace is small enough that an unsalted SHA-256 is recoverable via precomputed dictionaries. Treat as PII for retention, consent, and access-control purposes. See docs/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous.', pattern='^[a-f0-9]{64}$', ), + NoExplicitNull, ] = None hashed_phone: Annotated[ str | None, @@ -47,6 +48,7 @@ class AudienceMember(AdCPBaseModel): description='SHA-256 hash of E.164-formatted phone number (e.g. +12065551234). Pseudonymous PII, not anonymous — the E.164 namespace is small enough that an unsalted SHA-256 is recoverable via precomputed dictionaries. Treat as PII for retention, consent, and access-control purposes. See docs/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous.', pattern='^[a-f0-9]{64}$', ), + NoExplicitNull, ] = None uids: Annotated[ list[Uid] | None, @@ -54,8 +56,9 @@ class AudienceMember(AdCPBaseModel): description='Universal ID values (MAIDs, RampID, UID2, etc.) for user matching.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> AudienceMember: diff --git a/src/adcp/types/domains/core/audience_selector.py b/src/adcp/types/domains/core/audience_selector.py index 0fb308a27..f0c4400c5 100644 --- a/src/adcp/types/domains/core/audience_selector.py +++ b/src/adcp/types/domains/core/audience_selector.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/audience_selector.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat from . import signal_id as signal_id_1 @@ -33,6 +33,7 @@ class AudienceSelector4(AdCPBaseModel): Field( description="Optional grouping hint for the governance agent (e.g., 'demographic', 'behavioral', 'contextual', 'financial')" ), + NoExplicitNull, ] = None @@ -46,6 +47,7 @@ class AudienceSelector1(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New selectors SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -53,6 +55,7 @@ class AudienceSelector1(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[Literal['binary'], Field(description='Discriminator for binary signals')] = 'binary' value: Annotated[ @@ -73,6 +76,7 @@ class AudienceSelector2(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New selectors SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -80,6 +84,7 @@ class AudienceSelector2(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[ Literal['categorical'], Field(description='Discriminator for categorical signals') @@ -103,6 +108,7 @@ class AudienceSelector3(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New selectors SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -110,6 +116,7 @@ class AudienceSelector3(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[ Literal['numeric'], Field(description='Discriminator for numeric signals') @@ -119,12 +126,14 @@ class AudienceSelector3(AdCPBaseModel): Field( description='Minimum value (inclusive). Omit for no minimum. Must be <= max_value when both are provided.' ), + NoExplicitNull, ] = None max_value: Annotated[ StrictFloat | None, Field( description='Maximum value (inclusive). Omit for no maximum. Must be >= min_value when both are provided.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/audience_source.py b/src/adcp/types/domains/core/audience_source.py index d4ac99053..7d709856c 100644 --- a/src/adcp/types/domains/core/audience_source.py +++ b/src/adcp/types/domains/core/audience_source.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/audience_source.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from . import brand_ref @@ -34,6 +34,7 @@ class AudienceSource1(AdCPBaseModel): Field( description='ISO 8601 time after which the buyer will revoke the grant. Declarative — tells the seller when the pipe closes so re-read behavior is predictable. Expiry bounds the access window, not retention: revocation does not claw back matched membership, and retention remains governed by the buyer-seller data processing agreement.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/authorized_agent_base.py b/src/adcp/types/domains/core/authorized_agent_base.py index 46dcfef5a..91a2594bd 100644 --- a/src/adcp/types/domains/core/authorized_agent_base.py +++ b/src/adcp/types/domains/core/authorized_agent_base.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/authorized_agent_base.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, Field from . import agent_encryption_key, agent_signing_key @@ -33,6 +33,7 @@ class AuthorizedAgentBaseFields(AdCPBaseModel): description='Optional publisher-attested public signing keys for this agent. Use these as the trust anchor for verifying signed agent responses instead of relying on key discovery from the agent domain alone.', min_length=1, ), + NoExplicitNull, ] = None encryption_keys: Annotated[ list[agent_encryption_key.AgentEncryptionKey] | None, @@ -40,10 +41,12 @@ class AuthorizedAgentBaseFields(AdCPBaseModel): description='X25519 public keys for TMPX exposure token encryption. Each key identifies a cluster master that can decrypt TMPX tokens. Used with HPKE mode_base — read replicas encrypt with this public key, only the master can decrypt.', min_length=1, ), + NoExplicitNull, ] = None last_updated: Annotated[ AwareDatetime | None, Field( description='Optional ISO 8601 timestamp indicating when this `authorized_agents[]` entry last changed. Independent of the file-level `last_updated`. Lets validators perform a partial walk by skipping entries whose `last_updated` is older than their indexed value. Advisory — consumers MAY ignore and re-index the full file.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/bidding_policy.py b/src/adcp/types/domains/core/bidding_policy.py index d6d448201..5b131de30 100644 --- a/src/adcp/types/domains/core/bidding_policy.py +++ b/src/adcp/types/domains/core/bidding_policy.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/bidding_policy.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T12:09:21+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -68,6 +68,7 @@ class BiddingPolicy(AdCPBaseModel): Field( description='Explicitly use seller/provider automatic bidding at this authored scope. At package scope this is a complete override of a media-buy policy, not inheritance. It MUST be the only field in the block and MUST be preserved on readback.' ), + NoExplicitNull, ] = None bid_amount: Annotated[ StrictFloat | None, @@ -75,6 +76,7 @@ class BiddingPolicy(AdCPBaseModel): description="Manual auction bid denominated in the media-buy currency and expressed per the selected pricing option's auction unit. For example, a CPM option interprets the amount per thousand impressions. This is the amount submitted to the auction, not a promise that the clearing price equals it. Requires an auction-priced pricing option whose currency equals the media-buy currency.", gt=0.0, ), + NoExplicitNull, ] = None max_bid: Annotated[ StrictFloat | None, @@ -82,16 +84,19 @@ class BiddingPolicy(AdCPBaseModel): description="Hard per-auction ceiling denominated in the media-buy currency and expressed per the selected pricing option's auction unit. This is the only canonical hard auction ceiling and MUST NOT be translated into an average outcome-cost control. Requires an auction-priced pricing option whose currency equals the media-buy currency. May stand alone or supplement cost_per/roas only when the relevant scope capability advertises that combination.", gt=0.0, ), + NoExplicitNull, ] = None cost_per: Annotated[ CostPer | None, Field( description="Average cost control per result of the scope-bound primary optimization goal. At seller-optimized media-buy scope it binds to budget_allocation.optimization_goals; at package scope it binds to that package's optimization_goals; at fixed media-buy scope it binds independently to each inheriting package and is valid only when their primary-goal result units are compatible. Metric goals are compatible only when metric and every result-defining qualifier match; vendor_metric goals only when vendor and metric_id match; event goals only when the event_type/custom_event_name set and resolved attribution_window match. Primary is the earliest array entry among goals tied for the lowest explicit numeric priority; unprioritized goals follow explicitly prioritized goals; when all priorities are absent, the first entry is primary." ), + NoExplicitNull, ] = None roas: Annotated[ Roas | None, Field( description='Dimensionless return-on-ad-spend control bound to the same scope-specific primary goal rules as cost_per. The bound goal must be value-bearing; a fixed media-buy default requires a value-bearing primary goal on every inheriting package. Every referenced value-bearing event source MUST declare value_currencies containing the media-buy currency. The seller validates this at buy creation; each buy consumes only exact-currency records, while other declared currencies remain available to other buys. Sellers MUST NOT perform currency conversion.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/bidding_policy_capability.py b/src/adcp/types/domains/core/bidding_policy_capability.py index 02de476bf..2303ae026 100644 --- a/src/adcp/types/domains/core/bidding_policy_capability.py +++ b/src/adcp/types/domains/core/bidding_policy_capability.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/bidding_policy_capability.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -61,6 +61,7 @@ class PolicyProfile(AdCPBaseModel): description='Standalone policy modes accepted in this exact scope and allocation context. Combination-only components belong only in supported_combinations and need not appear here. `automatic` means the seller accepts and preserves an explicit `{automatic:true}` authored block; omission only invokes inheritance/default behavior.', min_length=1, ), + NoExplicitNull, ] = None cost_per_strengths: Annotated[ list[CostPerStrength] | None, @@ -68,6 +69,7 @@ class PolicyProfile(AdCPBaseModel): description='Supported standalone cost_per strengths in this profile. Required exactly when modes includes cost_per.', min_length=1, ), + NoExplicitNull, ] = None roas_strengths: Annotated[ list[RoasStrength] | None, @@ -75,6 +77,7 @@ class PolicyProfile(AdCPBaseModel): description='Supported standalone roas strengths in this profile. Required exactly when modes includes roas.', min_length=1, ), + NoExplicitNull, ] = None supported_combinations: Annotated[ list[MaxBidWithCostPer | MaxBidWithRoas] | None, @@ -82,6 +85,7 @@ class PolicyProfile(AdCPBaseModel): description='Multi-field policies supported in this exact scope and allocation context. Each entry independently qualifies the strengths supported in that combination; standalone strength support does not imply combination support. Absence means no multi-field combination is claimed.', min_length=1, ), + NoExplicitNull, ] = None @@ -92,10 +96,12 @@ class ScopeCapability(AdCPBaseModel): fixed: Annotated[ PolicyProfile | None, Field(description='Policies supported when budget allocation is fixed or omitted.'), + NoExplicitNull, ] = None seller_optimized: Annotated[ PolicyProfile | None, Field(description='Policies supported when budget_allocation.mode is seller_optimized.'), + NoExplicitNull, ] = None @@ -108,10 +114,12 @@ class BiddingPolicyCapability(AdCPBaseModel): Field( description='Policies accepted at media-buy scope and inherited by packages that omit a package override.' ), + NoExplicitNull, ] = None package: Annotated[ ScopeCapability | None, Field( description='Policies accepted as package-authored policies, including explicit automatic overrides.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/brand_key.py b/src/adcp/types/domains/core/brand_key.py index 57dfbd434..bf5e2b589 100644 --- a/src/adcp/types/domains/core/brand_key.py +++ b/src/adcp/types/domains/core/brand_key.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/brand_key.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -34,6 +34,7 @@ class BrandKey(AdCPBaseModel): Field( description='Brand within a house-of-brands manifest. Omit for a single-brand domain.' ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -41,4 +42,5 @@ class BrandKey(AdCPBaseModel): description='Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit when the identity is global or the house does not split the brand geographically. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account and proposal identity and is not delivery targeting.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/brand_ref.py b/src/adcp/types/domains/core/brand_ref.py index 9ce889552..7ef0dfc49 100644 --- a/src/adcp/types/domains/core/brand_ref.py +++ b/src/adcp/types/domains/core/brand_ref.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/brand_ref.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, ConfigDict, EmailStr, Field @@ -23,35 +23,38 @@ class DataSubjectContestation(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - url: AnyUrl | None = None - email: EmailStr | None = None - languages: list[str] | None = None + url: Annotated[AnyUrl | None, NoExplicitNull] = None + email: Annotated[EmailStr | None, NoExplicitNull] = None + languages: Annotated[list[str] | None, NoExplicitNull] = None class Colors(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - primary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$')] = None - secondary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$')] = None - accent: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$')] = None + primary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$'), NoExplicitNull] = None + secondary: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$'), NoExplicitNull] = None + accent: Annotated[str | None, Field(pattern='^#[0-9a-fA-F]{6}$'), NoExplicitNull] = None class BrandKitOverride(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - logo: Annotated[image_asset.ImageAsset | None, Field(description='Override logo asset.')] = None - colors: Annotated[Colors | None, Field(description='Override brand colors (hex strings).')] = ( - None - ) + logo: Annotated[ + image_asset.ImageAsset | None, Field(description='Override logo asset.'), NoExplicitNull + ] = None + colors: Annotated[ + Colors | None, Field(description='Override brand colors (hex strings).'), NoExplicitNull + ] = None voice: Annotated[ str | None, Field( description='Override brand-voice description for surface-composed text/audio output.' ), + NoExplicitNull, ] = None - tagline: Annotated[str | None, Field(description='Override tagline.')] = None + tagline: Annotated[str | None, Field(description='Override tagline.'), NoExplicitNull] = None class BrandReference(AdCPBaseModel): @@ -70,6 +73,7 @@ class BrandReference(AdCPBaseModel): Field( description='Brand identifier within the house portfolio. Optional for single-brand domains.' ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -77,22 +81,26 @@ class BrandReference(AdCPBaseModel): description='Canonical set of ISO 3166-1 alpha-2 countries for this advertiser identity. Omit for a global/default identity. Array order is not meaningful; producers MUST sort codes lexicographically before computing keys or signatures. This qualifies account identity and is not delivery targeting.', min_length=1, ), + NoExplicitNull, ] = None industries: Annotated[ list[str] | None, Field( description="Inline override for the brand's industries. Useful when the caller cannot modify the brand's canonical brand.json but needs to declare industries for governance (e.g., Annex III vertical detection). brand.json remains the canonical source; when omitted here, governance agents SHOULD resolve from brand.json." ), + NoExplicitNull, ] = None data_subject_contestation: Annotated[ DataSubjectContestation | None, Field( description="Inline override for the brand's contestation contact point. Useful when the operator does not control brand.json but needs to discharge Art 22(3) for this plan. brand.json is canonical; when omitted, governance agents resolve brand → house → missing." ), + NoExplicitNull, ] = None brand_kit_override: Annotated[ BrandKitOverride | None, Field( description="Inline override for brand-kit fields normally resolved from `/.well-known/brand.json` on `domain` (logo, colors, voice, tagline). Use when brand.json is missing, stale, or inappropriate for this specific call — e.g., a campaign-scoped tagline, a co-branded creative, a freshly-rebranded color palette the brand.json hasn't shipped yet. Same inline-override pattern as `industries` and `data_subject_contestation` above: brand.json is canonical, the override is per-call. Adopters needing to override fields outside this subset (`voice_attributes`, `prohibited_terms`, etc.) MUST publish a different brand.json and reference it via a different `domain` — the inline override is intentionally narrow to a small high-traffic subset.\n\n**Merge semantics (normative).** The merge is **field-level**, not whole-object replacement. Each field within `brand_kit_override` (`logo`, `colors`, `voice`, `tagline`) is evaluated independently — when a field is present on the override the override value applies; when a field is absent the brand.json value applies (or is absent if brand.json doesn't carry one either). For composite fields (`colors.primary`, `colors.secondary`, `colors.accent`), the merge is one level deeper: each color slot is evaluated independently — a producer can override `colors.primary` while still inheriting `colors.secondary` from brand.json. SDKs MUST NOT treat a present `brand_kit_override.colors` as wiping the brand.json `colors` block entirely; only the per-slot fields present in the override take precedence. Without this rule, a partial-override semantics would diverge across SDKs and produce inconsistent rendering for the same payload." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/brand_response_authorization_result.py b/src/adcp/types/domains/core/brand_response_authorization_result.py index 33a5de9f6..21271220e 100644 --- a/src/adcp/types/domains/core/brand_response_authorization_result.py +++ b/src/adcp/types/domains/core/brand_response_authorization_result.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/brand_response_authorization_result.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -38,7 +38,9 @@ class BrandResponseAuthorizationResult1(AdCPBaseModel): ), ] = 'trusted' reason: Annotated[ - Reason | None, Field(description='Machine-readable reason for an untrusted result.') + Reason | None, + Field(description='Machine-readable reason for an untrusted result.'), + NoExplicitNull, ] = None kid: Annotated[str, Field(description='JWS kid evaluated by the cross-check.', min_length=1)] jwks_uri: Annotated[ @@ -61,13 +63,16 @@ class BrandResponseAuthorizationResult2(AdCPBaseModel): ] = 'untrusted' reason: Annotated[Reason, Field(description='Machine-readable reason for an untrusted result.')] kid: Annotated[ - str | None, Field(description='JWS kid evaluated by the cross-check.', min_length=1) + str | None, + Field(description='JWS kid evaluated by the cross-check.', min_length=1), + NoExplicitNull, ] = None jwks_uri: Annotated[ AnyUrl | None, Field( description='JWKS URI selected only from the matched brand.json agent entry, or its same-origin default.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/budget_allocation.py b/src/adcp/types/domains/core/budget_allocation.py index 1a7055a44..1cca97cbe 100644 --- a/src/adcp/types/domains/core/budget_allocation.py +++ b/src/adcp/types/domains/core/budget_allocation.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/budget_allocation.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..enums import event_type as event_type_1 @@ -138,6 +138,7 @@ class TargetFrequency(AdCPBaseModel): description='Minimum frequency for an entity to be considered meaningfully reached within the window. Impressions that would bring an entity below this threshold are treated as high-value (growing reach). When omitted, the seller uses their platform default (typically 1).', ge=1, ), + NoExplicitNull, ] = None max: Annotated[ SchemaInt | None, @@ -145,6 +146,7 @@ class TargetFrequency(AdCPBaseModel): description='Frequency at which an entity is considered saturated within the window. Impressions toward entities at or above this threshold are treated as lower-value. When both min and max are present, max must be greater than or equal to min. When omitted, the seller determines the saturation point.', ge=1, ), + NoExplicitNull, ] = None window: Annotated[ duration.Duration, @@ -176,18 +178,21 @@ class EventSource(AdCPBaseModel): Field( description="Required when event_type is 'custom'. Platform-specific name for the custom event." ), + NoExplicitNull, ] = None value_field: Annotated[ str | None, Field( description="Which field in the event's custom_data carries the monetary value. The seller must use this field for value extraction and aggregation when computing ROAS and conversion value metrics. Required on at least one entry when target.kind is 'per_ad_spend' or 'maximize_value' — sellers must reject these target kinds when no event source entry includes value_field. When canonical bidding.roas binds to this goal, every referenced value-bearing source MUST declare value_currencies containing the media-buy currency; the seller validates this at buy creation. Once that source contract is declared, log_event rejects monetary records whose currency is missing or absent from the list. For each buy, only records exactly matching its media-buy currency contribute to ROAS; records in other declared currencies remain available to matching buys, and sellers MUST NOT convert between them. When present without a value-oriented target, the seller may use it for delivery reporting but must not change the objective. Common values: 'value', 'order_total', 'profit_margin'. This is not passed as a parameter to underlying platform APIs — the seller maps it to their platform's value ingestion mechanism." ), + NoExplicitNull, ] = None value_factor: Annotated[ StrictFloat | None, Field( description="Unit-scaling multiplier the seller must apply to value_field before aggregation. Use -1 for refund events (negate the value), 0.01 for values in cents, -0.01 for refunds in cents. It MUST NOT be used for currency conversion. A value of 0 zeroes out this source's value contribution (the source still counts for event dedup). Defaults to 1. This is not passed as a parameter to underlying platform APIs — the seller applies it when computing aggregated value metrics." ), + NoExplicitNull, ] = 1 @@ -209,12 +214,14 @@ class OptimizationGoal2(AdCPBaseModel): description='Target cost or return for this event goal. When omitted, the seller optimizes for maximum conversion count within budget — regardless of whether value_field is present on event sources. The presence of value_field alone does not change the optimization objective; it only makes value available for reporting. An explicit target of maximize_value or per_ad_spend is required to steer toward value.', discriminator='kind', ), + NoExplicitNull, ] = None attribution_window: Annotated[ attribution_window_1.AttributionWindow | None, Field( description="Attribution window for this optimization goal — references the canonical `attribution-window` shape (post_click, post_view, model). Values must match an option declared in the seller's `conversion_tracking.attribution_windows` capability. Sellers MUST reject windows not in their declared capabilities. When the entire field is omitted, the seller uses their default window." ), + NoExplicitNull, ] = None priority: Annotated[ SchemaInt | None, @@ -222,6 +229,7 @@ class OptimizationGoal2(AdCPBaseModel): description='Relative priority among sibling goals. Lower numbers rank first. Goals without priority follow explicitly prioritized goals. Ties use array order, so the earliest goal at the lowest explicit priority is primary; when all priorities are omitted, the first goal is primary.', ge=1, ), + NoExplicitNull, ] = None @@ -245,24 +253,28 @@ class OptimizationGoal1(AdCPBaseModel): Field( description="Viewability standard the goal is judged against. Required when metric is 'viewable_rate'; optional for 'viewed_seconds' (seller default standard when omitted); not allowed for other metrics. Must be in metric_optimization.supported_viewability_standards when declared. A goal below a same-standard viewability performance_standard never relaxes that standard." ), + NoExplicitNull, ] = None vendor: Annotated[ brand_ref.BrandReference | None, Field( description="Measurement vendor judging a viewable_rate or viewed_seconds goal; not allowed for other metrics. When omitted, the seller's default viewability measurement applies. Sellers MUST reject a vendor they cannot optimize against rather than substitute another." ), + NoExplicitNull, ] = None reach_unit: Annotated[ reach_unit_1.ReachUnit | None, Field( description="Unit for reach measurement. Required when metric is 'reach'. Must be a value declared in the product's metric_optimization.supported_reach_units." ), + NoExplicitNull, ] = None target_frequency: Annotated[ TargetFrequency | None, Field( description="Target frequency band for reach optimization. Only applicable when metric is 'reach'. Frames frequency as an optimization signal: the seller should treat impressions toward entities already within the [min, max] band as lower-value, and impressions toward unreached entities as higher-value. This shifts budget toward fresh reach rather than re-reaching known users. When omitted, the seller maximizes unique reach without a frequency constraint. A hard cap can still be layered via targeting_overlay.frequency_cap if a ceiling is needed." ), + NoExplicitNull, ] = None view_duration_seconds: Annotated[ StrictFloat | None, @@ -270,6 +282,7 @@ class OptimizationGoal1(AdCPBaseModel): description="Minimum video view duration in seconds that qualifies as a completed_view for this goal. Only applicable when metric is 'completed_views'. When omitted, the seller uses their platform default (typically 2–15 seconds). Common values: 2 (Snap/LinkedIn default), 6 (TikTok), 15 (Snap 15-second views, Meta ThruPlay). Sellers declare which durations they support in metric_optimization.supported_view_durations. Sellers must reject goals with unsupported values — silent rounding would create measurement discrepancies.", gt=0.0, ), + NoExplicitNull, ] = None target: Annotated[ Target | Target3 | None, @@ -277,6 +290,7 @@ class OptimizationGoal1(AdCPBaseModel): description='Target for this metric. When omitted, the seller optimizes for maximum metric volume within budget.', discriminator='kind', ), + NoExplicitNull, ] = None priority: Annotated[ SchemaInt | None, @@ -284,6 +298,7 @@ class OptimizationGoal1(AdCPBaseModel): description='Relative priority among sibling goals. Lower numbers rank first. Goals without priority follow explicitly prioritized goals. Ties use array order, so the earliest goal at the lowest explicit priority is primary; when all priorities are omitted, the first goal is primary.', ge=1, ), + NoExplicitNull, ] = None @@ -310,6 +325,7 @@ class OptimizationGoal3(AdCPBaseModel): description="Target for this vendor metric. When omitted, the seller optimizes for maximum metric volume / score within budget. `cost_per` and `threshold_rate` semantics mirror the same target kinds on the `metric` kind — units are vendor-defined and depend on the vendor's `measurement.metrics[]` declaration for this `metric_id`.", discriminator='kind', ), + NoExplicitNull, ] = None priority: Annotated[ SchemaInt | None, @@ -317,6 +333,7 @@ class OptimizationGoal3(AdCPBaseModel): description='Relative priority among sibling goals. Lower numbers rank first. Goals without priority follow explicitly prioritized goals. Ties use array order, so the earliest goal at the lowest explicit priority is primary; when all priorities are omitted, the first goal is primary.', ge=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/budget_range.py b/src/adcp/types/domains/core/budget_range.py index a7580e938..0dd64575e 100644 --- a/src/adcp/types/domains/core/budget_range.py +++ b/src/adcp/types/domains/core/budget_range.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/budget_range.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat, model_validator @@ -17,8 +17,8 @@ class BudgetRange(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - min: Annotated[StrictFloat | None, Field(ge=0.0)] = None - max: Annotated[StrictFloat | None, Field(ge=0.0)] = None + min: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + max: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/business_entity.py b/src/adcp/types/domains/core/business_entity.py index 8d5f43ecb..c1cbf241d 100644 --- a/src/adcp/types/domains/core/business_entity.py +++ b/src/adcp/types/domains/core/business_entity.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/business_entity.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, EmailStr, Field from . import ext as ext_1 @@ -23,7 +23,7 @@ class Address(AdCPBaseModel): city: Annotated[str, Field(max_length=100)] postal_code: Annotated[str, Field(max_length=20)] region: Annotated[ - str | None, Field(description='State, province, or region', max_length=100) + str | None, Field(description='State, province, or region', max_length=100), NoExplicitNull ] = None country: Annotated[ str, Field(description='ISO 3166-1 alpha-2 country code', pattern='^[A-Z]{2}$') @@ -44,11 +44,11 @@ class Contact(AdCPBaseModel): role: Annotated[ Role, Field(description="Contact's functional role in the business relationship") ] - name: Annotated[str | None, Field(description='Full name of the contact', max_length=200)] = ( - None - ) - email: Annotated[EmailStr | None, Field(max_length=254)] = None - phone: Annotated[str | None, Field(max_length=30)] = None + name: Annotated[ + str | None, Field(description='Full name of the contact', max_length=200), NoExplicitNull + ] = None + email: Annotated[EmailStr | None, Field(max_length=254), NoExplicitNull] = None + phone: Annotated[str | None, Field(max_length=30), NoExplicitNull] = None class Bank(AdCPBaseModel): @@ -62,6 +62,7 @@ class Bank(AdCPBaseModel): description='International Bank Account Number (SEPA markets)', pattern='^[A-Z]{2}[0-9]{2}[A-Z0-9]{4,30}$', ), + NoExplicitNull, ] = None bic: Annotated[ str | None, @@ -69,6 +70,7 @@ class Bank(AdCPBaseModel): description='Bank Identifier Code / SWIFT code (SEPA markets)', pattern='^[A-Z]{4}[A-Z]{2}[A-Z0-9]{2}([A-Z0-9]{3})?$', ), + NoExplicitNull, ] = None routing_number: Annotated[ str | None, @@ -76,9 +78,12 @@ class Bank(AdCPBaseModel): description='Bank routing number for non-SEPA markets (e.g., US ABA routing number, Canadian transit/institution number)', max_length=30, ), + NoExplicitNull, ] = None account_number: Annotated[ - str | None, Field(description='Bank account number for non-SEPA markets', max_length=30) + str | None, + Field(description='Bank account number for non-SEPA markets', max_length=30), + NoExplicitNull, ] = None @@ -95,6 +100,7 @@ class BusinessEntity(AdCPBaseModel): description='VAT identification number (e.g., DE123456789 for Germany, FR12345678901 for France). Required for B2B invoicing in the EU. Must be normalized: no spaces, dots, or dashes.', pattern='^[A-Z]{2}[A-Z0-9]{2,13}$', ), + NoExplicitNull, ] = None tax_id: Annotated[ str | None, @@ -102,6 +108,7 @@ class BusinessEntity(AdCPBaseModel): description='Tax identification number for jurisdictions that do not use VAT (e.g., US EIN)', max_length=30, ), + NoExplicitNull, ] = None registration_number: Annotated[ str | None, @@ -109,9 +116,12 @@ class BusinessEntity(AdCPBaseModel): description='Company registration number (e.g., HRB 12345 for German Handelsregister)', max_length=50, ), + NoExplicitNull, ] = None address: Annotated[ - Address | None, Field(description='Postal address for invoicing and legal correspondence') + Address | None, + Field(description='Postal address for invoicing and legal correspondence'), + NoExplicitNull, ] = None contacts: Annotated[ list[Contact] | None, @@ -119,11 +129,13 @@ class BusinessEntity(AdCPBaseModel): description='Contacts for billing, legal, and operational matters. Contains personal data subject to GDPR and equivalent regulations. Implementations MUST use this data only for invoicing and account management.', max_length=10, ), + NoExplicitNull, ] = None bank: Annotated[ Bank | None, Field( description='Bank account details for payment processing. Write-only: included in requests to provide payment coordinates, but MUST NOT be echoed in responses. Sellers store these details and confirm receipt without returning them.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/cancellation_policy.py b/src/adcp/types/domains/core/cancellation_policy.py index a053919fe..2a4312f9c 100644 --- a/src/adcp/types/domains/core/cancellation_policy.py +++ b/src/adcp/types/domains/core/cancellation_policy.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/cancellation_policy.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from . import duration @@ -37,6 +37,7 @@ class CancellationFee(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None amount: Annotated[ StrictFloat | None, @@ -44,6 +45,7 @@ class CancellationFee(AdCPBaseModel): description="Fixed fee amount in the buy's currency. Required when type is 'fixed_fee'.", ge=0.0, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/canonical_account_ref.py b/src/adcp/types/domains/core/canonical_account_ref.py index e8972c2a6..425c6b81f 100644 --- a/src/adcp/types/domains/core/canonical_account_ref.py +++ b/src/adcp/types/domains/core/canonical_account_ref.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_account_ref.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from . import brand_key @@ -33,6 +33,7 @@ class CanonicalAccountReference2(AdCPBaseModel): Field( description='Optional operator-owned business unit, agency seat, or platform account. Only id participates in identity; name is mutable display metadata.' ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -40,6 +41,7 @@ class CanonicalAccountReference2(AdCPBaseModel): description='Immutable ISO 4217 transaction currency when the advertiser object is currency-bound. When present, this is part of the natural key and all media buys on the account use it. Omit for per-media-buy currency selection.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None timezone: Annotated[ str | None, @@ -47,8 +49,9 @@ class CanonicalAccountReference2(AdCPBaseModel): description='Immutable account timezone. Include it in the natural key only for buyer-selected account_fixed provisioning.', min_length=1, ), + NoExplicitNull, ] = None - sandbox: StrictBool | None = False + sandbox: Annotated[StrictBool | None, NoExplicitNull] = False CanonicalAccountReference = Annotated[ diff --git a/src/adcp/types/domains/core/canonical_audience_evidence.py b/src/adcp/types/domains/core/canonical_audience_evidence.py index 3b07cb61a..a31e1f918 100644 --- a/src/adcp/types/domains/core/canonical_audience_evidence.py +++ b/src/adcp/types/domains/core/canonical_audience_evidence.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_audience_evidence.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat @@ -35,7 +35,7 @@ class Baseline(AdCPBaseModel): system: AnyUrl population_id: Annotated[str, Field(min_length=1)] version: Annotated[str, Field(min_length=1)] - description: str | None = None + description: Annotated[str | None, NoExplicitNull] = None class EvidenceType(StrEnum): @@ -65,18 +65,21 @@ class CanonicalAudienceEvidence(AdCPBaseModel): evidence_type: EvidenceType methodology: audience_evidence_methodology.AudienceEvidenceMethodology subject_type: audience_subject_type.AudienceSubjectType - resolution_method: audience_resolution_method.AudienceResolutionMethod | None = None + resolution_method: Annotated[ + audience_resolution_method.AudienceResolutionMethod | None, NoExplicitNull + ] = None provider: brand_key.BrandKey measurement_window: date_range.DateRange - sample_size: Annotated[SchemaInt | None, Field(ge=1)] = None - confidence: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] = None + sample_size: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + confidence: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0), NoExplicitNull] = None last_updated: AwareDatetime - methodology_url: AnyUrl | None = None + methodology_url: Annotated[AnyUrl | None, NoExplicitNull] = None attestation_digests: Annotated[ list[AttestationDigest] | None, Field( description='Portable-attestation reference digests available through the evidence provider; credential bodies are not inlined into product discovery.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/canonical_audience_evidence_selection.py b/src/adcp/types/domains/core/canonical_audience_evidence_selection.py index 870e13462..bc1ae73db 100644 --- a/src/adcp/types/domains/core/canonical_audience_evidence_selection.py +++ b/src/adcp/types/domains/core/canonical_audience_evidence_selection.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_audience_evidence_selection.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -35,8 +35,10 @@ class CanonicalAudienceEvidenceSelection(AdCPBaseModel): version: Annotated[str, Field(min_length=1)] content_digest: Annotated[str, Field(pattern='^sha256:[a-f0-9]{64}$')] decision_use: DecisionUse - evidence: canonical_audience_evidence.CanonicalAudienceEvidence | None = None + evidence: Annotated[ + canonical_audience_evidence.CanonicalAudienceEvidence | None, NoExplicitNull + ] = None verified_attestation_digests: Annotated[ - list[VerifiedAttestationDigest] | None, Field(min_length=1) + list[VerifiedAttestationDigest] | None, Field(min_length=1), NoExplicitNull ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/canonical_delivery_forecast.py b/src/adcp/types/domains/core/canonical_delivery_forecast.py index fbf98ddee..03a2c45c9 100644 --- a/src/adcp/types/domains/core/canonical_delivery_forecast.py +++ b/src/adcp/types/domains/core/canonical_delivery_forecast.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_delivery_forecast.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import demographic_system as demographic_system_1 @@ -22,13 +22,19 @@ class CanonicalDeliveryForecast(AdCPBaseModel): extra='forbid', ) points: Annotated[list[canonical_forecast_point.CanonicalForecastPoint], Field(min_length=1)] - forecast_range_unit: forecast_range_unit_1.ForecastRangeUnit | None = None + forecast_range_unit: Annotated[ + forecast_range_unit_1.ForecastRangeUnit | None, NoExplicitNull + ] = None method: forecast_method.ForecastMethod currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] - demographic_system: demographic_system_1.DemographicSystem | None = None - demographic: str | None = None - measurement_source: Annotated[str | None, Field(max_length=64, pattern='^[a-z0-9_]+$')] = None - reach_unit: reach_unit_1.ReachUnit | None = None - generated_at: AwareDatetime | None = None - valid_until: AwareDatetime | None = None - ext: ext_1.ExtensionObject | None = None + demographic_system: Annotated[demographic_system_1.DemographicSystem | None, NoExplicitNull] = ( + None + ) + demographic: Annotated[str | None, NoExplicitNull] = None + measurement_source: Annotated[ + str | None, Field(max_length=64, pattern='^[a-z0-9_]+$'), NoExplicitNull + ] = None + reach_unit: Annotated[reach_unit_1.ReachUnit | None, NoExplicitNull] = None + generated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + valid_until: Annotated[AwareDatetime | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/canonical_forecast_point.py b/src/adcp/types/domains/core/canonical_forecast_point.py index 2513975a5..48fbecadd 100644 --- a/src/adcp/types/domains/core/canonical_forecast_point.py +++ b/src/adcp/types/domains/core/canonical_forecast_point.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_forecast_point.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Dict -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import availability_status as availability_status_1 @@ -25,7 +25,7 @@ class Metrics(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - coverage_rate: forecast_rate_range.ForecastRateRange | None = None + coverage_rate: Annotated[forecast_rate_range.ForecastRateRange | None, NoExplicitNull] = None @@ -34,25 +34,30 @@ class Viewability(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - vendor: brand_key.BrandKey | None = None - measurable_impressions: forecast_range.ForecastRange | None = None - viewable_impressions: forecast_range.ForecastRange | None = None - viewable_rate: forecast_rate_range.ForecastRateRange | None = None - viewed_seconds: forecast_range.ForecastRange | None = None - standard: viewability_standard.ViewabilityStandard | None = None + vendor: Annotated[brand_key.BrandKey | None, NoExplicitNull] = None + measurable_impressions: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + viewable_impressions: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + viewable_rate: Annotated[forecast_rate_range.ForecastRateRange | None, NoExplicitNull] = None + viewed_seconds: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + standard: Annotated[viewability_standard.ViewabilityStandard | None, NoExplicitNull] = None class CanonicalForecastPoint(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - label: Annotated[str | None, Field(max_length=128)] = None - budget: Annotated[StrictFloat | None, Field(ge=0.0)] = None - product_id: str | None = None - dimensions: forecast_point_dimensions.ForecastPointDimensions | None = None - availability_status: availability_status_1.AvailabilityStatus | None = None + label: Annotated[str | None, Field(max_length=128), NoExplicitNull] = None + budget: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + product_id: Annotated[str | None, NoExplicitNull] = None + dimensions: Annotated[ + forecast_point_dimensions.ForecastPointDimensions | None, NoExplicitNull + ] = None + availability_status: Annotated[ + availability_status_1.AvailabilityStatus | None, NoExplicitNull + ] = None metrics: Metrics - viewability: Viewability | None = None - vendor_metric_values: ( - list[canonical_forecast_vendor_metric_value.CanonicalForecastVendorMetricValue] | None - ) = None + viewability: Annotated[Viewability | None, NoExplicitNull] = None + vendor_metric_values: Annotated[ + list[canonical_forecast_vendor_metric_value.CanonicalForecastVendorMetricValue] | None, + NoExplicitNull, + ] = None diff --git a/src/adcp/types/domains/core/canonical_forecast_vendor_metric_value.py b/src/adcp/types/domains/core/canonical_forecast_vendor_metric_value.py index 0132ddf65..88946a282 100644 --- a/src/adcp/types/domains/core/canonical_forecast_vendor_metric_value.py +++ b/src/adcp/types/domains/core/canonical_forecast_vendor_metric_value.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_forecast_vendor_metric_value.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from typing import Any +from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict from . import brand_key, forecast_range, vendor_metric_id @@ -19,6 +19,6 @@ class CanonicalForecastVendorMetricValue(AdCPBaseModel): vendor: brand_key.BrandKey metric_id: vendor_metric_id.VendorMetricId value: forecast_range.ForecastRange - unit: str | None = None - measurable_impressions: forecast_range.ForecastRange | None = None - breakdown: dict[str, Any] | None = None + unit: Annotated[str | None, NoExplicitNull] = None + measurable_impressions: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + breakdown: Annotated[dict[str, Any] | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/canonical_format_option.py b/src/adcp/types/domains/core/canonical_format_option.py index 85983d6f1..676f2b26b 100644 --- a/src/adcp/types/domains/core/canonical_format_option.py +++ b/src/adcp/types/domains/core/canonical_format_option.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_format_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool from ..enums import channels @@ -42,16 +42,18 @@ class CanonicalFormatOption(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - format_option_id: Annotated[str | None, Field(min_length=1)] = None + format_option_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None publisher_domain: Annotated[ str | None, Field(pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$'), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored production commitment for first-class manifest tracker execution. Presence requires a stable format_option_id. Creative-agent projections MUST strip this authority-bearing field.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -59,21 +61,29 @@ class CanonicalFormatOption(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='When true, every technical creative-acceptance constraint for this declaration layer is expressed and omitted technical fields mean no constraint. When false or absent, omitted technical fields are undeclared and MUST NOT be inferred. The effective contract is complete only when every applicable declaration layer asserts true.' ), + NoExplicitNull, ] = None - display_name: Annotated[str | None, Field(min_length=1)] = None - sample_render_url: AnyUrl | None = None - applies_to_channels: Annotated[list[channels.MediaChannel] | None, Field(min_length=1)] = None - seller_preference: SellerPreference | None = None - locale_policy: creative_locale_policy.CreativeLocalePolicy | None = None - canonical_formats_only: StrictBool | None = False - experimental: StrictBool | None = False + display_name: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + sample_render_url: Annotated[AnyUrl | None, NoExplicitNull] = None + applies_to_channels: Annotated[ + list[channels.MediaChannel] | None, Field(min_length=1), NoExplicitNull + ] = None + seller_preference: Annotated[SellerPreference | None, NoExplicitNull] = None + locale_policy: Annotated[creative_locale_policy.CreativeLocalePolicy | None, NoExplicitNull] = ( + None + ) + canonical_formats_only: Annotated[StrictBool | None, NoExplicitNull] = False + experimental: Annotated[StrictBool | None, NoExplicitNull] = False format_kind: FormatKind params: dict[str, Any] - format_shape: Annotated[str | None, Field(min_length=1)] = None - format_schema: platform_extension_ref.PlatformExtensionReference | None = None + format_shape: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + format_schema: Annotated[ + platform_extension_ref.PlatformExtensionReference | None, NoExplicitNull + ] = None diff --git a/src/adcp/types/domains/core/canonical_measurement_terms.py b/src/adcp/types/domains/core/canonical_measurement_terms.py index 96c9c47b0..e4808c386 100644 --- a/src/adcp/types/domains/core/canonical_measurement_terms.py +++ b/src/adcp/types/domains/core/canonical_measurement_terms.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_measurement_terms.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..enums import makegood_remedy @@ -18,9 +18,11 @@ class BillingMeasurement(AdCPBaseModel): extra='forbid', ) vendor: brand_key.BrandKey - max_variance_percent: Annotated[StrictFloat | None, Field(ge=0.0, lt=100.0)] = None - measurement_window: Annotated[str | None, Field(min_length=1)] = None - finalization_deadline_hours: Annotated[SchemaInt | None, Field(ge=0)] = None + max_variance_percent: Annotated[StrictFloat | None, Field(ge=0.0, lt=100.0), NoExplicitNull] = ( + None + ) + measurement_window: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + finalization_deadline_hours: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None class MakegoodPolicy(AdCPBaseModel): @@ -34,5 +36,5 @@ class CanonicalMeasurementTerms(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - billing_measurement: BillingMeasurement | None = None - makegood_policy: MakegoodPolicy | None = None + billing_measurement: Annotated[BillingMeasurement | None, NoExplicitNull] = None + makegood_policy: Annotated[MakegoodPolicy | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/canonical_media_buy_action.py b/src/adcp/types/domains/core/canonical_media_buy_action.py index 75b00f1c0..182a845a8 100644 --- a/src/adcp/types/domains/core/canonical_media_buy_action.py +++ b/src/adcp/types/domains/core/canonical_media_buy_action.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/canonical_media_buy_action.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:13:02+00:00 from __future__ import annotations diff --git a/src/adcp/types/domains/core/canonical_media_buy_action_fields.py b/src/adcp/types/domains/core/canonical_media_buy_action_fields.py index 278979e8b..f9917c16e 100644 --- a/src/adcp/types/domains/core/canonical_media_buy_action_fields.py +++ b/src/adcp/types/domains/core/canonical_media_buy_action_fields.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_media_buy_action_fields.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import canonical_media_buy_action_mode @@ -32,13 +32,18 @@ class CanonicalMediaBuyActionFields(AdCPBaseModel): task: Task action: str mode: canonical_media_buy_action_mode.CanonicalMediaBuyActionMode - sla: sla_window.SlaWindow | None = None - change_term_id: media_buy_change_term_id.MediaBuyChangeTermId | None = None - terms_ref: media_buy_legacy_terms_ref.MediaBuyTermsReference | None = None + sla: Annotated[sla_window.SlaWindow | None, NoExplicitNull] = None + change_term_id: Annotated[ + media_buy_change_term_id.MediaBuyChangeTermId | None, NoExplicitNull + ] = None + terms_ref: Annotated[ + media_buy_legacy_terms_ref.MediaBuyTermsReference | None, NoExplicitNull + ] = None applicable_package_ids: Annotated[ list[applicable_package_id.ApplicablePackageId] | None, Field( description='Exact eligible packages for a package-scoped action; omission means all relevant packages.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/canonical_media_buy_features.py b/src/adcp/types/domains/core/canonical_media_buy_features.py index 75711473a..4cad892e7 100644 --- a/src/adcp/types/domains/core/canonical_media_buy_features.py +++ b/src/adcp/types/domains/core/canonical_media_buy_features.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_media_buy_features.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Dict -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from . import bidding_policy_capability @@ -17,36 +17,43 @@ class CanonicalMediaBuyFeatures(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - property_filtering: StrictBool | None = None - catalog_management: StrictBool | None = None + property_filtering: Annotated[StrictBool | None, NoExplicitNull] = None + catalog_management: Annotated[StrictBool | None, NoExplicitNull] = None reporting_commitment_snapshots: Annotated[ StrictBool | None, Field( description='Seller preserves the accepted per-purchase reporting contract and exposes it on proposal and MediaBuy readback.' ), + NoExplicitNull, ] = None seller_optimized_budget: Annotated[ StrictBool | None, Field( description='Core seller-optimized shared-budget contract: shared total_budget, seller allocation across packages, media-buy pacing, and allocation echo.' ), + NoExplicitNull, ] = None seller_optimized_package_budgets: Annotated[ StrictBool | None, Field( description='Package budget caps inside seller-optimized buys; implies seller_optimized_budget.' ), + NoExplicitNull, ] = None seller_optimized_min_spend_targets: Annotated[ StrictBool | None, Field( description='Package minimum-spend targets inside seller-optimized buys; implies seller_optimized_budget.' ), + NoExplicitNull, ] = None seller_optimized_package_pacing: Annotated[ StrictBool | None, Field( description='Package pacing inside seller-optimized buys; implies seller_optimized_budget.' ), + NoExplicitNull, + ] = None + bidding_policy: Annotated[ + bidding_policy_capability.BiddingPolicyCapability | None, NoExplicitNull ] = None - bidding_policy: bidding_policy_capability.BiddingPolicyCapability | None = None diff --git a/src/adcp/types/domains/core/canonical_metric_qualifier.py b/src/adcp/types/domains/core/canonical_metric_qualifier.py index c58737f95..ade981c1b 100644 --- a/src/adcp/types/domains/core/canonical_metric_qualifier.py +++ b/src/adcp/types/domains/core/canonical_metric_qualifier.py @@ -1,10 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_metric_qualifier.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from adcp.types.base import AdCPBaseModel +from typing import Annotated + +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict from ..enums import attribution_methodology as attribution_methodology_1 @@ -18,8 +20,12 @@ class CanonicalMetricQualifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - viewability_standard: viewability_standard_1.ViewabilityStandard | None = None - completion_source: completion_source_1.CompletionSource | None = None - attribution_methodology: attribution_methodology_1.AttributionMethodology | None = None - attribution_window: duration.Duration | None = None - lift_dimension: lift_dimension_1.LiftDimension | None = None + viewability_standard: Annotated[ + viewability_standard_1.ViewabilityStandard | None, NoExplicitNull + ] = None + completion_source: Annotated[completion_source_1.CompletionSource | None, NoExplicitNull] = None + attribution_methodology: Annotated[ + attribution_methodology_1.AttributionMethodology | None, NoExplicitNull + ] = None + attribution_window: Annotated[duration.Duration | None, NoExplicitNull] = None + lift_dimension: Annotated[lift_dimension_1.LiftDimension | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/canonical_optimization_goal.py b/src/adcp/types/domains/core/canonical_optimization_goal.py index 200de5597..be23c45d7 100644 --- a/src/adcp/types/domains/core/canonical_optimization_goal.py +++ b/src/adcp/types/domains/core/canonical_optimization_goal.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_optimization_goal.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..enums import event_type as event_type_1 @@ -53,8 +53,8 @@ class TargetFrequency(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - min: Annotated[SchemaInt | None, Field(ge=1)] = None - max: Annotated[SchemaInt | None, Field(ge=1)] = None + min: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None window: duration.Duration @@ -64,13 +64,13 @@ class CanonicalOptimizationGoal1(AdCPBaseModel): ) kind: Literal['metric'] = 'metric' metric: Metric - standard: viewability_standard.ViewabilityStandard | None = None - vendor: brand_key.BrandKey | None = None - reach_unit: reach_unit_1.ReachUnit | None = None - target_frequency: TargetFrequency | None = None - view_duration_seconds: Annotated[StrictFloat | None, Field(gt=0.0)] = None - target: Target | None = None - priority: Annotated[SchemaInt | None, Field(ge=1)] = None + standard: Annotated[viewability_standard.ViewabilityStandard | None, NoExplicitNull] = None + vendor: Annotated[brand_key.BrandKey | None, NoExplicitNull] = None + reach_unit: Annotated[reach_unit_1.ReachUnit | None, NoExplicitNull] = None + target_frequency: Annotated[TargetFrequency | None, NoExplicitNull] = None + view_duration_seconds: Annotated[StrictFloat | None, Field(gt=0.0), NoExplicitNull] = None + target: Annotated[Target | None, NoExplicitNull] = None + priority: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None class EventSource(AdCPBaseModel): @@ -79,9 +79,9 @@ class EventSource(AdCPBaseModel): ) event_source_id: Annotated[str, Field(min_length=1)] event_type: event_type_1.EventType - custom_event_name: Annotated[str | None, Field(min_length=1)] = None - value_field: Annotated[str | None, Field(min_length=1)] = None - value_factor: StrictFloat | None = 1 + custom_event_name: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + value_field: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + value_factor: Annotated[StrictFloat | None, NoExplicitNull] = 1 class CanonicalOptimizationGoal2(AdCPBaseModel): @@ -90,9 +90,11 @@ class CanonicalOptimizationGoal2(AdCPBaseModel): ) kind: Literal['event'] = 'event' event_sources: Annotated[list[EventSource], Field(min_length=1)] - target: Target10 | None = None - attribution_window: attribution_window_1.AttributionWindow | None = None - priority: Annotated[SchemaInt | None, Field(ge=1)] = None + target: Annotated[Target10 | None, NoExplicitNull] = None + attribution_window: Annotated[attribution_window_1.AttributionWindow | None, NoExplicitNull] = ( + None + ) + priority: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None class CanonicalOptimizationGoal3(AdCPBaseModel): @@ -102,8 +104,8 @@ class CanonicalOptimizationGoal3(AdCPBaseModel): kind: Literal['vendor_metric'] = 'vendor_metric' vendor: brand_key.BrandKey metric_id: vendor_metric_id.VendorMetricId - target: Target11 | None = None - priority: Annotated[SchemaInt | None, Field(ge=1)] = None + target: Annotated[Target11 | None, NoExplicitNull] = None + priority: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None CanonicalOptimizationGoal = Annotated[ diff --git a/src/adcp/types/domains/core/canonical_performance_standard.py b/src/adcp/types/domains/core/canonical_performance_standard.py index 193960353..e0696d395 100644 --- a/src/adcp/types/domains/core/canonical_performance_standard.py +++ b/src/adcp/types/domains/core/canonical_performance_standard.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_performance_standard.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import performance_standard_metric, viewability_standard @@ -19,5 +19,5 @@ class CanonicalPerformanceStandard(AdCPBaseModel): ) metric: performance_standard_metric.PerformanceStandardMetric threshold: Annotated[StrictFloat, Field(ge=0.0, le=1.0)] - standard: viewability_standard.ViewabilityStandard | None = None + standard: Annotated[viewability_standard.ViewabilityStandard | None, NoExplicitNull] = None vendor: brand_key.BrandKey diff --git a/src/adcp/types/domains/core/canonical_placement.py b/src/adcp/types/domains/core/canonical_placement.py index 738eabc38..4c9ad4ac4 100644 --- a/src/adcp/types/domains/core/canonical_placement.py +++ b/src/adcp/types/domains/core/canonical_placement.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_placement.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from ..enums import ( @@ -57,6 +57,7 @@ class CanonicalDoohPlacementAttributes(AdCPBaseModel): description='Scheduled duration of one ad slot in seconds; not the creative-duration contract.', ge=1, ), + NoExplicitNull, ] = None loop_duration_seconds: Annotated[ SchemaInt | None, @@ -64,13 +65,15 @@ class CanonicalDoohPlacementAttributes(AdCPBaseModel): description='Duration of the full ad loop rotation in seconds and the canonical source for loop duration.', ge=1, ), + NoExplicitNull, ] = None - screen_resolution: CanonicalDoohScreenResolution | None = None + screen_resolution: Annotated[CanonicalDoohScreenResolution | None, NoExplicitNull] = None motion: Annotated[ dooh_motion_type.DoohMotionType | None, Field( description='Physical motion capability of a visual DOOH screen, not an accepted-format declaration. Omit for audio-only placements.' ), + NoExplicitNull, ] = None @@ -92,25 +95,34 @@ class CanonicalProductPlacement1(AdCPBaseModel): Field( description='Defining sales agent for seller_inline identity. Recommended for new declarations; optional only to preserve legacy product-context placements.' ), + NoExplicitNull, ] = None - name: Annotated[str | None, Field(min_length=1)] = None - description: str | None = None + name: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + description: Annotated[str | None, NoExplicitNull] = None mode: Mode - tags: list[str] | None = None + tags: Annotated[list[str] | None, NoExplicitNull] = None format_options: Annotated[ - list[canonical_format_option.CanonicalFormatOption] | None, Field(min_length=1) + list[canonical_format_option.CanonicalFormatOption] | None, + Field(min_length=1), + NoExplicitNull, ] = None video_placement_types: Annotated[ - list[video_placement_type.VideoPlacementType] | None, Field(min_length=1) + list[video_placement_type.VideoPlacementType] | None, Field(min_length=1), NoExplicitNull ] = None audio_distribution_types: Annotated[ - list[audio_distribution_type.AudioDistributionType] | None, Field(min_length=1) + list[audio_distribution_type.AudioDistributionType] | None, + Field(min_length=1), + NoExplicitNull, ] = None sponsored_placement_types: Annotated[ - list[sponsored_placement_type.SponsoredPlacementType] | None, Field(min_length=1) + list[sponsored_placement_type.SponsoredPlacementType] | None, + Field(min_length=1), + NoExplicitNull, ] = None social_placement_surfaces: Annotated[ - list[social_placement_surface.SocialPlacementSurface] | None, Field(min_length=1) + list[social_placement_surface.SocialPlacementSurface] | None, + Field(min_length=1), + NoExplicitNull, ] = None identifiers: Annotated[ list[Identifier] | None, @@ -118,8 +130,11 @@ class CanonicalProductPlacement1(AdCPBaseModel): description='Optional external inventory identifiers for this placement. Externally governed values should be authority-prefixed; seller-local values are scoped by the surrounding publisher namespace. For publisher_ref placements, the effective set is the union of publisher and product declarations, de-duplicated by exact (type, value).', min_length=1, ), + NoExplicitNull, + ] = None + dooh_placement_attributes: Annotated[ + CanonicalDoohPlacementAttributes | None, NoExplicitNull ] = None - dooh_placement_attributes: CanonicalDoohPlacementAttributes | None = None class CanonicalProductPlacement2(AdCPBaseModel): @@ -134,31 +149,41 @@ class CanonicalProductPlacement2(AdCPBaseModel): description='For publisher_ref, the adagents.json publisher namespace. For seller_inline, optional inventory-publisher attribution only.', pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None seller_agent: Annotated[ seller_agent_ref.SellerAgentReference | None, Field( description='Defining sales agent for seller_inline identity. Recommended for new declarations; optional only to preserve legacy product-context placements.' ), + NoExplicitNull, ] = None name: Annotated[str, Field(min_length=1)] - description: str | None = None + description: Annotated[str | None, NoExplicitNull] = None mode: Mode - tags: list[str] | None = None + tags: Annotated[list[str] | None, NoExplicitNull] = None format_options: Annotated[ - list[canonical_format_option.CanonicalFormatOption] | None, Field(min_length=1) + list[canonical_format_option.CanonicalFormatOption] | None, + Field(min_length=1), + NoExplicitNull, ] = None video_placement_types: Annotated[ - list[video_placement_type.VideoPlacementType] | None, Field(min_length=1) + list[video_placement_type.VideoPlacementType] | None, Field(min_length=1), NoExplicitNull ] = None audio_distribution_types: Annotated[ - list[audio_distribution_type.AudioDistributionType] | None, Field(min_length=1) + list[audio_distribution_type.AudioDistributionType] | None, + Field(min_length=1), + NoExplicitNull, ] = None sponsored_placement_types: Annotated[ - list[sponsored_placement_type.SponsoredPlacementType] | None, Field(min_length=1) + list[sponsored_placement_type.SponsoredPlacementType] | None, + Field(min_length=1), + NoExplicitNull, ] = None social_placement_surfaces: Annotated[ - list[social_placement_surface.SocialPlacementSurface] | None, Field(min_length=1) + list[social_placement_surface.SocialPlacementSurface] | None, + Field(min_length=1), + NoExplicitNull, ] = None identifiers: Annotated[ list[Identifier] | None, @@ -166,8 +191,11 @@ class CanonicalProductPlacement2(AdCPBaseModel): description='Optional external inventory identifiers for this placement. Externally governed values should be authority-prefixed; seller-local values are scoped by the surrounding publisher namespace. For publisher_ref placements, the effective set is the union of publisher and product declarations, de-duplicated by exact (type, value).', min_length=1, ), + NoExplicitNull, + ] = None + dooh_placement_attributes: Annotated[ + CanonicalDoohPlacementAttributes | None, NoExplicitNull ] = None - dooh_placement_attributes: CanonicalDoohPlacementAttributes | None = None CanonicalProductPlacement = Annotated[ diff --git a/src/adcp/types/domains/core/canonical_pricing_option.py b/src/adcp/types/domains/core/canonical_pricing_option.py index 09fed7ff6..d235185c3 100644 --- a/src/adcp/types/domains/core/canonical_pricing_option.py +++ b/src/adcp/types/domains/core/canonical_pricing_option.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_pricing_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import adjustment_kind @@ -36,15 +36,19 @@ class CanonicalPricingOption(AdCPBaseModel): pricing_option_id: Annotated[str, Field(min_length=1)] pricing_model: PricingModel currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] - fixed_price: Annotated[StrictFloat | None, Field(ge=0.0)] = None - floor_price: Annotated[StrictFloat | None, Field(ge=0.0)] = None - price_guidance: price_guidance_1.PriceGuidance | None = None - min_spend_per_package: Annotated[StrictFloat | None, Field(ge=0.0)] = None - price_breakdown: price_breakdown_1.PriceBreakdown | None = None - eligible_adjustments: list[adjustment_kind.PriceAdjustmentKind] | None = None - parameters: dict[str, Any] | None = None - event_type: event_type_1.EventType | None = None - custom_event_name: Annotated[str | None, Field(min_length=1)] = None - event_source_id: Annotated[str | None, Field(min_length=1)] = None - commission_rate: Annotated[StrictFloat | None, Field(gt=0.0, le=1.0)] = None - commission_basis_description: Annotated[str | None, Field(max_length=1000, min_length=1)] = None + fixed_price: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + floor_price: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + price_guidance: Annotated[price_guidance_1.PriceGuidance | None, NoExplicitNull] = None + min_spend_per_package: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + price_breakdown: Annotated[price_breakdown_1.PriceBreakdown | None, NoExplicitNull] = None + eligible_adjustments: Annotated[ + list[adjustment_kind.PriceAdjustmentKind] | None, NoExplicitNull + ] = None + parameters: Annotated[dict[str, Any] | None, NoExplicitNull] = None + event_type: Annotated[event_type_1.EventType | None, NoExplicitNull] = None + custom_event_name: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + event_source_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + commission_rate: Annotated[StrictFloat | None, Field(gt=0.0, le=1.0), NoExplicitNull] = None + commission_basis_description: Annotated[ + str | None, Field(max_length=1000, min_length=1), NoExplicitNull + ] = None diff --git a/src/adcp/types/domains/core/canonical_product.py b/src/adcp/types/domains/core/canonical_product.py index 68373692c..b1797ccc7 100644 --- a/src/adcp/types/domains/core/canonical_product.py +++ b/src/adcp/types/domains/core/canonical_product.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_product.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field, StrictBool @@ -61,6 +61,7 @@ class PublisherProperty1(AdCPBaseModel): description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None publisher_domains: Annotated[ list[PublisherDomain] | None, @@ -68,6 +69,7 @@ class PublisherProperty1(AdCPBaseModel): description="Compact form for fanning the same selector across many publishers (e.g., a managed network listing every publisher it represents). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", min_length=1, ), + NoExplicitNull, ] = None selection_type: Annotated[ Literal['all'], @@ -94,9 +96,9 @@ class CatalogMatch(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - matched_gtins: list[MatchedGtin] | None = None - matched_ids: list[str] | None = None - matched_count: Annotated[SchemaInt | None, Field(ge=0)] = None + matched_gtins: Annotated[list[MatchedGtin] | None, NoExplicitNull] = None + matched_ids: Annotated[list[str] | None, NoExplicitNull] = None + matched_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None submitted_count: Annotated[SchemaInt, Field(ge=0)] @@ -131,6 +133,7 @@ class PublisherProperty3(AdCPBaseModel): description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None publisher_domains: Annotated[ list[PublisherDomain] | None, @@ -138,6 +141,7 @@ class PublisherProperty3(AdCPBaseModel): description="Compact form for fanning the same tag predicate across many publishers (canonical managed-network shape). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", min_length=1, ), + NoExplicitNull, ] = None selection_type: Annotated[ Literal['by_tag'], Field(description='Discriminator indicating selection by property tags') @@ -168,52 +172,71 @@ class CanonicalProduct(AdCPBaseModel): ) product_id: Annotated[str, Field(min_length=1)] name: Annotated[str, Field(min_length=1)] - description: str | None = None - publisher_properties: Annotated[list[PublisherProperty] | None, Field(min_length=1)] = None - channels: list[channels_1.MediaChannel] | None = None + description: Annotated[str | None, NoExplicitNull] = None + publisher_properties: Annotated[ + list[PublisherProperty] | None, Field(min_length=1), NoExplicitNull + ] = None + channels: Annotated[list[channels_1.MediaChannel] | None, NoExplicitNull] = None video_placement_types: Annotated[ - list[video_placement_type.VideoPlacementType] | None, Field(min_length=1) + list[video_placement_type.VideoPlacementType] | None, Field(min_length=1), NoExplicitNull ] = None audio_distribution_types: Annotated[ - list[audio_distribution_type.AudioDistributionType] | None, Field(min_length=1) + list[audio_distribution_type.AudioDistributionType] | None, + Field(min_length=1), + NoExplicitNull, ] = None sponsored_placement_types: Annotated[ - list[sponsored_placement_type.SponsoredPlacementType] | None, Field(min_length=1) + list[sponsored_placement_type.SponsoredPlacementType] | None, + Field(min_length=1), + NoExplicitNull, ] = None social_placement_surfaces: Annotated[ - list[social_placement_surface.SocialPlacementSurface] | None, Field(min_length=1) + list[social_placement_surface.SocialPlacementSurface] | None, + Field(min_length=1), + NoExplicitNull, ] = None format_options: Annotated[ - list[canonical_format_option.CanonicalFormatOption] | None, Field(min_length=1) + list[canonical_format_option.CanonicalFormatOption] | None, + Field(min_length=1), + NoExplicitNull, + ] = None + placements: Annotated[ + list[canonical_placement.CanonicalProductPlacement] | None, NoExplicitNull ] = None - placements: list[canonical_placement.CanonicalProductPlacement] | None = None collections: Annotated[ list[collection_selector.CollectionSelector] | None, Field( description='Collections available in this product, each referencing collections declared in an adagents.json by domain and explicit collection_ids. The domain-only bulk-grant selector form is for authorization scoping, not product composition. A selected-mode collection_selection names collections from this set.', min_length=1, ), + NoExplicitNull, ] = None collection_targeting_allowed: Annotated[ StrictBool | None, Field( description="Whether buyers can select a subset of this product's collections through targeting_overlay.collection_list or targeting_overlay.collection_selection. When false, the product is a fixed bundle (a collection_selection that exactly restates the complete bundle remains an inherent match)." ), + NoExplicitNull, ] = False - delivery_type: delivery_type_1.DeliveryType | None = None - exclusivity: exclusivity_1.Exclusivity | None = None + delivery_type: Annotated[delivery_type_1.DeliveryType | None, NoExplicitNull] = None + exclusivity: Annotated[exclusivity_1.Exclusivity | None, NoExplicitNull] = None pricing_options: Annotated[ - list[canonical_pricing_option.CanonicalPricingOption] | None, Field(min_length=1) + list[canonical_pricing_option.CanonicalPricingOption] | None, + Field(min_length=1), + NoExplicitNull, + ] = None + forecast: Annotated[ + canonical_delivery_forecast.CanonicalDeliveryForecast | None, NoExplicitNull + ] = None + reporting_capabilities: Annotated[ + canonical_reporting_capabilities.CanonicalReportingCapabilities | None, NoExplicitNull ] = None - forecast: canonical_delivery_forecast.CanonicalDeliveryForecast | None = None - reporting_capabilities: ( - canonical_reporting_capabilities.CanonicalReportingCapabilities | None - ) = None measurement_terms: Annotated[ canonical_measurement_terms.CanonicalMeasurementTerms | None, Field( description='Default billing measurement and makegood terms inherited by a direct purchase unless a negotiated proposal replaces them.' ), + NoExplicitNull, ] = None performance_standards: Annotated[ list[canonical_performance_standard.CanonicalPerformanceStandard] | None, @@ -221,28 +244,36 @@ class CanonicalProduct(AdCPBaseModel): description='Default performance thresholds and measurement vendors inherited by a direct purchase.', min_length=1, ), + NoExplicitNull, + ] = None + catalog_types: Annotated[ + list[catalog_type.CatalogType] | None, Field(min_length=1), NoExplicitNull + ] = None + signal_targeting_allowed: Annotated[StrictBool | None, NoExplicitNull] = None + signal_targeting_rules: Annotated[ + signal_targeting_rules_1.SignalTargetingRules | None, NoExplicitNull + ] = None + demographic_targeting: Annotated[ + demographic_targeting_capability.DemographicTargetingCapability | None, NoExplicitNull ] = None - catalog_types: Annotated[list[catalog_type.CatalogType] | None, Field(min_length=1)] = None - signal_targeting_allowed: StrictBool | None = None - signal_targeting_rules: signal_targeting_rules_1.SignalTargetingRules | None = None - demographic_targeting: ( - demographic_targeting_capability.DemographicTargetingCapability | None - ) = None overlay_support: Annotated[ targeting_overlay_support.TargetingOverlaySupport | None, Field( description='Binding product-scoped targeting dimensions the buyer may set independently on a package after discovery.' ), + NoExplicitNull, ] = None media_buy_support: Annotated[ media_buy_support_1.ProductMediaBuySupport | None, Field(description='Binding product participation in shared MediaBuy-level controls.'), + NoExplicitNull, ] = None identity: Annotated[ product_identity.ProductIdentity | None, Field( description='Experimental product-scoped identity and reach-measurement facts. See Product.identity for the cross-object delivery and frequency-cap rules.' ), + NoExplicitNull, ] = None execution_requirements: Annotated[ list[product_execution_requirement.ProductExecutionRequirement] | None, @@ -250,9 +281,12 @@ class CanonicalProduct(AdCPBaseModel): description='Experimental account resources a package on this product needs before it can be created. See Product.execution_requirements for completeness, binding, and rejection rules.', min_length=1, ), + NoExplicitNull, ] = None audience_evidence: Annotated[ - list[canonical_audience_evidence.CanonicalAudienceEvidence] | None, Field(min_length=1) + list[canonical_audience_evidence.CanonicalAudienceEvidence] | None, + Field(min_length=1), + NoExplicitNull, ] = None audience_evidence_selections: Annotated[ list[canonical_audience_evidence_selection.CanonicalAudienceEvidenceSelection] | None, @@ -260,31 +294,37 @@ class CanonicalProduct(AdCPBaseModel): description='Exact evidence snapshots that affected eligibility or ranking. Returned whenever evidence requirements affected the result, even if not requested explicitly.', min_length=1, ), + NoExplicitNull, ] = None - max_optimization_goals: Annotated[SchemaInt | None, Field(ge=0)] = None - catalog_match: CatalogMatch | None = None + max_optimization_goals: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + catalog_match: Annotated[CatalogMatch | None, NoExplicitNull] = None list_applications: Annotated[ list[inventory_list_application.InventoryListApplication] | None, Field( description='Product-scoped receipts for every effective property- or collection-list targeting reference. Sellers MUST return one receipt per application regardless of response field projection; exclusion applications receive a receipt even when summary.matched is zero, while zero matches for any inclusion application make the product ineligible and it is not returned. Each receipt uses the same pre-list product inventory baseline; pricing and forecast reflect inventory remaining after all effective lists are composed.', min_length=1, ), + NoExplicitNull, ] = None - brief_relevance: str | None = None + brief_relevance: Annotated[str | None, NoExplicitNull] = None targeting_resolution: Annotated[ product_targeting_resolution.ProductTargetingResolution | None, Field( description='Discovery-time targeting resolution bound to this configured product. modifications sparsely disclose product-specific differences from criteria.targeting_overlay; absence means exact acceptance of the structured overlay. Request-level brief interpretation is returned once on the response-root targeting_resolution. Selecting product_id accepts the disclosed modifications; pricing and forecast MUST reflect them. Requires expires_at.' ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='Expiration of a request-specific configured offer. Canonical products have no is_custom flag; expires_at is what marks an offer as request-specific. After it, the buyer rediscovers.' ), + NoExplicitNull, + ] = None + allowed_actions: Annotated[ + list[canonical_product_action.CanonicalProductAction] | None, NoExplicitNull + ] = None + acceptance_policy_profile_ids: Annotated[ + acceptance_policy_profile_ids_1.AcceptancePolicyProfileIds | None, NoExplicitNull ] = None - allowed_actions: list[canonical_product_action.CanonicalProductAction] | None = None - acceptance_policy_profile_ids: ( - acceptance_policy_profile_ids_1.AcceptancePolicyProfileIds | None - ) = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/canonical_product_action.py b/src/adcp/types/domains/core/canonical_product_action.py index c15fdeb61..92f6691e5 100644 --- a/src/adcp/types/domains/core/canonical_product_action.py +++ b/src/adcp/types/domains/core/canonical_product_action.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_product_action.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import canonical_media_buy_action, canonical_media_buy_action_mode, media_buy_status @@ -23,18 +23,20 @@ class CanonicalProductAction(AdCPBaseModel): list[canonical_media_buy_action_mode.CanonicalMediaBuyActionMode], Field(min_length=1) ] allowed_statuses: Annotated[ - list[media_buy_status.MediaBuyStatus] | None, Field(min_length=1) + list[media_buy_status.MediaBuyStatus] | None, Field(min_length=1), NoExplicitNull ] = None - sla: sla_window.SlaWindow | None = None + sla: Annotated[sla_window.SlaWindow | None, NoExplicitNull] = None constraints: Annotated[ change_term_constraints.MediaBuyChangeTermConstraints | None, Field( description='Advisory machine-readable bounds for product selection; proposal change terms restate binding bounds.' ), + NoExplicitNull, ] = None terms_ref: Annotated[ str | None, Field( description='Optional advisory pointer to published commercial terms. It is not a proposal change-term identity.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/canonical_projection_ref.py b/src/adcp/types/domains/core/canonical_projection_ref.py index ba7572552..45113aca3 100644 --- a/src/adcp/types/domains/core/canonical_projection_ref.py +++ b/src/adcp/types/domains/core/canonical_projection_ref.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_projection_ref.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import canonical_projection_slot_override @@ -37,6 +37,7 @@ class CanonicalProjectionReference(AdCPBaseModel): Field( description="Where the rendered asset bytes come from on the projected v2 declaration. Default (when omitted) is `buyer_uploaded` — the canonical's default. Set explicitly when the v1 named format doesn't follow that default. Required for generative entries (`agent_synthesized` or `seller_pre_rendered_from_brief`) because their asset shape doesn't carry image/video/audio bytes, and for published-post reference entries (`publisher_owned_reference`) because their asset shape carries a post reference rather than uploaded bytes. Projection without this hint produces a lossy v2 declaration that claims buyer-uploaded bytes." ), + NoExplicitNull, ] = None slots_override: Annotated[ list[canonical_projection_slot_override.CanonicalProjectionSlotOverride] | None, @@ -44,4 +45,5 @@ class CanonicalProjectionReference(AdCPBaseModel): description="When the v1 named format's slot shape differs from the canonical's default slots, this carries the override that the projected v2 declaration's `params.slots[]` should use. REPLACES (does not merge with) the canonical's default slots — projection-time semantics. The slot vocabulary follows `asset-group-vocabulary.json`. Asset IDs in the v1 format's `assets[*]` MUST resolve (directly or via the vocabulary's aliases) to the `asset_group_id` values declared here.", min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/canonical_projection_slot_override.py b/src/adcp/types/domains/core/canonical_projection_slot_override.py index 25df5ee65..c8f797fc9 100644 --- a/src/adcp/types/domains/core/canonical_projection_slot_override.py +++ b/src/adcp/types/domains/core/canonical_projection_slot_override.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_projection_slot_override.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -29,13 +29,17 @@ class CanonicalProjectionSlotOverride(AdCPBaseModel): required: Annotated[ StrictBool | None, Field(description='Whether the slot is required in the projected declaration.'), + NoExplicitNull, ] = False max_chars: Annotated[ - SchemaInt | None, Field(description='Max character count for text slots.', ge=1) + SchemaInt | None, + Field(description='Max character count for text slots.', ge=1), + NoExplicitNull, ] = None consumed_for_production: Annotated[ StrictBool | None, Field( description="When false, slot is for moderation/review only and is NOT consumed by the seller's renderer (e.g., a brand-safety brief that informs review but doesn't appear in the rendered ad)." ), + NoExplicitNull, ] = True diff --git a/src/adcp/types/domains/core/canonical_proposal.py b/src/adcp/types/domains/core/canonical_proposal.py index 8e085820c..198266239 100644 --- a/src/adcp/types/domains/core/canonical_proposal.py +++ b/src/adcp/types/domains/core/canonical_proposal.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_proposal.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..enums import proposal_status as proposal_status_1 @@ -26,9 +26,9 @@ class TotalBudgetGuidance(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - min: Annotated[StrictFloat | None, Field(ge=0.0)] = None - recommended: Annotated[StrictFloat | None, Field(ge=0.0)] = None - max: Annotated[StrictFloat | None, Field(ge=0.0)] = None + min: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + recommended: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + max: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] @@ -45,8 +45,9 @@ class CanonicalProposal(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None - media_buy_id: Annotated[str | None, Field(min_length=1)] = None + media_buy_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None opportunity_id: Annotated[ str | None, Field( @@ -55,24 +56,26 @@ class CanonicalProposal(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None - base_media_buy_revision: Annotated[SchemaInt | None, Field(ge=1)] = None + base_media_buy_revision: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None proposal_status: Annotated[ proposal_status_1.ProposalStatus, Field( description='draft is indicative and unreserved; committed has firm terms with inventory reserved until expires_at; accepted is the historical snapshot attached to a MediaBuy.' ), ] - accepted_at: AwareDatetime | None = None + accepted_at: Annotated[AwareDatetime | None, NoExplicitNull] = None expires_at: Annotated[ AwareDatetime | None, Field( description='For a draft, the indicative-terms freshness deadline. For a committed proposal, the inventory-hold deadline.' ), + NoExplicitNull, ] = None name: Annotated[str, Field(max_length=500, min_length=1)] - description: Annotated[str | None, Field(max_length=2000)] = None - brief_alignment: Annotated[str | None, Field(max_length=2000)] = None + description: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + brief_alignment: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None commercial_terms: commercial_terms_1.CommercialTerms terms_digest: Annotated[ str, @@ -81,16 +84,18 @@ class CanonicalProposal(AdCPBaseModel): pattern='^sha256:[A-Za-z0-9_-]{43}$', ), ] - insertion_order: insertion_order_1.InsertionOrder | None = None + insertion_order: Annotated[insertion_order_1.InsertionOrder | None, NoExplicitNull] = None total_budget_guidance: Annotated[ TotalBudgetGuidance | None, Field( description="Optional budget guidance for this proposal — the planning answer to criteria.outcome_target and to open-budget briefs. commercial_terms_1.total_budget remains the concrete figure the plan is priced at; this band expresses the seller's recommended range around it. When criteria.outcome_target carries cost_per, the cost answer is commercial_terms_1.bidding.cost_per and this band's currency equals cost_per.currency." ), + NoExplicitNull, ] = None forecast: Annotated[ canonical_delivery_forecast.CanonicalDeliveryForecast | None, Field( description="Aggregate forecasted delivery for the proposal. For outcome_target requests, points carry the goal's metric or event key in metrics; with cost_per, that is the goal volume planned under the commercial_terms_1.bidding policy, and currency equals cost_per.currency." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/canonical_reporting_capabilities.py b/src/adcp/types/domains/core/canonical_reporting_capabilities.py index 5130f4082..1480e782c 100644 --- a/src/adcp/types/domains/core/canonical_reporting_capabilities.py +++ b/src/adcp/types/domains/core/canonical_reporting_capabilities.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_reporting_capabilities.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ..enums import available_metric, reporting_frequency @@ -55,29 +55,36 @@ class CanonicalReportingCapabilities(AdCPBaseModel): Field( description='Product-scoped subset of get_adcp_capabilities.media_buy.reporting_delivery.offerings[].offering_id that packages using this product can satisfy. This binds seller-wide managed-delivery offerings to product/package eligibility. An empty array explicitly declares no managed offering; absence means product-level applicability is unknown and MUST NOT be inferred from the seller-wide list. Account, seat, credential, or provider constraints may narrow support further during sync_accounts validation.' ), + NoExplicitNull, ] = None available_metrics: list[available_metric.AvailableMetric] - vendor_metrics: list[VendorMetric] | None = None - supports_creative_breakdown: StrictBool | None = None - supports_format_breakdown: StrictBool | None = None - supports_keyword_breakdown: StrictBool | None = None - supports_geo_breakdown: geo_breakdown_support.GeographicBreakdownSupport | None = None - supports_device_type_breakdown: StrictBool | None = None - supports_device_platform_breakdown: StrictBool | None = None - supports_audience_breakdown: StrictBool | None = None - supports_demographic_breakdown: ( - demographic_reporting_capability.DemographicReportingCapability | None - ) = None - supports_placement_breakdown: StrictBool | None = None - supports_property_breakdown: StrictBool | None = None - supports_collection_breakdown: StrictBool | None = None - supports_installment_breakdown: StrictBool | None = None - supports_collection_property_breakdown: StrictBool | None = None - supports_installment_property_breakdown: StrictBool | None = None - supports_placement_property_breakdown: StrictBool | None = None - supports_spot_breakdown: spot_reporting_capability.SpotReportingCapability | None = None + vendor_metrics: Annotated[list[VendorMetric] | None, NoExplicitNull] = None + supports_creative_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_format_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_keyword_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_geo_breakdown: Annotated[ + geo_breakdown_support.GeographicBreakdownSupport | None, NoExplicitNull + ] = None + supports_device_type_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_device_platform_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_audience_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_demographic_breakdown: Annotated[ + demographic_reporting_capability.DemographicReportingCapability | None, NoExplicitNull + ] = None + supports_placement_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_property_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_collection_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_installment_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_collection_property_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_installment_property_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_placement_property_breakdown: Annotated[StrictBool | None, NoExplicitNull] = None + supports_spot_breakdown: Annotated[ + spot_reporting_capability.SpotReportingCapability | None, NoExplicitNull + ] = None date_range_support: DateRangeSupport - windowed_pull_granularities: list[reporting_frequency.ReportingFrequency] | None = None + windowed_pull_granularities: Annotated[ + list[reporting_frequency.ReportingFrequency] | None, NoExplicitNull + ] = None measurement_windows: Annotated[ - list[measurement_window.MeasurementWindow] | None, Field(min_length=1) + list[measurement_window.MeasurementWindow] | None, Field(min_length=1), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/core/canonical_reporting_commitment.py b/src/adcp/types/domains/core/canonical_reporting_commitment.py index e7d121d35..04e0cb474 100644 --- a/src/adcp/types/domains/core/canonical_reporting_commitment.py +++ b/src/adcp/types/domains/core/canonical_reporting_commitment.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/canonical_reporting_commitment.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import available_metric @@ -19,8 +19,10 @@ class CanonicalReportingCommitment1(AdCPBaseModel): ) scope: Literal['standard'] = 'standard' metric_id: available_metric.AvailableMetric - qualifier: canonical_metric_qualifier.CanonicalMetricQualifier | None = None - effective_at: AwareDatetime | None = None + qualifier: Annotated[ + canonical_metric_qualifier.CanonicalMetricQualifier | None, NoExplicitNull + ] = None + effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None class CanonicalReportingCommitment2(AdCPBaseModel): @@ -30,8 +32,10 @@ class CanonicalReportingCommitment2(AdCPBaseModel): scope: Literal['vendor'] = 'vendor' vendor: brand_key.BrandKey metric_id: vendor_metric_id.VendorMetricId - qualifier: canonical_metric_qualifier.CanonicalMetricQualifier | None = None - effective_at: AwareDatetime | None = None + qualifier: Annotated[ + canonical_metric_qualifier.CanonicalMetricQualifier | None, NoExplicitNull + ] = None + effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None CanonicalReportingCommitment = Annotated[ diff --git a/src/adcp/types/domains/core/canvas_constraint.py b/src/adcp/types/domains/core/canvas_constraint.py index 27dd15603..9183a1f1d 100644 --- a/src/adcp/types/domains/core/canvas_constraint.py +++ b/src/adcp/types/domains/core/canvas_constraint.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canvas_constraint.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -31,7 +31,7 @@ class Region(AdCPBaseModel): y: Annotated[StrictFloat, Field(ge=0.0)] width: Annotated[StrictFloat, Field(gt=0.0)] height: Annotated[StrictFloat, Field(gt=0.0)] - unit: Unit | None = Unit.px + unit: Annotated[Unit | None, NoExplicitNull] = Unit.px class CanvasConstraint(AdCPBaseModel): @@ -39,6 +39,6 @@ class CanvasConstraint(AdCPBaseModel): extra='allow', ) constraint: Constraint - state_id: Annotated[str | None, Field(pattern='^[a-z][a-z0-9_]*$')] = None - breakpoint_id: Annotated[str | None, Field(pattern='^[a-z][a-z0-9_]*$')] = None + state_id: Annotated[str | None, Field(pattern='^[a-z][a-z0-9_]*$'), NoExplicitNull] = None + breakpoint_id: Annotated[str | None, Field(pattern='^[a-z][a-z0-9_]*$'), NoExplicitNull] = None region: Region diff --git a/src/adcp/types/domains/core/capabilities_changed_webhook.py b/src/adcp/types/domains/core/capabilities_changed_webhook.py index 7bc784bb7..fc16c9071 100644 --- a/src/adcp/types/domains/core/capabilities_changed_webhook.py +++ b/src/adcp/types/domains/core/capabilities_changed_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/capabilities_changed_webhook.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field @@ -104,5 +104,6 @@ class CapabilitiesChangedWebhook(AdCPBaseModel): description='Optional advisory JSON Pointer-style paths that identify the top-level or nested capability subtrees that changed (for example `/account/sandbox` or `/supported_protocols`). Receivers MAY use this for logging or selective downstream invalidation, but MUST still treat the full `get_adcp_capabilities` response as the authoritative replacement snapshot.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/catalog.py b/src/adcp/types/domains/core/catalog.py index 9cd49ead5..9b6d401d7 100644 --- a/src/adcp/types/domains/core/catalog.py +++ b/src/adcp/types/domains/core/catalog.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/catalog.json -# timestamp: 2026-10-04T00:56:30+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, ConfigDict, Field @@ -32,12 +32,14 @@ class Catalog(AdCPBaseModel): Field( description="Buyer's identifier for this catalog. Required when syncing via sync_catalogs. When used in creatives, references a previously synced catalog on the account." ), + NoExplicitNull, ] = None name: Annotated[ str | None, Field( description="Human-readable name for this catalog (e.g., 'Summer Products 2025', 'Amsterdam Store Locations')." ), + NoExplicitNull, ] = None type: Annotated[ catalog_type.CatalogType, @@ -50,18 +52,21 @@ class Catalog(AdCPBaseModel): Field( description="URL to an external catalog feed. The platform fetches and resolves items from this URL. For offering-type catalogs, the feed contains an array of Offering objects. For other types, the feed format is determined by feed_format. When omitted with type 'product', the platform uses its synced copy of the brand's product catalog." ), + NoExplicitNull, ] = None feed_format: Annotated[ feed_format_1.FeedFormat | None, Field( description='Format of the external feed at url. Required when url points to a non-AdCP feed (e.g., Google Merchant Center XML, Meta Product Catalog). Omit for offering-type catalogs where the feed is native AdCP JSON.' ), + NoExplicitNull, ] = None update_frequency: Annotated[ update_frequency_1.UpdateFrequency | None, Field( description='How often the platform should re-fetch the feed from url. Only applicable when url is provided. Platforms may use this as a hint for polling schedules.' ), + NoExplicitNull, ] = None items: Annotated[ list[dict[str, Any]] | None, @@ -69,6 +74,7 @@ class Catalog(AdCPBaseModel): description="Inline catalog data. The item schema depends on the catalog type: Offering objects for 'offering', StoreItem for 'store', HotelItem for 'hotel', FlightItem for 'flight', JobItem for 'job', VehicleItem for 'vehicle', RealEstateItem for 'real_estate', EducationItem for 'education', DestinationItem for 'destination', AppItem for 'app', or freeform objects for 'product', 'inventory', and 'promotion'. Mutually exclusive with url — provide one or the other, not both. Implementations should validate items against the type-specific schema.", min_length=1, ), + NoExplicitNull, ] = None ids: Annotated[ list[str] | None, @@ -76,6 +82,7 @@ class Catalog(AdCPBaseModel): description='Filter catalog to exact canonical item keys. The key field is offering_id for offering, store_id for store, hotel_id for hotel, flight_id for flight, job_id for job, vehicle_id for vehicle, listing_id for real_estate, program_id for education, destination_id for destination, and app_id for app. Product, inventory, and promotion catalogs use the stable normalized source identifier retained during ingestion (for example a retailer SKU). The same canonical key is used by catalog availability item_id.', min_length=1, ), + NoExplicitNull, ] = None gtins: Annotated[ list[Gtin] | None, @@ -83,6 +90,7 @@ class Catalog(AdCPBaseModel): description="Filter product-type catalogs by GTIN identifiers for cross-retailer catalog matching. Accepts standard GTIN formats (GTIN-8, UPC-A/GTIN-12, EAN-13/GTIN-13, GTIN-14). Only applicable when type is 'product'.", min_length=1, ), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, @@ -90,18 +98,21 @@ class Catalog(AdCPBaseModel): description='Filter catalog to items with these tags. Tags are matched using OR logic — items matching any tag are included.', min_length=1, ), + NoExplicitNull, ] = None category: Annotated[ str | None, Field( description="Filter catalog to items in this category (e.g., 'beverages/soft-drinks', 'chef-positions')." ), + NoExplicitNull, ] = None query: Annotated[ str | None, Field( description="Natural language filter for catalog items (e.g., 'all pasta sauces under $5', 'amsterdam vacancies')." ), + NoExplicitNull, ] = None conversion_events: Annotated[ list[event_type.EventType] | None, @@ -109,12 +120,14 @@ class Catalog(AdCPBaseModel): description="Event types that represent conversions for items in this catalog. Declares what events the platform should attribute to catalog items — e.g., a job catalog converts via submit_application, a product catalog via purchase. The event's content_ids field carries the item IDs that connect back to catalog items. Use content_id_type to declare what identifier type content_ids values represent.", min_length=1, ), + NoExplicitNull, ] = None content_id_type: Annotated[ content_id_type_1.ContentIdType | None, Field( description="Identifier type that the event's content_ids field should be matched against for items in this catalog. For example, 'gtin' means content_ids values are Global Trade Item Numbers, 'sku' means retailer SKUs. Omit when using a custom identifier scheme not listed in the enum." ), + NoExplicitNull, ] = None feed_field_mappings: Annotated[ list[catalog_field_mapping.CatalogFieldMapping] | None, @@ -122,4 +135,5 @@ class Catalog(AdCPBaseModel): description='Declarative normalization rules for external feeds. Maps non-standard feed field names, date formats, price encodings, and image URLs to the AdCP catalog item schema. Applied during sync_catalogs ingestion. Supports field renames, named transforms (date, divide, boolean, split), static literal injection, and assignment of image URLs to typed asset pools.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/catalog_field_mapping.py b/src/adcp/types/domains/core/catalog_field_mapping.py index e02382404..cac824da7 100644 --- a/src/adcp/types/domains/core/catalog_field_mapping.py +++ b/src/adcp/types/domains/core/catalog_field_mapping.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/catalog_field_mapping.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -29,18 +29,21 @@ class CatalogFieldMapping(AdCPBaseModel): Field( description='Field name in the external feed record. Omit when injecting a static literal value (use the value property instead).' ), + NoExplicitNull, ] = None catalog_field: Annotated[ str | None, Field( description="Target field on the catalog item schema, using dot notation for nested fields (e.g., 'name', 'price.amount', 'location.city'). Mutually exclusive with asset_group_id." ), + NoExplicitNull, ] = None asset_group_id: Annotated[ str | None, Field( description="Places the feed field value (a URL) into a typed asset pool on the catalog item's assets array. The value is wrapped as an image or video asset in a group with this ID. Use standard group IDs: 'images_landscape', 'images_vertical', 'images_square', 'logo', 'video'. Mutually exclusive with catalog_field." ), + NoExplicitNull, ] = None value: Annotated[ Any | None, @@ -53,18 +56,21 @@ class CatalogFieldMapping(AdCPBaseModel): Field( description='Named transform to apply to the feed field value before writing to the catalog schema. See transform-specific parameters (format, timezone, by, separator).' ), + NoExplicitNull, ] = None format: Annotated[ str | None, Field( description="For transform 'date': the input date format string (e.g., 'YYYYMMDD', 'MM/DD/YYYY', 'DD-MM-YYYY'). Output is always ISO 8601 (e.g., '2025-03-01'). Uses Unicode date pattern tokens." ), + NoExplicitNull, ] = None timezone: Annotated[ str | None, Field( description="For transform 'date': the timezone of the input value. IANA timezone identifier (e.g., 'UTC', 'America/New_York', 'Europe/Amsterdam'). Defaults to UTC when omitted." ), + NoExplicitNull, ] = None by: Annotated[ StrictFloat | None, @@ -72,12 +78,14 @@ class CatalogFieldMapping(AdCPBaseModel): description="For transform 'divide': the divisor to apply (e.g., 100 to convert integer cents to decimal dollars).", gt=0.0, ), + NoExplicitNull, ] = None separator: Annotated[ str | None, Field( description="For transform 'split': the separator character or string to split on. Defaults to ','." ), + NoExplicitNull, ] = ',' default: Annotated[ Any | None, @@ -85,4 +93,4 @@ class CatalogFieldMapping(AdCPBaseModel): description='Fallback value to use when feed_field is absent, null, or empty. Applied after any transform would have been applied. Allows optional feed fields to have a guaranteed baseline value.' ), ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/catalog_item_availability_state.py b/src/adcp/types/domains/core/catalog_item_availability_state.py index 48a4ec100..52e3d05b7 100644 --- a/src/adcp/types/domains/core/catalog_item_availability_state.py +++ b/src/adcp/types/domains/core/catalog_item_availability_state.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/catalog_item_availability_state.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from . import catalog_item_availability_error @@ -49,6 +49,7 @@ class CatalogItemAvailabilityState(AdCPBaseModel): Field( description='Current buyer-authored overlay state. active does not imply seller approval or delivery eligibility.' ), + NoExplicitNull, ] = None overlay_revision: Annotated[ SchemaInt | None, @@ -56,19 +57,23 @@ class CatalogItemAvailabilityState(AdCPBaseModel): description='Current optimistic-concurrency token. Revision 0 is the initial active state. Every applied suppress, applied restore, and automatic expiry increments it exactly once; unchanged updates, reads, and idempotent replays do not increment it.', ge=0, ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='Current automatic expiry, present only while availability is suppressed with an expiry.' ), + NoExplicitNull, ] = None updated_at: Annotated[ AwareDatetime | None, Field(description='Seller timestamp of the state represented by overlay_revision.'), + NoExplicitNull, ] = None errors: Annotated[ list[catalog_item_availability_error.CatalogItemAvailabilityError] | None, Field(min_length=1), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/catalog_item_availability_update.py b/src/adcp/types/domains/core/catalog_item_availability_update.py index 0e4d5164f..afa7f41f1 100644 --- a/src/adcp/types/domains/core/catalog_item_availability_update.py +++ b/src/adcp/types/domains/core/catalog_item_availability_update.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/catalog_item_availability_update.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from . import ext as ext_1 @@ -85,11 +85,13 @@ class CatalogItemAvailabilityUpdate(AdCPBaseModel): max_length=1000, min_length=1, ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='Optional expiry for a suppress overlay. At this instant the seller automatically removes the buyer-authored suppression as if it received restore. Valid only with action suppress; omit for an indefinite suppression. Sellers MUST reject a timestamp that is not in the future when the request is processed. On a repeated suppress, this value is complete replacement state: a changed timestamp replaces the prior expiry, and omission clears a prior expiry to make suppression indefinite. Either change returns status applied, not unchanged.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/catalog_item_availability_update_result.py b/src/adcp/types/domains/core/catalog_item_availability_update_result.py index 675646cfc..38a5821dc 100644 --- a/src/adcp/types/domains/core/catalog_item_availability_update_result.py +++ b/src/adcp/types/domains/core/catalog_item_availability_update_result.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/catalog_item_availability_update_result.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from . import catalog_item_availability_error @@ -56,6 +56,7 @@ class CatalogItemAvailabilityUpdateResult(AdCPBaseModel): availability: Annotated[ Availability | None, Field(description='Persisted buyer-authored state after an applied or unchanged result.'), + NoExplicitNull, ] = None overlay_revision: Annotated[ SchemaInt | None, @@ -63,23 +64,27 @@ class CatalogItemAvailabilityUpdateResult(AdCPBaseModel): description="Persisted state revision after an applied or unchanged result. Applied increments the request's expected_overlay_revision exactly once; unchanged preserves it.", ge=0, ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='Persisted expiry after this update, present only for a suppressed state with an expiry.' ), + NoExplicitNull, ] = None applied_at: Annotated[ AwareDatetime | None, Field( description='Seller timestamp when the buyer-availability state took effect. Required for applied. Optional for unchanged when the seller knows the timestamp of the already-persisted state.' ), + NoExplicitNull, ] = None errors: Annotated[ list[catalog_item_availability_error.CatalogItemAvailabilityError] | None, Field( description='Why this item update failed. Required when status is failed.', min_length=1 ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/catalog_item_delivery_metrics.py b/src/adcp/types/domains/core/catalog_item_delivery_metrics.py index d8ce20ece..fff368f08 100644 --- a/src/adcp/types/domains/core/catalog_item_delivery_metrics.py +++ b/src/adcp/types/domains/core/catalog_item_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/catalog_item_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import Field from ..enums import content_id_type as content_id_type_1 @@ -19,6 +20,7 @@ class CatalogItemDeliveryMetrics(DeliveryMetrics): content_id_type: Annotated[ content_id_type_1.ContentIdType | None, Field(description='Identifier type for this content_id'), + NoExplicitNull, ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/catalog_selection.py b/src/adcp/types/domains/core/catalog_selection.py index 3db938d81..43bf583e2 100644 --- a/src/adcp/types/domains/core/catalog_selection.py +++ b/src/adcp/types/domains/core/catalog_selection.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/catalog_selection.json -# timestamp: 2026-10-04T00:56:30+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -33,6 +33,7 @@ class CatalogSelection(AdCPBaseModel): Field( description="Catalog type. Structural types: 'offering' (AdCP Offering objects), 'product' (ecommerce entries), 'inventory' (stock per location), 'store' (physical locations), 'promotion' (deals and pricing). Vertical types: 'hotel', 'flight', 'job', 'vehicle', 'real_estate', 'education', 'destination', 'app' — each with an industry-specific item schema." ), + NoExplicitNull, ] = None ids: Annotated[ list[str] | None, @@ -40,6 +41,7 @@ class CatalogSelection(AdCPBaseModel): description='Filter catalog to exact canonical item keys. The key field is offering_id for offering, store_id for store, hotel_id for hotel, flight_id for flight, job_id for job, vehicle_id for vehicle, listing_id for real_estate, program_id for education, destination_id for destination, and app_id for app. Product, inventory, and promotion catalogs use the stable normalized source identifier retained during ingestion (for example a retailer SKU). The same canonical key is used by catalog availability item_id.', min_length=1, ), + NoExplicitNull, ] = None gtins: Annotated[ list[Gtin] | None, @@ -47,6 +49,7 @@ class CatalogSelection(AdCPBaseModel): description="Filter product-type catalogs by GTIN identifiers for cross-retailer catalog matching. Accepts standard GTIN formats (GTIN-8, UPC-A/GTIN-12, EAN-13/GTIN-13, GTIN-14). Only applicable when type is 'product'.", min_length=1, ), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, @@ -54,12 +57,14 @@ class CatalogSelection(AdCPBaseModel): description='Filter catalog to items with these tags. Tags are matched using OR logic — items matching any tag are included.', min_length=1, ), + NoExplicitNull, ] = None category: Annotated[ str | None, Field( description="Filter catalog to items in this category (e.g., 'beverages/soft-drinks', 'chef-positions')." ), + NoExplicitNull, ] = None query: Annotated[ str | None, @@ -67,4 +72,5 @@ class CatalogSelection(AdCPBaseModel): description="Natural language filter for catalog items (e.g., 'all pasta sauces under $5', 'amsterdam vacancies').", min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/catchment.py b/src/adcp/types/domains/core/catchment.py index 533729c7b..8dcf33199 100644 --- a/src/adcp/types/domains/core/catchment.py +++ b/src/adcp/types/domains/core/catchment.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/catchment.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..enums import distance_unit @@ -68,32 +68,37 @@ class Catchment(AdCPBaseModel): Field( description="Human-readable label for this catchment (e.g., '15-min drive', '1km walking radius')." ), + NoExplicitNull, ] = None travel_time: Annotated[ TravelTime | None, Field( description='Travel time limit for isochrone calculation. The platform resolves this to a geographic boundary based on actual transportation networks, accounting for road connectivity, transit schedules, and terrain.' ), + NoExplicitNull, ] = None transport_mode: Annotated[ transport_mode_1.TransportMode | None, Field( description='Transportation mode for isochrone calculation. Required when travel_time is provided.' ), + NoExplicitNull, ] = None radius: Annotated[ Radius | None, Field( description="Simple radius from the store location. The platform draws a circle of this distance around the store's coordinates." ), + NoExplicitNull, ] = None geometry: Annotated[ Geometry | None, Field( description='Pre-computed GeoJSON geometry defining the catchment boundary. Use this when the buyer has already calculated isochrones (via TravelTime, Mapbox, etc.) or has custom trade area boundaries. Supports Polygon and MultiPolygon types.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> Catchment: diff --git a/src/adcp/types/domains/core/collection.py b/src/adcp/types/domains/core/collection.py index 093ddae7a..0e736a853 100644 --- a/src/adcp/types/domains/core/collection.py +++ b/src/adcp/types/domains/core/collection.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/collection.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import collection_cadence, collection_kind, collection_relationship, collection_status @@ -44,6 +44,7 @@ class Collection(AdCPBaseModel): description="Publisher namespace that owns this collection. Required for supply-path verification when the declaration is read from a cross-origin authoritative document; it must match the publisher whose origin delegated retrieval. A pointer alone cannot claim another publisher's collection. May be omitted for a publisher-origin document, where that origin supplies the namespace.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None collection_id: Annotated[ str, @@ -57,83 +58,101 @@ class Collection(AdCPBaseModel): Field( description="What kind of content program this is. Helps agents interpret installments correctly. A channel collection represents the persistent programmed stream; its installments, when present, are scheduled airings or programming blocks. Defaults to 'series' when absent." ), + NoExplicitNull, + ] = None + description: Annotated[ + str | None, Field(description='What the collection is about'), NoExplicitNull ] = None - description: Annotated[str | None, Field(description='What the collection is about')] = None genre: Annotated[ list[str] | None, Field( description='Genre tags. When genre_taxonomy is present, values are taxonomy IDs (e.g., IAB Content Taxonomy 3.0 codes). Otherwise free-form.' ), + NoExplicitNull, ] = None genre_taxonomy: Annotated[ str | None, Field( description="Taxonomy system for genre values (e.g., 'iab_content_3.0'). When present, genre values should be valid taxonomy IDs. Recommended for machine-readable brand safety evaluation." ), + NoExplicitNull, ] = None language: Annotated[ - str | None, Field(description="Primary language (BCP 47 tag, e.g., 'en', 'es-MX')") + str | None, + Field(description="Primary language (BCP 47 tag, e.g., 'en', 'es-MX')"), + NoExplicitNull, ] = None content_rating: Annotated[ content_rating_1.ContentRating | None, Field( description='Baseline content rating for the collection. Individual installments may override this.' ), + NoExplicitNull, ] = None cadence: Annotated[ collection_cadence.CollectionCadence | None, Field(description='How frequently the collection releases new installments'), + NoExplicitNull, ] = None season: Annotated[ str | None, Field( description="Current or most recent season identifier (e.g., '3', '2026', 'spring_2026'). A lightweight label — not a full season object." ), + NoExplicitNull, ] = None status: Annotated[ collection_status.CollectionStatus | None, Field(description='Lifecycle status of the collection'), + NoExplicitNull, ] = None production_quality: Annotated[ production_quality_1.ProductionQuality | None, Field( description='Production quality tier. Seller-declared. Maps to OpenRTB content.prodq (professional=1, prosumer=2, ugc=3).' ), + NoExplicitNull, ] = None talent: Annotated[ list[talent_1.Talent] | None, Field( description='Hosts, recurring cast, creators associated with the collection. Each talent entry may include a brand_url linking to their brand.json identity.' ), + NoExplicitNull, ] = None special: Annotated[ special_1.Special | None, Field( description='When present, this collection is a special — content anchored to a real-world event or occasion. Individual installments may override with their own event context.' ), + NoExplicitNull, ] = None limited_series: Annotated[ limited_series_1.LimitedSeries | None, Field( description='When present, this collection is a limited series — a bounded run with a defined arc, installment count, and end date.' ), + NoExplicitNull, ] = None distribution: Annotated[ list[collection_distribution.CollectionDistribution] | None, Field( description="Where this collection is distributed. Each entry maps the collection to a publisher platform using host property IDs, identifiers, or both. For channel collections this is the carriage map. It is a publisher assertion for discovery, not sales authorization; buyers validate owner-sold carriage against the host publisher's collection-scoped authorized_agents declaration. Collections SHOULD include at least one platform-independent identifier (imdb_id, gracenote_id, eidr_id) when available." ), + NoExplicitNull, ] = None deadline_policy: Annotated[ deadline_policy_1.DeadlinePolicy | None, Field( description="Default deadline rules for installments of this collection. Agents compute absolute deadlines from each installment's scheduled_at and these lead times. Installments with explicit deadlines override this policy. Only meaningful when installments carry scheduled_at; a continuously programmed channel collection without enumerated installments has no deadlines to derive." ), + NoExplicitNull, ] = None related_collections: Annotated[ list[RelatedCollection] | None, Field( description="Relationships to other collections (spin-offs, companion collections, etc.). Each entry references another collection by collection_id within the same publisher's adagents.json." ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/collection_delivery_metrics.py b/src/adcp/types/domains/core/collection_delivery_metrics.py index b18260eec..028ec8c01 100644 --- a/src/adcp/types/domains/core/collection_delivery_metrics.py +++ b/src/adcp/types/domains/core/collection_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/collection_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import Field from . import collection_ref as collection_ref_1 @@ -19,6 +20,7 @@ class CollectionDeliveryMetrics(DeliveryMetrics): Field( description='Current human-readable collection name. Convenience metadata only; collection_ref is stable identity.' ), + NoExplicitNull, ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/collection_distribution.py b/src/adcp/types/domains/core/collection_distribution.py index b89aa5e22..08511d4a7 100644 --- a/src/adcp/types/domains/core/collection_distribution.py +++ b/src/adcp/types/domains/core/collection_distribution.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/collection_distribution.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, model_validator from ..enums import distribution_identifier_type @@ -43,6 +43,7 @@ class CollectionDistribution(AdCPBaseModel): description="Publisher-scoped property IDs from publisher_domain's adagents.json that carry this collection. For owner-sold channel inventory, these identify the host apps without requiring the host to publish channel-owner placement definitions.", min_length=1, ), + NoExplicitNull, ] = None identifiers: Annotated[ list[Identifier] | None, @@ -50,6 +51,7 @@ class CollectionDistribution(AdCPBaseModel): description='Identifiers for the collection on this publisher, including publisher-scoped channel/EPG identifiers and platform-independent metadata identifiers', min_length=1, ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/collection_list_ref.py b/src/adcp/types/domains/core/collection_list_ref.py index 622bb3130..876e2220c 100644 --- a/src/adcp/types/domains/core/collection_list_ref.py +++ b/src/adcp/types/domains/core/collection_list_ref.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/collection_list_ref.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -23,4 +23,5 @@ class CollectionListReference(AdCPBaseModel): Field( description='JWT or other authorization token for accessing the list. Optional if the list is public or caller has implicit access.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/collection_property_delivery_metrics.py b/src/adcp/types/domains/core/collection_property_delivery_metrics.py index ba0a0ff65..5ed6e4294 100644 --- a/src/adcp/types/domains/core/collection_property_delivery_metrics.py +++ b/src/adcp/types/domains/core/collection_property_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/collection_property_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import Field from . import collection_ref as collection_ref_1 @@ -19,6 +20,7 @@ class CollectionPropertyDeliveryMetrics(DeliveryMetrics): collection_name: Annotated[ str | None, Field(description='Current human-readable collection name. Convenience metadata only.'), + NoExplicitNull, ] = None publisher_domain: Annotated[ str, @@ -36,10 +38,12 @@ class CollectionPropertyDeliveryMetrics(DeliveryMetrics): Field( description="Canonical publisher-scoped catalog identity when available. Its publisher_domain MUST equal the row's publisher_domain." ), + NoExplicitNull, ] = None property_name: Annotated[ str | None, Field(description='Current human-readable property name. Convenience metadata only.'), + NoExplicitNull, ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/collection_selection.py b/src/adcp/types/domains/core/collection_selection.py index 334f3ed50..01388a560 100644 --- a/src/adcp/types/domains/core/collection_selection.py +++ b/src/adcp/types/domains/core/collection_selection.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/collection_selection.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import collection_selector @@ -25,7 +25,7 @@ class CollectionSelection1(AdCPBaseModel): min_length=1, ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class CollectionSelection2(AdCPBaseModel): @@ -33,7 +33,7 @@ class CollectionSelection2(AdCPBaseModel): extra='forbid', ) mode: Literal['default'] = 'default' - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None CollectionSelection = Annotated[ diff --git a/src/adcp/types/domains/core/collection_selector.py b/src/adcp/types/domains/core/collection_selector.py index 08e0e9415..6a64c0473 100644 --- a/src/adcp/types/domains/core/collection_selector.py +++ b/src/adcp/types/domains/core/collection_selector.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/collection_selector.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -27,4 +27,5 @@ class CollectionSelector(AdCPBaseModel): description='Collection IDs from the adagents.json collections array. Each ID must match a collection_id declared in that file. Omit to reference all collections declared in that file.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/committed_metric.py b/src/adcp/types/domains/core/committed_metric.py index edb267c8c..dfea203f0 100644 --- a/src/adcp/types/domains/core/committed_metric.py +++ b/src/adcp/types/domains/core/committed_metric.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/committed_metric.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import attribution_methodology as attribution_methodology_1 @@ -24,24 +24,29 @@ class Qualifier(AdCPBaseModel): viewability_standard: Annotated[ viewability_standard_1.ViewabilityStandard | None, Field(description='Viewability standard for this vendor metric, as a closed enum.'), + NoExplicitNull, ] = None completion_source: Annotated[ completion_source_1.CompletionSource | None, Field(description='Completion attestation source for this vendor metric.'), + NoExplicitNull, ] = None attribution_methodology: Annotated[ attribution_methodology_1.AttributionMethodology | None, Field(description='Attribution methodology for this vendor outcome metric.'), + NoExplicitNull, ] = None attribution_window: Annotated[ duration.Duration | None, Field( description='Attribution window for this vendor outcome metric, as a structured duration.' ), + NoExplicitNull, ] = None lift_dimension: Annotated[ lift_dimension_1.LiftDimension | None, Field(description='Brand-lift dimension for this vendor metric.'), + NoExplicitNull, ] = None @@ -54,30 +59,35 @@ class QualifierModel(AdCPBaseModel): Field( description='Viewability standard the seller commits to for this metric. MUST be set when `metric_id` ∈ {`viewable_impressions`, `viewable_rate`, `measurable_impressions`, `viewed_seconds`, `viewed_seconds_percentiles`, `viewed_seconds_histogram`} and the seller commits to a specific standard; absence means the contract leaves the standard unspecified and reconciliation falls back to whatever standard the delivery report carries on its `viewability.standard` field. A distribution whose standard remains unspecified MUST NOT be combined with any other row, including another row with an unspecified standard.' ), + NoExplicitNull, ] = None completion_source: Annotated[ completion_source_1.CompletionSource | None, Field( description="Source of `completion_rate` attestation. MUST be set when `metric_id` is `completion_rate` and the seller commits to a specific source — `seller_attested` when the player/ad server's own completion event is the contract, `vendor_attested` when a third-party measurement vendor (anchored on the matching `performance_standard.vendor` BrandRef) is the contract. Absence means the contract leaves the source unspecified and reconciliation falls back to whatever the delivery report happens to carry." ), + NoExplicitNull, ] = None attribution_methodology: Annotated[ attribution_methodology_1.AttributionMethodology | None, Field( description='How attribution between ad exposure and outcome events was computed. SHOULD be set when `metric_id` is an outcome metric (`conversions`, `conversion_value`, `roas`, `cost_per_acquisition`, `incremental_sales_lift`, `brand_lift`, `foot_traffic`, `conversion_lift`, `brand_search_lift`, `units_sold`, `new_to_brand_rate`, `new_to_brand_units`, `leads`) and the seller commits to a specific methodology. `deterministic_purchase` is the retail-media default; `modeled` covers MMM and clean-room outputs; `probabilistic` and `panel_based` cover their respective methodologies. Two outcome rows with the same `metric_id` and different `attribution_methodology` are not interchangeable and must not be summed.' ), + NoExplicitNull, ] = None attribution_window: Annotated[ duration.Duration | None, Field( description="Time window over which outcome attribution is computed. **Object-valued, not string** — MUST be a structured duration object like `{interval: 14, unit: 'days'}`, NEVER a shorthand string like `'14d'`. SHOULD be set when `metric_id` is an outcome metric and the seller commits to a specific window. Common windows: `{interval: 7, unit: 'days'}`, `{interval: 14, unit: 'days'}`, `{interval: 30, unit: 'days'}`, `{interval: 90, unit: 'days'}`. Two outcome rows with the same `metric_id` and `attribution_methodology` but different `attribution_window` represent the same metric measured over different periods — the join on `(metric_id, qualifier)` keeps them as separate rows so buyers don't accidentally aggregate across windows." ), + NoExplicitNull, ] = None lift_dimension: Annotated[ lift_dimension_1.LiftDimension | None, Field( description='Which dimension of `brand_lift` this row represents — awareness, consideration, favorability, purchase intent, or ad recall. MUST be set when `metric_id` is `brand_lift` and the seller commits to (or is reporting) a specific dimension. Brand-lift vendors (Kantar, Upwave, Cint, DoubleVerify) report each dimension separately with its own sample size and confidence interval; combining them into a single number is a category error. Two `brand_lift` rows under different `lift_dimension` are different surveyed outcomes and must not be summed.' ), + NoExplicitNull, ] = None @@ -97,6 +107,7 @@ class CommittedMetric1(AdCPBaseModel): Field( description="Disambiguates metrics whose definition varies by qualifier. Today carries five keys — `viewability_standard` (MRC vs GroupM viewability), `completion_source` (seller- vs vendor-attested completion), `attribution_methodology` (how attribution was computed for outcome metrics), `attribution_window` (the time window over which outcomes were attributed), and `lift_dimension` (which dimension of brand_lift this row represents — awareness, consideration, etc.). Required when the underlying `metric_id` has multiple incompatible measurement paths AND the seller commits to a specific one. Symmetric on `missing_metrics`. Reserved for additive qualifiers in future minors — schema is closed (`additionalProperties: false`); new keys ship explicitly. **Heterogeneous value types**: qualifier values can be either string enums (`viewability_standard`, `completion_source`, `attribution_methodology`, `lift_dimension`) or structured objects (`attribution_window` is a duration `{interval, unit}`). Consumers MUST dispatch on key name to know value shape; structured-value qualifiers join on canonical (key-sorted) deep equality so `{interval: 14, unit: 'days'}` and `{unit: 'days', interval: 14}` resolve to the same partition. Rate-style metrics (`new_to_brand_rate`, `engagement_rate`, etc.) inherit the methodology of their numerator — when a rate carries `attribution_methodology` qualifier, it applies to the underlying conversions/events being rated." ), + NoExplicitNull, ] = None committed_at: Annotated[ AwareDatetime, @@ -133,12 +144,14 @@ class CommittedMetric2(AdCPBaseModel): Field( description="Optional pin of the vendor's `get_adcp_capabilities.measurement.metrics[].methodology_version` that this commitment is contracted against. When present, the seller commits to reporting values computed under this methodology version; a vendor-side methodology change that alters the value definition is a contract change and SHOULD surface as a new appended entry with its own `committed_at`, never a silent substitution. Absence means the contract does not pin a version and buyers MUST treat methodology changes as untracked. Opaque string — compare for equality, do not parse." ), + NoExplicitNull, ] = None qualifier: Annotated[ Qualifier1 | None, Field( description='Optional qualifier disambiguating commitments to the same vendor metric measured under different methodologies or windows. Same closed key set as standard-scope entries; new keys ship explicitly.' ), + NoExplicitNull, ] = None committed_at: Annotated[ AwareDatetime, diff --git a/src/adcp/types/domains/core/compact_task_input_required.py b/src/adcp/types/domains/core/compact_task_input_required.py index 2973fa742..d2fe0b687 100644 --- a/src/adcp/types/domains/core/compact_task_input_required.py +++ b/src/adcp/types/domains/core/compact_task_input_required.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/compact_task_input_required.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import context as context_1 @@ -18,8 +18,8 @@ class CompactTaskInputRequired(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - reason: Annotated[str | None, Field(max_length=200, min_length=1)] = None - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + reason: Annotated[str | None, Field(max_length=200, min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/compact_task_submitted.py b/src/adcp/types/domains/core/compact_task_submitted.py index 7daf42857..31af1a265 100644 --- a/src/adcp/types/domains/core/compact_task_submitted.py +++ b/src/adcp/types/domains/core/compact_task_submitted.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/compact_task_submitted.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from . import context as context_1 @@ -21,7 +22,7 @@ class CompactTaskSubmitted(AdcpVersionEnvelope, ProtocolEnvelope): ) status: Literal['submitted'] = 'submitted' task_id: Annotated[str, Field(min_length=1)] - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/compact_task_working.py b/src/adcp/types/domains/core/compact_task_working.py index 6e5601ed3..4871accce 100644 --- a/src/adcp/types/domains/core/compact_task_working.py +++ b/src/adcp/types/domains/core/compact_task_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/compact_task_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from . import context as context_1 @@ -17,9 +17,9 @@ class CompactTaskWorking(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - percentage: Annotated[StrictFloat | None, Field(ge=0.0, le=100.0)] = None - current_step: Annotated[str | None, Field(max_length=500)] = None - total_steps: Annotated[SchemaInt | None, Field(ge=1)] = None - step_number: Annotated[SchemaInt | None, Field(ge=1)] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + percentage: Annotated[StrictFloat | None, Field(ge=0.0, le=100.0), NoExplicitNull] = None + current_step: Annotated[str | None, Field(max_length=500), NoExplicitNull] = None + total_steps: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + step_number: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/creative_approval_scope.py b/src/adcp/types/domains/core/creative_approval_scope.py index ed2909a68..f5872c8e8 100644 --- a/src/adcp/types/domains/core/creative_approval_scope.py +++ b/src/adcp/types/domains/core/creative_approval_scope.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_approval_scope.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import creative_approval_status @@ -23,5 +23,7 @@ class ScopedCreativeApproval(AdCPBaseModel): ] approval_status: creative_approval_status.CreativeApprovalStatus rejection_reason: Annotated[ - str | None, Field(description='Human-readable explanation when this scope is rejected.') + str | None, + Field(description='Human-readable explanation when this scope is rejected.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/creative_asset.py b/src/adcp/types/domains/core/creative_asset.py index a198d3825..88706d85e 100644 --- a/src/adcp/types/domains/core/creative_asset.py +++ b/src/adcp/types/domains/core/creative_asset.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_asset.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, RootModel, StrictFloat, StringConstraints, model_validator from ..enums import creative_status @@ -29,11 +29,14 @@ class Input(AdCPBaseModel): ) name: Annotated[str, Field(description='Human-readable name for this preview variant')] macros: Annotated[ - dict[str, str] | None, Field(description='Macro values to apply for this preview') + dict[str, str] | None, + Field(description='Macro values to apply for this preview'), + NoExplicitNull, ] = None context_description: Annotated[ str | None, Field(description='Natural language description of the context for AI-generated content'), + NoExplicitNull, ] = None @@ -58,24 +61,28 @@ class CreativeAsset(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format path retained for older 3.x peers. New creative assets use `format_kind` and optional `format_option_ref`.', ), + NoExplicitNull, ] = None format_kind: Annotated[ str | None, Field( description='Canonical format name this creative targets (e.g., `image`, `video_hosted`). Mutually exclusive with deprecated `format_id`.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='3.1+ format-option path, optional. Structured format option reference matching one of the target product\'s `format_options[]` declarations. Publisher-catalog-backed options match by `{ scope: "publisher", publisher_domain, format_option_id }`; product-local options match by `{ scope: "product", format_option_id }`. Required when the target product has multiple `format_options` entries sharing the same `format_kind`; optional when `format_kind` alone routes the creative to a single declaration. Product-scoped refs require an enclosing target product/package context.' ), + NoExplicitNull, ] = None representation_selection: Annotated[ representation_selection_1.RepresentationSelection | None, Field( description='Readback lineage to the complete CreativeRepresentationSet revision and representation selected before this seller-bound creative was synced.' ), + NoExplicitNull, ] = None assets: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[a-z0-9_]+$')], asset_union.AssetVariant | Assets], @@ -88,21 +95,26 @@ class CreativeAsset(AdCPBaseModel): Field( description='Component-addressed canonical asset maps for `coordinated_placements`. Keys match coordinated component IDs. This field is preserved by creative-library sync and list readback; it MUST be absent for every other format kind.' ), + NoExplicitNull, ] = None inputs: Annotated[ list[Input] | None, Field( description='Preview contexts for generative formats - defines what scenarios to generate previews for' ), + NoExplicitNull, ] = None tags: Annotated[ - list[str] | None, Field(description='User-defined tags for organization and searchability') + list[str] | None, + Field(description='User-defined tags for organization and searchability'), + NoExplicitNull, ] = None status: Annotated[ creative_status.CreativeStatus | None, Field( description="For generative creatives: set to 'approved' to finalize, 'rejected' to request regeneration with updated assets/message. Omit for non-generative creatives (system will set based on processing state)." ), + NoExplicitNull, ] = None weight: Annotated[ StrictFloat | None, @@ -111,6 +123,7 @@ class CreativeAsset(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -118,6 +131,7 @@ class CreativeAsset(AdCPBaseModel): description="Optional structured product-context placement references where this uploaded creative should run when uploading via create_media_buy or update_media_buy. These items always use placement-ref product-context semantics, even when tolerated additional members make an item resemble placement-identity. Receivers match only against the target package's committed placement set; kind and seller_agent are non-authoritative for routing and MUST NOT expand or reinterpret that set. A receiver MUST reject a ref when the enclosing product and committed set do not yield one unambiguous match. New senders SHOULD include publisher_domain for publisher-catalog placements. If omitted, creative runs on all buyer-targetable placements. If both `placement_refs` and legacy `placement_ids` are present, `placement_refs` wins and receivers MUST ignore `placement_ids`. Only used during upload to media buy - not stored in creative library.", min_length=1, ), + NoExplicitNull, ] = None placement_ids: Annotated[ list[str] | None, @@ -126,18 +140,21 @@ class CreativeAsset(AdCPBaseModel): description='Legacy shorthand array of placement IDs where this creative should run when uploading via create_media_buy or update_media_buy. New senders SHOULD use `placement_refs` because placement IDs are publisher-scoped and strings are ambiguous in multi-publisher products. If omitted, creative runs on all buyer-targetable placements. If `placement_refs` is also present, receivers MUST ignore this field. Only used during upload to media buy - not stored in creative library.', min_length=1, ), + NoExplicitNull, ] = None industry_identifiers: Annotated[ list[industry_identifier.IndustryIdentifier] | None, Field( description='Industry-standard or market-specific identifiers for this creative (e.g., Ad-ID, ISCI, Clearcast clock number, IDcrea). In broadcast and scheduled audio/video buying, these identifiers tie the creative to rotation instructions, clearance records, and traffic systems. A creative may have multiple identifiers when different systems reference the same asset. Add a PR to extend creative-identifier-type when another shared identifier scheme needs first-class support.' ), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this creative. Serves as the default provenance for all manifests and assets within this creative. A manifest or asset with its own provenance replaces this object entirely (no field-level merging).' ), + NoExplicitNull, ] = None rights: Annotated[ list[rights_constraint.RightsConstraint] | None, @@ -145,6 +162,7 @@ class CreativeAsset(AdCPBaseModel): description='Rights constraints that MUST survive sync, package assignment, and list readback. Buyer-carried constraints and references do not authorize serving; the seller evaluates them under media_buy.rights_attestations and its adcp.attestations policy.', min_length=1, ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/creative_assignment.py b/src/adcp/types/domains/core/creative_assignment.py index c2b5857fe..9e6f38425 100644 --- a/src/adcp/types/domains/core/creative_assignment.py +++ b/src/adcp/types/domains/core/creative_assignment.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_assignment.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from . import placement_ref @@ -32,12 +32,14 @@ class CreativeAssignment(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None rotation_mode: Annotated[ RotationMode | None, Field( description='Package-scoped rotation policy repeated on assignment rows for wire compatibility. Omission means weighted, preserving existing weight behavior. Every assignment in a package MUST resolve to the same effective mode: weighted uses relative weights; even balances delivery across eligible assignments; sequential cycles through sequence_position in ascending order within each group; random makes an independent uniform selection from eligible assignments. Sellers MUST reject conflicting effective modes rather than choose one by array order.' ), + NoExplicitNull, ] = None group_id: Annotated[ str | None, @@ -45,6 +47,7 @@ class CreativeAssignment(AdCPBaseModel): description="Package-local creative pool identifier. The identifier has no meaning outside this package. Assignments that omit group_id belong to the package's default group; one eligible creative is selected from each applicable group per serving opportunity.", min_length=1, ), + NoExplicitNull, ] = None sequence_position: Annotated[ SchemaInt | None, @@ -52,6 +55,7 @@ class CreativeAssignment(AdCPBaseModel): description="One-based order within the assignment's package-local group. Required only for sequential rotation and unique within that group.", ge=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -59,6 +63,7 @@ class CreativeAssignment(AdCPBaseModel): description="Optional structured product-context refs routing this creative within already-purchased package inventory. These items always use placement-ref product-context semantics, even when tolerated additional members make an item resemble placement-identity. Receivers match only against the package's committed placement set; kind and seller_agent are non-authoritative for routing and MUST NOT expand or reinterpret that set. A receiver MUST reject a ref when the enclosing product and committed set do not yield one unambiguous match. This field never narrows purchased inventory; use targeting_overlay.placement_selection for that. Every ref MUST fall within the package's committed placement selection. New senders SHOULD include publisher_domain for publisher-catalog placements. When omitted, the creative runs across the purchased placements compatible with its format. If both placement_refs and legacy placement_ids are present, placement_refs wins.", min_length=1, ), + NoExplicitNull, ] = None placement_ids: Annotated[ list[str] | None, @@ -67,4 +72,5 @@ class CreativeAssignment(AdCPBaseModel): description='Legacy shorthand routing IDs within already-purchased inventory. This field never narrows purchased inventory; use targeting_overlay.placement_selection. New senders SHOULD use placement_refs because IDs are publisher-scoped. If placement_refs is also present, receivers MUST ignore this field.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/creative_brief.py b/src/adcp/types/domains/core/creative_brief.py index 5a5c4a3f4..826fbc934 100644 --- a/src/adcp/types/domains/core/creative_brief.py +++ b/src/adcp/types/domains/core/creative_brief.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_brief.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -27,11 +27,15 @@ class Messaging(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - headline: Annotated[str | None, Field(description='Primary headline')] = None - tagline: Annotated[str | None, Field(description='Supporting tagline or sub-headline')] = None - cta: Annotated[str | None, Field(description='Call-to-action text')] = None + headline: Annotated[str | None, Field(description='Primary headline'), NoExplicitNull] = None + tagline: Annotated[ + str | None, Field(description='Supporting tagline or sub-headline'), NoExplicitNull + ] = None + cta: Annotated[str | None, Field(description='Call-to-action text'), NoExplicitNull] = None key_messages: Annotated[ - list[str] | None, Field(description='Key messages to communicate in priority order') + list[str] | None, + Field(description='Key messages to communicate in priority order'), + NoExplicitNull, ] = None @@ -50,6 +54,7 @@ class RequiredDisclosure(AdCPBaseModel): Field( description='Where the disclosure should appear within the creative. prominent: clearly visible in the main creative area; footer: at the bottom of visual creatives; audio: spoken in audio/video creatives; subtitle: displayed in the subtitle or closed-caption track; overlay: superimposed on video content; end_card: displayed on video end card; pre_roll: spoken or displayed before main content; companion: in companion ad unit alongside primary creative' ), + NoExplicitNull, ] = None jurisdictions: Annotated[ list[Jurisdiction] | None, @@ -57,12 +62,14 @@ class RequiredDisclosure(AdCPBaseModel): description="Jurisdictions where this disclosure is required. ISO 3166-1 alpha-2 country codes or ISO 3166-2 subdivision codes (e.g., 'US', 'GB', 'US-NJ', 'CA-QC'). If omitted, the disclosure applies to all jurisdictions in the campaign.", min_length=1, ), + NoExplicitNull, ] = None regulation: Annotated[ str | None, Field( description="The regulation or legal authority requiring this disclosure (e.g., 'SEC Rule 156', 'FCA COBS 4.5', 'FDA 21 CFR 202')" ), + NoExplicitNull, ] = None min_duration_ms: Annotated[ SchemaInt | None, @@ -70,18 +77,21 @@ class RequiredDisclosure(AdCPBaseModel): description='Minimum display duration in milliseconds. For video/audio disclosures, how long the disclosure must be visible or audible. For static formats, how long the disclosure must remain on screen before any auto-advance.', ge=1, ), + NoExplicitNull, ] = None language: Annotated[ str | None, Field( description="Language of the disclosure text as a BCP 47 language tag (e.g., 'en', 'fr-CA', 'es'). When omitted, the disclosure is assumed to match the creative's language." ), + NoExplicitNull, ] = None persistence: Annotated[ disclosure_persistence.DisclosurePersistence | None, Field( description='How long the disclosure must persist during content playback or display. When omitted, the creative agent should use the most restrictive persistence mode supported by the target format.' ), + NoExplicitNull, ] = None @@ -95,6 +105,7 @@ class Compliance(AdCPBaseModel): description='Disclosures that must appear in creatives for this campaign. Each disclosure specifies the text, where it should appear, and which jurisdictions require it.', min_length=1, ), + NoExplicitNull, ] = None prohibited_claims: Annotated[ list[str] | None, @@ -102,6 +113,7 @@ class Compliance(AdCPBaseModel): description='Claims that must not appear in creatives for this campaign. Creative agents should ensure generated content avoids these claims.', min_length=1, ), + NoExplicitNull, ] = None @@ -115,32 +127,39 @@ class CreativeBrief(AdCPBaseModel): Field( description='Campaign objective that guides creative tone and call-to-action strategy' ), + NoExplicitNull, ] = None tone: Annotated[ str | None, Field( description="Desired tone for this campaign, modulating the brand's base tone (e.g., 'playful and festive', 'premium and aspirational')" ), + NoExplicitNull, ] = None audience: Annotated[ - str | None, Field(description='Target audience description for this campaign') + str | None, + Field(description='Target audience description for this campaign'), + NoExplicitNull, ] = None territory: Annotated[ str | None, Field(description='Creative territory or positioning the campaign should occupy'), + NoExplicitNull, ] = None messaging: Annotated[ - Messaging | None, Field(description='Messaging framework for the campaign') + Messaging | None, Field(description='Messaging framework for the campaign'), NoExplicitNull ] = None reference_assets: Annotated[ list[reference_asset.ReferenceAsset] | None, Field( description='Visual and strategic reference materials such as mood boards, product shots, example creatives, and strategy documents' ), + NoExplicitNull, ] = None compliance: Annotated[ Compliance | None, Field( description='Regulatory and legal compliance requirements for this campaign. Campaign-specific, regional, and product-based — distinct from brand-level disclaimers in brand.json.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/creative_consumption.py b/src/adcp/types/domains/core/creative_consumption.py index 1028709f6..a3339dc6e 100644 --- a/src/adcp/types/domains/core/creative_consumption.py +++ b/src/adcp/types/domains/core/creative_consumption.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_consumption.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat @@ -17,13 +17,17 @@ class CreativeConsumption(AdCPBaseModel): tokens: Annotated[ SchemaInt | None, Field(description='LLM or generation tokens consumed during creative generation.', ge=0), + NoExplicitNull, ] = None images_generated: Annotated[ - SchemaInt | None, Field(description='Number of images produced during generation.', ge=0) + SchemaInt | None, + Field(description='Number of images produced during generation.', ge=0), + NoExplicitNull, ] = None renders: Annotated[ SchemaInt | None, Field(description='Number of render passes performed (video, animation).', ge=0), + NoExplicitNull, ] = None duration_seconds: Annotated[ StrictFloat | None, @@ -31,4 +35,5 @@ class CreativeConsumption(AdCPBaseModel): description='Processing time billed, in seconds. For compute-time pricing models.', ge=0.0, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/creative_delivery_metrics.py b/src/adcp/types/domains/core/creative_delivery_metrics.py index 3f5ce36b8..e44925b09 100644 --- a/src/adcp/types/domains/core/creative_delivery_metrics.py +++ b/src/adcp/types/domains/core/creative_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import Field, StrictFloat from .delivery_metrics import DeliveryMetrics @@ -20,6 +21,7 @@ class CreativeDeliveryMetrics(DeliveryMetrics): Field( description='Optional human-readable creative name current when the report is generated. Convenience metadata only: names may change and buyers MUST use creative_id as the stable identity.' ), + NoExplicitNull, ] = None weight: Annotated[ StrictFloat | None, @@ -28,6 +30,7 @@ class CreativeDeliveryMetrics(DeliveryMetrics): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/creative_filters.py b/src/adcp/types/domains/core/creative_filters.py index 380bb8dca..47fe442bf 100644 --- a/src/adcp/types/domains/core/creative_filters.py +++ b/src/adcp/types/domains/core/creative_filters.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_filters.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..enums import asset_content_type, creative_status, indicator_type @@ -25,42 +25,52 @@ class CreativeFilters(AdCPBaseModel): description='Filter creatives by owning accounts. Useful for agencies managing multiple client accounts.', min_length=1, ), + NoExplicitNull, ] = None statuses: Annotated[ list[creative_status.CreativeStatus] | None, Field(description='Filter by creative approval statuses', min_length=1), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, Field(description='Filter by creative tags (all tags must match)', min_length=1), + NoExplicitNull, ] = None tags_any: Annotated[ list[str] | None, Field(description='Filter by creative tags (any tag must match)', min_length=1), + NoExplicitNull, ] = None name_contains: Annotated[ str | None, Field(description='Filter by creative names containing this text (case-insensitive)'), + NoExplicitNull, ] = None creative_ids: Annotated[ list[str] | None, Field(description='Filter by specific creative IDs', max_length=100, min_length=1), + NoExplicitNull, ] = None created_after: Annotated[ AwareDatetime | None, Field(description='Filter creatives created after this date (ISO 8601)'), + NoExplicitNull, ] = None created_before: Annotated[ AwareDatetime | None, Field(description='Filter creatives created before this date (ISO 8601)'), + NoExplicitNull, ] = None updated_after: Annotated[ AwareDatetime | None, Field(description='Filter creatives last updated after this date (ISO 8601)'), + NoExplicitNull, ] = None updated_before: Annotated[ AwareDatetime | None, Field(description='Filter creatives last updated before this date (ISO 8601)'), + NoExplicitNull, ] = None assigned_to_packages: Annotated[ list[str] | None, @@ -68,6 +78,7 @@ class CreativeFilters(AdCPBaseModel): description='Filter creatives assigned to any of these packages. Sales-agent-specific — standalone creative agents SHOULD ignore this filter.', min_length=1, ), + NoExplicitNull, ] = None media_buy_ids: Annotated[ list[str] | None, @@ -75,18 +86,21 @@ class CreativeFilters(AdCPBaseModel): description='Filter creatives assigned to any of these media buys. Sales-agent-specific — standalone creative agents SHOULD ignore this filter.', min_length=1, ), + NoExplicitNull, ] = None unassigned: Annotated[ StrictBool | None, Field( description='Filter for unassigned creatives when true, assigned creatives when false. Sales-agent-specific — standalone creative agents SHOULD ignore this filter.' ), + NoExplicitNull, ] = None has_served: Annotated[ StrictBool | None, Field( description='When true, return only creatives that have served at least one impression. When false, return only creatives that have never served.' ), + NoExplicitNull, ] = None indicator_types: Annotated[ list[indicator_type.IndicatorType] | None, @@ -94,6 +108,7 @@ class CreativeFilters(AdCPBaseModel): description='Return creatives with at least one package assignment carrying any requested current indicator type. Values within this field use OR logic; this field composes with other filters using AND logic. Sales-agent-specific: sellers support this filter only when media_buy.relationship_notifications.projection_tasks includes list_creatives. Other agents SHOULD ignore it and apply remaining filters. Buyers needing exact results MUST verify capability support and paginate the outer result set; assignment_projection: matching bounds nested rows.', min_length=1, ), + NoExplicitNull, ] = None concept_ids: Annotated[ list[str] | None, @@ -101,6 +116,7 @@ class CreativeFilters(AdCPBaseModel): description='Filter by creative concept IDs. Concepts group related creatives across sizes and formats (e.g., Flashtalking concepts, Celtra campaign folders, CM360 creative groups).', min_length=1, ), + NoExplicitNull, ] = None format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -109,6 +125,7 @@ class CreativeFilters(AdCPBaseModel): description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Filter legacy named-format creatives. Use `format_kinds` for canonical libraries.', min_length=1, ), + NoExplicitNull, ] = None format_kinds: Annotated[ list[str] | None, @@ -116,6 +133,7 @@ class CreativeFilters(AdCPBaseModel): description='Filter by canonical format kinds. Returns creatives matching any supplied kind.', min_length=1, ), + NoExplicitNull, ] = None asset_types: Annotated[ list[asset_content_type.AssetContentType] | None, @@ -123,16 +141,19 @@ class CreativeFilters(AdCPBaseModel): description="Filter by asset types present on direct object values in the creative's top-level `assets` map. A creative matches when any directly assigned object has an `asset_type` in this array (OR within this field); this filter is conjunctive with every other active filter (AND across fields). Do not inspect array-valued slots or recurse into nested asset fields such as `cards[].media`; broader traversal is deferred. Agents that do not implement this filter MUST ignore it and apply the remaining filters rather than reject the request. Exact asset-type values use the shared AssetContentType vocabulary; `published_post` selects existing-published-post reference creatives without relying on publisher-specific format IDs.", min_length=1, ), + NoExplicitNull, ] = None has_variables: Annotated[ StrictBool | None, Field( description='When true, return only creatives with dynamic variables (DCO). When false, return only static creatives.' ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Vendor-namespaced extension parameters for seller- or platform-specific creative filter criteria not covered by standard fields. Keys MUST be namespaced under a vendor or platform key (e.g., ext.gam, ext.platform_x). Sellers MUST treat all values as untrusted buyer input; avoid unbounded logging or labels, and do not interpolate values into caller-visible error strings, LLM prompts, SQL queries, or system commands without sanitization. Persistent use of an extension key across multiple buyers is a signal to propose standardization.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/creative_localization.py b/src/adcp/types/domains/core/creative_localization.py index ac8e26459..2e009526e 100644 --- a/src/adcp/types/domains/core/creative_localization.py +++ b/src/adcp/types/domains/core/creative_localization.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_localization.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, RootModel, StringConstraints from . import locale_tag, localized_creative_asset @@ -106,6 +106,7 @@ class CreativeLocalization(AdCPBaseModel): max_length=50, min_length=1, ), + NoExplicitNull, ] = None default_locale_variant_id: Annotated[ str, diff --git a/src/adcp/types/domains/core/creative_localization_readback.py b/src/adcp/types/domains/core/creative_localization_readback.py index b8c16b238..dd765809d 100644 --- a/src/adcp/types/domains/core/creative_localization_readback.py +++ b/src/adcp/types/domains/core/creative_localization_readback.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_localization_readback.json -# timestamp: 2026-10-03T00:36:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, RootModel, StringConstraints from . import locale_tag, localized_creative_asset @@ -124,6 +124,7 @@ class CreativeLocalizationReadback(AdCPBaseModel): max_length=50, min_length=1, ), + NoExplicitNull, ] = None variants: Annotated[ list[Variants], diff --git a/src/adcp/types/domains/core/creative_manifest.py b/src/adcp/types/domains/core/creative_manifest.py index 24e57fb11..5f3800c93 100644 --- a/src/adcp/types/domains/core/creative_manifest.py +++ b/src/adcp/types/domains/core/creative_manifest.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_manifest.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, RootModel, StringConstraints, model_validator from . import brand_ref, creative_assets @@ -56,24 +56,28 @@ def _coerce_standalone_assets(cls, data: Any) -> Any: deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format path retained for 3.x compatibility. New manifests use canonical `format_kind` and, when product routing requires it, `format_option_ref`. Mutually exclusive with format_kind.', ), + NoExplicitNull, ] = None format_kind: Annotated[ str | None, Field( description="Canonical 3.2 path. The canonical format name this manifest targets (e.g., `image`, `video_hosted`, `audio_vast`, `seller_rendered_stateful_display`, `coordinated_placements`). Selects the contract against which the seller validates the manifest's assets. Mutually exclusive with deprecated `format_id`." ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='3.1+ format-option path, optional. Structured format option reference matching one of the target product\'s `format_options[]` declarations. Publisher-catalog-backed options match by `{ scope: "publisher", publisher_domain, format_option_id }`; product-local options match by `{ scope: "product", format_option_id }`. Required when the target product carries multiple `format_options` entries sharing the same `format_kind`; optional when `format_kind` alone routes the manifest to a single declaration. Product-scoped refs require an enclosing target product/package context.' ), + NoExplicitNull, ] = None representation_selection: Annotated[ representation_selection_1.RepresentationSelection | None, Field( description='Present when this seller-bound manifest was selected from a CreativeRepresentationSet. Preserves creative, complete revision digest, and selected representation lineage through sync and reporting.' ), + NoExplicitNull, ] = None assets: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[a-z0-9_]+$')], asset_union.AssetVariant | Assets], @@ -86,32 +90,37 @@ def _coerce_standalone_assets(cls, data: Any) -> Any: Field( description="Component-addressed asset maps for `coordinated_placements`. Each key MUST match one `params.components[].component_id`; its value supplies that component's canonical slots. Shared assets remain in top-level `assets` and are injected only into components named by `shared_slots[].consumed_by`. This namespace allows two components to use the same canonical slot name, such as `image_main`, without collision. It MUST be absent for non-`coordinated_placements` manifests." ), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description="Brand identity reference (BrandRef — `domain` plus optional `brand_id` for house-of-brands; plus optional inline `brand_kit_override` for per-creative tweaks where brand.json is missing/stale). When present, the seller pulls master brand identity (logo, palette, fonts, voice, and visual guidelines) from the brand's brand.json automatically; supported fields present in `brand_kit_override` take precedence, and all other master identity fields continue to come from brand.json. Catalogs supply product or item payload. Catalog item asset groups — including an item-level `logo` for a property or franchise — are item identity selected through format field bindings; they do not override brand.json's master logo or other brand identity fields. v2 formats no longer redeclare brand_logo / brand_colors / brand_voice as explicit slots — brand identity is implicit context." ), + NoExplicitNull, ] = None rights: Annotated[ list[rights_constraint.RightsConstraint] | None, Field( description='Rights constraints attached to this creative. Buyer-carried fields are informational until a serving party evaluates an issuer-bound attestation reference under its own policy. Only a verified, unexpired, unrevoked, digest-matched evaluation can support serving authorization; verification_url is never authority.' ), + NoExplicitNull, ] = None industry_identifiers: Annotated[ list[industry_identifier.IndustryIdentifier] | None, Field( description='Industry-standard or market-specific identifiers for this specific manifest (e.g., Ad-ID, ISCI, Clearcast clock number, IDcrea). When present, overrides creative-level identifiers. Use when different format versions of the same source creative have distinct traffic identifiers (e.g., the :15 and :30 cuts, or separate TV and radio versions). Add a PR to extend creative-identifier-type when another shared identifier scheme needs first-class support.' ), + NoExplicitNull, ] = None provenance: Annotated[ provenance_1.Provenance | None, Field( description='Provenance metadata for this creative manifest. Serves as the default provenance for all assets in this manifest. An asset with its own provenance replaces this object entirely (no field-level merging).' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> CreativeManifest: diff --git a/src/adcp/types/domains/core/creative_operation_format_declaration.py b/src/adcp/types/domains/core/creative_operation_format_declaration.py index 939a09e67..7b685a320 100644 --- a/src/adcp/types/domains/core/creative_operation_format_declaration.py +++ b/src/adcp/types/domains/core/creative_operation_format_declaration.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_operation_format_declaration.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, Field, StrictBool from ..enums import channels @@ -26,6 +26,7 @@ class CreativeOperationFormatDeclaration(AdCPBaseModel): Field( description='Stable publisher- or product-declaration identity that this creative operation can satisfy. When publisher_domain is present, this field is required.' ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -33,6 +34,7 @@ class CreativeOperationFormatDeclaration(AdCPBaseModel): description='Publisher namespace for format_option_id when this operation claims compatibility with a publisher declaration.', pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -40,20 +42,25 @@ class CreativeOperationFormatDeclaration(AdCPBaseModel): description='Creative-route processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. These capabilities describe build, validation, or preview processing and do not grant seller production authority.', min_length=1, ), + NoExplicitNull, ] = None - technical_requirements_complete: StrictBool | None = None - display_name: str | None = None - sample_render_url: AnyUrl | None = None - applies_to_channels: list[channels.MediaChannel] | None = None - seller_preference: SellerPreference | None = None - locale_policy: creative_locale_policy.CreativeLocalePolicy | None = None - canonical_formats_only: StrictBool | None = False - experimental: StrictBool | None = False - format_shape: str | None = None + technical_requirements_complete: Annotated[StrictBool | None, NoExplicitNull] = None + display_name: Annotated[str | None, NoExplicitNull] = None + sample_render_url: Annotated[AnyUrl | None, NoExplicitNull] = None + applies_to_channels: Annotated[list[channels.MediaChannel] | None, NoExplicitNull] = None + seller_preference: Annotated[SellerPreference | None, NoExplicitNull] = None + locale_policy: Annotated[creative_locale_policy.CreativeLocalePolicy | None, NoExplicitNull] = ( + None + ) + canonical_formats_only: Annotated[StrictBool | None, NoExplicitNull] = False + experimental: Annotated[StrictBool | None, NoExplicitNull] = False + format_shape: Annotated[str | None, NoExplicitNull] = None v1_format_ref: Annotated[ - list[format_id.FormatReferenceStructuredObject] | None, Field(min_length=1) + list[format_id.FormatReferenceStructuredObject] | None, Field(min_length=1), NoExplicitNull + ] = None + format_schema: Annotated[ + platform_extension_ref.PlatformExtensionReference | None, NoExplicitNull ] = None - format_schema: platform_extension_ref.PlatformExtensionReference | None = None format_kind: str params: Annotated[ dict[str, Any], diff --git a/src/adcp/types/domains/core/creative_policy.py b/src/adcp/types/domains/core/creative_policy.py index e7e2b1f8b..70c2593ac 100644 --- a/src/adcp/types/domains/core/creative_policy.py +++ b/src/adcp/types/domains/core/creative_policy.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_policy.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool from ..enums import co_branding_requirement, landing_page_requirement @@ -21,24 +21,28 @@ class ProvenanceRequirements(AdCPBaseModel): Field( description='When true, the seller requires creatives to include a `digital_source_type` field in their provenance, set to a valid value from the `digital-source-type` enum (not null or absent). Submissions that omit this field are rejected with `PROVENANCE_DIGITAL_SOURCE_TYPE_MISSING`. Supports EU AI Act Art. 50 and CA SB 942 compliance workflows where AI disclosure metadata must be present at the protocol level.' ), + NoExplicitNull, ] = None require_synthetic_depiction: Annotated[ StrictBool | None, Field( description='When true, the seller requires creatives to include an assessed `synthetic_depiction` boolean in their resolved provenance. Both true and false satisfy the requirement; absence means unassessed and is rejected with `PROVENANCE_SYNTHETIC_DEPICTION_MISSING`. This gate requires a declaration only — it does not establish consent, legality, or independent verification.' ), + NoExplicitNull, ] = None require_disclosure_metadata: Annotated[ StrictBool | None, Field( description='When true, the seller requires creatives to include a `disclosure` object in their provenance with `disclosure.required` set to a boolean value (true or false). When `disclosure.required` is true, at least one entry in `disclosure.jurisdictions` is expected. Submissions that omit `disclosure.required` are rejected with `PROVENANCE_DISCLOSURE_MISSING`.' ), + NoExplicitNull, ] = None require_embedded_provenance: Annotated[ StrictBool | None, Field( description='When true, the seller requires creatives to include at least one `embedded_provenance` entry. For pipelines where sidecar metadata is stripped by intermediaries, this ensures provenance data persists through delivery. Submissions that omit `embedded_provenance` are rejected with `PROVENANCE_EMBEDDED_MISSING`.' ), + NoExplicitNull, ] = None @@ -57,6 +61,7 @@ class AcceptedVerifier(AdCPBaseModel): Field( description="Optional canonical `feature_id` the seller will request against this agent (e.g., `encypher.markers_present_v2`). When present, the buyer's `verify_agent.feature_id` SHOULD either match this value or be omitted. When absent, the seller selects a feature from the agent's `governance.creative_features` catalog at evaluation time. Resolves the selector ambiguity that would otherwise let two compliant receivers reach different verdicts." ), + NoExplicitNull, ] = None providers: Annotated[ list[str] | None, @@ -64,6 +69,7 @@ class AcceptedVerifier(AdCPBaseModel): description="Optional `provider` labels this agent verifies (e.g., `['Encypher', 'Digimarc']`). When present, sellers SHOULD only invoke this agent for `embedded_provenance[]` / `watermarks[]` entries whose `provider` field matches one of these labels — letting buyers pre-flight whether their attached evidence is verifiable against the seller's allowlist. When absent, the agent is treated as provider-agnostic.", min_length=1, ), + NoExplicitNull, ] = None @@ -86,12 +92,14 @@ class CreativePolicy(AdCPBaseModel): Field( description='Whether creatives must include provenance metadata. When true, the seller requires buyers to attach provenance declarations to creative submissions. The seller may independently verify claims via get_creative_features.' ), + NoExplicitNull, ] = None provenance_requirements: Annotated[ ProvenanceRequirements | None, Field( description='Structured provenance requirements for creatives. Refines `provenance_required`: when `provenance_required` is true, the fields in this object specify which provenance features the seller requires. When `provenance_required` is false or absent, this object SHOULD be absent; if present, receivers MUST ignore it. Existing seller agents that do not read this object are unaffected; the wire shape does not change for them. Sellers that publish a requirement here MUST enforce it on creative submission: a `sync_creatives` request that omits a required field is rejected with the corresponding `PROVENANCE_*` error code (see error-code.json), and a creative whose provenance claim is contradicted by an independent verification (`get_creative_features` against a governance agent the seller operates or has allowlisted via `accepted_verifiers`) is rejected with `PROVENANCE_CLAIM_CONTRADICTED`. This is the structural-rejection surface; the truth-of-claim surface lives in `get_creative_features`. Field-level requirements are seller-enforced — JSON Schema validation does not check them.' ), + NoExplicitNull, ] = None accepted_verifiers: Annotated[ list[AcceptedVerifier] | None, @@ -99,4 +107,5 @@ class CreativePolicy(AdCPBaseModel): description='Governance agents the seller operates, has allowlisted, or otherwise trusts to verify provenance claims via `get_creative_features`. Buyers attaching a `verify_agent` pointer on `embedded_provenance[]` or `watermarks[]` MUST select an `agent_url` that appears in this list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments) - the buyer is *representing* that they used a verifier the seller will recognize, not asserting unilateral routing. Sellers MUST reject `sync_creatives` submissions whose `verify_agent.agent_url` does not match any entry here with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. The seller is the verifier-of-record: it is the seller, not the buyer, that decides which agent it will call. Publishing the list lets buyers pre-flight their creative shape against `get_products` and lets multiple buyers converge on the same verifier without coordinating with each other.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/creative_representation.py b/src/adcp/types/domains/core/creative_representation.py index 9b2021655..c81567243 100644 --- a/src/adcp/types/domains/core/creative_representation.py +++ b/src/adcp/types/domains/core/creative_representation.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_representation.json -# timestamp: 2026-10-05T14:13:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, model_validator from .creative_manifest import CreativeManifest @@ -26,6 +26,7 @@ class Source(AdCPBaseModel): source_id: Annotated[ str | None, Field(description='Optional immutable source-system identifier for this representation.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/creative_representation_set.py b/src/adcp/types/domains/core/creative_representation_set.py index acb11b8f9..e5e7b515c 100644 --- a/src/adcp/types/domains/core/creative_representation_set.py +++ b/src/adcp/types/domains/core/creative_representation_set.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_representation_set.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import creative_representation, creative_revision_id @@ -50,4 +50,5 @@ class CreativeRepresentationSet(AdCPBaseModel): Field( description='Default provenance for every retained representation and for the selected seller-bound output unless a representation supplies a narrower provenance object.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/creative_variable.py b/src/adcp/types/domains/core/creative_variable.py index 1f53dc90c..f13e1e825 100644 --- a/src/adcp/types/domains/core/creative_variable.py +++ b/src/adcp/types/domains/core/creative_variable.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_variable.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool @@ -40,8 +40,10 @@ class CreativeVariable(AdCPBaseModel): Field( description='Default value used when no dynamic value is provided at serve time. All types are string-encoded: text/image/video/audio/url as literal strings, number as decimal (e.g., "42.99"), boolean as "true"/"false", color as "#RRGGBB", date as ISO 8601 (e.g., "2026-12-25T00:00:00Z").' ), + NoExplicitNull, ] = None required: Annotated[ StrictBool | None, Field(description='Whether this variable must have a value for the creative to serve'), + NoExplicitNull, ] = False diff --git a/src/adcp/types/domains/core/creative_variant.py b/src/adcp/types/domains/core/creative_variant.py index 5b0571c9e..fec223414 100644 --- a/src/adcp/types/domains/core/creative_variant.py +++ b/src/adcp/types/domains/core/creative_variant.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/creative_variant.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import creative_manifest, creative_revision_id @@ -34,14 +34,16 @@ class GenerationContext(AdCPBaseModel): Field( description="Type of context that triggered generation (e.g., 'web_page', 'conversational', 'search', 'app', 'dooh')" ), + NoExplicitNull, ] = None artifact: Annotated[ Artifact | None, Field( description='Reference to the content-standards artifact that provided the generation context. Links this variant to the specific piece of content (article, video, podcast segment, etc.) where the ad was placed.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class CreativeVariant(DeliveryMetrics): @@ -56,6 +58,7 @@ class CreativeVariant(DeliveryMetrics): Field( description='Buyer-authored input revision from which this served execution was derived. Required when that execution derives from a revision-aware creative. Historical reporting may legitimately return an older revision after list_creatives shows a newer current revision. Delivery optimization, generation, transcoding, or representation selection does not mint a revision.' ), + NoExplicitNull, ] = None locale_variant_id: Annotated[ str | None, @@ -64,16 +67,19 @@ class CreativeVariant(DeliveryMetrics): max_length=255, min_length=1, ), + NoExplicitNull, ] = None manifest: Annotated[ creative_manifest.CreativeManifest | None, Field( description='The rendered creative manifest for this variant — the actual output that was served, not the input assets. In 3.2 it carries canonical `format_kind`, optional `format_option_ref`, and the resolved assets (specific headline, image, video, etc. the platform selected or generated). For Tier 2, shows which asset combination was picked. For Tier 3, contains the generated assets which may differ entirely from the input brand identity. Pass to preview_creative to re-render.' ), + NoExplicitNull, ] = None generation_context: Annotated[ GenerationContext | None, Field( description='Input signals that triggered generation of this variant (Tier 3). Describes why the platform created this specific variant. Platforms should provide summarized or anonymized signals rather than raw user input. For web contexts, may include page topic or URL. For conversational contexts, an anonymized content signal. For search, query category or intent. When the content context is managed through AdCP content standards, reference the artifact directly via the artifact field.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/daast_tracker_constraints.py b/src/adcp/types/domains/core/daast_tracker_constraints.py index e28dfb3b8..3d0a9f0f4 100644 --- a/src/adcp/types/domains/core/daast_tracker_constraints.py +++ b/src/adcp/types/domains/core/daast_tracker_constraints.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/daast_tracker_constraints.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import Field, RootModel @@ -46,7 +46,7 @@ class DaastVersions(RootModel[list[daast_version.DaastVersion]]): class DaastTrackerConstraints(AdCPBaseModel): - daast_event: DaastEvent | None = None - target: DaastTarget | None = DaastTarget.linear - offset: DaastOffset | None = None - daast_versions: DaastVersions | None = None + daast_event: Annotated[DaastEvent | None, NoExplicitNull] = None + target: Annotated[DaastTarget | None, NoExplicitNull] = DaastTarget.linear + offset: Annotated[DaastOffset | None, NoExplicitNull] = None + daast_versions: Annotated[DaastVersions | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/daypart_target.py b/src/adcp/types/domains/core/daypart_target.py index a6734ee28..51635049a 100644 --- a/src/adcp/types/domains/core/daypart_target.py +++ b/src/adcp/types/domains/core/daypart_target.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/daypart_target.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from ..enums import day_of_week @@ -46,10 +46,12 @@ class DaypartTarget(AdCPBaseModel): description="Civil-time clock used to evaluate this window. 'inventory_local' evaluates the hours in the seller-assigned local timezone of each inventory unit that can deliver the impression, such as a screen, venue, station, or publisher property; it never means the buyer, account, or server timezone. A concrete IANA timezone identifier (for example, 'America/New_York', 'CET', or 'UTC') evaluates one shared civil-time clock across the targeted inventory. Omission defaults to 'inventory_local'. Buyers that begin with a user or account preference MUST resolve it to a concrete IANA identifier before sending the daypart; 'user_timezone' and 'account_timezone' are not wire values. For each candidate delivery instant, convert the instant into this clock and compare its resulting local day and hour with the half-open window: a skipped DST hour has no matching instants, while both occurrences of a repeated hour match. This delivery clock is independent of reporting_capabilities.timezone.", validate_default=True, ), + NoExplicitNull, ] = 'inventory_local' label: Annotated[ str | None, Field( description="Optional human-readable name for this time window (e.g., 'Morning Drive', 'Prime Time')" ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/deadline_policy.py b/src/adcp/types/domains/core/deadline_policy.py index 1f01c52cd..a153d7bb2 100644 --- a/src/adcp/types/domains/core/deadline_policy.py +++ b/src/adcp/types/domains/core/deadline_policy.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/deadline_policy.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -24,7 +24,9 @@ class MaterialStage(AdCPBaseModel): lead_days: Annotated[ SchemaInt, Field(description='Days before scheduled_at this stage is due', ge=0) ] - label: Annotated[str | None, Field(description='What the seller needs at this stage')] = None + label: Annotated[ + str | None, Field(description='What the seller needs at this stage'), NoExplicitNull + ] = None class DeadlinePolicy(AdCPBaseModel): @@ -34,10 +36,12 @@ class DeadlinePolicy(AdCPBaseModel): booking_lead_days: Annotated[ SchemaInt | None, Field(description='Days before scheduled_at by which the placement must be booked', ge=0), + NoExplicitNull, ] = None cancellation_lead_days: Annotated[ SchemaInt | None, Field(description='Days before scheduled_at by which cancellation is penalty-free', ge=0), + NoExplicitNull, ] = None material_stages: Annotated[ list[MaterialStage] | None, @@ -45,10 +49,12 @@ class DeadlinePolicy(AdCPBaseModel): description='Default material submission stages. Items MUST be in chronological order (earliest due first). Agents compute due_at as: installment.scheduled_at minus lead_days.', min_length=1, ), + NoExplicitNull, ] = None business_days_only: Annotated[ StrictBool | None, Field( description='When true, lead_days counts business days (Mon-Fri) rather than calendar days. Defaults to false.' ), + NoExplicitNull, ] = False diff --git a/src/adcp/types/domains/core/delivery_breakdown_controls.py b/src/adcp/types/domains/core/delivery_breakdown_controls.py index 6dc9fb561..0ca46bb2c 100644 --- a/src/adcp/types/domains/core/delivery_breakdown_controls.py +++ b/src/adcp/types/domains/core/delivery_breakdown_controls.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/delivery_breakdown_controls.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from ..enums import sort_direction as sort_direction_1 @@ -20,16 +20,19 @@ class DeliveryBreakdownControls(AdCPBaseModel): limit: Annotated[ SchemaInt | None, Field(description='Maximum number of rows to return. Defaults to 25.', ge=1), + NoExplicitNull, ] = 25 sort_by: Annotated[ sort_metric.SortMetric | None, Field( description='Metric used to order rows. Falls back to spend when unavailable at this row grain; on fallback sort_direction resets to desc. Rows lacking a value for the applied sort metric order last regardless of direction.' ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description='Direction for sort_by ordering. Sellers MUST apply the requested direction to the applied sort metric; direction has no availability fallback.' ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc diff --git a/src/adcp/types/domains/core/delivery_forecast.py b/src/adcp/types/domains/core/delivery_forecast.py index 03c967c2f..a5f6eb90c 100644 --- a/src/adcp/types/domains/core/delivery_forecast.py +++ b/src/adcp/types/domains/core/delivery_forecast.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/delivery_forecast.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import demographic_system as demographic_system_1 @@ -33,6 +33,7 @@ class DeliveryForecast(AdCPBaseModel): Field( description="How to interpret the points array. 'spend' (default when omitted): points at ascending budget levels. 'availability': total available inventory, budget omitted. 'reach_freq': points at ascending reach/frequency targets. 'weekly'/'daily': metrics are per-period values. 'clicks'/'conversions': points at ascending outcome targets. 'package': each point is a distinct inventory package." ), + NoExplicitNull, ] = None method: Annotated[ forecast_method.ForecastMethod, Field(description='Method used to produce this forecast') @@ -48,6 +49,7 @@ class DeliveryForecast(AdCPBaseModel): Field( description='Measurement system for the demographic field. Ensures buyer and seller agree on demographic notation.' ), + NoExplicitNull, ] = None demographic: Annotated[ str | None, @@ -55,6 +57,7 @@ class DeliveryForecast(AdCPBaseModel): description='Target demographic code within the specified demographic_system. For Nielsen: P18-49, M25-54, W35+. For BARB: ABC1 Adults, 16-34. For AGF: E 14-49.', examples=['P18-49', 'A25-54', 'W35+', 'M18-34'], ), + NoExplicitNull, ] = None measurement_source: Annotated[ str | None, @@ -77,20 +80,23 @@ class DeliveryForecast(AdCPBaseModel): max_length=64, pattern='^[a-z0-9_]+$', ), + NoExplicitNull, ] = None reach_unit: Annotated[ reach_unit_1.ReachUnit | None, Field( description='Unit of measurement for reach and audience_size metrics in this forecast. Required for cross-channel forecast comparison.' ), + NoExplicitNull, ] = None generated_at: Annotated[ - AwareDatetime | None, Field(description='When this forecast was computed') + AwareDatetime | None, Field(description='When this forecast was computed'), NoExplicitNull ] = None valid_until: Annotated[ AwareDatetime | None, Field( description='When this forecast expires. After this time, the forecast should be refreshed. Forecast expiry does not affect proposal executability.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/delivery_metric_aggregate.py b/src/adcp/types/domains/core/delivery_metric_aggregate.py index ac7db5ec2..aea048872 100644 --- a/src/adcp/types/domains/core/delivery_metric_aggregate.py +++ b/src/adcp/types/domains/core/delivery_metric_aggregate.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/delivery_metric_aggregate.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import attribution_methodology as attribution_methodology_1 @@ -26,30 +26,35 @@ class Qualifier(AdCPBaseModel): Field( description='Viewability standard under which this row was measured. MRC and GroupM define materially different thresholds; never sum across standards.' ), + NoExplicitNull, ] = None completion_source: Annotated[ completion_source_1.CompletionSource | None, Field( description='Source of `completion_rate` attestation for this row — `seller_attested` from player/ad server, `vendor_attested` from a third-party measurement vendor. The two paths can yield different rates; never sum across sources.' ), + NoExplicitNull, ] = None attribution_methodology: Annotated[ attribution_methodology_1.AttributionMethodology | None, Field( description='Attribution methodology under which this outcome row was computed (`deterministic_purchase`, `probabilistic`, `panel_based`, `modeled`). Outcome metrics measured under different methodologies represent materially different numbers; never sum across methodologies.' ), + NoExplicitNull, ] = None attribution_window: Annotated[ duration.Duration | None, Field( description="Attribution window for this outcome row. Object-valued duration (`{interval, unit}`), not a shorthand string. Outcome metrics measured over different windows represent the same metric over different time periods; the partition keeps them as separate rows so buyers don't accidentally aggregate." ), + NoExplicitNull, ] = None lift_dimension: Annotated[ lift_dimension_1.LiftDimension | None, Field( description='Brand-lift dimension this row represents (awareness, consideration, favorability, purchase intent, or ad recall). Required when `metric_id` is `brand_lift`. Each dimension is a separate surveyed outcome with its own sample size and confidence interval; never sum across dimensions.' ), + NoExplicitNull, ] = None @@ -72,6 +77,7 @@ class Field0(AdCPBaseModel): Field( description="Qualifier keys disambiguating this row from sibling rows under the same `metric_id`. Symmetric with `committed_metrics.qualifier` today; expected to diverge in future minors as transparency disclosures buyers don't commit to ship delivery-only. Closed (`additionalProperties: false`) — new qualifier keys ship explicitly." ), + NoExplicitNull, ] = None value: Annotated[ StrictFloat, @@ -85,9 +91,12 @@ class Field0(AdCPBaseModel): description='Coverage denominator for verification metrics (e.g., `viewable_rate`). Buyers compute coverage as `measurable_impressions / impressions` from the partition.', ge=0.0, ), + NoExplicitNull, ] = None viewable_impressions: Annotated[ - StrictFloat | None, Field(description='Component for `viewable_rate` (numerator).', ge=0.0) + StrictFloat | None, + Field(description='Component for `viewable_rate` (numerator).', ge=0.0), + NoExplicitNull, ] = None impressions: Annotated[ StrictFloat | None, @@ -95,10 +104,12 @@ class Field0(AdCPBaseModel): description='Component for rate metrics whose denominator is total impressions (e.g., `completion_rate`, `engagement_rate`).', ge=0.0, ), + NoExplicitNull, ] = None completed_views: Annotated[ StrictFloat | None, Field(description='Component for `completion_rate` (numerator).', ge=0.0), + NoExplicitNull, ] = None spend: Annotated[ StrictFloat | None, @@ -106,17 +117,22 @@ class Field0(AdCPBaseModel): description='Component for cost-per metrics (denominator-ish; the cost half of the ratio).', ge=0.0, ), + NoExplicitNull, ] = None conversions: Annotated[ StrictFloat | None, Field(description='Component for `cost_per_acquisition` and ROAS-family metrics.', ge=0.0), + NoExplicitNull, ] = None conversion_value: Annotated[ - StrictFloat | None, Field(description='Component for `roas` (numerator).', ge=0.0) + StrictFloat | None, + Field(description='Component for `roas` (numerator).', ge=0.0), + NoExplicitNull, ] = None clicks: Annotated[ StrictFloat | None, Field(description='Component for `cost_per_click` and click-rate metrics.', ge=0.0), + NoExplicitNull, ] = None @@ -133,30 +149,35 @@ class Qualifier3(AdCPBaseModel): Field( description='Viewability standard under which this row was measured. MRC and GroupM define materially different thresholds; never sum across standards.' ), + NoExplicitNull, ] = None completion_source: Annotated[ completion_source_1.CompletionSource | None, Field( description='Attestation source for a vendor completion-style metric — seller_attested from player/ad server, vendor_attested from an independent measurement path. Applicability is defined by the vendor metric; never sum across sources.' ), + NoExplicitNull, ] = None attribution_methodology: Annotated[ attribution_methodology_1.AttributionMethodology | None, Field( description='Attribution methodology under which this outcome row was computed (`deterministic_purchase`, `probabilistic`, `panel_based`, `modeled`). Outcome metrics measured under different methodologies represent materially different numbers; never sum across methodologies.' ), + NoExplicitNull, ] = None attribution_window: Annotated[ duration.Duration | None, Field( description="Attribution window for this outcome row. Object-valued duration (`{interval, unit}`), not a shorthand string. Outcome metrics measured over different windows represent the same metric over different time periods; the partition keeps them as separate rows so buyers don't accidentally aggregate." ), + NoExplicitNull, ] = None lift_dimension: Annotated[ lift_dimension_1.LiftDimension | None, Field( description='Lift dimension this row represents (awareness, consideration, favorability, purchase intent, or ad recall) for vendor lift-style metrics. Applicability is defined by the vendor metric; each dimension is a separate surveyed outcome and rows under different dimensions must not be summed.' ), + NoExplicitNull, ] = None @@ -183,6 +204,7 @@ class DeliveryMetricAggregate2(AdCPBaseModel): Field( description='Optional qualifier keys disambiguating this vendor-metric row from sibling rows under the same (vendor, metric_id) — e.g., attribution_window on a vendor outcome metric. Same closed key set as the standard branch; new keys ship explicitly.' ), + NoExplicitNull, ] = None value: Annotated[ StrictFloat, @@ -196,6 +218,7 @@ class DeliveryMetricAggregate2(AdCPBaseModel): description="Coverage denominator — vendor measurement is rarely 100% of delivery (only impressions where the vendor's SDK fired or panel matched). Buyers compute coverage as `measurable_impressions / impressions`. Same convention as `vendor_metric_value.measurable_impressions`.", ge=0.0, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/delivery_metrics.py b/src/adcp/types/domains/core/delivery_metrics.py index ebd4f2f33..352bb3820 100644 --- a/src/adcp/types/domains/core/delivery_metrics.py +++ b/src/adcp/types/domains/core/delivery_metrics.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/delivery_metrics.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import action_source as action_source_1 @@ -25,18 +25,18 @@ class Kind(StrEnum): class QuartileData(AdCPBaseModel): - q1_views: Annotated[StrictFloat | None, Field(description='25% completion views', ge=0.0)] = ( - None - ) - q2_views: Annotated[StrictFloat | None, Field(description='50% completion views', ge=0.0)] = ( - None - ) - q3_views: Annotated[StrictFloat | None, Field(description='75% completion views', ge=0.0)] = ( - None - ) - q4_views: Annotated[StrictFloat | None, Field(description='100% completion views', ge=0.0)] = ( - None - ) + q1_views: Annotated[ + StrictFloat | None, Field(description='25% completion views', ge=0.0), NoExplicitNull + ] = None + q2_views: Annotated[ + StrictFloat | None, Field(description='50% completion views', ge=0.0), NoExplicitNull + ] = None + q3_views: Annotated[ + StrictFloat | None, Field(description='75% completion views', ge=0.0), NoExplicitNull + ] = None + q4_views: Annotated[ + StrictFloat | None, Field(description='100% completion views', ge=0.0), NoExplicitNull + ] = None class VenueBreakdownItem(AdCPBaseModel): @@ -44,19 +44,24 @@ class VenueBreakdownItem(AdCPBaseModel): extra='allow', ) venue_id: Annotated[str, Field(description='Venue identifier')] - venue_name: Annotated[str | None, Field(description='Human-readable venue name')] = None + venue_name: Annotated[ + str | None, Field(description='Human-readable venue name'), NoExplicitNull + ] = None venue_type: Annotated[ str | None, Field(description="Venue type (e.g., 'airport', 'transit', 'retail', 'billboard')"), + NoExplicitNull, ] = None impressions: Annotated[ SchemaInt, Field(description='Impressions delivered at this venue', ge=0) ] - loop_plays: Annotated[SchemaInt | None, Field(description='Loop plays at this venue', ge=0)] = ( - None - ) + loop_plays: Annotated[ + SchemaInt | None, Field(description='Loop plays at this venue', ge=0), NoExplicitNull + ] = None screens_used: Annotated[ - SchemaInt | None, Field(description='Number of screens used at this venue', ge=0) + SchemaInt | None, + Field(description='Number of screens used at this venue', ge=0), + NoExplicitNull, ] = None @@ -65,13 +70,17 @@ class DoohMetrics1(AdCPBaseModel): extra='allow', ) loop_plays: Annotated[ - SchemaInt | None, Field(description='Number of times ad played in rotation', ge=0) + SchemaInt | None, + Field(description='Number of times ad played in rotation', ge=0), + NoExplicitNull, ] = None screens_used: Annotated[ - SchemaInt | None, Field(description='Number of unique screens displaying the ad', ge=0) + SchemaInt | None, + Field(description='Number of unique screens displaying the ad', ge=0), + NoExplicitNull, ] = None screen_time_seconds: Annotated[ - SchemaInt | None, Field(description='Total display time in seconds', ge=0) + SchemaInt | None, Field(description='Total display time in seconds', ge=0), NoExplicitNull ] = None sov_achieved: Annotated[ StrictFloat | None, @@ -80,15 +89,19 @@ class DoohMetrics1(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None calculation_notes: Annotated[ str | None, Field( description="Per-row supplementary methodology notes for DOOH impression calculation (e.g., 'rotation-based; 6-second slot weighted by 70% audience overlap'). Free-form prose for context that doesn't fit the structured measurement-vendor surface. Canonical methodology declarations belong on the measurement vendor's `get_adcp_capabilities.measurement.metrics[]` block where they're discoverable once and inherited across delivery rows; this field is for row-specific context (a particular daypart's calculation, a venue-mix exception) rather than the seller's general methodology." ), + NoExplicitNull, ] = None venue_breakdown: Annotated[ - list[VenueBreakdownItem] | None, Field(description='Per-venue performance breakdown') + list[VenueBreakdownItem] | None, + Field(description='Per-venue performance breakdown'), + NoExplicitNull, ] = None @@ -125,6 +138,7 @@ class Panel(AdCPBaseModel): Field( description="Human-readable location description (e.g., 'I-95 N of Exit 12, right-hand read')" ), + NoExplicitNull, ] = None @@ -176,6 +190,7 @@ class ViewedSecondsHistogramItem(AdCPBaseModel): description='Exclusive upper bound of this duration bucket, in seconds. MUST be greater than `lower_bound_seconds`. Omit only on the final bucket to represent an unbounded upper range.', ge=0.0, ), + NoExplicitNull, ] = None impressions: Annotated[ SchemaInt, @@ -198,6 +213,7 @@ class ByActionSourceItem(AdCPBaseModel): Field( description='Event source that produced these conversions (for disambiguation when multiple event sources are configured)' ), + NoExplicitNull, ] = None count: Annotated[ StrictFloat, Field(description='Number of conversions from this action source', ge=0.0) @@ -205,6 +221,7 @@ class ByActionSourceItem(AdCPBaseModel): value: Annotated[ StrictFloat | None, Field(description='Total monetary value of conversions from this action source', ge=0.0), + NoExplicitNull, ] = None @@ -222,10 +239,13 @@ class ByEventTypeItem(AdCPBaseModel): Field( description='Event source that produced these conversions (for disambiguation when multiple event sources are configured)' ), + NoExplicitNull, ] = None count: Annotated[StrictFloat, Field(description='Number of events of this type', ge=0.0)] value: Annotated[ - StrictFloat | None, Field(description='Total monetary value of events of this type', ge=0.0) + StrictFloat | None, + Field(description='Total monetary value of events of this type', ge=0.0), + NoExplicitNull, ] = None @@ -244,6 +264,7 @@ class ReachWindow(AdCPBaseModel): Field( description='Duration of the measurement window. REQUIRED when `kind` is `period` or `rolling` — declares the snapshot length (e.g., `{"interval": 1, "unit": "days"}` for a daily snapshot) or the trailing-window length (e.g., `{"interval": 7, "unit": "days"}` for trailing-7-day rolling reach). When `kind` is `cumulative`, this field is implicit (campaign-to-date) and SHOULD be omitted.' ), + NoExplicitNull, ] = None @@ -273,6 +294,7 @@ class TimeBasedView(AdCPBaseModel): Field( description="Viewability standard governing the in-view clock for this entry. RECOMMENDED when basis is 'in_view' (MRC and GroupM thresholds differ); not applicable to play_time entries." ), + NoExplicitNull, ] = None @@ -288,6 +310,7 @@ class Posting(AdCPBaseModel): Field( description='What the record attests: initial posting, rotary rotation to a new face, repair/reposting after damage, or removal at flight end' ), + NoExplicitNull, ] = EventType.posted occurred_at: Annotated[ date, @@ -300,6 +323,7 @@ class Posting(AdCPBaseModel): Field( description='Evidence artifact for this event (completion photograph); capture time and location ride the artifact' ), + NoExplicitNull, ] = None @@ -312,24 +336,31 @@ class OohMetrics(AdCPBaseModel): Field( description="Panels (faces) covered by this row. A panel commonly carries multiple identifiers at once — OOH contracts key line items on the measurement-currency panel number AND the operator's own panel number together." ), + NoExplicitNull, ] = None posting_period_start: Annotated[ - date | None, Field(description='First in-charge date of the posting period this row covers') + date | None, + Field(description='First in-charge date of the posting period this row covers'), + NoExplicitNull, ] = None posting_period_end: Annotated[ - date | None, Field(description='Last date of the posting period this row covers') + date | None, + Field(description='Last date of the posting period this row covers'), + NoExplicitNull, ] = None average_posted_date: Annotated[ date | None, Field( description="Average actual posting date across the row's units — the date the display term runs from under OAAA Bulletin §2.2 / Poster §3.2(a) when materials were timely. Read with materials_timely to know whether that rule was in force." ), + NoExplicitNull, ] = None materials_timely: Annotated[ StrictBool | None, Field( description="Seller's assertion that the buyer delivered acceptable materials by the contractual deadline, which determines whether the §2.2 posting-completion rule (term runs from average posting date) applied. An assertion about the buyer's delivery, not a derived value — it is not recomputable from postings[] evidence, and is the one field in this block that isn't checkable against it." ), + NoExplicitNull, ] = None share_of_voice_contracted: Annotated[ StrictFloat | None, @@ -338,6 +369,7 @@ class OohMetrics(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None illuminated_hours: Annotated[ StrictFloat | None, @@ -346,6 +378,7 @@ class OohMetrics(AdCPBaseModel): ge=0.0, le=24.0, ), + NoExplicitNull, ] = None estimated_impressions: Annotated[ SchemaInt | None, @@ -353,24 +386,28 @@ class OohMetrics(AdCPBaseModel): description="Modeled audience impressions for the panels and period in this row. This is the channel's delivery number — there is no event-counted alternative. The methodology tier MUST be declared in estimation_basis; provider identity is declared in the row-level measurement_source; the billing vendor is declared in measurement_terms.billing_measurement. The row's top-level impressions SHOULD carry the same value so cross-channel aggregation works without channel-specific logic.", ge=0, ), + NoExplicitNull, ] = None estimation_basis: Annotated[ EstimationBasis | None, Field( description="Methodology tier of estimated_impressions: currency_measured (an audience measurement currency's estimate for the panel and period — Geopath, Route, MOVE, COMMB — with the provider named in the row-level measurement_source) or seller_modeled (the seller's own model — the honest fallback for markets without a measurement currency). Tier, not provider: currencies change over time, so provider identity is data on measurement_source, not values in this enum." ), + NoExplicitNull, ] = None postings: Annotated[ list[Posting] | None, Field( description='Posting records — the seller-attested settlement artifact proving each panel was posted for the period. Industry convention (OAAA model contracts, Proof of Performance §3.5): photo evidence per bulletin within five calendar days of posting and again after each rotary rotation; one representative close-up photograph per creative design variation for poster showings (no deadline attached). Distinct from the §2.2 posting-completion obligation (posting within five business days of the scheduled date), which governs average_posted_date, not evidence.' ), + NoExplicitNull, ] = None calculation_notes: Annotated[ str | None, Field( description="Row-specific methodology context that doesn't fit the structured fields (e.g., a partial-period proration or a market-specific estimate adjustment). Same role as dooh_metrics.calculation_notes — canonical methodology declarations belong on the measurement vendor's discoverable surfaces, not here." ), + NoExplicitNull, ] = None @@ -383,6 +420,7 @@ class Viewability1(AdCPBaseModel): Field( description="Vendor that produced these viewability values. Optional but RECOMMENDED so the row is self-describing — buyer agents reading delivery in isolation can attribute the numbers without joining back to `package.committed_metrics` or `package.performance_standards`. The vendor's `brand.json` `agents[type='measurement']` is the discovery anchor; the metric definitions live on the agent's `get_adcp_capabilities.measurement.metrics[]` block. Same shape as `vendor_metric_value.vendor` for symmetry across vendor-attested surfaces." ), + NoExplicitNull, ] = None measurable_impressions: Annotated[ StrictFloat | None, @@ -390,6 +428,7 @@ class Viewability1(AdCPBaseModel): description='Impressions where viewability could be measured. Excludes environments without measurement capability (e.g., non-Intersection Observer browsers, certain app environments). Coverage denominator for `viewable_rate`, `viewed_seconds`, and both viewed-seconds distributions — every duration statistic is computed over this same measurable population.', ge=0.0, ), + NoExplicitNull, ] = None viewable_impressions: Annotated[ StrictFloat | None, @@ -397,6 +436,7 @@ class Viewability1(AdCPBaseModel): description='Impressions that met the viewability threshold defined by the measurement standard.', ge=0.0, ), + NoExplicitNull, ] = None viewable_rate: Annotated[ StrictFloat | None, @@ -405,6 +445,7 @@ class Viewability1(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None viewed_seconds: Annotated[ StrictFloat | None, @@ -412,12 +453,14 @@ class Viewability1(AdCPBaseModel): description="Average in-view duration per measurable impression, in seconds. Reporting-side counterpart to the `viewed_seconds` optimization metric in `optimization-goal.json`. Computed over `measurable_impressions`, not total impressions — the same denominator as `viewable_rate`. The viewability `standard` governs the threshold (e.g., MRC's 50% pixels for 1s display / 2s video) that defines when an impression is in view and therefore when the clock is running. Sellers reporting against a `viewed_seconds` optimization goal MUST populate this field.", ge=0.0, ), + NoExplicitNull, ] = None viewed_seconds_percentiles: Annotated[ ViewedSecondsPercentiles | None, Field( description='Percentile summary of the per-impression in-view durations whose arithmetic mean is reported in `viewed_seconds`. This object MUST use the same reporting row, measurement vendor, viewability `standard`, and `measurable_impressions` population as `viewed_seconds`; sellers MUST omit it when `measurable_impressions` is zero. Percentiles use the nearest-rank definition: sort the N observed durations in ascending order and select rank `ceil(p × N)` (one-based) for percentile p. Values MUST be non-decreasing from p25 through p95. The structured metric identity `viewed_seconds_percentiles` makes this optional surface discoverable and requestable; it is not sortable and the nested object remains the canonical carrier.' ), + NoExplicitNull, ] = None viewed_seconds_histogram: Annotated[ list[ViewedSecondsHistogramItem] | None, @@ -425,12 +468,14 @@ class Viewability1(AdCPBaseModel): description='Bucketed counts of the per-impression in-view durations whose arithmetic mean is reported in `viewed_seconds`. Buckets MUST be ordered by ascending lower bound, MUST NOT overlap, and MUST partition every impression in the same `measurable_impressions` population exactly once; therefore the sum of `impressions` MUST equal `measurable_impressions`. Buckets need not be contiguous: a gap between consecutive bucket boundaries is permitted when no impressions fall within that range — the sum constraint enforces this implicitly, and validators MUST NOT independently require contiguity. Each bucket is half-open `[lower_bound_seconds, upper_bound_seconds)`; only the final bucket MAY omit `upper_bound_seconds`, representing an unbounded upper range. Sellers choose boundaries, but buyers MUST combine histograms only when the complete boundary sequence, measurement vendor, and viewability `standard` match. The structured metric identity `viewed_seconds_histogram` makes this optional surface discoverable and requestable; it is not sortable and this nested array remains the canonical carrier.', min_length=1, ), + NoExplicitNull, ] = None standard: Annotated[ viewability_standard.ViewabilityStandard | None, Field( description='Viewability measurement standard applied to these metrics. Governs the in-view threshold for `viewable_rate`, `viewed_seconds`, and both viewed-seconds distributions.' ), + NoExplicitNull, ] = None @@ -443,13 +488,18 @@ class DeliveryMetrics(AdCPBaseModel): extra='allow', ) impressions: Annotated[ - StrictFloat | None, Field(description='Impressions delivered', ge=0.0) + StrictFloat | None, Field(description='Impressions delivered', ge=0.0), NoExplicitNull + ] = None + spend: Annotated[ + StrictFloat | None, Field(description='Amount spent', ge=0.0), NoExplicitNull + ] = None + clicks: Annotated[ + StrictFloat | None, Field(description='Total clicks', ge=0.0), NoExplicitNull ] = None - spend: Annotated[StrictFloat | None, Field(description='Amount spent', ge=0.0)] = None - clicks: Annotated[StrictFloat | None, Field(description='Total clicks', ge=0.0)] = None ctr: Annotated[ StrictFloat | None, Field(description='Click-through rate (clicks/impressions)', ge=0.0, le=1.0), + NoExplicitNull, ] = None views: Annotated[ StrictFloat | None, @@ -457,6 +507,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Content engagements counted toward the billable view threshold. For video this is a platform-defined view event (e.g., 30 seconds or video midpoint); for audio/podcast it is a stream start; for other formats it follows the pricing model's view definition. When the package uses CPV pricing, spend = views × rate.", ge=0.0, ), + NoExplicitNull, ] = None completed_views: Annotated[ StrictFloat | None, @@ -464,6 +515,7 @@ class DeliveryMetrics(AdCPBaseModel): description='Video/audio completions. When the package has a completed_views optimization goal with view_duration_seconds, completions are counted at that threshold rather than 100% completion.', ge=0.0, ), + NoExplicitNull, ] = None completion_rate: Annotated[ StrictFloat | None, @@ -479,6 +531,7 @@ class DeliveryMetrics(AdCPBaseModel): description='Total conversions attributed to this delivery. When by_event_type is present, this equals the sum of all by_event_type[].count entries.', ge=0.0, ), + NoExplicitNull, ] = None conversion_value: Annotated[ StrictFloat | None, @@ -486,6 +539,7 @@ class DeliveryMetrics(AdCPBaseModel): description='Total monetary value of attributed conversions (in the reporting currency)', ge=0.0, ), + NoExplicitNull, ] = None commissionable_value: Annotated[ StrictFloat | None, @@ -493,13 +547,17 @@ class DeliveryMetrics(AdCPBaseModel): description='Settled portion of attributed conversion value eligible for revenue-share commission, in the reporting currency. For revenue_share pricing, spend = round_currency(commissionable_value × commission_rate). This is distinct from conversion_value because taxes, shipping, discounts, returns, cancellations, or ineligible items may be excluded under the agreed commission basis.', ge=0.0, ), + NoExplicitNull, ] = None roas: Annotated[ StrictFloat | None, Field(description='Return on ad spend (conversion_value / spend)', ge=0.0), + NoExplicitNull, ] = None cost_per_acquisition: Annotated[ - StrictFloat | None, Field(description='Cost per conversion (spend / conversions)', ge=0.0) + StrictFloat | None, + Field(description='Cost per conversion (spend / conversions)', ge=0.0), + NoExplicitNull, ] = None new_to_brand_rate: Annotated[ StrictFloat | None, @@ -508,6 +566,7 @@ class DeliveryMetrics(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None leads: Annotated[ StrictFloat | None, @@ -515,6 +574,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Leads generated (convenience alias for by_event_type where event_type='lead')", ge=0.0, ), + NoExplicitNull, ] = None incremental_sales_lift: Annotated[ StrictFloat | None, @@ -522,6 +582,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Incremental sales lift attributed to the campaign — sales above the control/holdout baseline. Reported as a fraction (0.15 = 15% lift) or as an absolute value depending on seller convention. The seller's `attribution_methodology` qualifier (typically `deterministic_purchase` or `modeled`) and `attribution_window` qualifier on the matching `committed_metrics` entry disambiguate the methodology and window.", ge=0.0, ), + NoExplicitNull, ] = None brand_lift: Annotated[ StrictFloat | None, @@ -529,6 +590,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Brand lift — measured change in a brand metric (awareness, consideration, favorability, purchase intent, or ad recall) attributed to the campaign. Typically panel-based or survey-based. Reported as a fraction (0.05 = 5% lift). **Multidimensional in production** — Kantar, Upwave, Cint, DV all report each dimension separately with its own sample size and confidence interval. The dimension flows through `qualifier.lift_dimension` on `committed_metrics` / `by_package[].metric_values` (`awareness` | `consideration` | `favorability` | `purchase_intent` | `ad_recall`); rows under different dimensions are different surveyed outcomes and must not be combined. Use `attribution_methodology: 'panel_based'` qualifier when the underlying methodology is a panel.", ge=0.0, ), + NoExplicitNull, ] = None foot_traffic: Annotated[ StrictFloat | None, @@ -536,6 +598,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Store visits attributed to ad exposure. Count of incremental visits over baseline. Typically uses location-data panel methodology (`attribution_methodology: 'panel_based'`) or deterministic loyalty-card match (`attribution_methodology: 'deterministic_purchase'`).", ge=0.0, ), + NoExplicitNull, ] = None conversion_lift: Annotated[ StrictFloat | None, @@ -543,6 +606,7 @@ class DeliveryMetrics(AdCPBaseModel): description='Incremental conversions attributed to the campaign — conversions above the control/holdout baseline. Reported as a fraction (0.10 = 10% lift) or as an absolute count depending on seller convention. Distinct from `conversions` (raw count of attributed conversions); conversion_lift requires a control group and an incrementality methodology.', ge=0.0, ), + NoExplicitNull, ] = None brand_search_lift: Annotated[ StrictFloat | None, @@ -550,6 +614,7 @@ class DeliveryMetrics(AdCPBaseModel): description='Lift in brand search query volume attributed to the campaign — measured via search-data partnerships (Google, Microsoft) or survey methodology. Reported as a fraction (0.20 = 20% lift in branded search).', ge=0.0, ), + NoExplicitNull, ] = None plays: Annotated[ StrictFloat | None, @@ -557,6 +622,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Number of times the ad creative was displayed or played on DOOH or broadcast inventory. Raw play count before any impression multiplier is applied. Mirrors `forecastable-metric.json`'s `plays` token for forecast↔delivery reconciliation. Distinct from `dooh_metrics.loop_plays` (scheduled-rotation count) and from `impressions` (multiplied audience figure).", ge=0.0, ), + NoExplicitNull, ] = None measurement_source: Annotated[ str | None, @@ -580,15 +646,19 @@ class DeliveryMetrics(AdCPBaseModel): max_length=64, pattern='^[a-z0-9_]+$', ), + NoExplicitNull, ] = None by_event_type: Annotated[ list[ByEventTypeItem] | None, Field( description='Conversion metrics broken down by event type. Spend-derived metrics (ROAS, CPA) are only available at the package/totals level since spend cannot be attributed to individual event types.' ), + NoExplicitNull, ] = None grps: Annotated[ - StrictFloat | None, Field(description='Gross Rating Points delivered (for CPP)', ge=0.0) + StrictFloat | None, + Field(description='Gross Rating Points delivered (for CPP)', ge=0.0), + NoExplicitNull, ] = None reach: Annotated[ StrictFloat | None, @@ -596,18 +666,21 @@ class DeliveryMetrics(AdCPBaseModel): description='Unique reach in the units specified by reach_unit. When reach_unit is omitted, units are unspecified — do not compare reach values across packages or media buys without a common reach_unit. The measurement window for this value is declared in `reach_window`; when `reach_window` is omitted, the window is unspecified and buyers MUST NOT sum reach across reports (the value MAY be a daily snapshot, a cumulative total, or something else).', ge=0.0, ), + NoExplicitNull, ] = None reach_unit: Annotated[ reach_unit_1.ReachUnit | None, Field( description='Unit of measurement for the reach field. Aligns with the reach_unit declared on optimization goals and delivery forecasts. Required when reach is present to enable cross-platform comparison.' ), + NoExplicitNull, ] = None reach_window: Annotated[ ReachWindow | None, Field( description='Measurement window for the reported `reach` and `frequency` values in this row. Declares whether the values are a per-period snapshot, a trailing rolling window, or cumulative-to-date — without this declaration, buyers summing `reach` across rows (e.g., daily delivery reports) can silently double-count audiences. Sellers SHOULD populate this whenever `reach` is present.' ), + NoExplicitNull, ] = None frequency: Annotated[ StrictFloat | None, @@ -615,6 +688,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Average frequency per reach unit, measured over the window declared in `reach_window`. When `reach_unit` is 'households', this is average exposures per household; when 'accounts', per logged-in account; etc. When `reach_window` is omitted, the window is unspecified — buyers MUST NOT compare or average frequency values across rows.", ge=0.0, ), + NoExplicitNull, ] = None quartile_data: Annotated[ QuartileData | None, @@ -627,22 +701,26 @@ class DeliveryMetrics(AdCPBaseModel): Field( description="Time-threshold video view counts. Each entry reports views that met a continuous duration threshold under a stated basis, rather than a completion percentage (percentage-based completion is quartile_data). Thresholds of 2 and 6 seconds are RECOMMENDED cross-platform reporting points; any seller-defined threshold is permitted. One entry per (threshold_seconds, basis) pair per reporting period — sellers MUST de-duplicate before emission and MUST NOT emit the same pair twice; buyers MAY treat duplicate pairs as a seller-side conformance bug. Entries under different bases are different metrics and MUST NOT be summed (see view-threshold-basis). Primarily an autoplay/skippable-video metric (social, olv, in-feed video); completion metrics remain the currency for lean-back CTV/cinema inventory. Distinct from `views` (the single billable-threshold scalar) and from `viewability.viewed_seconds` (average in-view duration, not a threshold count). Array entries are not individually sortable in breakdown sort_by. Disclosure-grade surface: (threshold_seconds, basis) is not part of the committed-metric qualifier vocabulary, so a `committed_metrics` entry for `time_based_views` contracts the array's presence, not specific thresholds." ), + NoExplicitNull, ] = None dooh_metrics: Annotated[ DoohMetrics1 | None, Field(description='DOOH-specific metrics (only included for DOOH campaigns)'), + NoExplicitNull, ] = None ooh_metrics: Annotated[ OohMetrics | None, Field( description='Classic (static) OOH metrics — printed bulletins, posters, transit, and street furniture (only included for ooh campaigns). Experimental in AdCP 3.2. Static units have no play event: the delivery number is a period-level modeled audience estimate whose methodology tier is declared in estimation_basis (provider identity rides the row-level measurement_source), and the settlement artifact is the posting record — it proves the posting period, not an airing.' ), + NoExplicitNull, ] = None viewability: Annotated[ Viewability1 | None, Field( description="Viewability metrics. Viewable rate should be calculated as viewable_impressions / measurable_impressions (not total impressions), since some environments cannot measure viewability. Includes `viewed_seconds` — average in-view duration — plus optional percentile and histogram distributions over that duration; all three use the same `measurable_impressions` population and are governed by the same viewability threshold (`standard`). Sellers SHOULD include `standard` whenever measured viewability values are reported because MRC and GroupM rows are not interchangeable. The numeric leaves are addressable via the leaf metric identities `viewable_rate`, `viewable_impressions`, `measurable_impressions`, and `viewed_seconds` for declaration, commitments, aggregates, and breakdown sorting. The structured distribution carriers require explicit `viewed_seconds_percentiles` and `viewed_seconds_histogram` identities for declaration, commitment, and selection; they are not numeric aggregate rows or sort keys. This object remains the canonical carrier of every value. When a buy reports under more than one standard, contract a specific standard via the `viewability_standard` qualifier on `committed_metrics`; when the package's `committed_metrics` carry a `viewability_standard` qualifier, sellers MUST populate `standard` on reported viewability objects so reconciliation can match the qualifier." ), + NoExplicitNull, ] = None engagements: Annotated[ StrictFloat | None, @@ -650,6 +728,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Total engagements — direct interactions with the ad beyond viewing. Includes social reactions/comments/shares, story/unit opens, interactive overlay taps on CTV, companion banner interactions on audio. Platform-specific; corresponds to the 'engagements' optimization metric. Maps to DBCFM KPI_INTERACTIONS (Interaktionen) in the Reporting/Performance block.", ge=0.0, ), + NoExplicitNull, ] = None follows: Annotated[ StrictFloat | None, @@ -657,12 +736,14 @@ class DeliveryMetrics(AdCPBaseModel): description='New followers, page likes, artist/podcast/channel follows, or free channel/feed subscribes attributed to this delivery. Paid subscriptions are conversion events with `event_type: subscribe`, not `follows`.', ge=0.0, ), + NoExplicitNull, ] = None saves: Annotated[ StrictFloat | None, Field( description='Saves, bookmarks, playlist adds, pins attributed to this delivery.', ge=0.0 ), + NoExplicitNull, ] = None profile_visits: Annotated[ StrictFloat | None, @@ -670,6 +751,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Visits to the brand's in-platform page (profile, artist page, channel, or storefront) attributed to this delivery. Does not include external website clicks.", ge=0.0, ), + NoExplicitNull, ] = None engagement_rate: Annotated[ StrictFloat | None, @@ -678,9 +760,12 @@ class DeliveryMetrics(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None cost_per_click: Annotated[ - StrictFloat | None, Field(description='Cost per click (spend / clicks)', ge=0.0) + StrictFloat | None, + Field(description='Cost per click (spend / clicks)', ge=0.0), + NoExplicitNull, ] = None cost_per_completed_view: Annotated[ StrictFloat | None, @@ -688,6 +773,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Cost per completed view (spend / completed_views). Primary CPCV pricing scalar for video/audio inventory; the package's `pricing_model` is `cpcv` when this field is the billing basis.", ge=0.0, ), + NoExplicitNull, ] = None cpm: Annotated[ StrictFloat | None, @@ -695,6 +781,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Cost per thousand impressions, computed as (spend / impressions) × 1000. Universal pricing scalar across CTV, display, mobile/web video, native, audio, and DOOH inventory; the package's `pricing_model` is `cpm` when this field is the billing basis. Field name aligns with the canonical `cpm` token in `enums/pricing-model.json` and `pricing-options/cpm-option.json` so buyers cross-walk pricing model → reported scalar without a translation table.", ge=0.0, ), + NoExplicitNull, ] = None downloads: Annotated[ StrictFloat | None, @@ -702,6 +789,7 @@ class DeliveryMetrics(AdCPBaseModel): description="Audio/podcast downloads (IAB Podcast Measurement Technical Guidelines 2.x methodology). Distinct from `views` — for podcast inventory this is the count of podcast episode downloads; for streaming audio it is the count of stream starts that meet the platform's download threshold. Prefer this over `views` for audio inventory.", ge=0.0, ), + NoExplicitNull, ] = None units_sold: Annotated[ StrictFloat | None, @@ -709,6 +797,7 @@ class DeliveryMetrics(AdCPBaseModel): description='Items sold attributed to this delivery. Retail-media scalar distinct from `conversions` — a single conversion (transaction) may carry multiple `units_sold`. Used by retail media platforms where the buyer optimizes against unit movement, not transaction count. Attribution lookback windows are platform-specific (commonly 7/14/30 days, view-through and click-through variants); sellers SHOULD declare the window via `reporting_capabilities.measurement_windows` or `measurement_terms` rather than encoding it in this scalar.', ge=0.0, ), + NoExplicitNull, ] = None new_to_brand_units: Annotated[ StrictFloat | None, @@ -716,16 +805,19 @@ class DeliveryMetrics(AdCPBaseModel): description='Units sold to first-time brand buyers (count, not rate). Retail-media scalar — the unit-volume parallel to the conversion-fraction `new_to_brand_rate`. Used by retail media platforms where new-customer acquisition unit volume is a primary KPI. Same attribution-window note as `units_sold` applies.', ge=0.0, ), + NoExplicitNull, ] = None by_action_source: Annotated[ list[ByActionSourceItem] | None, Field( description='Conversion metrics broken down by action source (website, app, in_store, etc.). Useful for omnichannel sellers where conversions occur across digital and physical channels.' ), + NoExplicitNull, ] = None vendor_metric_values: Annotated[ list[vendor_metric_value.VendorMetricValue] | None, Field( description="Reported values for vendor-defined metrics that the product's `reporting_capabilities.vendor_metrics` declared. Each entry carries the vendor (BrandRef), the metric identifier within the vendor's vocabulary, the value, optional unit, and `measurable_impressions` as the coverage denominator — vendor measurement is rarely 100% of delivered impressions, since vendors only score impressions where their SDK fires or their panel matches. When a declared vendor metric is omitted from this array, buyers infer no measurement happened (no integration). One row per `(vendor.domain, vendor.brand_id, metric_id, qualifier)` per reporting period — the same vendor metric MAY appear in multiple rows only when each carries a distinct qualifier (e.g., 7-day and 30-day attribution windows); sellers MUST de-duplicate before emission and MUST NOT emit two rows with the same tuple; buyers MAY treat duplicate rows as a seller-side conformance bug. The structured `vendor_metric_values` array is the recommended path for vendor metrics; `additionalProperties: true` on this parent object is preserved so existing free-form vendor emissions remain conformant during migration." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/delivery_recipient.py b/src/adcp/types/domains/core/delivery_recipient.py index 4eadba187..214ff7a15 100644 --- a/src/adcp/types/domains/core/delivery_recipient.py +++ b/src/adcp/types/domains/core/delivery_recipient.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/delivery_recipient.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import delivery_recipient_cloud @@ -17,5 +17,5 @@ class DeliveryRecipient(AdCPBaseModel): extra='forbid', ) identity: Annotated[str, Field(max_length=512, min_length=1)] - cloud: delivery_recipient_cloud.DeliveryRecipientCloud | None = None - region: Annotated[str | None, Field(max_length=128, min_length=1)] = None + cloud: Annotated[delivery_recipient_cloud.DeliveryRecipientCloud | None, NoExplicitNull] = None + region: Annotated[str | None, Field(max_length=128, min_length=1), NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/demographic_age_range.py b/src/adcp/types/domains/core/demographic_age_range.py index 80b37faaa..bd4964024 100644 --- a/src/adcp/types/domains/core/demographic_age_range.py +++ b/src/adcp/types/domains/core/demographic_age_range.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/demographic_age_range.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool, model_validator @@ -24,6 +24,7 @@ class DemographicAgeRange(AdCPBaseModel): ge=0, le=150, ), + NoExplicitNull, ] = None max: Annotated[ SchemaInt | None, @@ -32,6 +33,7 @@ class DemographicAgeRange(AdCPBaseModel): ge=0, le=150, ), + NoExplicitNull, ] = None include_unknown: Annotated[ StrictBool, diff --git a/src/adcp/types/domains/core/demographic_reporting_capability.py b/src/adcp/types/domains/core/demographic_reporting_capability.py index 259f9c477..12db9e008 100644 --- a/src/adcp/types/domains/core/demographic_reporting_capability.py +++ b/src/adcp/types/domains/core/demographic_reporting_capability.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/demographic_reporting_capability.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool, model_validator from ..enums import demographic_system as demographic_system_1 @@ -34,8 +34,11 @@ class Interval(AdCPBaseModel): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None - demographic_system: demographic_system_1.DemographicSystem | None = None + demographic_system: Annotated[demographic_system_1.DemographicSystem | None, NoExplicitNull] = ( + None + ) class Age(AdCPBaseModel): @@ -56,6 +59,7 @@ class Age(AdCPBaseModel): ge=0, le=150, ), + NoExplicitNull, ] = None max_reportable_age: Annotated[ SchemaInt | None, @@ -64,18 +68,21 @@ class Age(AdCPBaseModel): ge=0, le=150, ), + NoExplicitNull, ] = None supports_unbounded_min: Annotated[ StrictBool | None, Field( description='Whether exact_predicates accepts a canonical age predicate with min omitted. Required when that mode is declared.' ), + NoExplicitNull, ] = None supports_unbounded_max: Annotated[ StrictBool | None, Field( description='Whether exact_predicates accepts a canonical age predicate with max omitted. Required when that mode is declared.' ), + NoExplicitNull, ] = None intervals: Annotated[ list[Interval] | None, @@ -83,6 +90,7 @@ class Age(AdCPBaseModel): description='Fixed reportable age intervals for enumerated_intervals. Bounds are authoritative capability data, not a protocol-wide bucket taxonomy. Optional native labels identify the measurement-system code returned with the interval.', min_length=1, ), + NoExplicitNull, ] = None @@ -91,7 +99,9 @@ class DemographicReportingCapability(AdCPBaseModel): extra='forbid', ) age: Annotated[ - Age | None, Field(description='Machine-comparable age ranges this product can report.') + Age | None, + Field(description='Machine-comparable age ranges this product can report.'), + NoExplicitNull, ] = None demographic_systems: Annotated[ list[demographic_system_1.DemographicSystem] | None, @@ -99,6 +109,7 @@ class DemographicReportingCapability(AdCPBaseModel): description='Measurement-system notations this product may return. A code remains opaque unless its capability interval and response row also carry a canonical age predicate.', min_length=1, ), + NoExplicitNull, ] = None may_suppress_small_cells: Annotated[ StrictBool, diff --git a/src/adcp/types/domains/core/demographic_targeting_capability.py b/src/adcp/types/domains/core/demographic_targeting_capability.py index ff4cd3a67..a1e0ad695 100644 --- a/src/adcp/types/domains/core/demographic_targeting_capability.py +++ b/src/adcp/types/domains/core/demographic_targeting_capability.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/demographic_targeting_capability.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ..enums import age_determination_basis, age_verification_method @@ -58,6 +58,7 @@ class Age(AdCPBaseModel): ge=0, le=150, ), + NoExplicitNull, ] = None max_supported_age: Annotated[ SchemaInt | None, @@ -66,18 +67,21 @@ class Age(AdCPBaseModel): ge=0, le=150, ), + NoExplicitNull, ] = None supports_unbounded_min: Annotated[ StrictBool | None, Field( description='Whether continuous_bounds accepts a predicate with min omitted. When true, the omitted min means no lower age restriction and MUST NOT be replaced by min_supported_age. When false, the seller MUST reject open-lower predicates. Required when continuous_bounds is declared.' ), + NoExplicitNull, ] = None supports_unbounded_max: Annotated[ StrictBool | None, Field( description='Whether continuous_bounds accepts a predicate with max omitted. When true, the omitted max means no upper age restriction and MUST NOT be replaced by max_supported_age; for example, min 65 remains 65+. When false, the seller MUST reject open-upper predicates. Required when continuous_bounds is declared.' ), + NoExplicitNull, ] = None unknown_handling: Annotated[ UnknownHandling, @@ -91,6 +95,7 @@ class Age(AdCPBaseModel): description='User-level age determination bases this product can use for exact execution. Sellers MUST publish only bases whose provenance they can preserve and audit; they MUST NOT infer a basis from an opaque platform age bucket. When omitted, the product makes no basis-aware targeting commitment and MUST reject requests that specify accepted_bases.', min_length=1, ), + NoExplicitNull, ] = None supported_verification_methods: Annotated[ list[age_verification_method.AgeVerificationMethod] | None, @@ -98,6 +103,7 @@ class Age(AdCPBaseModel): description='Age verification methods available when supported_bases contains verified. This is an availability declaration only: a method must still establish the entire requested age predicate. Threshold-only methods such as World ID cannot establish an arbitrary bounded range.', min_length=1, ), + NoExplicitNull, ] = None intervals: Annotated[ list[Interval] | None, @@ -105,6 +111,7 @@ class Age(AdCPBaseModel): description='Seller-owned age intervals available to enumerated_intervals execution. Bounds are authoritative capability data, not a protocol-wide bucket taxonomy. Exact compilation may union adjacent intervals but MUST NOT introduce gaps or extra ages.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/demographic_targeting_intent.py b/src/adcp/types/domains/core/demographic_targeting_intent.py index cc75e5c63..fc8b6eebb 100644 --- a/src/adcp/types/domains/core/demographic_targeting_intent.py +++ b/src/adcp/types/domains/core/demographic_targeting_intent.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/demographic_targeting_intent.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import age_determination_basis, age_verification_method @@ -20,6 +20,7 @@ class Age(DemographicAgeRange): description='User-level age determination bases the buyer permits for this predicate. Sellers MUST use only these bases or reject the request; they MUST NOT silently fall back to another basis. When omitted, the buyer imposes no determination-basis constraint beyond product capability and any age_restriction. population_estimate is structurally unavailable because aggregate evidence cannot identify an eligible user.', min_length=1, ), + NoExplicitNull, ] = None accepted_verification_methods: Annotated[ list[age_verification_method.AgeVerificationMethod] | None, @@ -27,6 +28,7 @@ class Age(DemographicAgeRange): description='Verification methods acceptable when verified is an accepted basis. This field narrows verified demographic execution; age_restriction.accepted_methods independently governs legal eligibility and the two sets are intersected when both are present.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/demographic_targeting_resolution.py b/src/adcp/types/domains/core/demographic_targeting_resolution.py index 20bc8cbdb..a853c0e7a 100644 --- a/src/adcp/types/domains/core/demographic_targeting_resolution.py +++ b/src/adcp/types/domains/core/demographic_targeting_resolution.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/demographic_targeting_resolution.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -26,7 +26,7 @@ class Execution(AdCPBaseModel): extra='allow', ) type: Literal['continuous_bounds'] = 'continuous_bounds' - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Execution1(AdCPBaseModel): @@ -41,7 +41,7 @@ class Execution1(AdCPBaseModel): min_length=1, ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Execution2(AdCPBaseModel): @@ -56,7 +56,7 @@ class Execution2(AdCPBaseModel): min_length=1, ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class DemographicTargetingResolution(AdCPBaseModel): @@ -86,6 +86,7 @@ class DemographicTargetingResolution(AdCPBaseModel): description='Effective user-level determination bases configured for this package after intersecting buyer accepted_bases, product supported_bases, and age_restriction. This is an auditable configuration readback, not proof that every impression used every listed basis.', min_length=1, ), + NoExplicitNull, ] = None applied_verification_methods: Annotated[ list[age_verification_method.AgeVerificationMethod] | None, @@ -93,5 +94,6 @@ class DemographicTargetingResolution(AdCPBaseModel): description='Effective verification methods when applied_bases contains verified. A method name does not establish that a particular proof or claim was valid; runtime verification and claim entailment remain required.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/deployment.py b/src/adcp/types/domains/core/deployment.py index 533d40d74..602f26c6d 100644 --- a/src/adcp/types/domains/core/deployment.py +++ b/src/adcp/types/domains/core/deployment.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/deployment.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from . import activation_key as activation_key_1 @@ -21,7 +21,9 @@ class Deployment1(AdCPBaseModel): Field(description='Discriminator indicating this is a platform-based deployment'), ] = 'platform' platform: Annotated[str, Field(description='Platform identifier for DSPs')] - account: Annotated[str | None, Field(description='Account identifier if applicable')] = None + account: Annotated[ + str | None, Field(description='Account identifier if applicable'), NoExplicitNull + ] = None is_live: Annotated[ StrictBool, Field(description='Whether signal is currently active on this deployment') ] @@ -30,6 +32,7 @@ class Deployment1(AdCPBaseModel): Field( description='The key to use for targeting. Only present if is_live=true AND requester has access to this deployment.' ), + NoExplicitNull, ] = None estimated_activation_duration_minutes: Annotated[ StrictFloat | None, @@ -37,10 +40,12 @@ class Deployment1(AdCPBaseModel): description='Estimated time to activate if not live, or to complete activation if in progress', ge=0.0, ), + NoExplicitNull, ] = None deployed_at: Annotated[ AwareDatetime | None, Field(description='Timestamp when activation completed (if is_live=true)'), + NoExplicitNull, ] = None @@ -53,7 +58,9 @@ class Deployment2(AdCPBaseModel): Field(description='Discriminator indicating this is an agent URL-based deployment'), ] = 'agent' agent_url: Annotated[AnyUrl, Field(description='URL identifying the deployment agent')] - account: Annotated[str | None, Field(description='Account identifier if applicable')] = None + account: Annotated[ + str | None, Field(description='Account identifier if applicable'), NoExplicitNull + ] = None is_live: Annotated[ StrictBool, Field(description='Whether signal is currently active on this deployment') ] @@ -62,6 +69,7 @@ class Deployment2(AdCPBaseModel): Field( description='The key to use for targeting. Only present if is_live=true AND requester has access to this deployment.' ), + NoExplicitNull, ] = None estimated_activation_duration_minutes: Annotated[ StrictFloat | None, @@ -69,10 +77,12 @@ class Deployment2(AdCPBaseModel): description='Estimated time to activate if not live, or to complete activation if in progress', ge=0.0, ), + NoExplicitNull, ] = None deployed_at: Annotated[ AwareDatetime | None, Field(description='Timestamp when activation completed (if is_live=true)'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/destination.py b/src/adcp/types/domains/core/destination.py index cbb8703fe..cfe638cf1 100644 --- a/src/adcp/types/domains/core/destination.py +++ b/src/adcp/types/domains/core/destination.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/destination.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -23,7 +23,7 @@ class Destination1(AdCPBaseModel): Field(description="Platform identifier for DSPs (e.g., 'the-trade-desk', 'amazon-dsp')"), ] account: Annotated[ - str | None, Field(description='Optional account identifier on the platform') + str | None, Field(description='Optional account identifier on the platform'), NoExplicitNull ] = None @@ -39,7 +39,7 @@ class Destination2(AdCPBaseModel): AnyUrl, Field(description='URL identifying the deployment agent (for sales agents, etc.)') ] account: Annotated[ - str | None, Field(description='Optional account identifier on the agent') + str | None, Field(description='Optional account identifier on the agent'), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/core/destination_item.py b/src/adcp/types/domains/core/destination_item.py index d7d8aa0eb..02919603a 100644 --- a/src/adcp/types/domains/core/destination_item.py +++ b/src/adcp/types/domains/core/destination_item.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/destination_item.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -49,27 +49,42 @@ class DestinationItem(AdCPBaseModel): description: Annotated[ str | None, Field(description='Destination description highlighting attractions and appeal.'), + NoExplicitNull, + ] = None + city: Annotated[str | None, Field(description='City name, if applicable.'), NoExplicitNull] = ( + None + ) + region: Annotated[ + str | None, Field(description='State, province, or region name.'), NoExplicitNull ] = None - city: Annotated[str | None, Field(description='City name, if applicable.')] = None - region: Annotated[str | None, Field(description='State, province, or region name.')] = None country: Annotated[ - str | None, Field(description='ISO 3166-1 alpha-2 country code.', pattern='^[A-Z]{2}$') + str | None, + Field(description='ISO 3166-1 alpha-2 country code.', pattern='^[A-Z]{2}$'), + NoExplicitNull, ] = None location: Annotated[ - Location | None, Field(description='Geographic coordinates of the destination.') + Location | None, + Field(description='Geographic coordinates of the destination.'), + NoExplicitNull, ] = None destination_type: Annotated[ - DestinationType | None, Field(description='Destination category.') + DestinationType | None, Field(description='Destination category.'), NoExplicitNull ] = None price: Annotated[ - price_1.Price | None, Field(description='Starting price for a trip to this destination.') + price_1.Price | None, + Field(description='Starting price for a trip to this destination.'), + NoExplicitNull, + ] = None + image_url: Annotated[ + AnyUrl | None, Field(description='Destination hero image URL.'), NoExplicitNull + ] = None + url: Annotated[ + AnyUrl | None, Field(description='Destination landing page or booking URL.'), NoExplicitNull ] = None - image_url: Annotated[AnyUrl | None, Field(description='Destination hero image URL.')] = None - url: Annotated[AnyUrl | None, Field(description='Destination landing page or booking URL.')] = ( - None - ) rating: Annotated[ - StrictFloat | None, Field(description='Destination rating (1–5).', ge=1.0, le=5.0) + StrictFloat | None, + Field(description='Destination rating (1–5).', ge=1.0, le=5.0), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, @@ -77,6 +92,7 @@ class DestinationItem(AdCPBaseModel): description="Tags for filtering (e.g., 'family', 'romantic', 'solo', 'winter-sun').", min_length=1, ), + NoExplicitNull, ] = None assets: Annotated[ list[offering_asset_group.OfferingAssetGroup] | None, @@ -84,5 +100,6 @@ class DestinationItem(AdCPBaseModel): description="Typed creative asset pools for this destination. Uses the same OfferingAssetGroup structure as offering-type catalogs. Standard group IDs: 'images_landscape' (destination hero), 'images_vertical' (9:16 for Snap, Stories), 'images_square' (1:1). Enables formats to declare typed image requirements that map unambiguously to the right asset regardless of platform.", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/downstream_connection_requirement.py b/src/adcp/types/domains/core/downstream_connection_requirement.py index 8477377df..c3b37a119 100644 --- a/src/adcp/types/domains/core/downstream_connection_requirement.py +++ b/src/adcp/types/domains/core/downstream_connection_requirement.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/downstream_connection_requirement.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field @@ -52,28 +52,36 @@ class ResourceRef(AdCPBaseModel): Field( description='Provider-native advertiser or business account id, when safe to disclose.' ), + NoExplicitNull, ] = None identity_id: Annotated[ str | None, Field( description='Provider-native creator, page, channel, organization, or profile id, when safe to disclose.' ), + NoExplicitNull, ] = None handle: Annotated[ str | None, Field(description='Provider-native public handle for the owning identity, when available.'), + NoExplicitNull, ] = None profile_url: Annotated[ - AnyUrl | None, Field(description='Public URL for the owning identity, when available.') + AnyUrl | None, + Field(description='Public URL for the owning identity, when available.'), + NoExplicitNull, ] = None post_id: Annotated[ str | None, Field( description='Provider-native post id, when the grant is post-scoped or the failed request referenced a specific post.' ), + NoExplicitNull, ] = None post_url: Annotated[ - AnyUrl | None, Field(description='Public URL for the referenced post, when available.') + AnyUrl | None, + Field(description='Public URL for the referenced post, when available.'), + NoExplicitNull, ] = None @@ -86,6 +94,7 @@ class DownstreamConnectionRequirement(AdCPBaseModel): Field( description='Stable provider or platform namespace, preferably lowercase. Examples: `social.example`, `shortvideo.example`, or a seller-defined namespace. Omit only when the requirement is provider-agnostic, or when an `authorization_url` fully routes the human to the correct provider-specific connection flow.' ), + NoExplicitNull, ] = None connection_type: Annotated[ ConnectionType, @@ -98,39 +107,48 @@ class DownstreamConnectionRequirement(AdCPBaseModel): Field( description='Concrete AdCP protocol operation names that require this downstream connection. Sellers SHOULD include this in product declarations when the requirement is known ahead of time, and in AUTHORIZATION_REQUIRED details when it explains the failed operation. Prefer specific operation names such as `list_creatives`, `sync_creatives`, `create_media_buy`, `get_media_buy_delivery`, or `get_creative_delivery` over broad category labels such as `reporting`.' ), + NoExplicitNull, + ] = None + scope: Annotated[ + Scope | None, Field(description='Granularity of the downstream grant.'), NoExplicitNull ] = None - scope: Annotated[Scope | None, Field(description='Granularity of the downstream grant.')] = None status: Annotated[ Status | None, Field( description='Current seller-observed state for this downstream connection when known. Product declarations MAY omit status or use `unknown`; AUTHORIZATION_REQUIRED details SHOULD use `missing`, `expired`, or `revoked` for the connection that blocked the call.' ), + NoExplicitNull, ] = None connection_id: Annotated[ str | None, Field( description='Seller-defined identifier for an already-created downstream connection. Omit when no connection exists yet or when exposing it would leak platform/account state.' ), + NoExplicitNull, ] = None resource_ref: Annotated[ ResourceRef | None, Field( description='Optional opaque provider-native resource hint, such as a platform account id, profile URL, handle, channel id, post id, or post URL. This is a hint for routing authorization, not proof that authorization exists.' ), + NoExplicitNull, ] = None authorization_url: Annotated[ AnyUrl | None, Field( description='Seller-hosted or provider-hosted URL where a human can complete or restore this downstream connection.' ), + NoExplicitNull, ] = None authorization_instructions: Annotated[ str | None, Field( description='Human-readable instructions for completing or restoring this downstream connection.' ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field(description='Expiration time for the downstream grant, when known.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/education_item.py b/src/adcp/types/domains/core/education_item.py index 8871d79a5..dcabeba08 100644 --- a/src/adcp/types/domains/core/education_item.py +++ b/src/adcp/types/domains/core/education_item.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/education_item.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from . import ext as ext_1 @@ -53,49 +53,62 @@ class EducationItem(AdCPBaseModel): description: Annotated[ str | None, Field(description='Program description including curriculum highlights and outcomes.'), + NoExplicitNull, ] = None subject: Annotated[ str | None, Field( description="Subject area or field of study (e.g., 'computer-science', 'business', 'healthcare')." ), + NoExplicitNull, + ] = None + degree_type: Annotated[ + DegreeType | None, Field(description='Type of credential awarded.'), NoExplicitNull + ] = None + level: Annotated[ + Level | None, Field(description='Difficulty or prerequisite level.'), NoExplicitNull + ] = None + price: Annotated[ + price_1.Price | None, Field(description='Tuition or course fee.'), NoExplicitNull ] = None - degree_type: Annotated[DegreeType | None, Field(description='Type of credential awarded.')] = ( - None - ) - level: Annotated[Level | None, Field(description='Difficulty or prerequisite level.')] = None - price: Annotated[price_1.Price | None, Field(description='Tuition or course fee.')] = None duration: Annotated[ str | None, Field( description="Program duration as a human-readable string (e.g., '4 weeks', '2 years', '6 months')." ), + NoExplicitNull, ] = None start_date: Annotated[ - date | None, Field(description='Next available start date (ISO 8601 date).') + date | None, Field(description='Next available start date (ISO 8601 date).'), NoExplicitNull ] = None language: Annotated[ - str | None, Field(description="Language of instruction (e.g., 'en', 'nl', 'es').") + str | None, + Field(description="Language of instruction (e.g., 'en', 'nl', 'es')."), + NoExplicitNull, ] = None - modality: Annotated[Modality | None, Field(description='Delivery format.')] = None + modality: Annotated[Modality | None, Field(description='Delivery format.'), NoExplicitNull] = ( + None + ) location: Annotated[ str | None, Field( description="Campus or instruction location (e.g., 'Amsterdam, NL'). Omit for fully online programs." ), + NoExplicitNull, + ] = None + image_url: Annotated[ + AnyUrl | None, Field(description='Program or institution image URL.'), NoExplicitNull + ] = None + url: Annotated[ + AnyUrl | None, Field(description='Program landing page or enrollment URL.'), NoExplicitNull ] = None - image_url: Annotated[AnyUrl | None, Field(description='Program or institution image URL.')] = ( - None - ) - url: Annotated[AnyUrl | None, Field(description='Program landing page or enrollment URL.')] = ( - None - ) tags: Annotated[ list[str] | None, Field( description="Tags for filtering (e.g., 'stem', 'scholarship-available', 'evening-classes').", min_length=1, ), + NoExplicitNull, ] = None assets: Annotated[ list[offering_asset_group.OfferingAssetGroup] | None, @@ -103,5 +116,6 @@ class EducationItem(AdCPBaseModel): description="Typed creative asset pools for this program. Uses the same OfferingAssetGroup structure as offering-type catalogs. Standard group IDs: 'images_landscape' (campus/program hero), 'images_vertical' (9:16 for Stories), 'logo' (institution logo). Enables formats to declare typed image requirements that map unambiguously to the right asset regardless of platform.", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/error.py b/src/adcp/types/domains/core/error.py index 755bed423..273e1f98c 100644 --- a/src/adcp/types/domains/core/error.py +++ b/src/adcp/types/domains/core/error.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/error.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat @@ -75,18 +75,21 @@ class Issue(AdCPBaseModel): Field( description="Optional. JSON Schema tree path of the rejecting keyword (e.g. '#/properties/packages/items/oneOf/1'). 3.1+ consumers SHOULD prefer `schema_id`; `schemaPath` is retained for 3.0.x compatibility (renamed to `schema_path` in a future major). See error-handling.mdx for the validator-internals production-emit rules." ), + NoExplicitNull, ] = None schema_id: Annotated[ str | None, Field( description="Optional. `$id` of the rejecting (sub-)schema (e.g. `/schemas/3.1.0/core/activation-key.json`). MUST resolve to a `$id` published in the spec at the version the seller advertises via `get_adcp_capabilities` — either a deep sub-schema (the typical case) or the response-root `$id` (the bundled-tree fallback for tools served from bundles built before #3868). Sellers MUST NOT emit when the rejection occurred against a private extension, server-only sub-schema, or pre-release element — the public-spec replay rationale only holds when the rejecting element is reachable from the public bundle. Sellers populating `schemaPath` SHOULD also populate `schema_id` when they have it so 3.1+ readers don't get strictly less than 3.0.x readers. See error-handling.mdx for resolution guidance and the bundled-tree caveat." ), + NoExplicitNull, ] = None discriminator: Annotated[ list[DiscriminatorItem] | None, Field( description="Optional. Const-discriminator property/value pair(s) identifying the variant the validator selected from values present in the payload. Sellers MUST populate only when (a) the rejecting schema is a const-discriminated `oneOf` / `anyOf` and (b) the discriminator property is present in the payload — emission on partial-match inference would fingerprint the seller's validator implementation. MUST omit when zero variants survive. Compound discriminators (e.g. `(type, value_type)`) produce multiple entries ordered by declaration in the rejecting schema's `properties` block. Same private-extensions / version-skew carve-out as `schema_id`. See error-handling.mdx." ), + NoExplicitNull, ] = None @@ -119,14 +122,18 @@ class Error(AdCPBaseModel): Field( description='Optional buyer-actionable classification of the failure. Use this when the enclosing error code or message is too coarse or contains producer-internal context that must not cross the buyer trust boundary. `code` reuses the comprehensive standard vocabulary and recovery classifications published in `enums/error-code.json`, including policy, governance, account, commercial, inventory, and creative failures. The wire field remains open for forward compatibility. `message` MUST be safe to show to the buyer and MUST NOT contain vendor identifiers, ad-server type names, internal object names, internal IDs, stack traces, or other producer-private implementation details. When present, the enclosing `error.recovery` MUST classify the buyer-actionable reason. If both the enclosing code and buyer reason are registered, their standard recovery classifications MUST agree with each other and with `error.recovery`; sellers MUST choose the closest compatible enclosing code rather than retain a conflicting upstream transport wrapper. One buyer_reason classifies one error object. If a rejection contains independently actionable failures from different classes, sellers MUST emit separate error objects or omit buyer_reason rather than select a misleading single class.' ), + NoExplicitNull, ] = None field: Annotated[ str | None, Field( description="Field path associated with the error in JSONPath-lite format (e.g., 'packages[0].targeting'). When `issues[]` is also present, sellers MUST set this to `issues[0].pointer` translated from RFC 6901 to JSONPath-lite (e.g., '/packages/0/targeting' → 'packages[0].targeting') so pre-3.1 consumers reading `field` only get deterministic behavior. Will be deprecated in a future major version in favor of `issues[].pointer`." ), + NoExplicitNull, + ] = None + suggestion: Annotated[ + str | None, Field(description='Suggested fix for the error'), NoExplicitNull ] = None - suggestion: Annotated[str | None, Field(description='Suggested fix for the error')] = None retry_after: Annotated[ StrictFloat | None, Field( @@ -134,34 +141,40 @@ class Error(AdCPBaseModel): ge=1.0, le=3600.0, ), + NoExplicitNull, ] = None issues: Annotated[ list[Issue] | None, Field( description='Structured list of validation failures. Primary use is `VALIDATION_ERROR`, where multi-field rejections are common and `field` (singular) cannot carry the full pointer map. MAY appear on other error codes that reject multiple fields at once. When `issues` is present, sellers MUST also populate `field` from `issues[0]` for backward compatibility with pre-3.1 consumers that read `field` only — translating the RFC 6901 `pointer` format to the JSONPath-lite format `field` uses (e.g., `/packages/0/targeting` → `packages[0].targeting`). MUST (not SHOULD) so consumers reading `field` get deterministic behavior across sellers — the cost is one line of dual-write per seller; the cost of SHOULD is a long tail of seller-A-vs-seller-B inconsistency. Future major versions will deprecate `field` in favor of `issues[].pointer`.' ), + NoExplicitNull, ] = None details: Annotated[ dict[str, Any] | None, Field( description='Additional task-specific error details. Sellers MAY mirror `issues[]` here as `details.issues` for backward compatibility with pre-3.1 consumers reading from `details`; new consumers SHOULD prefer the top-level `issues` field.\n\n**Canonical rejection-set shape (3.1+).** When the error reports a rejected value against a closed set of accepted values (e.g., enum mismatch, unsupported pricing option, invalid signal id), sellers SHOULD use the canonical key `accepted_values: ` under `details` rather than seller-specific variants observed in the wild (`available`, `allowed`, `accepted_values` at the error root, etc.). The canonical shape:\n\n```json\n{\n "code": "INVALID_PRICING_OPTION",\n "message": "Pricing option not found: po_prism_abandoner_cpm",\n "field": "pricing_option_id",\n "details": {\n "rejected_value": "po_prism_abandoner_cpm",\n "accepted_values": ["po_prism_cart_cpm", "po_prism_view_cpm"]\n }\n}\n```\n\n- `rejected_value` (optional): the offending value the buyer supplied, echoed for buyer-side diagnostic clarity (especially when the offending field is nested or transformed before validation).\n- `accepted_values` (optional): the closed set the seller would have accepted at this field on this call. Sellers MUST NOT enumerate the full ecosystem-wide accepted set if it differs from what\'s accepted for *this caller in this context* (account, brand, scope) — leaking ecosystem-wide accepted sets to a per-caller rejection turns the error into an enumeration oracle.\n\nThis is **SHOULD-level guidance**, not MUST: `details` remains `additionalProperties: true` and pre-3.1 sellers using `available` / `allowed` / `accepted_values` at the error root remain conformant. The canonical shape lets buyer-side diagnostic tooling (SDK runner hints, dashboards, error classifiers) reliably surface the accepted-set without per-seller pattern matching. SDKs SHOULD accept any of the legacy variants and normalize on read; the canonical shape is what new sellers and 3.1+ adopters should emit going forward.' ), + NoExplicitNull, ] = None recovery: Annotated[ Recovery | None, Field( description='Agent recovery classification. transient: retry after delay (rate limit, service unavailable, timeout). correctable: fix the request and resend (invalid field, budget too low, creative rejected). terminal: requires human action (account suspended, payment required, account not found). AdCP 3.2 producers MUST populate `recovery` on every error; 3.1 producers SHOULD populate it. The shared 3.x schema intentionally does not add `recovery` to `required` so retained and live errors from earlier 3.x producers remain decodable. When `buyer_reason` is present, `recovery` is required and MUST classify that buyer-actionable reason. If the enclosing code and buyer reason are both registered, their standard recovery classifications MUST agree with each other and with this field. A receiver that does not recognize `error.code` (a newer code, or a platform-specific code) MUST still be able to classify the error from `recovery`. When a legacy error omits `recovery`, receivers use the registered classification for a known code and fall back to `transient` for an unknown code, subject to the bounded retry budget. The `enumMetadata.recovery` block in `enums/error-code.json` is the documentary mirror for known top-level and buyer-reason codes; `error.recovery` on the wire is authoritative when present.' ), + NoExplicitNull, ] = None source: Annotated[ Source | None, Field( description='Who emitted this error entry. `producer` (default when absent): emitted by the response\'s authoring agent (the seller for `get_products`, the creative agent for `build_creative`, etc.). `sdk`: augmented by a consuming SDK that detected a non-fatal advisory condition on consumption (e.g., `FORMAT_PROJECTION_FAILED` when the buyer SDK couldn\'t project a v1 format to a canonical, or `FORMAT_DECLARATION_DIVERGENT` when the SDK detected a producer bug on read). SDK-augmented entries SHOULD also set `sdk_id` so downstream consumers can identify which intermediate processor inserted the entry.\n\n**Multi-hop propagation (normative).** AdCP is a federated agent network — responses commonly traverse multiple SDKs (e.g., sales agent → interchange → DSP → buyer). When an SDK augments `errors[]` with a consumption-detected entry, the augmented response carries the entry forward to subsequent hops. Each hop that detects the same condition independently SHOULD deduplicate by `(code, field)` rather than re-emit; the existing entry\'s `sdk_id` identifies which earlier processor saw it first. Producer entries (those without `source: "sdk"`) are authoritative for what the response\'s authoring agent self-detected; SDK entries are observations made on top.\n\n**Replay/audit safety.** Persisted or replayed responses carry `source` and `sdk_id` so the audit trail can distinguish seller-emitted entries from SDK-augmented ones. Without `source`, a downstream consumer can\'t tell whether a code came from the seller or an intermediate SDK, which corrupts attribution.' ), + NoExplicitNull, ] = None sdk_id: Annotated[ str | None, Field( description='Optional identifier for the SDK that augmented this error entry. Format: `@` (e.g., `@adcontextprotocol/adcp@7.3.0`, `adcontextprotocol-adcp-python@1.2.0`). MUST be set when `source: "sdk"`; MUST be absent when `source: "producer"` or absent. Lets downstream consumers identify which intermediate processor inserted the entry, useful for debugging cross-SDK divergence (e.g., one SDK detects a projection failure that another SDK\'s registry version doesn\'t).' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/evaluator_spec.py b/src/adcp/types/domains/core/evaluator_spec.py index 7ad721736..e99bc95af 100644 --- a/src/adcp/types/domains/core/evaluator_spec.py +++ b/src/adcp/types/domains/core/evaluator_spec.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/evaluator_spec.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from ..content_standards import artifact @@ -35,6 +35,7 @@ class RankByItem(AdCPBaseModel): Field( description='Sort direction for this feature: `maximize` ranks higher feature values first (e.g. creative_quality_score), `minimize` ranks lower values first (e.g. predicted_cpa).' ), + NoExplicitNull, ] = Direction.maximize @@ -53,6 +54,7 @@ class FeatureAgent(AdCPBaseModel): Field( description="Optional canonical feature_id the producing agent SHOULD request against this agent. When present it SHOULD match the agent's `accepted_verifiers[].feature_id` or be omitted; when absent the seller selects a feature at evaluation time. Resolves selector ambiguity exactly as the provenance verify_agent.feature_id does." ), + NoExplicitNull, ] = None @@ -63,10 +65,12 @@ class EvalBudget(AdCPBaseModel): max_calls: Annotated[ SchemaInt | None, Field(description='Soft cap on the number of judge calls the evaluator should make.', ge=1), + NoExplicitNull, ] = None max_seconds: Annotated[ StrictFloat | None, Field(description='Soft cap on wall-clock seconds the evaluation should consume.', ge=0.0), + NoExplicitNull, ] = None @@ -88,6 +92,7 @@ class EvaluatorSpec2(AdCPBaseModel): description='Optional hard GATE over creative-feature values — the predicates a leaf MUST satisfy for the producing agent to recommend/return it. Reuses the feature-requirement shape (min_value/max_value for quantitative features like creative_quality_score, allowed_values for binary/categorical) — the same predicate vocabulary that gates property/audience filters, which its own schema names as an intended creative-gate reuse. A leaf that fails any predicate is DROPPED from the agent\'s best_of_n survivors before ranking — this is internal pruning of which leaves the agent recommends, not an AdCP-layer block of an already-produced billable leaf (what is produced/billed is governed by max_variants/max_creatives/max_spend). Distinct from `rank_by`: the gate is a pass/fail predicate (drop on fail), `rank_by` is an ordering over survivors. Each predicate\'s `if_not_covered` (exclude|include, default exclude) is the fail-open knob when the source cannot measure that feature. A pass/warn/fail verdict is expressed as a categorical string feature value gated via `allowed_values` (e.g. ["pass"] or ["pass","warn"]) — the buyer\'s predicate decides whether warn passes; the verdict is derived, never stored on creative-feature-result. Omit to leave evaluation advisory (no leaf is dropped).', min_length=1, ), + NoExplicitNull, ] = None rank_by: Annotated[ list[RankByItem1] | None, @@ -95,20 +100,23 @@ class EvaluatorSpec2(AdCPBaseModel): description='Optional RANK ordering over creative-feature values — an ordered list (most significant first) the agent uses to order the gate survivors into recommended/rank. An explicit {feature_id, direction} ordering rather than the feature-requirement predicate (which has no sort direction): the gate decides pass/fail, rank_by decides better/worse. Soft preference, never a gate: leaves are not dropped by rank_by, they are only ordered. Omit to let the evaluator/seller choose the ordering.', min_length=1, ), + NoExplicitNull, ] = None feature_agent: Annotated[ FeatureAgent | None, Field( description="Optional buyer-attached pointer to a get_creative_features-capable creative-feature / governance agent the producing agent calls to evaluate each leaf (the gate's SOURCE of feature values). This is the buyer-represents → seller-calls pattern #5280 established for provenance, generalized to the evaluator gate: the buyer REPRESENTS which agent it used, but the seller is the verifier-of-record and decides which agent it actually calls. `agent_url` MUST appear (canonicalized per /docs/reference/url-canonicalization) in the seller's `creative_policy.accepted_verifiers[].agent_url`; an off-list agent is rejected with `EVALUATOR_AGENT_NOT_ACCEPTED` (mirrors PROVENANCE_VERIFIER_NOT_ACCEPTED) before any outbound call. The outbound evaluator call authenticates on the transport; this pointer MUST NOT carry API keys, bearer tokens, client secrets, authorization values, JWKs, JWKS documents, or JWKS URIs. Reuses the same allowlist mechanism — no new allowlist is introduced. Distinct from the `agent_url` oneOf form, which names the evaluator's source directly; `feature_agent` attaches the gate's measurement source alongside any of the three forms." ), + NoExplicitNull, ] = None eval_budget: Annotated[ EvalBudget | None, Field( description='Optional soft ceiling on evaluation effort. Advisory in v1 with no billing coupling. Well-known soft fields max_calls / max_seconds; open for evaluator-specific knobs.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None evaluator_id: Annotated[ str, Field( @@ -127,6 +135,7 @@ class EvaluatorSpec3(AdCPBaseModel): description='Optional hard GATE over creative-feature values — the predicates a leaf MUST satisfy for the producing agent to recommend/return it. Reuses the feature-requirement shape (min_value/max_value for quantitative features like creative_quality_score, allowed_values for binary/categorical) — the same predicate vocabulary that gates property/audience filters, which its own schema names as an intended creative-gate reuse. A leaf that fails any predicate is DROPPED from the agent\'s best_of_n survivors before ranking — this is internal pruning of which leaves the agent recommends, not an AdCP-layer block of an already-produced billable leaf (what is produced/billed is governed by max_variants/max_creatives/max_spend). Distinct from `rank_by`: the gate is a pass/fail predicate (drop on fail), `rank_by` is an ordering over survivors. Each predicate\'s `if_not_covered` (exclude|include, default exclude) is the fail-open knob when the source cannot measure that feature. A pass/warn/fail verdict is expressed as a categorical string feature value gated via `allowed_values` (e.g. ["pass"] or ["pass","warn"]) — the buyer\'s predicate decides whether warn passes; the verdict is derived, never stored on creative-feature-result. Omit to leave evaluation advisory (no leaf is dropped).', min_length=1, ), + NoExplicitNull, ] = None rank_by: Annotated[ list[RankByItem2] | None, @@ -134,20 +143,23 @@ class EvaluatorSpec3(AdCPBaseModel): description='Optional RANK ordering over creative-feature values — an ordered list (most significant first) the agent uses to order the gate survivors into recommended/rank. An explicit {feature_id, direction} ordering rather than the feature-requirement predicate (which has no sort direction): the gate decides pass/fail, rank_by decides better/worse. Soft preference, never a gate: leaves are not dropped by rank_by, they are only ordered. Omit to let the evaluator/seller choose the ordering.', min_length=1, ), + NoExplicitNull, ] = None feature_agent: Annotated[ FeatureAgent | None, Field( description="Optional buyer-attached pointer to a get_creative_features-capable creative-feature / governance agent the producing agent calls to evaluate each leaf (the gate's SOURCE of feature values). This is the buyer-represents → seller-calls pattern #5280 established for provenance, generalized to the evaluator gate: the buyer REPRESENTS which agent it used, but the seller is the verifier-of-record and decides which agent it actually calls. `agent_url` MUST appear (canonicalized per /docs/reference/url-canonicalization) in the seller's `creative_policy.accepted_verifiers[].agent_url`; an off-list agent is rejected with `EVALUATOR_AGENT_NOT_ACCEPTED` (mirrors PROVENANCE_VERIFIER_NOT_ACCEPTED) before any outbound call. The outbound evaluator call authenticates on the transport; this pointer MUST NOT carry API keys, bearer tokens, client secrets, authorization values, JWKs, JWKS documents, or JWKS URIs. Reuses the same allowlist mechanism — no new allowlist is introduced. Distinct from the `agent_url` oneOf form, which names the evaluator's source directly; `feature_agent` attaches the gate's measurement source alongside any of the three forms." ), + NoExplicitNull, ] = None eval_budget: Annotated[ EvalBudget | None, Field( description='Optional soft ceiling on evaluation effort. Advisory in v1 with no billing coupling. Well-known soft fields max_calls / max_seconds; open for evaluator-specific knobs.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None agent_url: Annotated[ AnyUrl, Field( @@ -163,12 +175,14 @@ class Exemplars(AdCPBaseModel): alias='pass', description='Artifacts exemplifying variants the buyer considers good — the high end (≈1) of the calibrated prediction feature.', ), + NoExplicitNull, ] = None fail: Annotated[ list[artifact.Artifact] | None, Field( description='Artifacts exemplifying variants the buyer considers bad — the low end (≈0) of the calibrated prediction feature.' ), + NoExplicitNull, ] = None @@ -182,6 +196,7 @@ class EvaluatorSpec1(AdCPBaseModel): description='Optional hard GATE over creative-feature values — the predicates a leaf MUST satisfy for the producing agent to recommend/return it. Reuses the feature-requirement shape (min_value/max_value for quantitative features like creative_quality_score, allowed_values for binary/categorical) — the same predicate vocabulary that gates property/audience filters, which its own schema names as an intended creative-gate reuse. A leaf that fails any predicate is DROPPED from the agent\'s best_of_n survivors before ranking — this is internal pruning of which leaves the agent recommends, not an AdCP-layer block of an already-produced billable leaf (what is produced/billed is governed by max_variants/max_creatives/max_spend). Distinct from `rank_by`: the gate is a pass/fail predicate (drop on fail), `rank_by` is an ordering over survivors. Each predicate\'s `if_not_covered` (exclude|include, default exclude) is the fail-open knob when the source cannot measure that feature. A pass/warn/fail verdict is expressed as a categorical string feature value gated via `allowed_values` (e.g. ["pass"] or ["pass","warn"]) — the buyer\'s predicate decides whether warn passes; the verdict is derived, never stored on creative-feature-result. Omit to leave evaluation advisory (no leaf is dropped).', min_length=1, ), + NoExplicitNull, ] = None rank_by: Annotated[ list[RankByItem] | None, @@ -189,20 +204,23 @@ class EvaluatorSpec1(AdCPBaseModel): description='Optional RANK ordering over creative-feature values — an ordered list (most significant first) the agent uses to order the gate survivors into recommended/rank. An explicit {feature_id, direction} ordering rather than the feature-requirement predicate (which has no sort direction): the gate decides pass/fail, rank_by decides better/worse. Soft preference, never a gate: leaves are not dropped by rank_by, they are only ordered. Omit to let the evaluator/seller choose the ordering.', min_length=1, ), + NoExplicitNull, ] = None feature_agent: Annotated[ FeatureAgent | None, Field( description="Optional buyer-attached pointer to a get_creative_features-capable creative-feature / governance agent the producing agent calls to evaluate each leaf (the gate's SOURCE of feature values). This is the buyer-represents → seller-calls pattern #5280 established for provenance, generalized to the evaluator gate: the buyer REPRESENTS which agent it used, but the seller is the verifier-of-record and decides which agent it actually calls. `agent_url` MUST appear (canonicalized per /docs/reference/url-canonicalization) in the seller's `creative_policy.accepted_verifiers[].agent_url`; an off-list agent is rejected with `EVALUATOR_AGENT_NOT_ACCEPTED` (mirrors PROVENANCE_VERIFIER_NOT_ACCEPTED) before any outbound call. The outbound evaluator call authenticates on the transport; this pointer MUST NOT carry API keys, bearer tokens, client secrets, authorization values, JWKs, JWKS documents, or JWKS URIs. Reuses the same allowlist mechanism — no new allowlist is introduced. Distinct from the `agent_url` oneOf form, which names the evaluator's source directly; `feature_agent` attaches the gate's measurement source alongside any of the three forms." ), + NoExplicitNull, ] = None eval_budget: Annotated[ EvalBudget | None, Field( description='Optional soft ceiling on evaluation effort. Advisory in v1 with no billing coupling. Well-known soft fields max_calls / max_seconds; open for evaluator-specific knobs.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None exemplars: Annotated[ Exemplars, Field( diff --git a/src/adcp/types/domains/core/event.py b/src/adcp/types/domains/core/event.py index 96c9afd97..d0350947d 100644 --- a/src/adcp/types/domains/core/event.py +++ b/src/adcp/types/domains/core/event.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/event.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from ..enums import action_source as action_source_1 @@ -35,27 +35,35 @@ class Event(AdCPBaseModel): user_match: Annotated[ user_match_1.UserMatch | None, Field(description='User identifiers for attribution matching'), + NoExplicitNull, ] = None custom_data: Annotated[ event_custom_data.EventCustomData | None, Field(description='Event-specific data (value, currency, items, etc.)'), + NoExplicitNull, ] = None action_source: Annotated[ - action_source_1.ActionSource | None, Field(description='Where the event originated') + action_source_1.ActionSource | None, + Field(description='Where the event originated'), + NoExplicitNull, ] = None surface: Annotated[ event_surface.EventSurface | None, Field( description='Optional structured surface context for the event, such as an owned channel, profile, feed, podcast, newsletter list, website, app, or store. Complements `action_source`; use it when optimization-relevant meaning would otherwise live only in platform-specific `ext` metadata.' ), + NoExplicitNull, ] = None event_source_url: Annotated[ AnyUrl | None, Field( description="URL where the event occurred (required when action_source is 'website')" ), + NoExplicitNull, ] = None custom_event_name: Annotated[ - str | None, Field(description="Name for custom events (used when event_type is 'custom')") + str | None, + Field(description="Name for custom events (used when event_type is 'custom')"), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/event_custom_data.py b/src/adcp/types/domains/core/event_custom_data.py index 222cf806f..6096df326 100644 --- a/src/adcp/types/domains/core/event_custom_data.py +++ b/src/adcp/types/domains/core/event_custom_data.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/event_custom_data.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -17,11 +17,15 @@ class Content(AdCPBaseModel): extra='allow', ) id: Annotated[str, Field(description='Product or content identifier')] - quantity: Annotated[SchemaInt | None, Field(description='Quantity of this item', ge=1)] = None + quantity: Annotated[ + SchemaInt | None, Field(description='Quantity of this item', ge=1), NoExplicitNull + ] = None price: Annotated[ - StrictFloat | None, Field(description='Price per unit of this item', ge=0.0) + StrictFloat | None, Field(description='Price per unit of this item', ge=0.0), NoExplicitNull ] = None - brand: Annotated[str | None, Field(description='Brand name of this item')] = None + brand: Annotated[str | None, Field(description='Brand name of this item'), NoExplicitNull] = ( + None + ) class EventCustomData(AdCPBaseModel): @@ -34,6 +38,7 @@ class EventCustomData(AdCPBaseModel): description='Monetary value of the event. For an event source that declares value_currencies, currency is required and MUST appear in that list. Legacy sources without the new contract remain schema-compatible but are ineligible for canonical ROAS.', ge=0.0, ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -41,30 +46,37 @@ class EventCustomData(AdCPBaseModel): description='ISO 4217 currency code. For a source declaring value_currencies, monetary records MUST carry a currency from that list.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, + ] = None + order_id: Annotated[ + str | None, Field(description='Unique order or transaction identifier'), NoExplicitNull ] = None - order_id: Annotated[str | None, Field(description='Unique order or transaction identifier')] = ( - None - ) content_ids: Annotated[ list[str] | None, Field( description="Item identifiers for catalog attribution. Values are matched against catalog items using the identifier type declared by the catalog's content_id_type field (e.g., SKUs, GTINs, or vertical-specific IDs like job_id)." ), + NoExplicitNull, ] = None content_type: Annotated[ str | None, Field( description="Category of content associated with the event (e.g., 'product', 'job', 'hotel'). Corresponds to the catalog type when used for catalog attribution." ), + NoExplicitNull, + ] = None + content_name: Annotated[ + str | None, Field(description='Name of the product or content'), NoExplicitNull ] = None - content_name: Annotated[str | None, Field(description='Name of the product or content')] = None content_category: Annotated[ - str | None, Field(description='Category of the product or content') + str | None, Field(description='Category of the product or content'), NoExplicitNull ] = None num_items: Annotated[ - SchemaInt | None, Field(description='Number of items in the event', ge=0) + SchemaInt | None, Field(description='Number of items in the event', ge=0), NoExplicitNull + ] = None + search_string: Annotated[ + str | None, Field(description='Search query for search events'), NoExplicitNull ] = None - search_string: Annotated[str | None, Field(description='Search query for search events')] = None progress_percent: Annotated[ StrictFloat | None, Field( @@ -72,6 +84,7 @@ class EventCustomData(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None progress_seconds: Annotated[ StrictFloat | None, @@ -79,8 +92,11 @@ class EventCustomData(AdCPBaseModel): description='Content progress duration reached in seconds, primarily for `watch_milestone` events. Use when the milestone is time-based rather than percentage-based.', ge=0.0, ), + NoExplicitNull, ] = None contents: Annotated[ - list[Content] | None, Field(description='Per-item details for e-commerce events') + list[Content] | None, + Field(description='Per-item details for e-commerce events'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/event_source_health.py b/src/adcp/types/domains/core/event_source_health.py index 01c4f5c1f..d8e91c2c8 100644 --- a/src/adcp/types/domains/core/event_source_health.py +++ b/src/adcp/types/domains/core/event_source_health.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/event_source_health.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..enums import assessment_status @@ -32,6 +32,7 @@ class Detail(AdCPBaseModel): Field( description="Seller's name for this score (e.g., 'Event Quality Score', 'Event Match Quality')." ), + NoExplicitNull, ] = None @@ -50,6 +51,7 @@ class EventSourceHealth(AdCPBaseModel): Field( description='Seller-specific scoring detail. Only present when the seller has a native quality score to relay. Buyer agents should use status (not detail) for cross-seller decisions. Detail is supplementary context for human review or advanced diagnostics.' ), + NoExplicitNull, ] = None match_rate: Annotated[ StrictFloat | None, @@ -58,18 +60,21 @@ class EventSourceHealth(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None last_event_at: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp of the most recent event received from this source. Absent when no events have been received.' ), + NoExplicitNull, ] = None evaluated_at: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp of when this health assessment was computed. When health is derived from reporting data, this may lag real-time. Buyer agents can use this to decide whether to trust stale assessments or re-request.' ), + NoExplicitNull, ] = None events_received_24h: Annotated[ SchemaInt | None, @@ -77,10 +82,12 @@ class EventSourceHealth(AdCPBaseModel): description='Number of events received from this source in the last 24 hours. Zero indicates the source is configured but not firing.', ge=0, ), + NoExplicitNull, ] = None issues: Annotated[ list[diagnostic_issue.DiagnosticIssue] | None, Field( description='Actionable issues detected with this event source. Sellers should limit to the top 3-5 most actionable items. Buyer agents should sort by severity rather than relying on array position.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/event_surface.py b/src/adcp/types/domains/core/event_surface.py index 1255d8a3c..f20f1b5e6 100644 --- a/src/adcp/types/domains/core/event_surface.py +++ b/src/adcp/types/domains/core/event_surface.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/event_surface.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -43,6 +43,7 @@ class EventSurface(AdCPBaseModel): max_length=128, min_length=1, ), + NoExplicitNull, ] = None namespace: Annotated[ str | None, @@ -51,6 +52,7 @@ class EventSurface(AdCPBaseModel): max_length=128, min_length=1, ), + NoExplicitNull, ] = None property_id: Annotated[ str | None, @@ -59,5 +61,6 @@ class EventSurface(AdCPBaseModel): max_length=256, min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/feature_requirement.py b/src/adcp/types/domains/core/feature_requirement.py index 101e00bf0..bdfafb329 100644 --- a/src/adcp/types/domains/core/feature_requirement.py +++ b/src/adcp/types/domains/core/feature_requirement.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/feature_requirement.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -26,10 +26,12 @@ class FeatureRequirement(AdCPBaseModel): min_value: Annotated[ StrictFloat | None, Field(description='Minimum numeric value required (for quantitative features)'), + NoExplicitNull, ] = None max_value: Annotated[ StrictFloat | None, Field(description='Maximum numeric value allowed (for quantitative features)'), + NoExplicitNull, ] = None allowed_values: Annotated[ list[Any] | None, @@ -37,16 +39,19 @@ class FeatureRequirement(AdCPBaseModel): description='Values that pass the requirement (for binary/categorical features)', min_length=1, ), + NoExplicitNull, ] = None if_not_covered: Annotated[ IfNotCovered | None, Field( description="How to handle properties where this feature is not covered. 'exclude' (default): property is removed from the list. 'include': property passes this requirement (fail-open)." ), + NoExplicitNull, ] = IfNotCovered.exclude policy_id: Annotated[ str | None, Field( description='Optional attribution — when this requirement encodes a specific buyer-chosen threshold authorized by a policy, policy_id references the authorizing PolicyEntry. Producers populate when the mechanism exists because of a specific policy (e.g., max_value: 15 on audience_children_composition because of uk_hfss); do NOT populate when the requirement is a general filter unrelated to any policy. Governance findings echo this policy_id when emitting denials traced to the requirement. See /docs/governance/policy-attribution.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/flight_item.py b/src/adcp/types/domains/core/flight_item.py index eacf6d9c7..4f065dd4c 100644 --- a/src/adcp/types/domains/core/flight_item.py +++ b/src/adcp/types/domains/core/flight_item.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/flight_item.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from . import ext as ext_1 @@ -22,9 +22,9 @@ class Origin(AdCPBaseModel): str, Field(description="IATA airport code (e.g., 'AMS', 'JFK', 'LHR').", pattern='^[A-Z]{3}$'), ] - city: Annotated[str | None, Field(description="City name (e.g., 'Amsterdam', 'New York').")] = ( - None - ) + city: Annotated[ + str | None, Field(description="City name (e.g., 'Amsterdam', 'New York')."), NoExplicitNull + ] = None class Destination(AdCPBaseModel): @@ -32,7 +32,7 @@ class Destination(AdCPBaseModel): extra='forbid', ) airport_code: Annotated[str, Field(description='IATA airport code.', pattern='^[A-Z]{3}$')] - city: Annotated[str | None, Field(description='City name.')] = None + city: Annotated[str | None, Field(description='City name.'), NoExplicitNull] = None class FlightItem(AdCPBaseModel): @@ -44,29 +44,38 @@ class FlightItem(AdCPBaseModel): ] origin: Annotated[Origin, Field(description='Departure airport or city.')] destination: Annotated[Destination, Field(description='Arrival airport or city.')] - airline: Annotated[str | None, Field(description='Airline name or IATA airline code.')] = None - price: Annotated[price_1.Price | None, Field(description='Ticket price or starting fare.')] = ( - None - ) + airline: Annotated[ + str | None, Field(description='Airline name or IATA airline code.'), NoExplicitNull + ] = None + price: Annotated[ + price_1.Price | None, Field(description='Ticket price or starting fare.'), NoExplicitNull + ] = None description: Annotated[ - str | None, Field(description='Route description or promotional text.') + str | None, Field(description='Route description or promotional text.'), NoExplicitNull ] = None departure_time: Annotated[ - AwareDatetime | None, Field(description='Departure date and time (ISO 8601).') + AwareDatetime | None, + Field(description='Departure date and time (ISO 8601).'), + NoExplicitNull, ] = None arrival_time: Annotated[ - AwareDatetime | None, Field(description='Arrival date and time (ISO 8601).') + AwareDatetime | None, Field(description='Arrival date and time (ISO 8601).'), NoExplicitNull ] = None image_url: Annotated[ - AnyUrl | None, Field(description='Promotional image URL (typically a destination photo).') + AnyUrl | None, + Field(description='Promotional image URL (typically a destination photo).'), + NoExplicitNull, + ] = None + url: Annotated[ + AnyUrl | None, Field(description='Booking page URL for this route.'), NoExplicitNull ] = None - url: Annotated[AnyUrl | None, Field(description='Booking page URL for this route.')] = None tags: Annotated[ list[str] | None, Field( description="Tags for filtering (e.g., 'direct', 'red-eye', 'business-class').", min_length=1, ), + NoExplicitNull, ] = None assets: Annotated[ list[offering_asset_group.OfferingAssetGroup] | None, @@ -74,5 +83,6 @@ class FlightItem(AdCPBaseModel): description="Typed creative asset pools for this flight. Uses the same OfferingAssetGroup structure as offering-type catalogs. Standard group IDs: 'images_landscape' (destination hero), 'images_vertical' (9:16 for Stories), 'images_square' (1:1). Enables formats to declare typed image requirements that map unambiguously to the right asset regardless of platform.", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/forecast_dimension_audience.py b/src/adcp/types/domains/core/forecast_dimension_audience.py index d523f7699..eff799c62 100644 --- a/src/adcp/types/domains/core/forecast_dimension_audience.py +++ b/src/adcp/types/domains/core/forecast_dimension_audience.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/forecast_dimension_audience.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import audience_source as audience_source_1 @@ -24,5 +24,5 @@ class AudienceForecastDimension(AdCPBaseModel): audience_source_1.AudienceSource, Field(description='Origin of the audience segment.') ] audience_name: Annotated[ - str | None, Field(description='Human-readable audience segment name.') + str | None, Field(description='Human-readable audience segment name.'), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/core/forecast_dimension_geo.py b/src/adcp/types/domains/core/forecast_dimension_geo.py index c53d9c428..6f3b1432c 100644 --- a/src/adcp/types/domains/core/forecast_dimension_geo.py +++ b/src/adcp/types/domains/core/forecast_dimension_geo.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/forecast_dimension_geo.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import geo_level as geo_level_1 @@ -26,6 +26,7 @@ class GeoForecastDimension(AdCPBaseModel): Field( description="Classification system for metro or postal_area levels. Required when geo_level is 'metro' or 'postal_area'. Metro rows use metro-system enum values such as 'nielsen_dma'; native postal rows use country-local postal-system enum values such as 'zip' with country 'US'; deprecated legacy postal rows may use legacy-postal-system enum values such as 'us_zip'. Omit for country and region rows." ), + NoExplicitNull, ] = None country: Annotated[ str | None, @@ -33,6 +34,7 @@ class GeoForecastDimension(AdCPBaseModel): description='ISO 3166-1 alpha-2 country code. Required for native postal_area rows and omitted for legacy postal rows, metro rows, country rows, and region rows.', pattern='^[A-Z]{2}$', ), + NoExplicitNull, ] = None geo_code: Annotated[ str, @@ -45,4 +47,5 @@ class GeoForecastDimension(AdCPBaseModel): Field( description="Human-readable geographic name (e.g., 'United States', 'California', 'New York DMA')." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/forecast_dimension_placement.py b/src/adcp/types/domains/core/forecast_dimension_placement.py index d0baa3c53..80537f788 100644 --- a/src/adcp/types/domains/core/forecast_dimension_placement.py +++ b/src/adcp/types/domains/core/forecast_dimension_placement.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/forecast_dimension_placement.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import placement_ref as placement_ref_1 @@ -28,4 +28,5 @@ class PlacementForecastDimension(AdCPBaseModel): Field( description='Human-readable placement name, useful when the buyer has not resolved the placement catalog.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/forecast_dimension_signal.py b/src/adcp/types/domains/core/forecast_dimension_signal.py index 2105ae6cb..e53511c3a 100644 --- a/src/adcp/types/domains/core/forecast_dimension_signal.py +++ b/src/adcp/types/domains/core/forecast_dimension_signal.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/forecast_dimension_signal.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from . import signal_ref as signal_ref_1 @@ -31,6 +31,7 @@ class SignalForecastDimension(AdCPBaseModel): Field( description='Canonical signal reference for this forecast row. Required when the row needs to disambiguate product-local, data-provider, or signal-source identity. Product-relative forecasts SHOULD use signal_ref.' ), + NoExplicitNull, ] = None signal_id: Annotated[ str | None, @@ -38,6 +39,7 @@ class SignalForecastDimension(AdCPBaseModel): description='Signal identifier shorthand for this forecast row. Use only when the enclosing context already identifies the signal unambiguously, such as a coverage_forecast nested directly under one get_signals signal item. Otherwise use signal_ref.', pattern='^[a-zA-Z0-9_-]+$', ), + NoExplicitNull, ] = None signal_value: Annotated[ str | StrictFloat | StrictBool | None, @@ -56,9 +58,12 @@ class SignalForecastDimension(AdCPBaseModel): Field( description='Human-readable signal name, useful when the buyer has not resolved the signal definition.' ), + NoExplicitNull, ] = None signal_value_name: Annotated[ - str | None, Field(description='Human-readable label for the signal value bucket.') + str | None, + Field(description='Human-readable label for the signal value bucket.'), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/forecast_point.py b/src/adcp/types/domains/core/forecast_point.py index 33eaa000d..04a7b12c9 100644 --- a/src/adcp/types/domains/core/forecast_point.py +++ b/src/adcp/types/domains/core/forecast_point.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/forecast_point.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Dict -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import availability_status as availability_status_1 @@ -19,14 +19,17 @@ class CoverageRate(ForecastRange): low: Annotated[ StrictFloat | None, Field(description='Conservative (low-end) forecast value', ge=0.0, le=1.0), + NoExplicitNull, ] = None mid: Annotated[ StrictFloat | None, Field(description='Expected (most likely) forecast value', ge=0.0, le=1.0), + NoExplicitNull, ] = None high: Annotated[ StrictFloat | None, Field(description='Optimistic (high-end) forecast value', ge=0.0, le=1.0), + NoExplicitNull, ] = None @@ -35,27 +38,28 @@ class Metrics(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - audience_size: forecast_range.ForecastRange | None = None - reach: forecast_range.ForecastRange | None = None - frequency: forecast_range.ForecastRange | None = None - impressions: forecast_range.ForecastRange | None = None - clicks: forecast_range.ForecastRange | None = None - spend: forecast_range.ForecastRange | None = None - views: forecast_range.ForecastRange | None = None - completed_views: forecast_range.ForecastRange | None = None - grps: forecast_range.ForecastRange | None = None - engagements: forecast_range.ForecastRange | None = None - follows: forecast_range.ForecastRange | None = None - saves: forecast_range.ForecastRange | None = None - profile_visits: forecast_range.ForecastRange | None = None - measured_impressions: forecast_range.ForecastRange | None = None - downloads: forecast_range.ForecastRange | None = None - plays: forecast_range.ForecastRange | None = None + audience_size: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + reach: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + frequency: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + impressions: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + clicks: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + spend: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + views: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + completed_views: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + grps: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + engagements: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + follows: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + saves: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + profile_visits: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + measured_impressions: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + downloads: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + plays: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None coverage_rate: Annotated[ CoverageRate | None, Field( description="Share of the declared forecast scope represented by this point. For signal coverage forecasts, this is the point's count divided by the coverage_forecast.scope denominator. Range 0.0 to 1.0." ), + NoExplicitNull, ] = None @@ -72,34 +76,40 @@ class Viewability(AdCPBaseModel): vendor: Annotated[ brand_ref.BrandReference | None, Field(description='Vendor expected to produce or verify these viewability values.'), + NoExplicitNull, ] = None measurable_impressions: Annotated[ forecast_range.ForecastRange | None, Field( description='Forecasted impressions where viewability can be measured. Coverage denominator for viewable_rate and viewed_seconds.' ), + NoExplicitNull, ] = None viewable_impressions: Annotated[ forecast_range.ForecastRange | None, Field( description='Forecasted impressions expected to meet the viewability threshold defined by standard.' ), + NoExplicitNull, ] = None viewable_rate: Annotated[ ViewableRate | None, Field( description='Forecasted viewable impression rate (viewable_impressions / measurable_impressions). Range 0.0 to 1.0.' ), + NoExplicitNull, ] = None viewed_seconds: Annotated[ forecast_range.ForecastRange | None, Field( description='Forecasted average in-view duration per measurable impression, in seconds.' ), + NoExplicitNull, ] = None standard: Annotated[ viewability_standard.ViewabilityStandard | None, Field(description='Viewability measurement standard applied to these forecasted values.'), + NoExplicitNull, ] = None @@ -114,6 +124,7 @@ class ForecastPoint(AdCPBaseModel): examples=['Primetime', 'Morning Drive', 'Large Format Transit'], max_length=128, ), + NoExplicitNull, ] = None budget: Annotated[ StrictFloat | None, @@ -121,24 +132,28 @@ class ForecastPoint(AdCPBaseModel): description='Budget amount for this forecast point. Required for spend curves; omit for availability forecasts where the metrics represent total available inventory. For allocation-level forecasts, this is the absolute budget for that allocation (not the percentage). For proposal-level forecasts, this is the total proposal budget. When omitted, use metrics.spend to express the estimated cost of the available inventory.', ge=0.0, ), + NoExplicitNull, ] = None product_id: Annotated[ str | None, Field( description='Optional product context for this forecast row. Usually omitted on product-level and allocation-level forecasts where the product is already implied. On proposal-level forecasts, populate when a dimensional row, especially a placement row, maps to a specific product allocation so buyers can turn the row into an executable package choice. Omit for true aggregate proposal rows spanning multiple products.' ), + NoExplicitNull, ] = None dimensions: Annotated[ forecast_point_dimensions.ForecastPointDimensions | None, Field( description='Dimension constraints represented by this forecast point, such as country, region, placement, device type, platform, audience, signal value, time window, or intersections such as placement x country or product x signal. Each item declares one dimension family; when multiple items are present, the point represents their intersection. Sellers MUST NOT emit more than one item for each `kind` on a point; consumers MUST NOT treat repeated kinds as OR semantics. Use multiple points with dimensions to expose country/placement/signal availability within one product, proposal, or signal coverage forecast without creating separate products solely for each dimension. Dimensions describe the forecast row and are independent of pricing_options.' ), + NoExplicitNull, ] = None availability_status: Annotated[ availability_status_1.AvailabilityStatus | None, Field( description="Bookability of the inventory this row describes, as of the forecast's generated_at. Most meaningful on rows with a time dimension in an availability forecast (forecast_range_unit 'availability'). This is a snapshot, not a hold: valid_until bounds freshness, and proposal finalization or purchase remains the commitment boundary. When omitted, the row makes no bookability claim. 'unavailable' rows may carry empty metrics." ), + NoExplicitNull, ] = None metrics: Annotated[ Metrics, @@ -151,10 +166,12 @@ class ForecastPoint(AdCPBaseModel): Field( description='Forecasted viewability metrics. Mirrors delivery-metrics.viewability, but numeric values are ForecastRange objects because forecast rows may provide low/mid/high bounds. Use this for pre-buy viewability expectations by forecast point without folding measurement metrics into pricing_options.' ), + NoExplicitNull, ] = None vendor_metric_values: Annotated[ list[forecast_vendor_metric_value.ForecastVendorMetricValue] | None, Field( description="Forecasted values for vendor-defined metrics that the product's reporting_capabilities.vendor_metrics declared. Mirrors delivery-metrics.vendor_metric_values, but value and measurable_impressions use ForecastRange. These forecasted measurement values are independent of pricing_options." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/forecast_range.py b/src/adcp/types/domains/core/forecast_range.py index fd237d1c2..606390671 100644 --- a/src/adcp/types/domains/core/forecast_range.py +++ b/src/adcp/types/domains/core/forecast_range.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/forecast_range.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat, model_validator @@ -18,13 +18,19 @@ class ForecastRange(AdCPBaseModel): extra='allow', ) low: Annotated[ - StrictFloat | None, Field(description='Conservative (low-end) forecast value', ge=0.0) + StrictFloat | None, + Field(description='Conservative (low-end) forecast value', ge=0.0), + NoExplicitNull, ] = None mid: Annotated[ - StrictFloat | None, Field(description='Expected (most likely) forecast value', ge=0.0) + StrictFloat | None, + Field(description='Expected (most likely) forecast value', ge=0.0), + NoExplicitNull, ] = None high: Annotated[ - StrictFloat | None, Field(description='Optimistic (high-end) forecast value', ge=0.0) + StrictFloat | None, + Field(description='Optimistic (high-end) forecast value', ge=0.0), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/forecast_rate_range.py b/src/adcp/types/domains/core/forecast_rate_range.py index b91ad0e10..8bf39cf1d 100644 --- a/src/adcp/types/domains/core/forecast_rate_range.py +++ b/src/adcp/types/domains/core/forecast_rate_range.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/forecast_rate_range.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import Field, StrictFloat from .forecast_range import ForecastRange @@ -15,12 +16,15 @@ class ForecastRateRange(ForecastRange): low: Annotated[ StrictFloat | None, Field(description='Conservative (low-end) forecast value', ge=0.0, le=1.0), + NoExplicitNull, ] = None mid: Annotated[ StrictFloat | None, Field(description='Expected (most likely) forecast value', ge=0.0, le=1.0), + NoExplicitNull, ] = None high: Annotated[ StrictFloat | None, Field(description='Optimistic (high-end) forecast value', ge=0.0, le=1.0), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/forecast_vendor_metric_value.py b/src/adcp/types/domains/core/forecast_vendor_metric_value.py index 9ce6f425b..d9318dc0d 100644 --- a/src/adcp/types/domains/core/forecast_vendor_metric_value.py +++ b/src/adcp/types/domains/core/forecast_vendor_metric_value.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/forecast_vendor_metric_value.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import brand_ref, forecast_range, vendor_metric_id @@ -40,28 +40,33 @@ class ForecastVendorMetricValue(AdCPBaseModel): description="Unit of the value. Free-form to accommodate heterogeneous vendor metrics (e.g., 'score', 'seconds', 'persons', 'gCO2e', 'USD', 'lift_percent', 'index'). When populated inline, SHOULD match the vendor's published unit.", examples=['score', 'seconds', 'persons', 'gCO2e', 'USD', 'lift_percent', 'index'], ), + NoExplicitNull, ] = None measurable_impressions: Annotated[ forecast_range.ForecastRange | None, Field( description='Forecasted number of impressions the vendor expects to be able to measure. Coverage denominator for the vendor metric; buyers compute estimated coverage as measurable_impressions / impressions when both are present. For play-based channels (DOOH, cinema, place-based) forecast measurable_plays or measurable_play_seconds instead.' ), + NoExplicitNull, ] = None measurable_plays: Annotated[ forecast_range.ForecastRange | None, Field( description='Forecasted number of plays the vendor expects to be able to measure. Coverage denominator for channels where a play, not an impression, is the atomic observation unit; buyers compute estimated coverage as measurable_plays / plays when both are present.' ), + NoExplicitNull, ] = None measurable_play_seconds: Annotated[ forecast_range.ForecastRange | None, Field( description='Forecasted play-seconds (creative playout seconds summed across endpoints — screens, speakers, players) the vendor expects to be able to measure. Coverage denominator when the vendor meters exposure duration rather than discrete plays.' ), + NoExplicitNull, ] = None breakdown: Annotated[ dict[str, Any] | None, Field( description="Optional structured payload for vendor metrics that do not fit a single scalar. Forecast rows SHOULD use ForecastRange values inside breakdown when sub-values are numeric forecasts. Buyers MUST treat this object as opaque without consulting the vendor's documentation." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/format.py b/src/adcp/types/domains/core/format.py index 67e3670fc..56fa0cba1 100644 --- a/src/adcp/types/domains/core/format.py +++ b/src/adcp/types/domains/core/format.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/format.json -# timestamp: 2026-10-13T11:29:35+00:00 +# timestamp: 2026-10-22T12:24:23+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat from ..enums import ( @@ -71,33 +71,46 @@ class Dimensions(AdCPBaseModel): width: Annotated[ StrictFloat | None, Field(description='Fixed width. Interpretation depends on unit (default: pixels).', gt=0.0), + NoExplicitNull, ] = None height: Annotated[ StrictFloat | None, Field( description='Fixed height. Interpretation depends on unit (default: pixels).', gt=0.0 ), + NoExplicitNull, ] = None min_width: Annotated[ - StrictFloat | None, Field(description='Minimum width for responsive renders', gt=0.0) + StrictFloat | None, + Field(description='Minimum width for responsive renders', gt=0.0), + NoExplicitNull, ] = None min_height: Annotated[ - StrictFloat | None, Field(description='Minimum height for responsive renders', gt=0.0) + StrictFloat | None, + Field(description='Minimum height for responsive renders', gt=0.0), + NoExplicitNull, ] = None max_width: Annotated[ - StrictFloat | None, Field(description='Maximum width for responsive renders', gt=0.0) + StrictFloat | None, + Field(description='Maximum width for responsive renders', gt=0.0), + NoExplicitNull, ] = None max_height: Annotated[ - StrictFloat | None, Field(description='Maximum height for responsive renders', gt=0.0) + StrictFloat | None, + Field(description='Maximum height for responsive renders', gt=0.0), + NoExplicitNull, ] = None unit: Annotated[ dimension_unit.DimensionUnit | None, Field( description="Unit of measurement for width/height values. Defaults to 'px' when absent. Print formats use 'inches' or 'cm'." ), + NoExplicitNull, ] = None responsive: Annotated[ - Responsive | None, Field(description='Indicates which dimensions are responsive/fluid') + Responsive | None, + Field(description='Indicates which dimensions are responsive/fluid'), + NoExplicitNull, ] = None aspect_ratio: Annotated[ str | None, @@ -105,6 +118,7 @@ class Dimensions(AdCPBaseModel): description="Fixed aspect ratio constraint (e.g., '16:9', '4:3', '1:1', '1.91:1')", pattern='^\\d+(\\.\\d+)?:\\d+(\\.\\d+)?$', ), + NoExplicitNull, ] = None @@ -120,6 +134,7 @@ class Renders(AdCPBaseModel): Field( description='When true, parameters for this render (dimensions and/or duration) are specified in the format_id. Used for template formats that accept parameters. Mutually exclusive with specifying dimensions object explicitly.' ), + NoExplicitNull, ] = None dimensions: Annotated[ Dimensions, @@ -151,6 +166,7 @@ class Renders1(AdCPBaseModel): Field( description='Dimensions for this rendered piece. Defaults to pixels when unit is absent.' ), + NoExplicitNull, ] = None @@ -210,6 +226,7 @@ class Accessibility(AdCPBaseModel): Field( description='When true, all assets with x-accessibility fields must include those fields. For inspectable assets (image, video, audio), this means providing accessibility metadata like alt_text or captions. For opaque assets (HTML, JavaScript), this means providing self-declared accessibility properties.' ), + NoExplicitNull, ] = None @@ -247,6 +264,7 @@ class BaseIndividualAsset(AdCPBaseModel): Field( description="Descriptive label for this asset's purpose (e.g., 'hero_image', 'logo', 'third_party_tracking'). For documentation and UI display only — manifests key assets by asset_id, not asset_role." ), + NoExplicitNull, ] = None required: Annotated[ StrictBool, @@ -259,12 +277,14 @@ class BaseIndividualAsset(AdCPBaseModel): Field( description="Publisher-controlled elements rendered on top of buyer content at this asset's position (e.g., video player controls, publisher logos). Creative agents should avoid placing critical content (CTAs, logos, key copy) within overlay bounds." ), + NoExplicitNull, ] = None asset_group_id: Annotated[ str | None, Field( description="Optional canonical asset_group_id this slot fills, drawn from /schemas/core/asset-group-vocabulary.json. Lets buyers and migration tools resolve v1 author-invented slot names (e.g., `click_url`) to canonical names (e.g., `landing_page_url`). Validators MAY soft-warn when a v1 slot's asset_id is a known alias but no asset_group_id is declared." ), + NoExplicitNull, ] = None @@ -275,12 +295,14 @@ class BaseGroupAsset(AdCPBaseModel): Field( description="Descriptive label for this asset's purpose. For documentation and UI display only — manifests key assets by asset_id, not asset_role." ), + NoExplicitNull, ] = None asset_group_id: Annotated[ str | None, Field( description='Optional canonical asset_group_id this slot fills, drawn from /schemas/core/asset-group-vocabulary.json. Same semantics as on baseIndividualAsset — lets buyers and migration tools resolve v1 author-invented slot names to canonical names.' ), + NoExplicitNull, ] = None required: Annotated[ StrictBool, @@ -291,55 +313,72 @@ class BaseGroupAsset(AdCPBaseModel): Field( description="Publisher-controlled elements rendered on top of buyer content at this asset's position (e.g., carousel navigation arrows, slide indicators). Creative agents should avoid placing critical content within overlay bounds." ), + NoExplicitNull, ] = None class Assets(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['image'] = 'image' - requirements: image_asset_requirements.ImageAssetRequirements | None = None + requirements: Annotated[ + image_asset_requirements.ImageAssetRequirements | None, NoExplicitNull + ] = None class Assets9(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['video'] = 'video' - requirements: video_asset_requirements.VideoAssetRequirements | None = None + requirements: Annotated[ + video_asset_requirements.VideoAssetRequirements | None, NoExplicitNull + ] = None class Assets10(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['audio'] = 'audio' - requirements: audio_asset_requirements.AudioAssetRequirements | None = None + requirements: Annotated[ + audio_asset_requirements.AudioAssetRequirements | None, NoExplicitNull + ] = None class Assets11(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['text'] = 'text' - requirements: text_asset_requirements.TextAssetRequirements | None = None + requirements: Annotated[ + text_asset_requirements.TextAssetRequirements | None, NoExplicitNull + ] = None class Assets12(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['markdown'] = 'markdown' - requirements: markdown_asset_requirements.MarkdownAssetRequirements | None = None + requirements: Annotated[ + markdown_asset_requirements.MarkdownAssetRequirements | None, NoExplicitNull + ] = None class Assets13(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['html'] = 'html' - requirements: html_asset_requirements.HtmlAssetRequirements | None = None + requirements: Annotated[ + html_asset_requirements.HtmlAssetRequirements | None, NoExplicitNull + ] = None class Assets14(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['css'] = 'css' - requirements: css_asset_requirements.CssAssetRequirements | None = None + requirements: Annotated[css_asset_requirements.CssAssetRequirements | None, NoExplicitNull] = ( + None + ) class Assets15(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['javascript'] = 'javascript' - requirements: javascript_asset_requirements.JavascriptAssetRequirements | None = None + requirements: Annotated[ + javascript_asset_requirements.JavascriptAssetRequirements | None, NoExplicitNull + ] = None class Assets16(BaseIndividualAsset): @@ -350,25 +389,33 @@ class Assets16(BaseIndividualAsset): class Assets17(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['vast'] = 'vast' - requirements: vast_asset_requirements.VastAssetRequirements | None = None + requirements: Annotated[ + vast_asset_requirements.VastAssetRequirements | None, NoExplicitNull + ] = None class Assets18(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['daast'] = 'daast' - requirements: daast_asset_requirements.DaastAssetRequirements | None = None + requirements: Annotated[ + daast_asset_requirements.DaastAssetRequirements | None, NoExplicitNull + ] = None class Assets19(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['url'] = 'url' - requirements: url_asset_requirements.UrlAssetRequirements | None = None + requirements: Annotated[url_asset_requirements.UrlAssetRequirements | None, NoExplicitNull] = ( + None + ) class Assets20(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['webhook'] = 'webhook' - requirements: webhook_asset_requirements.WebhookAssetRequirements | None = None + requirements: Annotated[ + webhook_asset_requirements.WebhookAssetRequirements | None, NoExplicitNull + ] = None class Assets21(BaseIndividualAsset): @@ -408,42 +455,58 @@ class Assets28(BaseIndividualAsset): class Assets31(BaseGroupAsset): asset_type: Literal['image'] = 'image' - requirements: image_asset_requirements.ImageAssetRequirements | None = None + requirements: Annotated[ + image_asset_requirements.ImageAssetRequirements | None, NoExplicitNull + ] = None class Assets32(BaseGroupAsset): asset_type: Literal['video'] = 'video' - requirements: video_asset_requirements.VideoAssetRequirements | None = None + requirements: Annotated[ + video_asset_requirements.VideoAssetRequirements | None, NoExplicitNull + ] = None class Assets33(BaseGroupAsset): asset_type: Literal['audio'] = 'audio' - requirements: audio_asset_requirements.AudioAssetRequirements | None = None + requirements: Annotated[ + audio_asset_requirements.AudioAssetRequirements | None, NoExplicitNull + ] = None class Assets34(BaseGroupAsset): asset_type: Literal['text'] = 'text' - requirements: text_asset_requirements.TextAssetRequirements | None = None + requirements: Annotated[ + text_asset_requirements.TextAssetRequirements | None, NoExplicitNull + ] = None class Assets35(BaseGroupAsset): asset_type: Literal['markdown'] = 'markdown' - requirements: markdown_asset_requirements.MarkdownAssetRequirements | None = None + requirements: Annotated[ + markdown_asset_requirements.MarkdownAssetRequirements | None, NoExplicitNull + ] = None class Assets36(BaseGroupAsset): asset_type: Literal['html'] = 'html' - requirements: html_asset_requirements.HtmlAssetRequirements | None = None + requirements: Annotated[ + html_asset_requirements.HtmlAssetRequirements | None, NoExplicitNull + ] = None class Assets37(BaseGroupAsset): asset_type: Literal['css'] = 'css' - requirements: css_asset_requirements.CssAssetRequirements | None = None + requirements: Annotated[css_asset_requirements.CssAssetRequirements | None, NoExplicitNull] = ( + None + ) class Assets38(BaseGroupAsset): asset_type: Literal['javascript'] = 'javascript' - requirements: javascript_asset_requirements.JavascriptAssetRequirements | None = None + requirements: Annotated[ + javascript_asset_requirements.JavascriptAssetRequirements | None, NoExplicitNull + ] = None class Assets39(BaseGroupAsset): @@ -452,22 +515,30 @@ class Assets39(BaseGroupAsset): class Assets40(BaseGroupAsset): asset_type: Literal['vast'] = 'vast' - requirements: vast_asset_requirements.VastAssetRequirements | None = None + requirements: Annotated[ + vast_asset_requirements.VastAssetRequirements | None, NoExplicitNull + ] = None class Assets41(BaseGroupAsset): asset_type: Literal['daast'] = 'daast' - requirements: daast_asset_requirements.DaastAssetRequirements | None = None + requirements: Annotated[ + daast_asset_requirements.DaastAssetRequirements | None, NoExplicitNull + ] = None class Assets42(BaseGroupAsset): asset_type: Literal['url'] = 'url' - requirements: url_asset_requirements.UrlAssetRequirements | None = None + requirements: Annotated[url_asset_requirements.UrlAssetRequirements | None, NoExplicitNull] = ( + None + ) class Assets43(BaseGroupAsset): asset_type: Literal['webhook'] = 'webhook' - requirements: webhook_asset_requirements.WebhookAssetRequirements | None = None + requirements: Annotated[ + webhook_asset_requirements.WebhookAssetRequirements | None, NoExplicitNull + ] = None class Assets44(BaseGroupAsset): @@ -501,12 +572,12 @@ class Assets51(BaseGroupAsset): class Assets22(BaseIndividualAsset): item_type: Literal['individual'] = 'individual' asset_type: Literal['catalog'] = 'catalog' - requirements: catalog_requirements.CatalogRequirements | None = None + requirements: Annotated[catalog_requirements.CatalogRequirements | None, NoExplicitNull] = None class Assets45(BaseGroupAsset): asset_type: Literal['catalog'] = 'catalog' - requirements: catalog_requirements.CatalogRequirements | None = None + requirements: Annotated[catalog_requirements.CatalogRequirements | None, NoExplicitNull] = None Assets30 = Annotated[ @@ -656,6 +727,7 @@ class Assets29(AdCPBaseModel): Field( description="How the platform uses repetitions of this group. 'sequential' means all items display in order (carousels, playlists). 'optimize' means the platform selects the best-performing combination from alternatives (asset group optimization like Meta Advantage+ or Google Pmax)." ), + NoExplicitNull, ] = SelectionMode.sequential assets: Annotated[ list[GroupFormatAssetUnion], Field(description='Assets within each repetition of this group') @@ -668,6 +740,7 @@ class CanonicalParameters1(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -675,12 +748,14 @@ class CanonicalParameters1(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -688,60 +763,70 @@ class CanonicalParameters1(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -749,12 +834,14 @@ class CanonicalParameters1(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['image'] = 'image' params: image.CanonicalFormatImage @@ -766,6 +853,7 @@ class CanonicalParameters2(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -773,12 +861,14 @@ class CanonicalParameters2(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -786,60 +876,70 @@ class CanonicalParameters2(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -847,12 +947,14 @@ class CanonicalParameters2(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['html5'] = 'html5' params: html5.CanonicalFormatHtml5Banner @@ -864,6 +966,7 @@ class CanonicalParameters3(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -871,12 +974,14 @@ class CanonicalParameters3(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -884,60 +989,70 @@ class CanonicalParameters3(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -945,12 +1060,14 @@ class CanonicalParameters3(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['display_tag'] = 'display_tag' params: display_tag.CanonicalFormatDisplayTag @@ -962,6 +1079,7 @@ class CanonicalParameters4(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -969,12 +1087,14 @@ class CanonicalParameters4(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -982,60 +1102,70 @@ class CanonicalParameters4(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1043,12 +1173,14 @@ class CanonicalParameters4(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['image_carousel'] = 'image_carousel' params: image_carousel.CanonicalFormatImageCarousel @@ -1060,6 +1192,7 @@ class CanonicalParameters5(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1067,12 +1200,14 @@ class CanonicalParameters5(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1080,60 +1215,70 @@ class CanonicalParameters5(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1141,12 +1286,14 @@ class CanonicalParameters5(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['video_hosted'] = 'video_hosted' params: video_hosted.CanonicalFormatHostedVideo @@ -1158,6 +1305,7 @@ class CanonicalParameters6(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1165,12 +1313,14 @@ class CanonicalParameters6(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1178,60 +1328,70 @@ class CanonicalParameters6(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1239,12 +1399,14 @@ class CanonicalParameters6(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['video_vast'] = 'video_vast' params: video_vast.CanonicalFormatVastVideo @@ -1256,6 +1418,7 @@ class CanonicalParameters7(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1263,12 +1426,14 @@ class CanonicalParameters7(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1276,60 +1441,70 @@ class CanonicalParameters7(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1337,12 +1512,14 @@ class CanonicalParameters7(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_hosted'] = 'audio_hosted' params: audio_hosted.CanonicalFormatHostedAudio @@ -1354,6 +1531,7 @@ class CanonicalParameters8(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1361,12 +1539,14 @@ class CanonicalParameters8(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1374,60 +1554,70 @@ class CanonicalParameters8(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1435,12 +1625,14 @@ class CanonicalParameters8(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_vast'] = 'audio_vast' params: audio_vast.CanonicalFormatVastAudio @@ -1452,6 +1644,7 @@ class CanonicalParameters9(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1459,12 +1652,14 @@ class CanonicalParameters9(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1472,60 +1667,70 @@ class CanonicalParameters9(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1533,12 +1738,14 @@ class CanonicalParameters9(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_daast'] = 'audio_daast' params: audio_daast.CanonicalFormatDaastAudio @@ -1550,6 +1757,7 @@ class CanonicalParameters10(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1557,12 +1765,14 @@ class CanonicalParameters10(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1570,60 +1780,70 @@ class CanonicalParameters10(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1631,12 +1851,14 @@ class CanonicalParameters10(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['sponsored_placement'] = 'sponsored_placement' params: sponsored_placement.CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven @@ -1648,6 +1870,7 @@ class CanonicalParameters11(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1655,12 +1878,14 @@ class CanonicalParameters11(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1668,60 +1893,70 @@ class CanonicalParameters11(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1729,12 +1964,14 @@ class CanonicalParameters11(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['native_in_feed'] = 'native_in_feed' params: native_in_feed.CanonicalFormatNativeInFeed @@ -1746,6 +1983,7 @@ class CanonicalParameters12(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1753,12 +1991,14 @@ class CanonicalParameters12(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1766,60 +2006,70 @@ class CanonicalParameters12(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1827,12 +2077,14 @@ class CanonicalParameters12(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['responsive_creative'] = 'responsive_creative' params: responsive_creative.CanonicalFormatResponsiveCreative @@ -1844,6 +2096,7 @@ class CanonicalParameters13(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1851,12 +2104,14 @@ class CanonicalParameters13(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1864,60 +2119,70 @@ class CanonicalParameters13(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -1925,12 +2190,14 @@ class CanonicalParameters13(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['agent_placement'] = 'agent_placement' params: agent_placement.CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement @@ -1942,6 +2209,7 @@ class CanonicalParameters14(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1949,12 +2217,14 @@ class CanonicalParameters14(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1962,60 +2232,70 @@ class CanonicalParameters14(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -2023,12 +2303,14 @@ class CanonicalParameters14(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['seller_rendered_stateful_display'] = 'seller_rendered_stateful_display' params: seller_rendered_stateful_display.CanonicalFormatSellerRenderedStatefulDisplay @@ -2040,6 +2322,7 @@ class CanonicalParameters15(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -2047,12 +2330,14 @@ class CanonicalParameters15(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -2060,60 +2345,70 @@ class CanonicalParameters15(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -2121,12 +2416,14 @@ class CanonicalParameters15(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['coordinated_placements'] = 'coordinated_placements' params: coordinated_placements.CanonicalFormatCoordinatedPlacements @@ -2138,6 +2435,7 @@ class CanonicalParameters16(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -2145,12 +2443,14 @@ class CanonicalParameters16(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -2158,60 +2458,70 @@ class CanonicalParameters16(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -2219,12 +2529,14 @@ class CanonicalParameters16(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['custom'] = 'custom' params: Annotated[ @@ -2465,18 +2777,21 @@ class Format(AdCPBaseModel): Field( description='Plain text explanation of what this format does and what assets it requires' ), + NoExplicitNull, ] = None example_url: Annotated[ AnyUrl | None, Field( description='Optional URL to showcase page with examples and interactive demos of this format' ), + NoExplicitNull, ] = None accepts_parameters: Annotated[ list[format_id_parameter.FormatIdParameter] | None, Field( description='List of parameters this format accepts in format_id. Template formats define which parameters (dimensions, duration, etc.) can be specified when instantiating the format. Empty or omitted means this is a concrete format with fixed parameters.' ), + NoExplicitNull, ] = None renders: Annotated[ list[Renders | Renders1] | None, @@ -2484,6 +2799,7 @@ class Format(AdCPBaseModel): description='Specification of rendered pieces for this format. Most formats produce a single render. Companion ad formats (video + banner), adaptive formats, and multi-placement formats produce multiple renders. Each render specifies its role and dimensions.', min_length=1, ), + NoExplicitNull, ] = None assets: Annotated[ list[FormatAssetUnion] @@ -2491,16 +2807,19 @@ class Format(AdCPBaseModel): Field( description="Array of all assets supported for this format. Each asset is identified by its asset_id, which must be used as the key in creative manifests. Use the 'required' boolean on each asset to indicate whether it's mandatory." ), + NoExplicitNull, ] = None delivery: Annotated[ dict[str, Any] | None, Field(description='Delivery method specifications (e.g., hosted, VAST, third-party tags)'), + NoExplicitNull, ] = None supported_macros: Annotated[ list[universal_macro.UniversalMacro | str] | None, Field( description='List of universal macros supported by this format (e.g., MEDIA_BUY_ID, CACHEBUSTER, DEVICE_ID). Used for validation and developer tooling. See docs/creative/universal-macros.mdx for full documentation.' ), + NoExplicitNull, ] = None input_format_ids: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -2508,6 +2827,7 @@ class Format(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.1. Removed at 4.0.** Use `list_transformers` instead — a transformer declares its own `input_format_ids`/`output_format_ids`, so build capability is a property of the transformer (the unit you select and that carries pricing), not a relationship hung on a format. Discover build capability via `list_transformers` (optionally filtered by `input_format_ids`/`output_format_ids`).\n\nMigration: sellers that expressed transform capability by hanging `input_format_ids` on a format SHOULD declare a transformer via `list_transformers` instead. Buyers SHOULD discover build capability via `list_transformers` rather than filtering formats.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* array of format IDs this format accepts as input creative manifests; when present, indicates this format can take existing creatives in these formats as input. SDKs reading 3.1 catalogs MUST continue to honor this field when present; 4.0+ SDKs MAY reject it. New code SHOULD NOT emit this field.', ), + NoExplicitNull, ] = None output_format_ids: Annotated[ list[format_id_1.FormatReferenceStructuredObject] | None, @@ -2515,18 +2835,21 @@ class Format(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.1. Removed at 4.0.** Use `list_transformers` instead — a transformer declares its own `output_format_ids`, so what a builder can produce is a property of the transformer, not a relationship hung on a format. Discover via `list_transformers`.\n\nMigration: sellers that expressed multi-output build capability (e.g. a multi-publisher template) by hanging `output_format_ids` on a format SHOULD declare a transformer via `list_transformers` instead.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* array of format IDs this format can produce as output; when present, indicates this format can build creatives in these output formats. SDKs reading 3.1 catalogs MUST continue to honor this field when present; 4.0+ SDKs MAY reject it. New code SHOULD NOT emit this field.', ), + NoExplicitNull, ] = None format_card: Annotated[ FormatCard | None, Field( description='Optional standard visual card (300x400px) for displaying this format in user interfaces. Can be rendered via preview_creative or pre-generated.' ), + NoExplicitNull, ] = None accessibility: Annotated[ Accessibility | None, Field( description='Accessibility posture of this format. Declares the WCAG conformance level that creatives produced by this format will meet.' ), + NoExplicitNull, ] = None supported_disclosure_positions: Annotated[ list[disclosure_position.DisclosurePosition] | None, @@ -2534,6 +2857,7 @@ class Format(AdCPBaseModel): description='Disclosure positions this format can render. Buyers use this to determine whether a format can satisfy their compliance requirements before submitting a creative. When omitted, the format makes no disclosure rendering guarantees — creative agents SHOULD treat this as incompatible with briefs that require specific disclosure positions. Values correspond to positions on creative-brief.json required_disclosures.', min_length=1, ), + NoExplicitNull, ] = None disclosure_capabilities: Annotated[ list[DisclosureCapability] | None, @@ -2541,12 +2865,14 @@ class Format(AdCPBaseModel): description='Structured disclosure capabilities per position with persistence modes. Declares which persistence behaviors each disclosure position supports, enabling persistence-aware matching against provenance render guidance and brief requirements. When present, supersedes supported_disclosure_positions for persistence-aware queries. The flat supported_disclosure_positions field is retained for backward compatibility. Each position MUST appear at most once; validators and agents SHOULD reject duplicates.', min_length=1, ), + NoExplicitNull, ] = None format_card_detailed: Annotated[ FormatCardDetailed | None, Field( description='Optional detailed card with carousel and full specifications. Provides rich format documentation similar to ad spec pages.' ), + NoExplicitNull, ] = None reported_metrics: Annotated[ list[available_metric.AvailableMetric] | None, @@ -2554,6 +2880,7 @@ class Format(AdCPBaseModel): description='Metrics this format can produce in delivery reporting. Buyers receive the intersection of format reported_metrics and product available_metrics. Under `enums/available-metric.json`, a container such as `viewability` subsumes numeric leaves such as `viewable_rate`; structured distributions match only their explicit `viewed_seconds_percentiles` or `viewed_seconds_histogram` identities. If omitted, the format defers entirely to product-level metric declarations.', min_length=1, ), + NoExplicitNull, ] = None pricing_options: Annotated[ list[vendor_pricing_option.VendorPricingOption] | None, @@ -2562,12 +2889,14 @@ class Format(AdCPBaseModel): description='**DEPRECATED in 3.1. Removed at 4.0.** Use `transformer.pricing_options` (via `list_transformers`) instead — pricing belongs on the transformer (the unit selected and billed), exactly as it belongs on a media-buy product. Once formats only describe output shape, format-level pricing is vestigial.\n\nMigration: transformation/generation agents that charged via `format.pricing_options` SHOULD move the same `vendor-pricing-option` entries onto the corresponding transformer. The applied option is echoed per-leaf on the build_creative response and reconciled via report_usage, unchanged.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* pricing options for this format, used by transformation/generation agents that charge per format adapted, per image generated, or per unit of work; present when the request included include_pricing=true and account. SDKs reading 3.1 catalogs MUST continue to honor this field when present; 4.0+ SDKs MAY reject it. New code SHOULD NOT emit this field.', min_length=1, ), + NoExplicitNull, ] = None canonical: Annotated[ canonical_projection_ref.CanonicalProjectionReference | None, Field( description='Optional v2 canonical-projection annotation. Always an object — bare-string shorthand (`canonical: "image"`) is not supported; the minimal form is `canonical: { "kind": "image" }`. Carries `kind` (which canonical the v1 format projects to) plus optional `asset_source` and `slots_override` for cases where the v1 format\'s shape doesn\'t follow the canonical\'s defaults (e.g., generative entries whose input is `generation_prompt: text` instead of `image_main: image`).\n\nWhen set, SDKs use this annotation as the authoritative v1 → v2 mapping for this format, bypassing the [v1 canonical mapping registry](/schemas/registries/v1-canonical-mapping.json) lookup. Combined with the slot-level `asset_group_id` declarations on each `assets[i]` entry, a v1 format declaration with `canonical` set is fully self-describing for v1↔v2 translation.\n\nResolution order for SDK projection from v1 wire shape to v2 (per RFC #3305 amendment #3767):\n1. If this `canonical` field is set, use it (seller-declared, highest priority). Apply `asset_source` and `slots_override` from the projection ref when present; otherwise inherit the canonical\'s defaults.\n2. Else, look up `format_id` in the canonical mapping registry\'s `format_id_glob` entries.\n3. Else, attempt structural match against the registry\'s `structural` entries (asset types, slot shape, vast_versions, etc.).\n4. Else, fail closed: SDK MUST NOT emit `format_options` for products carrying this format. Surface `FORMAT_PROJECTION_FAILED` on the response `errors[]` suggesting the seller add an explicit `canonical` annotation or file a registry entry.\n\nWhen `canonical.kind` is `custom`, the seller MUST also declare `canonical_format_shape` and `canonical_format_schema` (parallel to ProductFormatDeclaration\'s `format_shape` and `format_schema`) so buyer SDKs can fetch the seller\'s custom format schema.\n\nSee `canonical-projection-ref.json` for full projection semantics and examples (default-slot case, generative case, brief-driven case).' ), + NoExplicitNull, ] = None canonical_parameters: Annotated[ CanonicalParameters | None, @@ -2575,6 +2904,7 @@ class Format(AdCPBaseModel): deprecated=True, description="**DEPRECATED in 3.1. Removed at 4.0.** Use `v1_format_ref` on the v2 `ProductFormatDeclaration` instead — the seller authors a v2 declaration (in `Product.format_options` or `creative.supported_formats`) and links it back to this v1 format via `v1_format_ref: { agent_url, id }`. The directional link from v2 → v1 is the same fact as `canonical_parameters` without the parallel-shape drift surface (v1 file and `canonical_parameters` were two declarations of the same thing; hand-authored, drifting silently).\n\nMigration: every seller currently authoring `canonical_parameters` SHOULD migrate to authoring a v2 declaration on the corresponding product (or capability) with `v1_format_ref` pointing back at this v1 format. v1 files become pure v1 again — no v2-shape mirroring.\n\n*Legacy behavior, retained for 3.1–3.x backward compatibility:* When `canonical` is set, this field carries the full ProductFormatDeclaration that the SDK projects this v1 format into. The `format_kind` MUST equal the `canonical` field value (validators enforce). When set, this is the authoritative source for SDK v1→v2 projection — the registry's structural-match parameter inference is bypassed. SDKs reading 3.1 catalogs MUST continue to honor `canonical_parameters` when present; 4.0+ SDKs MAY reject the field. New code SHOULD NOT emit this field. Seller execution authority is never projected through this deprecated field, so tracker_execution_contract and tracker_execution_contract_digest are forbidden.\n\n**Drift contract (still normative while supported).** Hand-authored `canonical_parameters` MUST satisfy the *narrows* relation against this v1 format's `requirements` and `assets[*]` shape (see canonical-formats.mdx 'Narrows — formal definition'). SDKs that read this v1 file SHOULD lint-time check the equivalence at build/load and emit `FORMAT_PROJECTION_FAILED` if the two disagree.", ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/format_id.py b/src/adcp/types/domains/core/format_id.py index ef25372f8..28f8eedea 100644 --- a/src/adcp/types/domains/core/format_id.py +++ b/src/adcp/types/domains/core/format_id.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/format_id.json -# timestamp: 2026-10-05T03:58:57+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt, WireUrl +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireUrl from pydantic import ConfigDict, Field, StrictFloat @@ -33,6 +33,7 @@ class FormatReferenceStructuredObject(AdCPBaseModel): description='Width in pixels for visual formats. When specified, height must also be specified. Both fields together create a parameterized format ID for dimension-specific variants.', ge=1, ), + NoExplicitNull, ] = None height: Annotated[ SchemaInt | None, @@ -40,6 +41,7 @@ class FormatReferenceStructuredObject(AdCPBaseModel): description='Height in pixels for visual formats. When specified, width must also be specified. Both fields together create a parameterized format ID for dimension-specific variants.', ge=1, ), + NoExplicitNull, ] = None duration_ms: Annotated[ StrictFloat | None, @@ -47,6 +49,7 @@ class FormatReferenceStructuredObject(AdCPBaseModel): description='Duration in milliseconds for time-based formats (video, audio). When specified, creates a parameterized format ID. Omit to reference a template format without parameters.', ge=1.0, ), + NoExplicitNull, ] = None pixel_ratio: Annotated[ StrictFloat | None, @@ -54,4 +57,5 @@ class FormatReferenceStructuredObject(AdCPBaseModel): description='Required intrinsic-pixel density for a parameterized visual format, expressed as intrinsic pixels per logical pixel. Requires `width` and `height`. Example: `{id: "display_image", width: 300, height: 250, pixel_ratio: 2}` identifies a 300×250 logical render supplied by a 600×500 image. Omit for the backward-compatible 1x variant.', gt=0.0, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/frequency_cap.py b/src/adcp/types/domains/core/frequency_cap.py index 6c05043c0..09ccd6bcd 100644 --- a/src/adcp/types/domains/core/frequency_cap.py +++ b/src/adcp/types/domains/core/frequency_cap.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/frequency_cap.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..enums import reach_unit @@ -25,6 +25,7 @@ class FrequencyCap(AdCPBaseModel): Field( description='Cooldown period between consecutive exposures to the same entity. Prevents back-to-back ad delivery (e.g. {"interval": 60, "unit": "minutes"} for a 1-hour cooldown). Preferred over suppress_minutes.' ), + NoExplicitNull, ] = None suppress_minutes: Annotated[ StrictFloat | None, @@ -33,6 +34,7 @@ class FrequencyCap(AdCPBaseModel): description='Deprecated — use suppress instead. Cooldown period in minutes between consecutive exposures to the same entity (e.g. 60 for a 1-hour cooldown).', ge=0.0, ), + NoExplicitNull, ] = None max_impressions: Annotated[ SchemaInt | None, @@ -40,18 +42,21 @@ class FrequencyCap(AdCPBaseModel): description="Maximum number of impressions per entity per window. For duration windows, implementations typically use a rolling window. campaign applies across the owning field's full flight: the package flight for a targeting overlay, or the MediaBuy flight for a root cap.", ge=1, ), + NoExplicitNull, ] = None per: Annotated[ reach_unit.ReachUnit | None, Field( description='Entity granularity for impression counting. Required when max_impressions is set.' ), + NoExplicitNull, ] = None window: Annotated[ duration.Duration | None, Field( description='Time window for the max_impressions cap (e.g. {"interval": 7, "unit": "days"} or {"interval": 1, "unit": "campaign"} for the full flight). Required when max_impressions is set.' ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/frequency_cap_constraints.py b/src/adcp/types/domains/core/frequency_cap_constraints.py index f2b42c145..3f0e6d035 100644 --- a/src/adcp/types/domains/core/frequency_cap_constraints.py +++ b/src/adcp/types/domains/core/frequency_cap_constraints.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_constraints.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, model_validator from ..enums import frequency_cap_control_mode, frequency_cap_mutable_field, reach_unit @@ -26,6 +26,7 @@ class FrequencyCapConstraints(AdCPBaseModel): Field( description='Logical cap fields that can change after creation. An empty array means create-only. For example, [max_impressions] permits changing the count while keeping per and window fixed. Omission means update support is undeclared and the legacy broad meaning applies.' ), + NoExplicitNull, ] = None supported_control_modes: Annotated[ list[frequency_cap_control_mode.FrequencyCapControlMode] | None, @@ -33,14 +34,17 @@ class FrequencyCapConstraints(AdCPBaseModel): description='FrequencyCap shapes accepted by the product. max_impressions_and_suppress means both controls may appear together and are enforced with AND semantics.', min_length=1, ), + NoExplicitNull, ] = None supported_per_units: Annotated[ list[reach_unit.ReachUnit] | None, Field(description='Entity granularities accepted by max_impressions caps.', min_length=1), + NoExplicitNull, ] = None max_impressions_constraints: Annotated[ frequency_cap_impression_constraints.FrequencyCapImpressionConstraints | None, Field(description='Exact supported max_impressions presets or range.'), + NoExplicitNull, ] = None window_constraints: Annotated[ list[frequency_cap_interval_constraints.FrequencyCapIntervalConstraints] | None, @@ -48,6 +52,7 @@ class FrequencyCapConstraints(AdCPBaseModel): description='Exact supported max_impressions.window intervals, one entry per duration unit.', min_length=1, ), + NoExplicitNull, ] = None suppression_constraints: Annotated[ list[frequency_cap_interval_constraints.FrequencyCapIntervalConstraints] | None, @@ -55,8 +60,9 @@ class FrequencyCapConstraints(AdCPBaseModel): description='Exact supported suppress cooldown intervals, one entry per duration unit.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> FrequencyCapConstraints: diff --git a/src/adcp/types/domains/core/frequency_cap_impression_constraints.py b/src/adcp/types/domains/core/frequency_cap_impression_constraints.py index 1adf67658..d464d7ffd 100644 --- a/src/adcp/types/domains/core/frequency_cap_impression_constraints.py +++ b/src/adcp/types/domains/core/frequency_cap_impression_constraints.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_impression_constraints.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarInt from pydantic import ConfigDict, Field, model_validator @@ -25,10 +25,10 @@ class FrequencyCapImpressionConstraints(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - minimum: Annotated[SchemaInt | None, Field(ge=1)] = None - maximum: Annotated[SchemaInt | None, Field(ge=1)] = None - allowed_values: Annotated[list[AllowedValue] | None, Field(min_length=1)] = None - ext: ext_1.ExtensionObject | None = None + minimum: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + maximum: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + allowed_values: Annotated[list[AllowedValue] | None, Field(min_length=1), NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> FrequencyCapImpressionConstraints: diff --git a/src/adcp/types/domains/core/frequency_cap_interval_constraints.py b/src/adcp/types/domains/core/frequency_cap_interval_constraints.py index d4e37447c..5be1b9f2e 100644 --- a/src/adcp/types/domains/core/frequency_cap_interval_constraints.py +++ b/src/adcp/types/domains/core/frequency_cap_interval_constraints.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_interval_constraints.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarInt from pydantic import ConfigDict, Field, model_validator @@ -27,10 +27,12 @@ class FrequencyCapIntervalConstraints(AdCPBaseModel): extra='forbid', ) unit: frequency_cap_duration_unit.FrequencyCapDurationUnit - minimum_interval: Annotated[SchemaInt | None, Field(ge=1)] = None - maximum_interval: Annotated[SchemaInt | None, Field(ge=1)] = None - allowed_intervals: Annotated[list[AllowedInterval] | None, Field(min_length=1)] = None - ext: ext_1.ExtensionObject | None = None + minimum_interval: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + maximum_interval: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + allowed_intervals: Annotated[ + list[AllowedInterval] | None, Field(min_length=1), NoExplicitNull + ] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> FrequencyCapIntervalConstraints: diff --git a/src/adcp/types/domains/core/frequency_cap_requirements.py b/src/adcp/types/domains/core/frequency_cap_requirements.py index 0a034bfd0..3f8555582 100644 --- a/src/adcp/types/domains/core/frequency_cap_requirements.py +++ b/src/adcp/types/domains/core/frequency_cap_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/frequency_cap_requirements.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import frequency_cap_control_mode, frequency_cap_mutable_field, reach_unit @@ -23,17 +23,23 @@ class FrequencyCapRequirements(AdCPBaseModel): description='Require each listed logical field to be mutable after creation. Matches a product whose mutable_fields contains every listed value, or a product that omits mutable_fields, or legacy frequency_cap: true. A product declaring mutable_fields: [] is create-only and never matches a non-empty list.', min_length=1, ), + NoExplicitNull, ] = None supported_control_modes: Annotated[ - list[frequency_cap_control_mode.FrequencyCapControlMode] | None, Field(min_length=1) + list[frequency_cap_control_mode.FrequencyCapControlMode] | None, + Field(min_length=1), + NoExplicitNull, + ] = None + supported_per_units: Annotated[ + list[reach_unit.ReachUnit] | None, Field(min_length=1), NoExplicitNull ] = None - supported_per_units: Annotated[list[reach_unit.ReachUnit] | None, Field(min_length=1)] = None supported_window_units: Annotated[ list[frequency_cap_duration_unit.FrequencyCapDurationUnit] | None, Field( description='Require each listed unit to be a supported max_impressions.window unit after seller-wide inheritance.', min_length=1, ), + NoExplicitNull, ] = None supported_suppression_units: Annotated[ list[frequency_cap_duration_unit.FrequencyCapDurationUnit] | None, @@ -41,4 +47,5 @@ class FrequencyCapRequirements(AdCPBaseModel): description='Require each listed unit to be a supported suppress unit after seller-wide inheritance.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/generation_credential.py b/src/adcp/types/domains/core/generation_credential.py index 88054752f..ec992dabd 100644 --- a/src/adcp/types/domains/core/generation_credential.py +++ b/src/adcp/types/domains/core/generation_credential.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/generation_credential.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from ..enums import right_use @@ -38,11 +38,13 @@ class GenerationCredential(AdCPBaseModel): Field( description='When this credential expires. Key lifetime is determined by the provider.' ), + NoExplicitNull, ] = None endpoint: Annotated[ AnyUrl | None, Field( description="Provider API endpoint to use with this credential, if different from the provider's default" ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/geo_breakdown_support.py b/src/adcp/types/domains/core/geo_breakdown_support.py index c69e3f8a1..e7b331baf 100644 --- a/src/adcp/types/domains/core/geo_breakdown_support.py +++ b/src/adcp/types/domains/core/geo_breakdown_support.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/geo_breakdown_support.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..enums import metro_system @@ -20,20 +20,24 @@ class GeographicBreakdownSupport(AdCPBaseModel): country: Annotated[ StrictBool | None, Field(description='Supports country-level geo breakdown (ISO 3166-1 alpha-2)'), + NoExplicitNull, ] = None region: Annotated[ StrictBool | None, Field(description='Supports region/state-level geo breakdown (ISO 3166-2)'), + NoExplicitNull, ] = None metro: Annotated[ dict[metro_system.MetroAreaSystem, StrictBool] | None, Field( description='Metro area breakdown support. Keys are metro-system enum values; true means supported.' ), + NoExplicitNull, ] = None postal_area: Annotated[ postal_area_support.PostalAreaSupport | None, Field( description='Postal area breakdown support. Prefer the native country-keyed map where each ISO 3166-1 alpha-2 country lists supported country-local postal systems. Deprecated legacy country-fused postal-system boolean maps remain accepted for compatibility.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/geo_delivery_metrics.py b/src/adcp/types/domains/core/geo_delivery_metrics.py index 05560e37c..e025bb924 100644 --- a/src/adcp/types/domains/core/geo_delivery_metrics.py +++ b/src/adcp/types/domains/core/geo_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/geo_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import Field from ..enums import geo_level as geo_level_1 @@ -22,6 +23,7 @@ class GeoDeliveryMetrics(DeliveryMetrics): Field( description='Classification system for metro or postal_area levels. Metro rows use metro-system values. Native postal rows use country-local postal-system values with country; deprecated legacy postal rows may use legacy-postal-system values.' ), + NoExplicitNull, ] = None country: Annotated[ str | None, @@ -29,6 +31,7 @@ class GeoDeliveryMetrics(DeliveryMetrics): description='ISO 3166-1 alpha-2 country code for native postal_area rows.', pattern='^[A-Z]{2}$', ), + NoExplicitNull, ] = None geo_code: Annotated[ str, @@ -41,6 +44,7 @@ class GeoDeliveryMetrics(DeliveryMetrics): Field( description="Human-readable geographic name (e.g., 'United States', 'California', 'New York DMA')" ), + NoExplicitNull, ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/geo_place_area.py b/src/adcp/types/domains/core/geo_place_area.py index 5268362c4..ae301513e 100644 --- a/src/adcp/types/domains/core/geo_place_area.py +++ b/src/adcp/types/domains/core/geo_place_area.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/geo_place_area.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -37,6 +37,7 @@ class GeographicPlaceArea(AdCPBaseModel): description="Optional exact catalog version from the seller's declared supported_versions. When omitted, the seller applies catalog.current_version and MUST echo that version on persisted package state.", min_length=1, ), + NoExplicitNull, ] = None place_type: geo_place_type.GeographicPlaceType values: Annotated[ @@ -51,5 +52,6 @@ class GeographicPlaceArea(AdCPBaseModel): Field( description='Optional human-readable diagnostic labels keyed by identifiers present in values. Extra keys are a conformance error. Labels are non-authoritative and MUST NOT be used to resolve or apply targeting.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/geo_place_catalog_capability.py b/src/adcp/types/domains/core/geo_place_catalog_capability.py index f4be47dc1..65077d279 100644 --- a/src/adcp/types/domains/core/geo_place_catalog_capability.py +++ b/src/adcp/types/domains/core/geo_place_catalog_capability.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/geo_place_catalog_capability.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, ConfigDict, Field @@ -27,6 +27,7 @@ class GeographicPlaceCatalogCapability(AdCPBaseModel): Field( description='Optional catalog dataset or derivative-source identifier. This records provenance/coverage and does not change the identifier namespace in system.' ), + NoExplicitNull, ] = None current_version: Annotated[ str, diff --git a/src/adcp/types/domains/core/geo_place_catalog_entry.py b/src/adcp/types/domains/core/geo_place_catalog_entry.py index 6967d6b1f..0976189b1 100644 --- a/src/adcp/types/domains/core/geo_place_catalog_entry.py +++ b/src/adcp/types/domains/core/geo_place_catalog_entry.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/geo_place_catalog_entry.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -47,6 +47,7 @@ class GeographicPlaceCatalogEntry(AdCPBaseModel): description='ISO 3166-2 subdivision containing the place when the catalog has a subdivision mapping. Required by resolver semantics when the request constrained subdivision.', pattern='^[A-Z]{2}-[A-Z0-9]{1,3}$', ), + NoExplicitNull, ] = None place_type: geo_place_type.GeographicPlaceType label: Annotated[ @@ -77,11 +78,13 @@ class GeographicPlaceCatalogEntry(AdCPBaseModel): description='Replacement identifiers in the same system and response system_version.', min_length=1, ), + NoExplicitNull, ] = None valid_until: Annotated[ AwareDatetime | None, Field( description='Known time after which the identifier must no longer be accepted for new targeting.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/geo_place_requirement.py b/src/adcp/types/domains/core/geo_place_requirement.py index 5af677c53..734dc3901 100644 --- a/src/adcp/types/domains/core/geo_place_requirement.py +++ b/src/adcp/types/domains/core/geo_place_requirement.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/geo_place_requirement.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey +from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StringConstraints @@ -32,6 +32,7 @@ class CatalogRequirement(AdCPBaseModel): description='Optional exact catalog versions that must remain selectable. Versions are opaque strings, not ordered ranges.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/geo_region_support.py b/src/adcp/types/domains/core/geo_region_support.py index e43e5c62c..876683659 100644 --- a/src/adcp/types/domains/core/geo_region_support.py +++ b/src/adcp/types/domains/core/geo_region_support.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/geo_region_support.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from datetime import date from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StringConstraints @@ -61,18 +61,21 @@ class GeographicRegionSupport(AdCPBaseModel): description='Optional opaque ISO subdivision catalog or seller-mapping version that identifies the support snapshot. It is provenance, not part of subdivision identity.', min_length=1, ), + NoExplicitNull, ] = None as_of: Annotated[ date | None, Field( description='Optional date on which the seller last validated this support declaration.' ), + NoExplicitNull, ] = None max_values_per_package: Annotated[ SchemaInt | None, Field( description='Optional maximum number of region values selectable on one package.', ge=1 ), + NoExplicitNull, ] = None max_packages: Annotated[ SchemaInt | None, @@ -80,5 +83,6 @@ class GeographicRegionSupport(AdCPBaseModel): description='Optional maximum number of independently region-targeted packages created from this configured product.', ge=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/get_geo_place_resolution_request.py b/src/adcp/types/domains/core/get_geo_place_resolution_request.py index 9aaefa7ff..e579c7bb1 100644 --- a/src/adcp/types/domains/core/get_geo_place_resolution_request.py +++ b/src/adcp/types/domains/core/get_geo_place_resolution_request.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/get_geo_place_resolution_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, model_validator from . import geo_place_type, locale_tag @@ -22,6 +22,7 @@ class GetGeographicPlaceResolutionRequest(AdCPBaseModel): q: Annotated[ str | None, Field(description='Unresolved place name or alias supplied by the user.', min_length=1), + NoExplicitNull, ] = None value: Annotated[ str | None, @@ -29,6 +30,7 @@ class GetGeographicPlaceResolutionRequest(AdCPBaseModel): description='Existing identifier to look up in the requested/current catalog version for lifecycle refresh or replacement discovery. Mutually exclusive with q.', min_length=1, ), + NoExplicitNull, ] = None country: Annotated[ str, @@ -43,18 +45,21 @@ class GetGeographicPlaceResolutionRequest(AdCPBaseModel): description='Optional ISO 3166-2 subdivision constraint.', pattern='^[A-Z]{2}-[A-Z0-9]{1,3}$', ), + NoExplicitNull, ] = None - place_type: geo_place_type.GeographicPlaceType | None = None + place_type: Annotated[geo_place_type.GeographicPlaceType | None, NoExplicitNull] = None system_version: Annotated[ str | None, Field(description='Optional exact supported catalog version to search.', min_length=1), + NoExplicitNull, ] = None locale: Annotated[ locale_tag.LanguageTag | None, Field(description='Optional BCP 47 language tag for returned labels.'), + NoExplicitNull, ] = None - cursor: Annotated[str | None, Field(min_length=1)] = None - limit: Annotated[SchemaInt | None, Field(ge=1, le=100)] = 20 + cursor: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + limit: Annotated[SchemaInt | None, Field(ge=1, le=100), NoExplicitNull] = 20 @model_validator(mode='after') def _require_schema_required_group(self) -> GetGeographicPlaceResolutionRequest: diff --git a/src/adcp/types/domains/core/get_geo_place_resolution_response.py b/src/adcp/types/domains/core/get_geo_place_resolution_response.py index b1cef6059..02357922e 100644 --- a/src/adcp/types/domains/core/get_geo_place_resolution_response.py +++ b/src/adcp/types/domains/core/get_geo_place_resolution_response.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/get_geo_place_resolution_response.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import geo_place_catalog_entry, geo_place_system, get_geo_place_resolution_request @@ -25,4 +25,4 @@ class GetGeographicPlaceResolutionResponse(AdCPBaseModel): system: geo_place_system.GeographicPlaceIdentifierSystem system_version: Annotated[str, Field(min_length=1)] matches: list[geo_place_catalog_entry.GeographicPlaceCatalogEntry] - next_cursor: Annotated[str | None, Field(min_length=1)] = None + next_cursor: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/hotel_item.py b/src/adcp/types/domains/core/hotel_item.py index 30f7a7e63..431733ca9 100644 --- a/src/adcp/types/domains/core/hotel_item.py +++ b/src/adcp/types/domains/core/hotel_item.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/hotel_item.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from datetime import date from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -32,12 +32,18 @@ class Address(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - street: Annotated[str | None, Field(description='Street address.')] = None - city: Annotated[str | None, Field(description='City name.')] = None - region: Annotated[str | None, Field(description='State, province, or region.')] = None - postal_code: Annotated[str | None, Field(description='Postal or ZIP code.')] = None + street: Annotated[str | None, Field(description='Street address.'), NoExplicitNull] = None + city: Annotated[str | None, Field(description='City name.'), NoExplicitNull] = None + region: Annotated[ + str | None, Field(description='State, province, or region.'), NoExplicitNull + ] = None + postal_code: Annotated[str | None, Field(description='Postal or ZIP code.'), NoExplicitNull] = ( + None + ) country: Annotated[ - str | None, Field(description='ISO 3166-1 alpha-2 country code.', pattern='^[A-Z]{2}$') + str | None, + Field(description='ISO 3166-1 alpha-2 country code.', pattern='^[A-Z]{2}$'), + NoExplicitNull, ] = None @@ -56,28 +62,42 @@ class HotelItem(AdCPBaseModel): Field(description="Property name (e.g., 'Grand Hotel Amsterdam', 'Seaside Resort & Spa')."), ] description: Annotated[ - str | None, Field(description='Property description highlighting features and location.') + str | None, + Field(description='Property description highlighting features and location.'), + NoExplicitNull, ] = None location: Annotated[Location, Field(description='Geographic coordinates of the property.')] address: Annotated[ - Address | None, Field(description='Structured address for display and geocoding.') + Address | None, + Field(description='Structured address for display and geocoding.'), + NoExplicitNull, ] = None star_rating: Annotated[ - SchemaInt | None, Field(description='Official star rating (1–5).', ge=1, le=5) + SchemaInt | None, + Field(description='Official star rating (1–5).', ge=1, le=5), + NoExplicitNull, ] = None price: Annotated[ price_1.Price | None, Field(description="Nightly rate or starting price. Use period 'night' for nightly rates."), + NoExplicitNull, + ] = None + image_url: Annotated[ + AnyUrl | None, Field(description='Primary property image URL.'), NoExplicitNull + ] = None + url: Annotated[ + AnyUrl | None, Field(description='Property landing page or booking URL.'), NoExplicitNull + ] = None + phone: Annotated[ + str | None, Field(description='Property phone number in E.164 format.'), NoExplicitNull ] = None - image_url: Annotated[AnyUrl | None, Field(description='Primary property image URL.')] = None - url: Annotated[AnyUrl | None, Field(description='Property landing page or booking URL.')] = None - phone: Annotated[str | None, Field(description='Property phone number in E.164 format.')] = None amenities: Annotated[ list[str] | None, Field( description="Property amenities (e.g., 'pool', 'wifi', 'spa', 'parking', 'restaurant').", min_length=1, ), + NoExplicitNull, ] = None check_in_time: Annotated[ str | None, @@ -85,6 +105,7 @@ class HotelItem(AdCPBaseModel): description="Standard check-in time in HH:MM format (e.g., '15:00').", pattern='^[0-2][0-9]:[0-5][0-9]$', ), + NoExplicitNull, ] = None check_out_time: Annotated[ str | None, @@ -92,6 +113,7 @@ class HotelItem(AdCPBaseModel): description="Standard check-out time in HH:MM format (e.g., '11:00').", pattern='^[0-2][0-9]:[0-5][0-9]$', ), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, @@ -99,18 +121,21 @@ class HotelItem(AdCPBaseModel): description="Tags for filtering and targeting (e.g., 'boutique', 'family', 'business', 'luxury').", min_length=1, ), + NoExplicitNull, ] = None valid_from: Annotated[ date | None, Field( description="Date from which this item is available or this rate applies (ISO 8601, e.g., '2025-03-01'). Used for seasonal availability windows in feed imports." ), + NoExplicitNull, ] = None valid_to: Annotated[ date | None, Field( description="Date until which this item is available or this rate applies (ISO 8601, e.g., '2025-09-30'). Used for seasonal availability windows in feed imports." ), + NoExplicitNull, ] = None assets: Annotated[ list[offering_asset_group.OfferingAssetGroup] | None, @@ -118,5 +143,6 @@ class HotelItem(AdCPBaseModel): description="Typed creative asset pools for this hotel. Uses the same OfferingAssetGroup structure as offering-type catalogs. Standard group IDs: 'images_landscape' (16:9 hero images), 'images_vertical' (9:16 for Snap, Stories), 'images_square' (1:1), 'logo'. Enables formats to declare typed image requirements that map unambiguously to the right asset regardless of platform.", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/impairment.py b/src/adcp/types/domains/core/impairment.py index 14d2e4a71..fd4ca21be 100644 --- a/src/adcp/types/domains/core/impairment.py +++ b/src/adcp/types/domains/core/impairment.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/impairment.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import impairment_offline_state, impairment_reason_code @@ -32,6 +32,7 @@ class Transition(AdCPBaseModel): description="Prior status of the resource (e.g., 'ready', 'approved', 'good'). Optional — sellers SHOULD include when known, MAY omit when the resource was discovered already in an offline state (e.g., a property depublished via brand.json crawl with no prior snapshot). Open string at the schema layer because each resource_type has its own serviceable-state vocabulary; the pattern constraint blocks free-form garbage, and the impairment.coherence assertion validates that 'from' is a known serviceable value for the resource_type.", pattern='^[a-z][a-z0-9_]*$', ), + NoExplicitNull, ] = None to: Annotated[ impairment_offline_state.ImpairmentOfflineState, @@ -86,6 +87,7 @@ class Impairment(AdCPBaseModel): description='Human-readable explanation. Supplements reason_code with seller-specific detail.', max_length=500, ), + NoExplicitNull, ] = None observed_at: Annotated[ AwareDatetime, @@ -99,4 +101,5 @@ class Impairment(AdCPBaseModel): description='Action the buyer can take to clear the impairment, if any. Free text. Absent when no buyer-side remediation is possible (e.g., seller-initiated withdrawal pending re-publication).', max_length=500, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/indicator.py b/src/adcp/types/domains/core/indicator.py index 03c30f6a0..e292654f2 100644 --- a/src/adcp/types/domains/core/indicator.py +++ b/src/adcp/types/domains/core/indicator.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/indicator.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import indicator_type @@ -24,6 +24,7 @@ class Indicator(AdCPBaseModel): Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), + NoExplicitNull, ] = None scope: Annotated[ list[indicator_scope.IndicatorScope] | None, @@ -31,10 +32,12 @@ class Indicator(AdCPBaseModel): description='Optional narrower publisher or placement scope within the enclosing media buy, package, or package–creative assignment. Omit only when the seller evaluated and asserts the indicator across the whole enclosing resource/relationship. When partial indicators_evaluated_scope is declared, every returned indicator MUST include scope and every entry MUST fall within that coverage. This is scope, not source: the responding seller remains the source.', min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Seller- or provider-specific detail such as scores, thresholds, evaluation windows, methodology identifiers, or upstream attribution. Core consumers must not need ext to understand the broad meaning of type.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/indicator_bearing.py b/src/adcp/types/domains/core/indicator_bearing.py index 3d5b17295..0ac4b8057 100644 --- a/src/adcp/types/domains/core/indicator_bearing.py +++ b/src/adcp/types/domains/core/indicator_bearing.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/indicator_bearing.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import indicator_type @@ -22,6 +22,7 @@ class IndicatorBearingResourceState(AdCPBaseModel): Field( description='Current seller assertions for the indicator types and publisher/placement coverage named by the sibling evaluation fields. Omitted means unknown or not evaluated. A present empty array means evaluated with no current assertion for indicator_types_evaluated in the evaluated scope.' ), + NoExplicitNull, ] = None indicator_types_evaluated: Annotated[ list[indicator_type.IndicatorType] | None, @@ -29,12 +30,14 @@ class IndicatorBearingResourceState(AdCPBaseModel): description='Indicator types covered by this snapshot. Required whenever indicators is present. Types omitted from this list remain unknown even when indicators is empty. Every returned indicator.type MUST appear in this list.', min_length=1, ), + NoExplicitNull, ] = None indicators_as_of: Annotated[ AwareDatetime | None, Field( description='When the seller completed this evaluation. Required whenever indicators is present, including an empty array. State changes require a strictly newer timestamp; equal-timestamp conflicts are invalid and buyers retain stored state.' ), + NoExplicitNull, ] = None indicators_evaluated_scope: Annotated[ list[indicator_scope.IndicatorScope] | None, @@ -42,4 +45,5 @@ class IndicatorBearingResourceState(AdCPBaseModel): description='Optional publisher or placement coverage for this evaluation. Omit when the named indicator types were evaluated across the whole enclosing resource or relationship. Unlisted scopes remain unknown. When present, every returned indicator MUST include scope and every scope entry MUST be contained by this coverage.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/indicator_scope.py b/src/adcp/types/domains/core/indicator_scope.py index 4d13d271b..7e57675a0 100644 --- a/src/adcp/types/domains/core/indicator_scope.py +++ b/src/adcp/types/domains/core/indicator_scope.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/indicator_scope.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -26,4 +26,5 @@ class IndicatorScope(AdCPBaseModel): Field( description='Optional placement ID within publisher_domain. Omit to scope the assertion or evaluation to all delivery for the publisher in the enclosing object.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/indicators_changed_webhook.py b/src/adcp/types/domains/core/indicators_changed_webhook.py index 9f5a27f27..c66092ae9 100644 --- a/src/adcp/types/domains/core/indicators_changed_webhook.py +++ b/src/adcp/types/domains/core/indicators_changed_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/indicators_changed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import indicator_type @@ -61,8 +61,8 @@ class IndicatorsChangedWebhook(AdCPBaseModel): RelationshipKind, Field(description='Identifies which indicator-bearing snapshot changed.') ] media_buy_id: str - package_id: str | None = None - creative_id: str | None = None + package_id: Annotated[str | None, NoExplicitNull] = None + creative_id: Annotated[str | None, NoExplicitNull] = None change_kind: Annotated[ ChangeKind, Field( @@ -79,4 +79,4 @@ class IndicatorsChangedWebhook(AdCPBaseModel): observed_at: Annotated[ AwareDatetime, Field(description='When the seller observed the semantic snapshot change.') ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/insertion_order.py b/src/adcp/types/domains/core/insertion_order.py index 3829890d7..452405186 100644 --- a/src/adcp/types/domains/core/insertion_order.py +++ b/src/adcp/types/domains/core/insertion_order.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/insertion_order.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat @@ -31,15 +31,27 @@ class Terms(AdCPBaseModel): extra='allow', ) advertiser: Annotated[ - str | None, Field(description='Advertiser name or identifier', max_length=500) + str | None, + Field(description='Advertiser name or identifier', max_length=500), + NoExplicitNull, ] = None publisher: Annotated[ - str | None, Field(description='Publisher name or identifier', max_length=500) + str | None, + Field(description='Publisher name or identifier', max_length=500), + NoExplicitNull, + ] = None + total_budget: Annotated[ + TotalBudget | None, Field(description='Total committed budget'), NoExplicitNull + ] = None + flight_start: Annotated[ + AwareDatetime | None, Field(description='Campaign start date'), NoExplicitNull + ] = None + flight_end: Annotated[ + AwareDatetime | None, Field(description='Campaign end date'), NoExplicitNull + ] = None + payment_terms: Annotated[ + PaymentTerms | None, Field(description='Payment terms'), NoExplicitNull ] = None - total_budget: Annotated[TotalBudget | None, Field(description='Total committed budget')] = None - flight_start: Annotated[AwareDatetime | None, Field(description='Campaign start date')] = None - flight_end: Annotated[AwareDatetime | None, Field(description='Campaign end date')] = None - payment_terms: Annotated[PaymentTerms | None, Field(description='Payment terms')] = None class InsertionOrder(AdCPBaseModel): @@ -58,18 +70,21 @@ class InsertionOrder(AdCPBaseModel): Field( description='Summary fields echoed from the committed proposal for agent verification. Buyer agents use these to confirm the IO matches what was negotiated before a human signs. These are read-only summaries, not negotiation surfaces — deal terms live on products and packages.' ), + NoExplicitNull, ] = None terms_url: Annotated[ AnyUrl | None, Field( description='URL to a human-readable document containing the full insertion order terms' ), + NoExplicitNull, ] = None signing_url: Annotated[ AnyUrl | None, Field( description='URL to an electronic signing service (e.g., DocuSign) for human signature workflows. When present, a human must sign before the buyer agent can proceed with create_media_buy.' ), + NoExplicitNull, ] = None requires_signature: Annotated[ StrictBool, diff --git a/src/adcp/types/domains/core/installment.py b/src/adcp/types/domains/core/installment.py index 6a5ccfe09..86ac0dfec 100644 --- a/src/adcp/types/domains/core/installment.py +++ b/src/adcp/types/domains/core/installment.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/installment.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..enums import derivative_type, installment_status @@ -43,6 +43,7 @@ class Installment(AdCPBaseModel): Field( description='Canonical parent collection identity. Products spanning multiple collections or publisher namespaces MUST use this field.' ), + NoExplicitNull, ] = None collection_id: Annotated[ str | None, @@ -50,74 +51,93 @@ class Installment(AdCPBaseModel): deprecated=True, description='Deprecated publisher-domain-free parent collection shorthand. Use collection_ref. It is unambiguous only when the enclosing product addresses one publisher namespace.', ), + NoExplicitNull, ] = None - name: Annotated[str | None, Field(description='Installment title')] = None + name: Annotated[str | None, Field(description='Installment title'), NoExplicitNull] = None season: Annotated[ - str | None, Field(description="Season identifier (e.g., '1', '2024', 'spring_2026')") + str | None, + Field(description="Season identifier (e.g., '1', '2024', 'spring_2026')"), + NoExplicitNull, ] = None installment_number: Annotated[ - str | None, Field(description="Installment number within the season (e.g., '3', '47')") + str | None, + Field(description="Installment number within the season (e.g., '3', '47')"), + NoExplicitNull, ] = None scheduled_at: Annotated[ - AwareDatetime | None, Field(description='When the installment airs or publishes (ISO 8601)') + AwareDatetime | None, + Field(description='When the installment airs or publishes (ISO 8601)'), + NoExplicitNull, ] = None status: Annotated[ installment_status.InstallmentStatus | None, Field(description='Lifecycle status of the installment'), + NoExplicitNull, ] = None duration_seconds: Annotated[ - SchemaInt | None, Field(description='Expected duration of the installment in seconds', ge=0) + SchemaInt | None, + Field(description='Expected duration of the installment in seconds', ge=0), + NoExplicitNull, ] = None flexible_end: Annotated[ StrictBool | None, Field(description='Whether the end time is approximate (live events, sports)'), + NoExplicitNull, ] = None valid_until: Annotated[ AwareDatetime | None, Field( description='When this installment data expires and should be re-queried. Agents should re-query before committing budget to products with tentative installments.' ), + NoExplicitNull, ] = None content_rating: Annotated[ content_rating_1.ContentRating | None, Field( description="Installment-specific content rating. Overrides the collection's baseline content_rating when present." ), + NoExplicitNull, ] = None topics: Annotated[ list[str] | None, Field( description="Content topics for this installment. Uses the same taxonomy as the collection's genre_taxonomy when present. Enables installment-level brand safety evaluation beyond content_rating." ), + NoExplicitNull, ] = None special: Annotated[ special_1.Special | None, Field( description='Installment-specific event context. When present, this installment is anchored to a real-world event. Overrides the collection-level special when present.' ), + NoExplicitNull, ] = None guest_talent: Annotated[ list[talent.Talent] | None, Field( description="Installment-specific guests and talent. Additive to the collection's recurring talent." ), + NoExplicitNull, ] = None ad_inventory: Annotated[ ad_inventory_config.AdInventoryConfiguration | None, Field( description='Break-based ad inventory for this installment. For non-break formats (host reads, integrations), use product placements.' ), + NoExplicitNull, ] = None deadlines: Annotated[ installment_deadlines.InstallmentDeadlines | None, Field( description='Booking, cancellation, and material submission deadlines for this installment. Present when the installment has time-sensitive inventory that requires advance commitment or material delivery.' ), + NoExplicitNull, ] = None derivative_of: Annotated[ DerivativeOf | None, Field( description='When this installment is a clip, highlight, or recap derived from a full installment. The source installment_id must reference an installment within the same response.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/installment_deadlines.py b/src/adcp/types/domains/core/installment_deadlines.py index 98619ea9a..6d9b4dbed 100644 --- a/src/adcp/types/domains/core/installment_deadlines.py +++ b/src/adcp/types/domains/core/installment_deadlines.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/installment_deadlines.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from . import material_deadline @@ -21,12 +21,14 @@ class InstallmentDeadlines(AdCPBaseModel): Field( description='Last date/time to book a placement in this installment (ISO 8601). After this point, the seller will not accept new bookings.' ), + NoExplicitNull, ] = None cancellation_deadline: Annotated[ AwareDatetime | None, Field( description="Last date/time to cancel without penalty (ISO 8601). Cancellations after this point may incur fees per the seller's terms." ), + NoExplicitNull, ] = None material_deadlines: Annotated[ list[material_deadline.MaterialDeadline] | None, @@ -34,4 +36,5 @@ class InstallmentDeadlines(AdCPBaseModel): description="Stages for creative material submission. Items MUST be in chronological order by due_at (earliest first). Typical pattern: 'draft' for raw materials the seller will process, 'final' for production-ready assets. Print example: draft artwork then press-ready PDF. Influencer example: talking points then approved script.", min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/installment_delivery_metrics.py b/src/adcp/types/domains/core/installment_delivery_metrics.py index f8a824060..26ae391b1 100644 --- a/src/adcp/types/domains/core/installment_delivery_metrics.py +++ b/src/adcp/types/domains/core/installment_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/installment_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import AwareDatetime, Field from . import installment_ref as installment_ref_1 @@ -19,12 +20,14 @@ class InstallmentDeliveryMetrics(DeliveryMetrics): Field( description='Current human-readable installment name. Convenience metadata only; installment_ref is stable identity.' ), + NoExplicitNull, ] = None scheduled_at: Annotated[ AwareDatetime | None, Field( description="Convenience echo of the installment's scheduled publication or air time." ), + NoExplicitNull, ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/installment_property_delivery_metrics.py b/src/adcp/types/domains/core/installment_property_delivery_metrics.py index 483577736..5819c2ff6 100644 --- a/src/adcp/types/domains/core/installment_property_delivery_metrics.py +++ b/src/adcp/types/domains/core/installment_property_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/installment_property_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import AwareDatetime, Field from . import identifier as identifier_1 @@ -19,12 +20,14 @@ class InstallmentPropertyDeliveryMetrics(DeliveryMetrics): installment_name: Annotated[ str | None, Field(description='Current human-readable installment name. Convenience metadata only.'), + NoExplicitNull, ] = None scheduled_at: Annotated[ AwareDatetime | None, Field( description="Convenience echo of the installment's scheduled publication or air time." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str, @@ -42,10 +45,12 @@ class InstallmentPropertyDeliveryMetrics(DeliveryMetrics): Field( description="Canonical publisher-scoped catalog identity when available. Its publisher_domain MUST equal the row's publisher_domain." ), + NoExplicitNull, ] = None property_name: Annotated[ str | None, Field(description='Current human-readable property name. Convenience metadata only.'), + NoExplicitNull, ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/job_item.py b/src/adcp/types/domains/core/job_item.py index 26139ed2f..6833d3895 100644 --- a/src/adcp/types/domains/core/job_item.py +++ b/src/adcp/types/domains/core/job_item.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/job_item.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -42,8 +42,12 @@ class Salary(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - min: Annotated[StrictFloat | None, Field(description='Minimum salary.', ge=0.0)] = None - max: Annotated[StrictFloat | None, Field(description='Maximum salary.', ge=0.0)] = None + min: Annotated[ + StrictFloat | None, Field(description='Minimum salary.', ge=0.0), NoExplicitNull + ] = None + max: Annotated[ + StrictFloat | None, Field(description='Maximum salary.', ge=0.0), NoExplicitNull + ] = None currency: Annotated[str, Field(description='ISO 4217 currency code.', pattern='^[A-Z]{3}$')] period: Annotated[Period, Field(description='Pay period.')] @@ -66,30 +70,35 @@ class JobItem(AdCPBaseModel): Field( description="Job location as a display string (e.g., 'Amsterdam, NL', 'Remote', 'New York, NY'). Use 'Remote' for fully remote positions." ), + NoExplicitNull, + ] = None + employment_type: Annotated[ + EmploymentType | None, Field(description='Type of employment.'), NoExplicitNull ] = None - employment_type: Annotated[EmploymentType | None, Field(description='Type of employment.')] = ( - None - ) experience_level: Annotated[ - ExperienceLevel | None, Field(description='Required experience level.') + ExperienceLevel | None, Field(description='Required experience level.'), NoExplicitNull ] = None salary: Annotated[ Salary | None, Field(description='Salary range. Specify min and/or max with currency and period.'), + NoExplicitNull, ] = None date_posted: Annotated[ - date | None, Field(description='Date the job was posted (ISO 8601 date).') + date | None, Field(description='Date the job was posted (ISO 8601 date).'), NoExplicitNull ] = None valid_through: Annotated[ - date | None, Field(description='Application deadline (ISO 8601 date).') + date | None, Field(description='Application deadline (ISO 8601 date).'), NoExplicitNull + ] = None + apply_url: Annotated[ + AnyUrl | None, Field(description='Direct application URL.'), NoExplicitNull ] = None - apply_url: Annotated[AnyUrl | None, Field(description='Direct application URL.')] = None job_functions: Annotated[ list[str] | None, Field( description="Job function categories (e.g., 'engineering', 'marketing', 'sales', 'finance').", min_length=1, ), + NoExplicitNull, ] = None industries: Annotated[ list[str] | None, @@ -97,6 +106,7 @@ class JobItem(AdCPBaseModel): description="Industry classifications (e.g., 'technology', 'healthcare', 'retail').", min_length=1, ), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, @@ -104,6 +114,7 @@ class JobItem(AdCPBaseModel): description="Tags for filtering (e.g., 'remote', 'visa-sponsorship', 'equity').", min_length=1, ), + NoExplicitNull, ] = None assets: Annotated[ list[offering_asset_group.OfferingAssetGroup] | None, @@ -111,5 +122,6 @@ class JobItem(AdCPBaseModel): description="Typed creative asset pools for this job. Uses the same OfferingAssetGroup structure as offering-type catalogs. Standard group IDs: 'images_landscape' (company/role hero), 'images_vertical' (9:16 for Stories), 'logo' (company logo). Enables formats to declare typed image requirements that map unambiguously to the right asset regardless of platform.", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/keyword_target.py b/src/adcp/types/domains/core/keyword_target.py index bd0f95b11..cdd4df8b9 100644 --- a/src/adcp/types/domains/core/keyword_target.py +++ b/src/adcp/types/domains/core/keyword_target.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/keyword_target.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import match_type as match_type_1 @@ -18,4 +18,4 @@ class KeywordTarget(AdCPBaseModel): ) keyword: Annotated[str, Field(min_length=1)] match_type: match_type_1.MatchType - bid_price: Annotated[StrictFloat | None, Field(ge=0.0)] = None + bid_price: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/limited_series.py b/src/adcp/types/domains/core/limited_series.py index b89c41294..d8b12715e 100644 --- a/src/adcp/types/domains/core/limited_series.py +++ b/src/adcp/types/domains/core/limited_series.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/limited_series.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field @@ -18,8 +18,8 @@ class LimitedSeries(AdCPBaseModel): SchemaInt, Field(description='Planned number of installments in the series', ge=1) ] starts: Annotated[ - AwareDatetime | None, Field(description='When the series begins (ISO 8601)') + AwareDatetime | None, Field(description='When the series begins (ISO 8601)'), NoExplicitNull + ] = None + ends: Annotated[ + AwareDatetime | None, Field(description='When the series ends (ISO 8601)'), NoExplicitNull ] = None - ends: Annotated[AwareDatetime | None, Field(description='When the series ends (ISO 8601)')] = ( - None - ) diff --git a/src/adcp/types/domains/core/macro_declaration.py b/src/adcp/types/domains/core/macro_declaration.py index 5d8879a08..ba983f075 100644 --- a/src/adcp/types/domains/core/macro_declaration.py +++ b/src/adcp/types/domains/core/macro_declaration.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/macro_declaration.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, StrictBool from .assets import asset_union @@ -67,6 +67,7 @@ class MacroDeclaration(AdCPBaseModel): Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), + NoExplicitNull, ] = None dialect_revision: Annotated[ str | None, @@ -74,6 +75,7 @@ class MacroDeclaration(AdCPBaseModel): description='Immutable registry/mapping revision, release, commit, or digest. Required for IAB VAST, IAB DAAST, and vendor dialects.', min_length=1, ), + NoExplicitNull, ] = None dialect_semantic: Annotated[ str, @@ -88,6 +90,7 @@ class MacroDeclaration(AdCPBaseModel): Field( description='Verified AdCP universal meaning. Present only when mapping_status is `verified_universal`.' ), + NoExplicitNull, ] = None operation: asset_union.MacroProcessingOperation performed_by: Annotated[ @@ -95,12 +98,14 @@ class MacroDeclaration(AdCPBaseModel): Field( description='Actor authorized to perform the declared translation or value resolution. Omitted for preservation.' ), + NoExplicitNull, ] = None translation_target: Annotated[ asset_union.MacroTranslationTarget | None, Field( description='Required only for `translate_to_native`. The output token contract that replaces this declaration after translation.' ), + NoExplicitNull, ] = None location: Location encoding: Annotated[ diff --git a/src/adcp/types/domains/core/macro_resolution_capability.py b/src/adcp/types/domains/core/macro_resolution_capability.py index de4877035..fbc5d7db6 100644 --- a/src/adcp/types/domains/core/macro_resolution_capability.py +++ b/src/adcp/types/domains/core/macro_resolution_capability.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/macro_resolution_capability.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..enums import macro_dialect, macro_resolver, macro_value_context, universal_macro @@ -29,11 +29,11 @@ class MacroProcessingCapability(AdCPBaseModel): extra='forbid', ) dialect: macro_dialect.MacroDialectFamily - dialect_namespace: AnyUrl | None = None - dialect_revision: Annotated[str | None, Field(min_length=1)] = None + dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MappingStatus - universal_semantic: universal_macro.UniversalMacro | None = None + universal_semantic: Annotated[universal_macro.UniversalMacro | None, NoExplicitNull] = None operation: Operation performed_by: macro_resolver.MacroProcessingActor supported_contexts: Annotated[list[macro_value_context.MacroValueContext], Field(min_length=1)] @@ -43,5 +43,8 @@ class MacroProcessingCapability(AdCPBaseModel): description='Exact supported encoding profiles, not a maximum depth. Required for value resolution.', min_length=1, ), + NoExplicitNull, + ] = None + translation_target: Annotated[ + macro_translation_target.MacroTranslationTarget | None, NoExplicitNull ] = None - translation_target: macro_translation_target.MacroTranslationTarget | None = None diff --git a/src/adcp/types/domains/core/macro_resolution_result.py b/src/adcp/types/domains/core/macro_resolution_result.py index 5529952a4..fd601c31a 100644 --- a/src/adcp/types/domains/core/macro_resolution_result.py +++ b/src/adcp/types/domains/core/macro_resolution_result.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/macro_resolution_result.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool from ..enums import ( @@ -45,13 +45,13 @@ class MacroResolutionResult(AdCPBaseModel): ] token: Annotated[str, Field(min_length=1)] dialect: macro_dialect.MacroDialectFamily - dialect_namespace: AnyUrl | None = None - dialect_revision: Annotated[str | None, Field(min_length=1)] = None + dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: macro_mapping_status.MacroMappingStatus - universal_semantic: universal_macro.UniversalMacro | None = None + universal_semantic: Annotated[universal_macro.UniversalMacro | None, NoExplicitNull] = None operation: macro_processing_operation.MacroProcessingOperation - performed_by: macro_resolver.MacroProcessingActor | None = None + performed_by: Annotated[macro_resolver.MacroProcessingActor | None, NoExplicitNull] = None requested_encoding: macro_encoding.MacroEncoding required: StrictBool unavailable_behavior: UnavailableBehavior @@ -60,5 +60,6 @@ class MacroResolutionResult(AdCPBaseModel): matched_encodings: Annotated[ list[macro_encoding.MacroEncoding] | None, Field(description='Exact advertised profiles considered for this match, when relevant.'), + NoExplicitNull, ] = None - message: Annotated[str | None, Field(min_length=1)] = None + message: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/macro_translation_target.py b/src/adcp/types/domains/core/macro_translation_target.py index e3d4143ed..88893b2f4 100644 --- a/src/adcp/types/domains/core/macro_translation_target.py +++ b/src/adcp/types/domains/core/macro_translation_target.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/macro_translation_target.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..enums import macro_dialect, macro_mapping_status, macro_resolver, universal_macro @@ -19,11 +19,11 @@ class MacroTranslationTarget(AdCPBaseModel): ) token: Annotated[str, Field(min_length=1)] dialect: macro_dialect.MacroDialectFamily - dialect_namespace: AnyUrl | None = None - dialect_revision: Annotated[str | None, Field(min_length=1)] = None + dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: macro_mapping_status.MacroMappingStatus - universal_semantic: universal_macro.UniversalMacro | None = None + universal_semantic: Annotated[universal_macro.UniversalMacro | None, NoExplicitNull] = None next_operation: Literal['resolve_value'] = 'resolve_value' performed_by: macro_resolver.MacroProcessingActor encoding: macro_encoding.MacroEncoding diff --git a/src/adcp/types/domains/core/material_deadline.py b/src/adcp/types/domains/core/material_deadline.py index 03a4deab2..d5eca4985 100644 --- a/src/adcp/types/domains/core/material_deadline.py +++ b/src/adcp/types/domains/core/material_deadline.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/material_deadline.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field @@ -29,4 +29,5 @@ class MaterialDeadline(AdCPBaseModel): Field( description="What the seller needs at this stage (e.g., 'Talking points and brand guidelines', 'Press-ready PDF with bleed')" ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/mcp_webhook_payload.py b/src/adcp/types/domains/core/mcp_webhook_payload.py index 9fad1fac0..d05d7489f 100644 --- a/src/adcp/types/domains/core/mcp_webhook_payload.py +++ b/src/adcp/types/domains/core/mcp_webhook_payload.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/mcp_webhook_payload.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import adcp_protocol, task_status @@ -35,6 +35,7 @@ class McpWebhookPayload(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None operation_id: Annotated[ str, @@ -59,6 +60,7 @@ class McpWebhookPayload(AdCPBaseModel): Field( description='AdCP protocol this task belongs to. Helps classify the operation type at a high level.' ), + NoExplicitNull, ] = None status: Annotated[ task_status.TaskStatus, @@ -77,12 +79,14 @@ class McpWebhookPayload(AdCPBaseModel): Field( description='Human-readable summary of the current task state. Provides context about what happened and what action may be needed.' ), + NoExplicitNull, ] = None context_id: Annotated[ str | None, Field( description='Compatibility metadata copied from the originating response when present. This value alone is not continuation authority and MUST NOT be used to resume input-required or auth-required work or to select session state.' ), + NoExplicitNull, ] = None token: Annotated[ str | None, @@ -91,10 +95,12 @@ class McpWebhookPayload(AdCPBaseModel): max_length=4096, min_length=16, ), + NoExplicitNull, ] = None result: Annotated[ async_response_data.AdcpAsyncResponseData | None, Field( description='Task-specific payload matching the status. For completed/failed, contains the full task response. For working/input-required/submitted, contains status-specific data. This is the data layer that AdCP specs - same structure used in A2A status.message.parts[].data.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/measurement_readiness.py b/src/adcp/types/domains/core/measurement_readiness.py index 79db1b455..8ef0f86b5 100644 --- a/src/adcp/types/domains/core/measurement_readiness.py +++ b/src/adcp/types/domains/core/measurement_readiness.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/measurement_readiness.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import assessment_status, event_type @@ -29,22 +29,26 @@ class MeasurementReadiness(AdCPBaseModel): description='Event types this product needs for effective optimization. Buyers should ensure their event sources cover these types.', min_length=1, ), + NoExplicitNull, ] = None missing_event_types: Annotated[ list[event_type.EventType] | None, Field( description='Event types this product requires that the buyer has not configured. Empty or absent when all required types are covered.' ), + NoExplicitNull, ] = None issues: Annotated[ list[diagnostic_issue.DiagnosticIssue] | None, Field( description='Actionable issues preventing full measurement readiness. Sellers should limit to the top 3-5 most actionable items. Buyer agents should sort by severity rather than relying on array position.' ), + NoExplicitNull, ] = None notes: Annotated[ str | None, Field( description='Seller explanation of the readiness assessment, recommendations for improvement, or context about what the buyer needs to change.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/measurement_terms.py b/src/adcp/types/domains/core/measurement_terms.py index 5e8b7144e..11c0d9720 100644 --- a/src/adcp/types/domains/core/measurement_terms.py +++ b/src/adcp/types/domains/core/measurement_terms.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/measurement_terms.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..enums import makegood_remedy @@ -43,6 +43,7 @@ class BillingMeasurement(AdCPBaseModel): ge=0.0, lt=100.0, ), + NoExplicitNull, ] = None measurement_window: Annotated[ str | None, @@ -59,6 +60,7 @@ class BillingMeasurement(AdCPBaseModel): 'downloads_30d', ], ), + NoExplicitNull, ] = None finalization_deadline_hours: Annotated[ SchemaInt | None, @@ -66,6 +68,7 @@ class BillingMeasurement(AdCPBaseModel): description='Maximum hours by which the authoritative party MUST publish a final record (`is_final: true` / `finalized_at` on `get_media_buy_delivery`, or `final: true` / `finalized_at` on `report_usage`). **Anchor:** when `measurement_window` is set, hours are counted from the close of that window (e.g., 240h after `c7` close = ~10 days after the 7-day DVR accumulation completes); when `measurement_window` is absent, hours are counted from `reporting_period.end`. Picking a single anchor avoids ambiguity for windowed channels where `reporting_period.end` and window close differ by days. The deadline applies to whichever party is named in `vendor` — seller, buyer, or third-party vendor — symmetrically. When the deadline elapses without a final record, the counterparty MAY fall back to its own attestation for invoicing (seller falls back to seller-attested numbers via `get_media_buy_delivery`; buyer falls back to a buyer-attested `report_usage` push), and the breach is treated like any other measurement-terms breach under `makegood_policy`. Absent means no contractual deadline — finalization is best-effort and disagreements resolve out of band.', ge=0, ), + NoExplicitNull, ] = None @@ -78,10 +81,12 @@ class MeasurementTerms(AdCPBaseModel): Field( description="Which vendor's value for the billing metric governs invoicing. The billing metric is determined by the pricing_model on the selected pricing_option (e.g., impressions for CPM, completed views for CPCV, commissionable_value for revenue_share)." ), + NoExplicitNull, ] = None makegood_policy: Annotated[ MakegoodPolicy | None, Field( description='Remedies available when a performance standard or billing measurement variance is breached. Seller declares which remedy types they support. When a breach occurs, the seller proposes a remedy from this menu; the buyer accepts or disputes.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/measurement_window.py b/src/adcp/types/domains/core/measurement_window.py index 073da0fae..953852945 100644 --- a/src/adcp/types/domains/core/measurement_window.py +++ b/src/adcp/types/domains/core/measurement_window.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/measurement_window.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -35,6 +35,7 @@ class MeasurementWindow(AdCPBaseModel): ], max_length=500, ), + NoExplicitNull, ] = None duration_days: Annotated[ SchemaInt, @@ -49,10 +50,12 @@ class MeasurementWindow(AdCPBaseModel): description="Expected number of days after delivery before this window's data is available from the measurement vendor. Captures accumulation time plus vendor processing time. Examples: broadcast C7 from VideoAmp ~22 days (7-day accumulation + ~15-day processing); DOOH tentative plays same-day; DOOH final (post-IVT/fraud-check) ~1 day; digital post-SIVT ~2–3 days.", ge=0, ), + NoExplicitNull, ] = None is_guarantee_basis: Annotated[ StrictBool | None, Field( description="Whether this window is the basis for delivery guarantees, reconciliation, and invoicing. A product typically has one guarantee basis window (e.g., C7 for most US broadcast, post-IVT final for DOOH). Buyers reconcile against the guarantee basis window's final numbers." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/media_buy.py b/src/adcp/types/domains/core/media_buy.py index 0fc9af67a..340a92505 100644 --- a/src/adcp/types/domains/core/media_buy.py +++ b/src/adcp/types/domains/core/media_buy.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/media_buy.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..enums import canceled_by as canceled_by_1 @@ -37,6 +37,7 @@ class Cancellation(AdCPBaseModel): reason: Annotated[ str | None, Field(description='Reason provided when the media buy was canceled.', max_length=500), + NoExplicitNull, ] = None @@ -53,6 +54,7 @@ class MediaBuy(AdCPBaseModel): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None accepted_proposal_id: Annotated[ str | None, @@ -61,6 +63,7 @@ class MediaBuy(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None accepted_proposal_terms_digest: Annotated[ str | None, @@ -68,9 +71,12 @@ class MediaBuy(AdCPBaseModel): description='Digest of the current accepted proposal commercial_terms, allowing buyers and governance agents to verify the recovered snapshot.', pattern='^sha256:[A-Za-z0-9_-]{43}$', ), + NoExplicitNull, ] = None account: Annotated[ - account_1.Account | None, Field(description='Account billed for this media buy') + account_1.Account | None, + Field(description='Account billed for this media buy'), + NoExplicitNull, ] = None status: media_buy_status.MediaBuyStatus health: Annotated[ @@ -78,18 +84,21 @@ class MediaBuy(AdCPBaseModel): Field( description="Aggregate health based on open impairments[]. Orthogonal to status — a paused, pending, or active buy can each be impaired. Defaults to 'ok' when impairments[] is empty." ), + NoExplicitNull, ] = media_buy_health.MediaBuyHealth.ok impairments: Annotated[ list[impairment.Impairment] | None, Field( description="Open impairments — upstream dependency state changes that affect delivery for at least one package on this buy. Empty when health is 'ok'. Sellers MUST add an entry on next sync/poll response after a referenced resource transitions to an offline state, and MUST remove the entry (flipping health to 'ok' when the array empties) when the resource returns to a serviceable state. Staleness budget: the snapshot MUST reflect the impairment within 5 minutes of impairment.observed_at regardless of buyer poll cadence — sellers cannot rely on rare buyer polls to defer write propagation. See impairment.coherence assertion for the cross-resource invariant." ), + NoExplicitNull, ] = None rejection_reason: Annotated[ str | None, Field( description="Reason provided by the seller when status is 'rejected'. Present only when status is 'rejected'." ), + NoExplicitNull, ] = None confirmed_at: Annotated[ AwareDatetime | None, @@ -100,6 +109,7 @@ class MediaBuy(AdCPBaseModel): cancellation: Annotated[ Cancellation | None, Field(description="Cancellation metadata. Present only when status is 'canceled'."), + NoExplicitNull, ] = None total_budget: Annotated[ StrictFloat, Field(description='Hard aggregate lifetime budget amount', ge=0.0) @@ -110,18 +120,21 @@ class MediaBuy(AdCPBaseModel): description='Current hard aggregate spend ceiling per calendar day. Sellers MUST echo this whenever an aggregate daily cap is set. It bounds total media-buy spend without allocating or reserving spend for packages.', ge=0.0, ), + NoExplicitNull, ] = None frequency_cap: Annotated[ media_buy_frequency_cap.MediaBuyFrequencyCap | None, Field( description='Current hard MediaBuy-level cap. Sellers MUST echo it whenever set. Its counter aggregates exposures across all participating packages; each package targeting_overlay.frequency_cap remains independently binding.' ), + NoExplicitNull, ] = None budget_cap_timezone: Annotated[ str | None, Field( description='IANA timezone defining the shared calendar-day boundary for every aggregate and package daily cap on this media buy. Sellers MUST echo it whenever any daily cap is set.' ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -129,24 +142,28 @@ class MediaBuy(AdCPBaseModel): description="Single ISO 4217 denomination for total_budget, every package budget/minimum, and every canonical BiddingPolicy monetary field. Every package's selected pricing option MUST declare this currency; packages requiring another currency belong in a separate media buy.", pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None budget_allocation: Annotated[ budget_allocation_1.BudgetAllocation | None, Field( description='Accepted cross-package budget allocation configuration. Omitted means fixed allocation for legacy buys.' ), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Aggregate pacing strategy for total_budget across the media-buy flight.' ), + NoExplicitNull, ] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, Field( description='Media-buy-authored bidding policy. This is the complete default inherited by packages that omit package.bidding; `{automatic:true}` is an explicit authored automatic policy. In seller-optimized mode, cost_per/roas bind to the primary budget_allocation optimization goal. In fixed mode, inherited cost_per requires compatible package primary-goal result units and inherited roas requires value-bearing primary goals. Monetary fields use media_buy.currency; every affected pricing option MUST declare the same currency. Package overrides are permitted only where advertised; conflicts MUST be rejected atomically with BIDDING_PLACEMENT_CONFLICT.' ), + NoExplicitNull, ] = None packages: Annotated[ list[package.Package], Field(description='Array of packages within this media buy') @@ -156,15 +173,19 @@ class MediaBuy(AdCPBaseModel): Field( description='Opaque media-buy-level correlation data echoed unchanged from the create_media_buy request. Sellers MUST include persisted context on read surfaces such as get_media_buys when the media buy was created through AdCP with context, so buyers can reconcile seller-assigned media_buy_id values with their own tracking state. Sellers MAY omit context for media buys created outside AdCP or created without context. Sellers MUST NOT parse this object for business logic.' ), + NoExplicitNull, ] = None invoice_recipient: Annotated[ business_entity.BusinessEntity | None, Field( description="Per-buy override for who receives the invoice. When provided, the seller invoices this entity instead of the account's default billing_entity. The seller MUST validate the invoice recipient is authorized for this account. When governance_agents are configured, the seller MUST include invoice_recipient in the check_governance request." ), + NoExplicitNull, ] = None creative_deadline: Annotated[ - AwareDatetime | None, Field(description='ISO 8601 timestamp for creative upload deadline') + AwareDatetime | None, + Field(description='ISO 8601 timestamp for creative upload deadline'), + NoExplicitNull, ] = None revision: Annotated[ SchemaInt, @@ -173,6 +194,10 @@ class MediaBuy(AdCPBaseModel): ge=1, ), ] - created_at: Annotated[AwareDatetime | None, Field(description='Creation timestamp')] = None - updated_at: Annotated[AwareDatetime | None, Field(description='Last update timestamp')] = None - ext: ext_1.ExtensionObject | None = None + created_at: Annotated[ + AwareDatetime | None, Field(description='Creation timestamp'), NoExplicitNull + ] = None + updated_at: Annotated[ + AwareDatetime | None, Field(description='Last update timestamp'), NoExplicitNull + ] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/media_buy_available_action.py b/src/adcp/types/domains/core/media_buy_available_action.py index e1527e69b..1416f4d94 100644 --- a/src/adcp/types/domains/core/media_buy_available_action.py +++ b/src/adcp/types/domains/core/media_buy_available_action.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/media_buy_available_action.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import media_buy_action_mode @@ -45,19 +45,26 @@ class MediaBuyAvailableAction(AdCPBaseModel): Field( description='Compact-lifecycle task for this resolved action: operational control, commercial refinement, or creative lifecycle mutation.' ), + NoExplicitNull, ] = None sla: Annotated[ sla_window.SlaWindow | None, Field( description='Optional SLA commitment for this action on this buy. Absence means no commitment, not zero commitment.' ), + NoExplicitNull, + ] = None + change_term_id: Annotated[ + media_buy_change_term_id.MediaBuyChangeTermId | None, NoExplicitNull + ] = None + terms_ref: Annotated[ + media_buy_legacy_terms_ref.MediaBuyTermsReference | None, NoExplicitNull ] = None - change_term_id: media_buy_change_term_id.MediaBuyChangeTermId | None = None - terms_ref: media_buy_legacy_terms_ref.MediaBuyTermsReference | None = None applicable_package_ids: Annotated[ list[applicable_package_id.ApplicablePackageId] | None, Field( description='For a package-scoped action, the exact packages currently eligible. Omission means every relevant package. Root actions omit this field.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/media_buy_features.py b/src/adcp/types/domains/core/media_buy_features.py index f41afaf1b..000b2836c 100644 --- a/src/adcp/types/domains/core/media_buy_features.py +++ b/src/adcp/types/domains/core/media_buy_features.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/media_buy_features.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Dict -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from . import bidding_policy_capability @@ -23,58 +23,68 @@ class MediaBuyFeatures(AdCPBaseModel): deprecated=True, description='Deprecated 3.x compatibility capability for creatives provided inline in create_media_buy and update_media_buy package payloads. buy_products, accept_proposal, and control_media_buy never accept inline creatives. New integrations use the dedicated creative lifecycle. Removed in 4.0.', ), + NoExplicitNull, ] = None property_list_filtering: Annotated[ StrictBool | None, Field( description='Honors property_list parameter in get_products to filter results to buyer-approved properties' ), + NoExplicitNull, ] = None catalog_management: Annotated[ StrictBool | None, Field( description='Supports sync_catalogs task for catalog feed management with platform review and approval' ), + NoExplicitNull, ] = None catalog_item_availability_updates: Annotated[ StrictBool | None, Field( description='Supports buyer-pushed item_availability_updates and item_availability_queries on sync_catalogs for immediate suppression/restoration and current-state readback in buyer-managed catalogs. Seller declarations may be true only with catalog_management: true; buyer required_features filters may request this feature alone. Requests containing availability operations are synchronous and accept at most 1,000 combined update and query entries. A successful suppress covers selection, dynamic rendering, and cached or pre-generated creatives materialized from the item. Internal lineage MUST retain resolved_account_id, catalog_id, catalog_generation, and item_id. Static creatives supplied or promoted by the buyer without catalog lineage remain outside this automatic guarantee. Suppression persists across feed refreshes and ordinary upserts until restore, expires_at, or catalog deletion. A seller that does not declare true MUST reject availability operations with UNSUPPORTED_FEATURE before lookup or mutation and MUST NOT interpret them as discovery. This does not control seller-owned wholesale inventory or let restore bypass seller controls.' ), + NoExplicitNull, ] = None committed_metrics_supported: Annotated[ StrictBool | None, Field( description="Seller has per-package snapshot infrastructure for the reporting contract. When true, the seller MUST populate `package.committed_metrics` on committed `create_media_buy` responses where `confirmed_at` is non-null, MUST omit `package.committed_metrics` while `confirmed_at` is null for a provisional buy, and MUST honor append-only mid-flight metric additions via `update_media_buy`. The unified `committed_metrics` array (per the metric-accountability design) covers both standard and vendor-defined metric entries, so a single flag is load-bearing. Buyers filtering on this flag are detecting 'this seller can stamp the reporting contract,' which closes the audit gap from PR #3510 where absence of `committed_metrics` was indistinguishable between 'didn't snapshot' and 'snapshot infrastructure not implemented.'" ), + NoExplicitNull, ] = None seller_optimized_budget: Annotated[ StrictBool | None, Field( description="Supports the core seller-optimized shared-budget contract for budget_allocation.mode `seller_optimized`: one hard shared total_budget, seller allocation of that total across the buy's packages against budget_allocation.optimization_goals, media-buy-level pacing, and echo of the allocation configuration on buy read surfaces. Sellers declaring true MUST accept eligible explicit-package and proposal executions that use only these core controls and MUST enforce the aggregate budget. Core media-buy pacing: sellers declaring true MUST accept omitted media-buy pacing (which defaults to even when total_budget is present) and pacing `even` on seller-optimized buys; they MAY reject `asap` or `front_loaded` with `UNSUPPORTED_FEATURE` (error.field `pacing`) before any provider mutation, and MUST NOT silently coerce them to `even`. Package-level controls inside a seller-optimized buy are separate capabilities: package budget caps (seller_optimized_package_budgets), package minimum-spend targets (seller_optimized_min_spend_targets), and package pacing (seller_optimized_package_pacing). A seller declaring this feature but not one of those sub-capabilities MUST reject any request that would leave that package control on a seller-optimized buy with `UNSUPPORTED_FEATURE` before any over-subscription validation or provider mutation, and MUST NOT silently drop, soften, or coerce it. Over-subscription validation (`INVALID_REQUEST`) applies only to package controls the seller has declared; see seller_optimized_min_spend_targets. Product combinations may still be rejected when their currencies, optimization capabilities, pricing terms, or delivery constraints are incompatible. Sellers that do not declare this feature MUST reject any request carrying `budget_allocation.mode: 'seller_optimized'` with `UNSUPPORTED_FEATURE` before any provider mutation, and MUST NOT coerce the request to fixed allocation." ), + NoExplicitNull, ] = None seller_optimized_package_budgets: Annotated[ StrictBool | None, Field( description='Honors package `budget` as an optional hard lifetime package spend cap inside a seller-optimized buy: packages[].budget and new_packages[].budget on create_media_buy and update_media_buy, purchases[].budget on buy_products, package budget controls on control_media_buy, and max_spend_percentage on seller-optimized proposal allocations. The cap is a ceiling, not a reserved or current allocation, and package caps may sum above total_budget. Meaningful only with seller_optimized_budget: true; seller declarations may be true only with seller_optimized_budget: true, while buyer required_features filters may request this feature alone. A seller that declares seller_optimized_budget without this feature MUST reject a request that would leave a package budget on a seller-optimized buy, including an allocation-mode switch that retains fixed-mode package budgets (the buyer clears them with null in the same atomic update), with `UNSUPPORTED_FEATURE` before any over-subscription validation or provider mutation, and MUST NOT issue seller-optimized proposals carrying max_spend_percentage. Does not govern fixed allocation, where package budgets remain required.' ), + NoExplicitNull, ] = None seller_optimized_min_spend_targets: Annotated[ StrictBool | None, Field( description='Honors package `min_spend_target` as a soft lifetime minimum-spend target inside a seller-optimized buy: packages[].min_spend_target and new_packages[].min_spend_target on create_media_buy and update_media_buy, purchases[].min_spend_target on buy_products, package min_spend_target controls on control_media_buy, and min_spend_target_percentage on seller-optimized proposal allocations. The seller SHOULD attempt to deliver at least the target before allocating incremental spend elsewhere; it is not a billing or delivery guarantee. Meaningful only with seller_optimized_budget: true; seller declarations may be true only with seller_optimized_budget: true, while buyer required_features filters may request this feature alone. Sellers declaring this feature MUST reject package minimum-spend targets summing above total_budget with `INVALID_REQUEST` before mutation, and, when they also declare seller_optimized_package_budgets, MUST likewise reject a min_spend_target above its own package budget. A seller that declares seller_optimized_budget without this feature MUST reject a request carrying a numeric min_spend_target with `UNSUPPORTED_FEATURE` before any over-subscription validation or provider mutation, so an over-subscribed target sent to such a seller yields `UNSUPPORTED_FEATURE`, and MUST NOT issue seller-optimized proposals carrying min_spend_target_percentage.' ), + NoExplicitNull, ] = None seller_optimized_package_pacing: Annotated[ StrictBool | None, Field( description='Honors package `pacing` as subordinate per-package pacing inside a seller-optimized buy, in addition to the media-buy-level pacing covered by seller_optimized_budget: packages[].pacing and new_packages[].pacing on create_media_buy and update_media_buy, purchases[].pacing on buy_products, package pacing controls on control_media_buy, and allocation pacing on seller-optimized proposals. Package pacing MUST NOT cause delivery to exceed aggregate media-buy pacing. Meaningful only with seller_optimized_budget: true; seller declarations may be true only with seller_optimized_budget: true, while buyer required_features filters may request this feature alone. Package pacing equal to the effective media-buy pacing adds no subordinate constraint and does not require this feature; buyers SHOULD omit package pacing on seller-optimized buys unless this feature is advertised. A seller that declares seller_optimized_budget without this feature MUST reject a request that would leave package pacing differing from media-buy pacing on a seller-optimized buy, including an allocation-mode switch that retains such fixed-mode package pacing (the buyer can align it in the same update), with `UNSUPPORTED_FEATURE` before any provider mutation, and MUST NOT issue seller-optimized proposals carrying allocation pacing. Does not govern package pacing in fixed allocation.' ), + NoExplicitNull, ] = None bidding_policy: Annotated[ bidding_policy_capability.BiddingPolicyCapability | None, Field( description='Structured support for canonical bidding by authored scope, allocation context, mode, strength, and strength-qualified multi-field combination. Presence does not imply support for both scopes, both allocation modes, or every policy shape. Sellers MUST preserve every advertised semantic exactly and reject unadvertised policies rather than translating them.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/media_buy_frequency_cap_requirement.py b/src/adcp/types/domains/core/media_buy_frequency_cap_requirement.py index 620276de3..9246af93f 100644 --- a/src/adcp/types/domains/core/media_buy_frequency_cap_requirement.py +++ b/src/adcp/types/domains/core/media_buy_frequency_cap_requirement.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/media_buy_frequency_cap_requirement.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import Field from ..enums import media_buy_frequency_cap_control_mode @@ -16,4 +17,5 @@ class MediaBuyFrequencyCapRequirement(FrequencyCapRequirements): supported_control_modes: Annotated[ list[media_buy_frequency_cap_control_mode.MediaBuyFrequencyCapControlMode] | None, Field(min_length=1), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/media_buy_frequency_cap_support.py b/src/adcp/types/domains/core/media_buy_frequency_cap_support.py index c8c103793..b2d406482 100644 --- a/src/adcp/types/domains/core/media_buy_frequency_cap_support.py +++ b/src/adcp/types/domains/core/media_buy_frequency_cap_support.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/media_buy_frequency_cap_support.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import Field from ..enums import media_buy_frequency_cap_control_mode @@ -19,4 +20,5 @@ class MediaBuyFrequencyCapSupport(FrequencyCapConstraints): description='FrequencyCap shapes accepted by the product. max_impressions_and_suppress means both controls may appear together and are enforced with AND semantics.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/media_buy_support.py b/src/adcp/types/domains/core/media_buy_support.py index dcf3c1975..a02b6ef39 100644 --- a/src/adcp/types/domains/core/media_buy_support.py +++ b/src/adcp/types/domains/core/media_buy_support.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/media_buy_support.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-21T12:09:21+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -22,8 +22,9 @@ class ProductMediaBuySupport(AdCPBaseModel): Field( description="This product can participate in the seller's shared counter for a root MediaBuy.frequency_cap. A buy mixing this product with one that lacks this declaration, or whose resolved supported_per_units omits the root cap's per value, is rejected atomically." ), + NoExplicitNull, ] = None - frequency_cap_constraints: ( - media_buy_frequency_cap_support.MediaBuyFrequencyCapSupport | None - ) = None - ext: ext_1.ExtensionObject | None = None + frequency_cap_constraints: Annotated[ + media_buy_frequency_cap_support.MediaBuyFrequencyCapSupport | None, NoExplicitNull + ] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/media_buy_support_requirements.py b/src/adcp/types/domains/core/media_buy_support_requirements.py index 5647803db..978545067 100644 --- a/src/adcp/types/domains/core/media_buy_support_requirements.py +++ b/src/adcp/types/domains/core/media_buy_support_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/media_buy_support_requirements.json -# timestamp: 2026-09-14T14:16:02+00:00 +# timestamp: 2026-10-21T12:09:21+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import media_buy_frequency_cap_requirement @@ -21,7 +21,8 @@ class ProductMediaBuySupportRequirements(AdCPBaseModel): Field( description='Require product participation in one shared MediaBuy frequency-cap counter.' ), + NoExplicitNull, + ] = None + frequency_cap_constraints: Annotated[ + media_buy_frequency_cap_requirement.MediaBuyFrequencyCapRequirement | None, NoExplicitNull ] = None - frequency_cap_constraints: ( - media_buy_frequency_cap_requirement.MediaBuyFrequencyCapRequirement | None - ) = None diff --git a/src/adcp/types/domains/core/missing_metric.py b/src/adcp/types/domains/core/missing_metric.py index a9e5c46ac..173738209 100644 --- a/src/adcp/types/domains/core/missing_metric.py +++ b/src/adcp/types/domains/core/missing_metric.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/missing_metric.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import attribution_methodology as attribution_methodology_1 @@ -21,11 +21,15 @@ class Qualifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - viewability_standard: viewability_standard_1.ViewabilityStandard | None = None - completion_source: completion_source_1.CompletionSource | None = None - attribution_methodology: attribution_methodology_1.AttributionMethodology | None = None - attribution_window: duration.Duration | None = None - lift_dimension: lift_dimension_1.LiftDimension | None = None + viewability_standard: Annotated[ + viewability_standard_1.ViewabilityStandard | None, NoExplicitNull + ] = None + completion_source: Annotated[completion_source_1.CompletionSource | None, NoExplicitNull] = None + attribution_methodology: Annotated[ + attribution_methodology_1.AttributionMethodology | None, NoExplicitNull + ] = None + attribution_window: Annotated[duration.Duration | None, NoExplicitNull] = None + lift_dimension: Annotated[lift_dimension_1.LiftDimension | None, NoExplicitNull] = None class MissingMetric1(AdCPBaseModel): @@ -39,6 +43,7 @@ class MissingMetric1(AdCPBaseModel): Field( description='Mirrors the qualifier on `committed_metrics` so the missing entry preserves the contract distinction (e.g., flagging MRC viewability as missing when only GroupM was reported, vendor-attested completion as missing when only seller-attested was reported, or deterministic_purchase attribution as missing when only probabilistic was reported). MUST match the qualifier on the corresponding `committed_metrics` entry the missing flag refers to.' ), + NoExplicitNull, ] = None @@ -54,6 +59,7 @@ class MissingMetric2(AdCPBaseModel): Field( description='Mirrors the qualifier on the corresponding vendor-scope `committed_metrics` entry. MUST match that entry so reconciliation joins on (vendor, metric_id, qualifier).' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/notification_config.py b/src/adcp/types/domains/core/notification_config.py index 4d4905261..7553e7aed 100644 --- a/src/adcp/types/domains/core/notification_config.py +++ b/src/adcp/types/domains/core/notification_config.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/notification_config.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool from ..enums import auth_scheme @@ -51,6 +51,7 @@ class Authentication(AdCPBaseModel): description='Credentials for the legacy scheme. Bearer: token. HMAC-SHA256: shared secret. Minimum 32 characters. Exchanged out-of-band during onboarding. Write-only.', min_length=32, ), + NoExplicitNull, ] = None @@ -85,6 +86,7 @@ class NotificationConfig(AdCPBaseModel): Field( description='Product webhook representation selected by this subscriber. Use canonical with lifecycle_tools.list_products; legacy is the default for 3.x get_products consumers. Sellers emit exactly canonical_product/canonical_pricing_options or product/pricing_options accordingly. Valid only when event_types includes a product.* event.' ), + NoExplicitNull, ] = ProductPayloadView.legacy authentication: Annotated[ Authentication | None, @@ -92,11 +94,13 @@ class NotificationConfig(AdCPBaseModel): deprecated=True, description="Legacy authentication selector. Same precedence and semantics as `push-notification-config.authentication` — presence opts the seller into Bearer or HMAC-SHA256 signing; absence selects the default RFC 9421 webhook profile keyed off the seller's brand.json `agents[]` JWKS. The same signed-registration downgrade-resistance rules apply to accounts[].notification_configs[].authentication. Deprecated; removed in AdCP 4.0. Credentials are write-only and MUST NOT be echoed on `list_accounts` reads.", ), + NoExplicitNull, ] = None active: Annotated[ StrictBool | None, Field( description="When false, the seller persists the configuration but suppresses fires. Use to pause a noisy subscriber without losing the registration. Sellers MUST NOT skip persisting the entry when `active: false` — the buyer's next `sync_accounts` MUST observe the same array, otherwise the buyer cannot distinguish pause from drop. Paused configs may skip only the outbound proof challenge while inactive; sellers MUST still enforce URL parsing, HTTPS, hostname normalization, and reserved-range rejection at write time. Reactivation requires full SSRF validation with connect pinning plus proof-of-control for any tuple without current valid proof." ), + NoExplicitNull, ] = True - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/offering.py b/src/adcp/types/domains/core/offering.py index ea6d21f8f..ac476ff9b 100644 --- a/src/adcp/types/domains/core/offering.py +++ b/src/adcp/types/domains/core/offering.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/offering.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field @@ -46,6 +46,7 @@ class GeoTargets(AdCPBaseModel): description="Countries where this offering is relevant. ISO 3166-1 alpha-2 codes (e.g., 'US', 'NL', 'DE').", min_length=1, ), + NoExplicitNull, ] = None regions: Annotated[ list[Region] | None, @@ -53,6 +54,7 @@ class GeoTargets(AdCPBaseModel): description="Regions or states where this offering is relevant. ISO 3166-2 subdivision codes (e.g., 'NL-NH', 'US-CA').", min_length=1, ), + NoExplicitNull, ] = None metros: Annotated[ list[Metro] | None, @@ -60,6 +62,7 @@ class GeoTargets(AdCPBaseModel): description='Metro areas where this offering is relevant. Each entry specifies the classification system and target values.', min_length=1, ), + NoExplicitNull, ] = None postal_areas: Annotated[ list[postal_area.PostalArea] | None, @@ -67,6 +70,7 @@ class GeoTargets(AdCPBaseModel): description='Postal areas where this offering is relevant. Prefer the native country + postal system form. Deprecated legacy country-fused postal-system tokens remain accepted for compatibility.', min_length=1, ), + NoExplicitNull, ] = None @@ -86,58 +90,66 @@ class Offering(AdCPBaseModel): description="Human-readable offering name (e.g., 'Winter Sale', 'Free Trial', 'Enterprise Platform')" ), ] - description: Annotated[str | None, Field(description="Description of what's being offered")] = ( - None - ) + description: Annotated[ + str | None, Field(description="Description of what's being offered"), NoExplicitNull + ] = None tagline: Annotated[ - str | None, Field(description='Short promotional tagline for the offering') + str | None, Field(description='Short promotional tagline for the offering'), NoExplicitNull ] = None valid_from: Annotated[ AwareDatetime | None, Field( description='When the offering becomes available. If not specified, offering is immediately available.' ), + NoExplicitNull, ] = None valid_to: Annotated[ AwareDatetime | None, Field( description='When the offering expires. If not specified, offering has no expiration.' ), + NoExplicitNull, ] = None checkout_url: Annotated[ AnyUrl | None, Field( description="URL for checkout/purchase flow when the brand doesn't support agentic checkout." ), + NoExplicitNull, ] = None landing_url: Annotated[ AnyUrl | None, Field( description="Landing page URL for this offering. For catalog-driven creatives, this is the per-item click-through destination that platforms map to the ad's link-out URL. Every offering in a catalog should have a landing_url unless the format provides its own destination logic." ), + NoExplicitNull, ] = None assets: Annotated[ list[offering_asset_group.OfferingAssetGroup] | None, Field( description='Structured asset groups for this offering. Each group carries a typed pool of creative assets (headlines, images, videos, etc.) identified by a group ID that matches format-level vocabulary.' ), + NoExplicitNull, ] = None geo_targets: Annotated[ GeoTargets | None, Field( description="Geographic scope of this offering. Declares where the offering is relevant — for location-specific offerings such as job vacancies, in-store promotions, or local events. Platforms use this to target geographically appropriate audiences and to filter out offerings irrelevant to a user's location. Uses the same geographic structures as targeting_overlay in create_media_buy." ), + NoExplicitNull, ] = None keywords: Annotated[ list[str] | None, Field( description='Keywords for matching this offering to user intent. Hosts use these for retrieval/relevance scoring.' ), + NoExplicitNull, ] = None categories: Annotated[ list[str] | None, Field( description="Categories this offering belongs to (e.g., 'measurement', 'identity', 'programmatic')" ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/offering_asset_group.py b/src/adcp/types/domains/core/offering_asset_group.py index cc091b559..356688f6c 100644 --- a/src/adcp/types/domains/core/offering_asset_group.py +++ b/src/adcp/types/domains/core/offering_asset_group.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/offering_asset_group.json -# timestamp: 2026-10-03T00:36:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import asset_content_type @@ -67,4 +67,4 @@ class OfferingAssetGroup(AdCPBaseModel): min_length=1, ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/operator_identity.py b/src/adcp/types/domains/core/operator_identity.py index 149b12a0e..b1548d704 100644 --- a/src/adcp/types/domains/core/operator_identity.py +++ b/src/adcp/types/domains/core/operator_identity.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/operator_identity.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import operator_unit as operator_unit_1 @@ -28,4 +28,5 @@ class OperatorIdentity(AdCPBaseModel): Field( description='Optional operator-owned business unit, agency seat, or platform account. Its id participates in account identity; name is mutable display metadata. Omission from a complete desired replacement means the account should have no operator unit.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/operator_unit.py b/src/adcp/types/domains/core/operator_unit.py index d3fe73828..66408003f 100644 --- a/src/adcp/types/domains/core/operator_unit.py +++ b/src/adcp/types/domains/core/operator_unit.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/operator_unit.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -30,4 +30,5 @@ class OperatorUnit(AdCPBaseModel): max_length=200, min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/opportunity_context.py b/src/adcp/types/domains/core/opportunity_context.py index 495781001..cf88a322d 100644 --- a/src/adcp/types/domains/core/opportunity_context.py +++ b/src/adcp/types/domains/core/opportunity_context.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/opportunity_context.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from . import date_range @@ -54,30 +54,37 @@ class OpportunityContext(AdCPBaseModel): pattern='^[A-Za-z0-9_.:-]{1,255}$', ), ] - phase: Annotated[Phase | None, Field(description='Current stage of buyer planning.')] = None + phase: Annotated[ + Phase | None, Field(description='Current stage of buyer planning.'), NoExplicitNull + ] = None intent: Annotated[ Intent | None, Field(description='How seriously the buyer is evaluating supply in this cycle.'), + NoExplicitNull, ] = None planning_horizon: Annotated[ date_range.DateRange | None, Field( description='Inclusive calendar range in which the buyer expects the campaign to run.' ), + NoExplicitNull, ] = None response_deadline: Annotated[ AwareDatetime | None, Field(description='Deadline by which the buyer needs a seller response.'), + NoExplicitNull, ] = None status: Annotated[ Status | None, Field( description='Whether the planning cycle remains open. On calls after initial creation, omission means no status update and MUST NOT reopen or close the opportunity implicitly, except that successful create_media_buy proposal execution explicitly infers accepted closure.' ), + NoExplicitNull, ] = None close_reason: Annotated[ CloseReason | None, Field(description='Why the opportunity closed. Required when status is closed.'), + NoExplicitNull, ] = None close_detail: Annotated[ str | None, @@ -86,4 +93,5 @@ class OpportunityContext(AdCPBaseModel): max_length=500, min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/optimization_goal.py b/src/adcp/types/domains/core/optimization_goal.py index b824e1591..b2515d606 100644 --- a/src/adcp/types/domains/core/optimization_goal.py +++ b/src/adcp/types/domains/core/optimization_goal.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/optimization_goal.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..enums import event_type as event_type_1 @@ -42,6 +42,7 @@ class TargetFrequency(AdCPBaseModel): description='Minimum frequency for an entity to be considered meaningfully reached within the window. Impressions that would bring an entity below this threshold are treated as high-value (growing reach). When omitted, the seller uses their platform default (typically 1).', ge=1, ), + NoExplicitNull, ] = None max: Annotated[ SchemaInt | None, @@ -49,6 +50,7 @@ class TargetFrequency(AdCPBaseModel): description='Frequency at which an entity is considered saturated within the window. Impressions toward entities at or above this threshold are treated as lower-value. When both min and max are present, max must be greater than or equal to min. When omitted, the seller determines the saturation point.', ge=1, ), + NoExplicitNull, ] = None window: Annotated[ duration.Duration, @@ -104,18 +106,21 @@ class EventSource(AdCPBaseModel): Field( description="Required when event_type is 'custom'. Platform-specific name for the custom event." ), + NoExplicitNull, ] = None value_field: Annotated[ str | None, Field( description="Which field in the event's custom_data carries the monetary value. The seller must use this field for value extraction and aggregation when computing ROAS and conversion value metrics. Required on at least one entry when target.kind is 'per_ad_spend' or 'maximize_value' — sellers must reject these target kinds when no event source entry includes value_field. When canonical bidding.roas binds to this goal, every referenced value-bearing source MUST declare value_currencies containing the media-buy currency; the seller validates this at buy creation. Once that source contract is declared, log_event rejects monetary records whose currency is missing or absent from the list. For each buy, only records exactly matching its media-buy currency contribute to ROAS; records in other declared currencies remain available to matching buys, and sellers MUST NOT convert between them. When present without a value-oriented target, the seller may use it for delivery reporting but must not change the objective. Common values: 'value', 'order_total', 'profit_margin'. This is not passed as a parameter to underlying platform APIs — the seller maps it to their platform's value ingestion mechanism." ), + NoExplicitNull, ] = None value_factor: Annotated[ StrictFloat | None, Field( description="Unit-scaling multiplier the seller must apply to value_field before aggregation. Use -1 for refund events (negate the value), 0.01 for values in cents, -0.01 for refunds in cents. It MUST NOT be used for currency conversion. A value of 0 zeroes out this source's value contribution (the source still counts for event dedup). Defaults to 1. This is not passed as a parameter to underlying platform APIs — the seller applies it when computing aggregated value metrics." ), + NoExplicitNull, ] = 1 @@ -193,12 +198,14 @@ class OptimizationGoal9(AdCPBaseModel): description='Target cost or return for this event goal. When omitted, the seller optimizes for maximum conversion count within budget — regardless of whether value_field is present on event sources. The presence of value_field alone does not change the optimization objective; it only makes value available for reporting. An explicit target of maximize_value or per_ad_spend is required to steer toward value.', discriminator='kind', ), + NoExplicitNull, ] = None attribution_window: Annotated[ attribution_window_1.AttributionWindow | None, Field( description="Attribution window for this optimization goal — references the canonical `attribution-window` shape (post_click, post_view, model). Values must match an option declared in the seller's `conversion_tracking.attribution_windows` capability. Sellers MUST reject windows not in their declared capabilities. When the entire field is omitted, the seller uses their default window." ), + NoExplicitNull, ] = None priority: Annotated[ SchemaInt | None, @@ -206,6 +213,7 @@ class OptimizationGoal9(AdCPBaseModel): description='Relative priority among sibling goals. Lower numbers rank first. Goals without priority follow explicitly prioritized goals. Ties use array order, so the earliest goal at the lowest explicit priority is primary; when all priorities are omitted, the first goal is primary.', ge=1, ), + NoExplicitNull, ] = None @@ -225,24 +233,28 @@ class OptimizationGoal8(AdCPBaseModel): Field( description="Viewability standard the goal is judged against. Required when metric is 'viewable_rate'; optional for 'viewed_seconds' (seller default standard when omitted); not allowed for other metrics. Must be in metric_optimization.supported_viewability_standards when declared. A goal below a same-standard viewability performance_standard never relaxes that standard." ), + NoExplicitNull, ] = None vendor: Annotated[ brand_ref.BrandReference | None, Field( description="Measurement vendor judging a viewable_rate or viewed_seconds goal; not allowed for other metrics. When omitted, the seller's default viewability measurement applies. Sellers MUST reject a vendor they cannot optimize against rather than substitute another." ), + NoExplicitNull, ] = None reach_unit: Annotated[ reach_unit_1.ReachUnit | None, Field( description="Unit for reach measurement. Required when metric is 'reach'. Must be a value declared in the product's metric_optimization.supported_reach_units." ), + NoExplicitNull, ] = None target_frequency: Annotated[ TargetFrequency | None, Field( description="Target frequency band for reach optimization. Only applicable when metric is 'reach'. Frames frequency as an optimization signal: the seller should treat impressions toward entities already within the [min, max] band as lower-value, and impressions toward unreached entities as higher-value. This shifts budget toward fresh reach rather than re-reaching known users. When omitted, the seller maximizes unique reach without a frequency constraint. A hard cap can still be layered via targeting_overlay.frequency_cap if a ceiling is needed." ), + NoExplicitNull, ] = None view_duration_seconds: Annotated[ StrictFloat | None, @@ -250,6 +262,7 @@ class OptimizationGoal8(AdCPBaseModel): description="Minimum video view duration in seconds that qualifies as a completed_view for this goal. Only applicable when metric is 'completed_views'. When omitted, the seller uses their platform default (typically 2–15 seconds). Common values: 2 (Snap/LinkedIn default), 6 (TikTok), 15 (Snap 15-second views, Meta ThruPlay). Sellers declare which durations they support in metric_optimization.supported_view_durations. Sellers must reject goals with unsupported values — silent rounding would create measurement discrepancies.", gt=0.0, ), + NoExplicitNull, ] = None target: Annotated[ Target | Target14 | None, @@ -257,6 +270,7 @@ class OptimizationGoal8(AdCPBaseModel): description='Target for this metric. When omitted, the seller optimizes for maximum metric volume within budget.', discriminator='kind', ), + NoExplicitNull, ] = None priority: Annotated[ SchemaInt | None, @@ -264,6 +278,7 @@ class OptimizationGoal8(AdCPBaseModel): description='Relative priority among sibling goals. Lower numbers rank first. Goals without priority follow explicitly prioritized goals. Ties use array order, so the earliest goal at the lowest explicit priority is primary; when all priorities are omitted, the first goal is primary.', ge=1, ), + NoExplicitNull, ] = None @@ -290,6 +305,7 @@ class OptimizationGoal10(AdCPBaseModel): description="Target for this vendor metric. When omitted, the seller optimizes for maximum metric volume / score within budget. `cost_per` and `threshold_rate` semantics mirror the same target kinds on the `metric` kind — units are vendor-defined and depend on the vendor's `measurement.metrics[]` declaration for this `metric_id`.", discriminator='kind', ), + NoExplicitNull, ] = None priority: Annotated[ SchemaInt | None, @@ -297,6 +313,7 @@ class OptimizationGoal10(AdCPBaseModel): description='Relative priority among sibling goals. Lower numbers rank first. Goals without priority follow explicitly prioritized goals. Ties use array order, so the earliest goal at the lowest explicit priority is primary; when all priorities are omitted, the first goal is primary.', ge=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/outcome_measurement.py b/src/adcp/types/domains/core/outcome_measurement.py index 95e9e18cf..26e52c999 100644 --- a/src/adcp/types/domains/core/outcome_measurement.py +++ b/src/adcp/types/domains/core/outcome_measurement.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/outcome_measurement.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import duration @@ -35,6 +35,7 @@ class OutcomeMeasurement(AdCPBaseModel): Field( description='Attribution window as a structured duration (e.g., {"interval": 30, "unit": "days"}).' ), + NoExplicitNull, ] = None reporting: Annotated[ str, diff --git a/src/adcp/types/domains/core/overlay.py b/src/adcp/types/domains/core/overlay.py index 7e825a113..2b326b754 100644 --- a/src/adcp/types/domains/core/overlay.py +++ b/src/adcp/types/domains/core/overlay.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/overlay.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictFloat @@ -20,16 +20,19 @@ class Visual(AdCPBaseModel): Field( description='URL to a theme-neutral overlay graphic (SVG or PNG). Use when a single file works for all backgrounds, e.g. an SVG using CSS custom properties or currentColor.' ), + NoExplicitNull, ] = None light: Annotated[ AnyUrl | None, Field( description='URL to the overlay graphic for use on light/bright backgrounds (SVG or PNG)' ), + NoExplicitNull, ] = None dark: Annotated[ AnyUrl | None, Field(description='URL to the overlay graphic for use on dark backgrounds (SVG or PNG)'), + NoExplicitNull, ] = None @@ -73,12 +76,14 @@ class Overlay(AdCPBaseModel): Field( description='Human-readable explanation of what this overlay is and how buyers should account for it' ), + NoExplicitNull, ] = None visual: Annotated[ Visual | None, Field( description='Optional visual reference for this overlay element. Useful for creative agents compositing previews and for buyers understanding what will appear over their content. Must include at least one of: url, light, or dark.' ), + NoExplicitNull, ] = None bounds: Annotated[ Bounds, diff --git a/src/adcp/types/domains/core/package.py b/src/adcp/types/domains/core/package.py index 3c9438943..03dfa499c 100644 --- a/src/adcp/types/domains/core/package.py +++ b/src/adcp/types/domains/core/package.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/package.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..enums import canceled_by as canceled_by_1 @@ -39,13 +39,16 @@ class Cancellation(AdCPBaseModel): Field(description='Which party initiated the package cancellation.'), ] reason: Annotated[ - str | None, Field(description='Reason the package was canceled.', max_length=500) + str | None, + Field(description='Reason the package was canceled.', max_length=500), + NoExplicitNull, ] = None acknowledged_at: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp when the seller acknowledged the cancellation. Confirms inventory has been released and billing stopped. Absent until the seller processes the cancellation.' ), + NoExplicitNull, ] = None @@ -59,6 +62,7 @@ class Package(AdCPBaseModel): Field( description="ID of the product this package is based on. For packages created from an explicit create_media_buy package request, sellers MUST echo the request package's product_id on every response package object that represents that requested package." ), + NoExplicitNull, ] = None audience_evidence_selections: Annotated[ list[audience_evidence_selection.AudienceEvidenceSelection] | None, @@ -66,6 +70,7 @@ class Package(AdCPBaseModel): description='Exact immutable audience-evidence snapshots that affected recommendation, eligibility, or package construction. A confirmed package MUST include a package_construction selection matching every buyer audience_evidence_pin and every snapshot used to satisfy package audience_evidence_requirements; this readback remains mandatory on subsequent package read surfaces. This is decision provenance only; applied targeting remains exclusively in targeting_overlay and targeting_resolution.demographics.', min_length=1, ), + NoExplicitNull, ] = None budget: Annotated[ StrictFloat | None, @@ -73,6 +78,7 @@ class Package(AdCPBaseModel): description='Hard lifetime spend cap for this package in the media-buy currency. Every selected pricing option in an AdCP-authored media buy MUST declare that same currency. In seller-optimized allocation mode this is a ceiling, not a current allocation. May be omitted when the package is bounded only by the shared media-buy total.', ge=0.0, ), + NoExplicitNull, ] = None min_spend_target: Annotated[ StrictFloat | None, @@ -80,6 +86,7 @@ class Package(AdCPBaseModel): description='Soft lifetime spend target accepted for this package under seller-optimized budget allocation. This is an allocation preference, not a billing or delivery guarantee.', ge=0.0, ), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -87,13 +94,15 @@ class Package(AdCPBaseModel): description="The hard package spend ceiling per shared media-buy cap day, in the media buy's currency. Sellers MUST echo this whenever a package daily cap is set. It is a subordinate ceiling, not a reserved or current allocation; the media buy's budget_cap_timezone defines its day boundary.", ge=0.0, ), + NoExplicitNull, ] = None - pacing: pacing_1.Pacing | None = None + pacing: Annotated[pacing_1.Pacing | None, NoExplicitNull] = None pricing_option_id: Annotated[ str | None, Field( description="ID of the selected pricing option from the product's pricing_options array" ), + NoExplicitNull, ] = None bid_price: Annotated[ StrictFloat | None, @@ -102,27 +111,33 @@ class Package(AdCPBaseModel): description='DEPRECATED legacy bidding representation. 3.2 sellers normalize accepted legacy input and SHOULD echo bidding instead. Removed in the next major.', ge=0.0, ), + NoExplicitNull, ] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, Field( description='Package-authored bidding policy, echoed only when the buyer authored a package override. `{automatic:true}` is an explicit automatic-bidding override. Omission means the package inherits media-buy bidding or, when both scopes are absent, uses provider automatic delivery. Monetary fields are denominated in the media-buy currency. Sellers MUST NOT materialize inherited media-buy policy here.' ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description="Breakdown of the effective price for this package. On fixed-price packages, echoes the pricing option's breakdown. On auction packages, shows the clearing price breakdown including any commission or settlement terms." ), + NoExplicitNull, ] = None impressions: Annotated[ - StrictFloat | None, Field(description='Impression goal for this package', ge=0.0) + StrictFloat | None, + Field(description='Impression goal for this package', ge=0.0), + NoExplicitNull, ] = None catalogs: Annotated[ list[catalog.Catalog] | None, Field( description='Catalogs this package promotes. Each catalog MUST have a distinct type (e.g., one product catalog, one store catalog). This constraint is enforced at the application level — sellers MUST reject requests containing multiple catalogs of the same type with a validation_error. Echoed from the create_media_buy request.' ), + NoExplicitNull, ] = None format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -130,6 +145,7 @@ class Package(AdCPBaseModel): deprecated=True, description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Legacy named-format IDs supplied for this package on create_media_buy. Sellers SHOULD echo this field whenever the request included it, including dual-emission cases where `format_option_refs` was the winning selector, so read surfaces preserve the original wire contract. Omitted means the request did not carry legacy format_ids unless the seller cannot reconstruct legacy requests created before this field was persisted.', ), + NoExplicitNull, ] = None format_option_refs: Annotated[ list[format_option_ref.FormatOptionReference] | None, @@ -137,36 +153,42 @@ class Package(AdCPBaseModel): description='Structured 3.1+ format option references supplied for this package on create_media_buy. Sellers SHOULD echo this field whenever the request included it. Publisher-catalog-backed options are identified by `{ scope: "publisher", publisher_domain, format_option_id }`; product-local options are identified by `{ scope: "product", format_option_id }` and resolve only against this package\'s target product. Omitted means the request did not carry format_option_refs unless the seller cannot reconstruct legacy requests created before this field was persisted.', min_length=1, ), + NoExplicitNull, ] = None format_kind: Annotated[ str | None, Field( description='Direct canonical selector supplied for this package on create_media_buy. Sellers SHOULD echo this field whenever the request included it, including informational-echo cases where `format_ids` was the winning selector, so read surfaces preserve the original wire contract.' ), + NoExplicitNull, ] = None params: Annotated[ dict[str, Any] | None, Field( description='Parameters for the direct canonical selector in `format_kind`, echoed from the create_media_buy request whenever the request included it. Requires `format_kind`; omitted only when the request did not carry direct canonical params or when the seller cannot reconstruct legacy requests created before this field was persisted.' ), + NoExplicitNull, ] = None targeting_overlay: Annotated[ targeting.TargetingOverlay | None, Field( description='Complete effective targeting accepted for this package, including targeting bound through configured product selection plus package-specific targeting. Sellers MUST echo an applied package frequency_cap independently from any MediaBuy root cap. Sellers MUST also echo placement, property, and collection selection so buyers can audit purchased inventory: placements via placement_selection, collections via collection_selection (the committed concrete selectors, materialized even when the selection was produced through collection_list references).' ), + NoExplicitNull, ] = None targeting_resolution: Annotated[ package_targeting_resolution.PackageTargetingResolution | None, Field( description="Execution details for the package's accepted targeting. Sellers MUST include targeting_resolution.demographics whenever demographic targeting was requested or applied." ), + NoExplicitNull, ] = None measurement_terms: Annotated[ measurement_terms_1.MeasurementTerms | None, Field( description="Agreed billing measurement and makegood terms for this package. Reflects what was negotiated — may differ from the buyer's proposal or the product's defaults. When present, these terms are binding for the package's duration." ), + NoExplicitNull, ] = None performance_standards: Annotated[ list[performance_standard.PerformanceStandard] | None, @@ -174,6 +196,7 @@ class Package(AdCPBaseModel): description='Agreed performance standards for this package. When any entry specifies a vendor, creatives assigned to this package MUST include corresponding tracker_script or tracker_pixel assets from that vendor.', min_length=1, ), + NoExplicitNull, ] = None committed_metrics: Annotated[ list[committed_metric.CommittedMetric] | None, @@ -212,12 +235,14 @@ class Package(AdCPBaseModel): ], min_length=1, ), + NoExplicitNull, ] = None creative_assignments: Annotated[ list[creative_assignment.CreativeAssignment] | None, Field( description='Creative assets assigned to this package, including the committed package-scoped rotation policy. Omitted rotation_mode reads as weighted for backward compatibility; all assignments resolve to one effective mode, and sequential positions are unique within each package-local group.' ), + NoExplicitNull, ] = None formats_to_provide: Annotated[ list[package_format_snapshot.PackageFormatSnapshot] | None, @@ -225,12 +250,14 @@ class Package(AdCPBaseModel): description='Immutable canonical creative contracts established for this package. Each entry is a PackageFormatSnapshot of the selected effective Product format declaration. A package whose selected format carries tracker_execution_contract MUST retain and return this checklist even after creative coverage is complete; the live Product is never substituted for the package snapshot.', min_length=1, ), + NoExplicitNull, ] = None formats_pending: Annotated[ list[package_format_snapshot.PackageFormatSnapshot] | None, Field( description='PackageFormatSnapshot entries from formats_to_provide that do not yet have creative coverage through sync_creatives or inline assignment. Every entry MUST equal its formats_to_provide snapshot after RFC 8785 canonicalization and, when product_snapshot_digest is present, carry the identical digest. An empty emitted array means every required format is covered. Absence means readiness was not reported.' ), + NoExplicitNull, ] = None format_ids_to_provide: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -238,6 +265,7 @@ class Package(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format projection of formats_to_provide retained for older 3.x peers. New sellers emit canonical formats_to_provide declarations.', ), + NoExplicitNull, ] = None format_ids_pending: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -245,6 +273,7 @@ class Package(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format projection of formats_pending retained for older 3.x peers. New sellers emit canonical formats_pending declarations. An empty emitted array means every projected requirement is covered. Absence means legacy readiness was not reported and MUST NOT be interpreted as full coverage.', ), + NoExplicitNull, ] = None optimization_goals: Annotated[ list[optimization_goal.OptimizationGoal] | None, @@ -252,34 +281,40 @@ class Package(AdCPBaseModel): description='Optimization targets for this package. The seller optimizes delivery toward these goals in priority order. Common pattern: event goals (purchase, install) as primary targets at priority 1; metric goals (clicks, views) as secondary proxy signals at priority 2+.', min_length=1, ), + NoExplicitNull, ] = None start_time: Annotated[ AwareDatetime | None, Field( description="Flight start date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's start_time. Sellers SHOULD always include the resolved value in responses, even when inherited." ), + NoExplicitNull, ] = None end_time: Annotated[ AwareDatetime | None, Field( description="Flight end date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's end_time. Sellers SHOULD always include the resolved value in responses, even when inherited." ), + NoExplicitNull, ] = None paused: Annotated[ StrictBool | None, Field( description='Whether this package is paused by the buyer. Paused packages do not deliver impressions. Defaults to false.' ), + NoExplicitNull, ] = False canceled: Annotated[ StrictBool | None, Field( description='Whether this package has been canceled. Canceled packages stop delivery and cannot be reactivated. Defaults to false.' ), + NoExplicitNull, ] = False cancellation: Annotated[ Cancellation | None, Field(description='Cancellation metadata. Present only when canceled is true.'), + NoExplicitNull, ] = None agency_estimate_number: Annotated[ str | None, @@ -287,17 +322,20 @@ class Package(AdCPBaseModel): description="Agency estimate or authorization number for this package. Echoed from the buyer's request. When present on the package, takes precedence over the media buy-level estimate number.", max_length=100, ), + NoExplicitNull, ] = None creative_deadline: Annotated[ AwareDatetime | None, Field( description="ISO 8601 timestamp for creative upload or change deadline for this package. After this deadline, creative changes are rejected. When absent, the media buy's creative_deadline applies." ), + NoExplicitNull, ] = None context: Annotated[ context_1.ContextObject | None, Field( description='Opaque package-level correlation data echoed unchanged in responses, webhooks, and read surfaces. Buyers targeting mixed seller populations SHOULD include a per-package correlation value here, commonly context.buyer_ref, so responses from legacy sellers that do not echo product_id can still be mapped back to the requested product or line item. Sellers MUST preserve this object unchanged and MUST NOT parse it for business logic.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/package_delivery_metric_value.py b/src/adcp/types/domains/core/package_delivery_metric_value.py index e54dda9c1..ac0c801b4 100644 --- a/src/adcp/types/domains/core/package_delivery_metric_value.py +++ b/src/adcp/types/domains/core/package_delivery_metric_value.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/package_delivery_metric_value.json -# timestamp: 2026-10-04T00:56:30+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import attribution_methodology as attribution_methodology_1 @@ -26,30 +26,35 @@ class Qualifier(AdCPBaseModel): Field( description='Viewability standard under which this row was measured. MRC and GroupM define materially different thresholds; never sum across standards.' ), + NoExplicitNull, ] = None completion_source: Annotated[ completion_source_1.CompletionSource | None, Field( description='Source of `completion_rate` attestation for this row — `seller_attested` from player/ad server, `vendor_attested` from a third-party measurement vendor. The two paths can yield different rates; never sum across sources.' ), + NoExplicitNull, ] = None attribution_methodology: Annotated[ attribution_methodology_1.AttributionMethodology | None, Field( description='Attribution methodology under which this outcome row was computed (`deterministic_purchase`, `probabilistic`, `panel_based`, `modeled`). Outcome metrics measured under different methodologies represent materially different numbers; never sum across methodologies.' ), + NoExplicitNull, ] = None attribution_window: Annotated[ duration.Duration | None, Field( description="Attribution window for this outcome row. Object-valued duration (`{interval, unit}`), not a shorthand string. Outcome metrics measured over different windows represent the same metric over different time periods; the partition keeps them as separate rows so buyers don't accidentally aggregate." ), + NoExplicitNull, ] = None lift_dimension: Annotated[ lift_dimension_1.LiftDimension | None, Field( description='Brand-lift dimension this row represents (awareness, consideration, favorability, purchase intent, or ad recall). Required when `metric_id` is `brand_lift`. Each dimension is a separate surveyed outcome with its own sample size and confidence interval; never sum across dimensions.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/package_format_snapshot.py b/src/adcp/types/domains/core/package_format_snapshot.py index f7caf677d..3bda32ec6 100644 --- a/src/adcp/types/domains/core/package_format_snapshot.py +++ b/src/adcp/types/domains/core/package_format_snapshot.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/package_format_snapshot.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool from ..enums import channels, daast_version, vast_version @@ -54,6 +54,7 @@ class PackageFormatSnapshot17(AdCPBaseModel): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -61,18 +62,21 @@ class PackageFormatSnapshot17(AdCPBaseModel): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -80,6 +84,7 @@ class PackageFormatSnapshot17(AdCPBaseModel): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -87,6 +92,7 @@ class PackageFormatSnapshot17(AdCPBaseModel): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Any params: Any @@ -98,6 +104,7 @@ class PackageFormatSnapshot1(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -105,12 +112,14 @@ class PackageFormatSnapshot1(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -118,60 +127,70 @@ class PackageFormatSnapshot1(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -179,12 +198,14 @@ class PackageFormatSnapshot1(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['image'] = 'image' params: image.CanonicalFormatImage @@ -196,6 +217,7 @@ class PackageFormatSnapshot2(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -203,12 +225,14 @@ class PackageFormatSnapshot2(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -216,60 +240,70 @@ class PackageFormatSnapshot2(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -277,12 +311,14 @@ class PackageFormatSnapshot2(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['html5'] = 'html5' params: html5.CanonicalFormatHtml5Banner @@ -294,6 +330,7 @@ class PackageFormatSnapshot3(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -301,12 +338,14 @@ class PackageFormatSnapshot3(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -314,60 +353,70 @@ class PackageFormatSnapshot3(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -375,12 +424,14 @@ class PackageFormatSnapshot3(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['display_tag'] = 'display_tag' params: display_tag.CanonicalFormatDisplayTag @@ -392,6 +443,7 @@ class PackageFormatSnapshot4(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -399,12 +451,14 @@ class PackageFormatSnapshot4(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -412,60 +466,70 @@ class PackageFormatSnapshot4(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -473,12 +537,14 @@ class PackageFormatSnapshot4(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['image_carousel'] = 'image_carousel' params: image_carousel.CanonicalFormatImageCarousel @@ -490,6 +556,7 @@ class PackageFormatSnapshot5(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -497,12 +564,14 @@ class PackageFormatSnapshot5(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -510,60 +579,70 @@ class PackageFormatSnapshot5(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -571,12 +650,14 @@ class PackageFormatSnapshot5(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['video_hosted'] = 'video_hosted' params: video_hosted.CanonicalFormatHostedVideo @@ -588,6 +669,7 @@ class PackageFormatSnapshot6(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -595,12 +677,14 @@ class PackageFormatSnapshot6(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -608,60 +692,70 @@ class PackageFormatSnapshot6(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -669,12 +763,14 @@ class PackageFormatSnapshot6(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['video_vast'] = 'video_vast' params: video_vast.CanonicalFormatVastVideo @@ -686,6 +782,7 @@ class PackageFormatSnapshot7(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -693,12 +790,14 @@ class PackageFormatSnapshot7(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -706,60 +805,70 @@ class PackageFormatSnapshot7(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -767,12 +876,14 @@ class PackageFormatSnapshot7(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_hosted'] = 'audio_hosted' params: audio_hosted.CanonicalFormatHostedAudio @@ -784,6 +895,7 @@ class PackageFormatSnapshot8(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -791,12 +903,14 @@ class PackageFormatSnapshot8(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -804,60 +918,70 @@ class PackageFormatSnapshot8(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -865,12 +989,14 @@ class PackageFormatSnapshot8(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_vast'] = 'audio_vast' params: audio_vast.CanonicalFormatVastAudio @@ -882,6 +1008,7 @@ class PackageFormatSnapshot9(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -889,12 +1016,14 @@ class PackageFormatSnapshot9(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -902,60 +1031,70 @@ class PackageFormatSnapshot9(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -963,12 +1102,14 @@ class PackageFormatSnapshot9(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_daast'] = 'audio_daast' params: audio_daast.CanonicalFormatDaastAudio @@ -980,6 +1121,7 @@ class PackageFormatSnapshot10(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -987,12 +1129,14 @@ class PackageFormatSnapshot10(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1000,60 +1144,70 @@ class PackageFormatSnapshot10(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1061,12 +1215,14 @@ class PackageFormatSnapshot10(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['sponsored_placement'] = 'sponsored_placement' params: sponsored_placement.CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven @@ -1078,6 +1234,7 @@ class PackageFormatSnapshot11(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1085,12 +1242,14 @@ class PackageFormatSnapshot11(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1098,60 +1257,70 @@ class PackageFormatSnapshot11(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1159,12 +1328,14 @@ class PackageFormatSnapshot11(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['native_in_feed'] = 'native_in_feed' params: native_in_feed.CanonicalFormatNativeInFeed @@ -1176,6 +1347,7 @@ class PackageFormatSnapshot12(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1183,12 +1355,14 @@ class PackageFormatSnapshot12(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1196,60 +1370,70 @@ class PackageFormatSnapshot12(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1257,12 +1441,14 @@ class PackageFormatSnapshot12(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['responsive_creative'] = 'responsive_creative' params: responsive_creative.CanonicalFormatResponsiveCreative @@ -1274,6 +1460,7 @@ class PackageFormatSnapshot13(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1281,12 +1468,14 @@ class PackageFormatSnapshot13(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1294,60 +1483,70 @@ class PackageFormatSnapshot13(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1355,12 +1554,14 @@ class PackageFormatSnapshot13(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['agent_placement'] = 'agent_placement' params: agent_placement.CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement @@ -1372,6 +1573,7 @@ class PackageFormatSnapshot14(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1379,12 +1581,14 @@ class PackageFormatSnapshot14(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1392,60 +1596,70 @@ class PackageFormatSnapshot14(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1453,12 +1667,14 @@ class PackageFormatSnapshot14(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['seller_rendered_stateful_display'] = 'seller_rendered_stateful_display' params: seller_rendered_stateful_display.CanonicalFormatSellerRenderedStatefulDisplay @@ -1470,6 +1686,7 @@ class PackageFormatSnapshot15(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1477,12 +1694,14 @@ class PackageFormatSnapshot15(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1490,60 +1709,70 @@ class PackageFormatSnapshot15(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1551,12 +1780,14 @@ class PackageFormatSnapshot15(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['coordinated_placements'] = 'coordinated_placements' params: coordinated_placements.CanonicalFormatCoordinatedPlacements @@ -1568,6 +1799,7 @@ class PackageFormatSnapshot16(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1575,12 +1807,14 @@ class PackageFormatSnapshot16(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1588,60 +1822,70 @@ class PackageFormatSnapshot16(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1649,12 +1893,14 @@ class PackageFormatSnapshot16(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['custom'] = 'custom' params: Annotated[ @@ -1675,6 +1921,7 @@ class PackageFormatSnapshot18(PackageFormatSnapshot1): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -1682,18 +1929,21 @@ class PackageFormatSnapshot18(PackageFormatSnapshot1): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -1701,6 +1951,7 @@ class PackageFormatSnapshot18(PackageFormatSnapshot1): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -1708,6 +1959,7 @@ class PackageFormatSnapshot18(PackageFormatSnapshot1): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['image'] = 'image' params: image.CanonicalFormatImage @@ -1723,6 +1975,7 @@ class PackageFormatSnapshot19(PackageFormatSnapshot2): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -1730,18 +1983,21 @@ class PackageFormatSnapshot19(PackageFormatSnapshot2): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -1749,6 +2005,7 @@ class PackageFormatSnapshot19(PackageFormatSnapshot2): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -1756,6 +2013,7 @@ class PackageFormatSnapshot19(PackageFormatSnapshot2): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['html5'] = 'html5' params: html5.CanonicalFormatHtml5Banner @@ -1771,6 +2029,7 @@ class PackageFormatSnapshot20(PackageFormatSnapshot3): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -1778,18 +2037,21 @@ class PackageFormatSnapshot20(PackageFormatSnapshot3): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -1797,6 +2059,7 @@ class PackageFormatSnapshot20(PackageFormatSnapshot3): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -1804,6 +2067,7 @@ class PackageFormatSnapshot20(PackageFormatSnapshot3): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['display_tag'] = 'display_tag' params: display_tag.CanonicalFormatDisplayTag @@ -1819,6 +2083,7 @@ class PackageFormatSnapshot21(PackageFormatSnapshot4): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -1826,18 +2091,21 @@ class PackageFormatSnapshot21(PackageFormatSnapshot4): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -1845,6 +2113,7 @@ class PackageFormatSnapshot21(PackageFormatSnapshot4): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -1852,6 +2121,7 @@ class PackageFormatSnapshot21(PackageFormatSnapshot4): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['image_carousel'] = 'image_carousel' params: image_carousel.CanonicalFormatImageCarousel @@ -1867,6 +2137,7 @@ class PackageFormatSnapshot22(PackageFormatSnapshot5): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -1874,18 +2145,21 @@ class PackageFormatSnapshot22(PackageFormatSnapshot5): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -1893,6 +2167,7 @@ class PackageFormatSnapshot22(PackageFormatSnapshot5): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -1900,6 +2175,7 @@ class PackageFormatSnapshot22(PackageFormatSnapshot5): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['video_hosted'] = 'video_hosted' params: video_hosted.CanonicalFormatHostedVideo @@ -1915,6 +2191,7 @@ class PackageFormatSnapshot23(PackageFormatSnapshot6): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -1922,18 +2199,21 @@ class PackageFormatSnapshot23(PackageFormatSnapshot6): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -1941,6 +2221,7 @@ class PackageFormatSnapshot23(PackageFormatSnapshot6): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -1948,6 +2229,7 @@ class PackageFormatSnapshot23(PackageFormatSnapshot6): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['video_vast'] = 'video_vast' params: video_vast.CanonicalFormatVastVideo @@ -1963,6 +2245,7 @@ class PackageFormatSnapshot24(PackageFormatSnapshot7): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -1970,18 +2253,21 @@ class PackageFormatSnapshot24(PackageFormatSnapshot7): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -1989,6 +2275,7 @@ class PackageFormatSnapshot24(PackageFormatSnapshot7): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -1996,6 +2283,7 @@ class PackageFormatSnapshot24(PackageFormatSnapshot7): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['audio_hosted'] = 'audio_hosted' params: audio_hosted.CanonicalFormatHostedAudio @@ -2011,6 +2299,7 @@ class PackageFormatSnapshot25(PackageFormatSnapshot8): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -2018,18 +2307,21 @@ class PackageFormatSnapshot25(PackageFormatSnapshot8): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -2037,6 +2329,7 @@ class PackageFormatSnapshot25(PackageFormatSnapshot8): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -2044,6 +2337,7 @@ class PackageFormatSnapshot25(PackageFormatSnapshot8): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['audio_vast'] = 'audio_vast' params: audio_vast.CanonicalFormatVastAudio @@ -2059,6 +2353,7 @@ class PackageFormatSnapshot26(PackageFormatSnapshot9): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -2066,18 +2361,21 @@ class PackageFormatSnapshot26(PackageFormatSnapshot9): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -2085,6 +2383,7 @@ class PackageFormatSnapshot26(PackageFormatSnapshot9): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -2092,6 +2391,7 @@ class PackageFormatSnapshot26(PackageFormatSnapshot9): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['audio_daast'] = 'audio_daast' params: audio_daast.CanonicalFormatDaastAudio @@ -2107,6 +2407,7 @@ class PackageFormatSnapshot27(PackageFormatSnapshot10): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -2114,18 +2415,21 @@ class PackageFormatSnapshot27(PackageFormatSnapshot10): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -2133,6 +2437,7 @@ class PackageFormatSnapshot27(PackageFormatSnapshot10): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -2140,6 +2445,7 @@ class PackageFormatSnapshot27(PackageFormatSnapshot10): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['sponsored_placement'] = 'sponsored_placement' params: sponsored_placement.CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven @@ -2155,6 +2461,7 @@ class PackageFormatSnapshot28(PackageFormatSnapshot11): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -2162,18 +2469,21 @@ class PackageFormatSnapshot28(PackageFormatSnapshot11): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -2181,6 +2491,7 @@ class PackageFormatSnapshot28(PackageFormatSnapshot11): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -2188,6 +2499,7 @@ class PackageFormatSnapshot28(PackageFormatSnapshot11): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['native_in_feed'] = 'native_in_feed' params: native_in_feed.CanonicalFormatNativeInFeed @@ -2203,6 +2515,7 @@ class PackageFormatSnapshot29(PackageFormatSnapshot12): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -2210,18 +2523,21 @@ class PackageFormatSnapshot29(PackageFormatSnapshot12): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -2229,6 +2545,7 @@ class PackageFormatSnapshot29(PackageFormatSnapshot12): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -2236,6 +2553,7 @@ class PackageFormatSnapshot29(PackageFormatSnapshot12): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['responsive_creative'] = 'responsive_creative' params: responsive_creative.CanonicalFormatResponsiveCreative @@ -2251,6 +2569,7 @@ class PackageFormatSnapshot30(PackageFormatSnapshot13): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -2258,18 +2577,21 @@ class PackageFormatSnapshot30(PackageFormatSnapshot13): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -2277,6 +2599,7 @@ class PackageFormatSnapshot30(PackageFormatSnapshot13): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -2284,6 +2607,7 @@ class PackageFormatSnapshot30(PackageFormatSnapshot13): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['agent_placement'] = 'agent_placement' params: agent_placement.CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement @@ -2299,6 +2623,7 @@ class PackageFormatSnapshot31(PackageFormatSnapshot14): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -2306,18 +2631,21 @@ class PackageFormatSnapshot31(PackageFormatSnapshot14): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -2325,6 +2653,7 @@ class PackageFormatSnapshot31(PackageFormatSnapshot14): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -2332,6 +2661,7 @@ class PackageFormatSnapshot31(PackageFormatSnapshot14): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['seller_rendered_stateful_display'] = 'seller_rendered_stateful_display' params: seller_rendered_stateful_display.CanonicalFormatSellerRenderedStatefulDisplay @@ -2347,6 +2677,7 @@ class PackageFormatSnapshot32(PackageFormatSnapshot15): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -2354,18 +2685,21 @@ class PackageFormatSnapshot32(PackageFormatSnapshot15): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -2373,6 +2707,7 @@ class PackageFormatSnapshot32(PackageFormatSnapshot15): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -2380,6 +2715,7 @@ class PackageFormatSnapshot32(PackageFormatSnapshot15): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['coordinated_placements'] = 'coordinated_placements' params: coordinated_placements.CanonicalFormatCoordinatedPlacements @@ -2395,6 +2731,7 @@ class PackageFormatSnapshot33(PackageFormatSnapshot16): description='Product whose effective selected format was snapshotted. Required whenever tracker_execution_contract is present.', min_length=1, ), + NoExplicitNull, ] = None placement_refs: Annotated[ list[placement_ref.PlacementReference] | None, @@ -2402,18 +2739,21 @@ class PackageFormatSnapshot33(PackageFormatSnapshot16): description='Nonempty, duplicate-free effective placement set, sorted by publisher_domain then placement_id using ascending UTF-8 bytes without Unicode normalization. Omission binds the product-wide common effective contract; it never means an inferred list of all current placements.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for first-class VAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for first-class DAAST tracker matching when no sibling delivery document supplies one. The value is immutable for the package.' ), + NoExplicitNull, ] = None tracker_execution_contract_digest: Annotated[ str | None, @@ -2421,6 +2761,7 @@ class PackageFormatSnapshot33(PackageFormatSnapshot16): description='SHA-256 of RFC 8785 canonical JSON for tracker_execution_contract alone. Required if and only if the snapshot contains that contract.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None product_snapshot_digest: Annotated[ str | None, @@ -2428,6 +2769,7 @@ class PackageFormatSnapshot33(PackageFormatSnapshot16): description="Immutable SHA-256 digest of the package format snapshot's closed product-binding preimage. Required for every contract-bearing snapshot and paired with product_id whenever present.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None format_kind: Literal['custom'] = 'custom' params: Annotated[dict[str, Any], Field(description="Custom shape's params. Validated against the schema fetched from `format_schema.uri` at the cached `format_schema.digest`.")] diff --git a/src/adcp/types/domains/core/package_signal_targeting.py b/src/adcp/types/domains/core/package_signal_targeting.py index d96d92c3d..d58b4485e 100644 --- a/src/adcp/types/domains/core/package_signal_targeting.py +++ b/src/adcp/types/domains/core/package_signal_targeting.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/package_signal_targeting.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from . import activation_key as activation_key_1 @@ -22,18 +22,21 @@ class PackageSignalTargeting4(AdCPBaseModel): Field( description="Pricing option selected for this signal. Use the pricing_option_id from the product's signal_targeting_options entry when product-scoped pricing is present; otherwise use the seller get_signals pricing only when the product option does not override it. Required when the selected signal has pricing_options; omit only when the signal is bundled into the product price or has no incremental cost." ), + NoExplicitNull, ] = None signal_agent_segment_id: Annotated[ str | None, Field( description='Optional opaque resolved-segment or seller execution handle for this signal. Omit when signal_ref plus the value expression is sufficient for the seller to resolve the signal. Include when the product option exposes a separate runtime or activation handle, and pass it verbatim. Buyers SHOULD prefer an exposed segment handle over reconstructing condition identity from categorical values because the handle can carry provider namespace and methodology distinctions.' ), + NoExplicitNull, ] = None activation_key: Annotated[ activation_key_1.ActivationKey | None, Field( description='Destination-specific activation key returned by get_signals or activate_signal. Usually omitted for seller-offered signals selected directly through the same seller; include only when the selected signal was separately activated and the seller requires the activation key to correlate the package selection.' ), + NoExplicitNull, ] = None @@ -81,12 +84,14 @@ class PackageSignalTargeting3(AdCPBaseModel): Field( description="Minimum value, inclusive. Omit for no minimum. Should be within the signal definition's range when declared." ), + NoExplicitNull, ] = None max_value: Annotated[ StrictFloat | None, Field( description="Maximum value, inclusive. Omit for no maximum. Should be within the signal definition's range when declared." ), + NoExplicitNull, ] = None @@ -99,18 +104,21 @@ class PackageSignalTargeting5(PackageSignalTargeting1, PackageSignalTargeting4): Field( description="Pricing option selected for this signal. Use the pricing_option_id from the product's signal_targeting_options entry when product-scoped pricing is present; otherwise use the seller get_signals pricing only when the product option does not override it. Required when the selected signal has pricing_options; omit only when the signal is bundled into the product price or has no incremental cost." ), + NoExplicitNull, ] = None signal_agent_segment_id: Annotated[ str | None, Field( description='Optional opaque resolved-segment or seller execution handle for this signal. Omit when signal_ref plus the value expression is sufficient for the seller to resolve the signal. Include when the product option exposes a separate runtime or activation handle, and pass it verbatim. Buyers SHOULD prefer an exposed segment handle over reconstructing condition identity from categorical values because the handle can carry provider namespace and methodology distinctions.' ), + NoExplicitNull, ] = None activation_key: Annotated[ activation_key_1.ActivationKey | None, Field( description='Destination-specific activation key returned by get_signals or activate_signal. Usually omitted for seller-offered signals selected directly through the same seller; include only when the selected signal was separately activated and the seller requires the activation key to correlate the package selection.' ), + NoExplicitNull, ] = None @@ -123,18 +131,21 @@ class PackageSignalTargeting6(PackageSignalTargeting2, PackageSignalTargeting4): Field( description="Pricing option selected for this signal. Use the pricing_option_id from the product's signal_targeting_options entry when product-scoped pricing is present; otherwise use the seller get_signals pricing only when the product option does not override it. Required when the selected signal has pricing_options; omit only when the signal is bundled into the product price or has no incremental cost." ), + NoExplicitNull, ] = None signal_agent_segment_id: Annotated[ str | None, Field( description='Optional opaque resolved-segment or seller execution handle for this signal. Omit when signal_ref plus the value expression is sufficient for the seller to resolve the signal. Include when the product option exposes a separate runtime or activation handle, and pass it verbatim. Buyers SHOULD prefer an exposed segment handle over reconstructing condition identity from categorical values because the handle can carry provider namespace and methodology distinctions.' ), + NoExplicitNull, ] = None activation_key: Annotated[ activation_key_1.ActivationKey | None, Field( description='Destination-specific activation key returned by get_signals or activate_signal. Usually omitted for seller-offered signals selected directly through the same seller; include only when the selected signal was separately activated and the seller requires the activation key to correlate the package selection.' ), + NoExplicitNull, ] = None @@ -147,18 +158,21 @@ class PackageSignalTargeting7(PackageSignalTargeting3, PackageSignalTargeting4): Field( description="Pricing option selected for this signal. Use the pricing_option_id from the product's signal_targeting_options entry when product-scoped pricing is present; otherwise use the seller get_signals pricing only when the product option does not override it. Required when the selected signal has pricing_options; omit only when the signal is bundled into the product price or has no incremental cost." ), + NoExplicitNull, ] = None signal_agent_segment_id: Annotated[ str | None, Field( description='Optional opaque resolved-segment or seller execution handle for this signal. Omit when signal_ref plus the value expression is sufficient for the seller to resolve the signal. Include when the product option exposes a separate runtime or activation handle, and pass it verbatim. Buyers SHOULD prefer an exposed segment handle over reconstructing condition identity from categorical values because the handle can carry provider namespace and methodology distinctions.' ), + NoExplicitNull, ] = None activation_key: Annotated[ activation_key_1.ActivationKey | None, Field( description='Destination-specific activation key returned by get_signals or activate_signal. Usually omitted for seller-offered signals selected directly through the same seller; include only when the selected signal was separately activated and the seller requires the activation key to correlate the package selection.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/package_targeting_resolution.py b/src/adcp/types/domains/core/package_targeting_resolution.py index 2c7d8c445..3ff114749 100644 --- a/src/adcp/types/domains/core/package_targeting_resolution.py +++ b/src/adcp/types/domains/core/package_targeting_resolution.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/package_targeting_resolution.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import demographic_targeting_resolution @@ -23,4 +23,4 @@ class PackageTargetingResolution(AdCPBaseModel): description='Canonical demographic predicate and exact seller execution details. Include whenever demographic targeting was requested or applied.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/pagination_request.py b/src/adcp/types/domains/core/pagination_request.py index 95ae0f082..633a44acb 100644 --- a/src/adcp/types/domains/core/pagination_request.py +++ b/src/adcp/types/domains/core/pagination_request.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/pagination_request.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field @@ -17,8 +17,10 @@ class PaginationRequest(AdCPBaseModel): max_results: Annotated[ SchemaInt | None, Field(description='Maximum number of items to return per page', ge=1, le=100), + NoExplicitNull, ] = 50 cursor: Annotated[ str | None, Field(description='Opaque cursor from a previous response to fetch the next page'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/pagination_response.py b/src/adcp/types/domains/core/pagination_response.py index 741a459e8..9c12bac40 100644 --- a/src/adcp/types/domains/core/pagination_response.py +++ b/src/adcp/types/domains/core/pagination_response.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/pagination_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -22,6 +22,7 @@ class PaginationResponse(AdCPBaseModel): Field( description='Opaque cursor to pass in the next request to fetch the next page. Only present when has_more is true.' ), + NoExplicitNull, ] = None total_count: Annotated[ SchemaInt | None, @@ -29,4 +30,5 @@ class PaginationResponse(AdCPBaseModel): description='Total number of items matching the query across all pages. Optional because not all backends can efficiently compute this.', ge=0, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/performance_feedback.py b/src/adcp/types/domains/core/performance_feedback.py index e00dcc6b7..de74472de 100644 --- a/src/adcp/types/domains/core/performance_feedback.py +++ b/src/adcp/types/domains/core/performance_feedback.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/performance_feedback.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..enums import attribution_methodology as attribution_methodology_1 @@ -43,11 +43,15 @@ class Qualifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - viewability_standard: viewability_standard_1.ViewabilityStandard | None = None - completion_source: completion_source_1.CompletionSource | None = None - attribution_methodology: attribution_methodology_1.AttributionMethodology | None = None - attribution_window: duration.Duration | None = None - lift_dimension: lift_dimension_1.LiftDimension | None = None + viewability_standard: Annotated[ + viewability_standard_1.ViewabilityStandard | None, NoExplicitNull + ] = None + completion_source: Annotated[completion_source_1.CompletionSource | None, NoExplicitNull] = None + attribution_methodology: Annotated[ + attribution_methodology_1.AttributionMethodology | None, NoExplicitNull + ] = None + attribution_window: Annotated[duration.Duration | None, NoExplicitNull] = None + lift_dimension: Annotated[lift_dimension_1.LiftDimension | None, NoExplicitNull] = None class Metric(AdCPBaseModel): @@ -66,6 +70,7 @@ class Metric(AdCPBaseModel): Field( description='Disambiguator — same shape as `committed_metrics.qualifier`. Carry `viewability_standard` for MRC vs GroupM viewability feedback; `completion_source` for seller- vs vendor-attested completion feedback; `attribution_methodology` and `attribution_window` for outcome-metric feedback (the buyer pinning a specific methodology or window when the feedback is methodology-sensitive). Closed (`additionalProperties: false`); new keys ship explicitly in subsequent minors.' ), + NoExplicitNull, ] = None @@ -92,6 +97,7 @@ class Metric7(AdCPBaseModel): Field( description='Optional disambiguator mirroring the vendor-scope qualifier on `committed_metrics` — same closed key set as standard-scope entries.' ), + NoExplicitNull, ] = None @@ -108,9 +114,12 @@ class PerformanceFeedback(AdCPBaseModel): Field( description='Specific package within the media buy (if feedback is package-specific)' ), + NoExplicitNull, ] = None creative_id: Annotated[ - str | None, Field(description='Specific creative asset (if feedback is creative-specific)') + str | None, + Field(description='Specific creative asset (if feedback is creative-specific)'), + NoExplicitNull, ] = None measurement_period: Annotated[ MeasurementPeriod, Field(description='Time period for performance measurement') @@ -128,6 +137,7 @@ class PerformanceFeedback(AdCPBaseModel): deprecated=True, description='**Deprecated as of this minor.** The legacy free-form metric enum that mixes metrics, verification, and attribution into one list. New implementations SHOULD use `metric` (the discriminated `(scope, metric_id, qualifier)` row shape) and populate `metric_type` with a best-effort string for one-minor backwards compatibility. When both `metric` and `metric_type` are present, consumers MUST use `metric` for dispatch. Removed at the next major. See [docs/measurement/taxonomy](https://docs.adcontextprotocol.org/docs/measurement/taxonomy) for why the layered shape replaces the flat enum.', ), + NoExplicitNull, ] = None metric: Annotated[ Metric | Metric7 | None, @@ -135,6 +145,7 @@ class PerformanceFeedback(AdCPBaseModel): description='The metric this feedback row pertains to, using the same `(scope, metric_id, qualifier)` row shape as `committed_metrics` and package-level delivery values (`metric_values` or `vendor_metric_values`). Preferred over the legacy `metric_type` field for new implementations. Brings performance-feedback into the same atomic unit and dispatch model as the rest of the measurement surface — buyer agents reconcile feedback against the contract surface using the row-level join on `(scope, metric_id, qualifier)`. **Optional and may be omitted entirely for holistic feedback** (e.g., a trader flagging a campaign as underperforming without a specific metric in mind — `performance_index` plus the response narrative carry the signal). Senders SHOULD populate `metric` when the feedback is metric-specific so consumers can route it to the right optimization path; senders MAY omit it for general performance feedback.', discriminator='scope', ), + NoExplicitNull, ] = None feedback_source: Annotated[ feedback_source_1.FeedbackSource, Field(description='Source of the performance data') @@ -144,6 +155,7 @@ class PerformanceFeedback(AdCPBaseModel): Field( description="Vendor that produced this feedback. SHOULD be populated when `feedback_source` is `third_party_measurement` or `verification_partner` AND a single attesting vendor exists — without it, the row is unattributed and consumers can't verify authorization, resolve metric definitions, or route disputes. OMIT for blended outputs where no single vendor owns the result: MMM mixes (Nielsen MMM, Analytic Partners, in-house mix models combining multiple vendor inputs), multi-touch attribution outputs that join across vendors, and clean-room outputs (LiveRamp, Habu, AWS Clean Rooms) where the clean room is not the measurement source. For these cases, leave `vendor` absent and use the response's narrative payload to describe provenance. Optional for `buyer_attribution` and `platform_analytics` (those sources are implicit from context). The vendor's `brand.json` `agents[type='measurement']` is the discovery anchor; metric definitions live on the agent's `get_adcp_capabilities.measurement.metrics[]` block. Same identity discipline as `vendor_metric_value.vendor` and `performance-standard.vendor`." ), + NoExplicitNull, ] = None status: Annotated[Status, Field(description='Processing status of the performance feedback')] submitted_at: Annotated[ @@ -154,4 +166,5 @@ class PerformanceFeedback(AdCPBaseModel): Field( description='ISO 8601 timestamp when feedback was applied to optimization algorithms' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/performance_feedback_assertion.py b/src/adcp/types/domains/core/performance_feedback_assertion.py index b7ee9f325..54f6dcf84 100644 --- a/src/adcp/types/domains/core/performance_feedback_assertion.py +++ b/src/adcp/types/domains/core/performance_feedback_assertion.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/performance_feedback_assertion.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..enums import feedback_source as feedback_source_1 @@ -28,12 +28,13 @@ class Evidence(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - sample_size: Annotated[SchemaInt | None, Field(ge=1)] = None + sample_size: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None confidence_interval: Annotated[ ConfidenceInterval | None, Field( description='Confidence interval on the performance_index scale. sample_size may be omitted when the producer cannot disclose it.' ), + NoExplicitNull, ] = None @@ -54,6 +55,7 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): description='Receiver-scoped package identifier when the assertion is package-specific. The orchestrator maps its measurement-facing identifier to seller-local identifiers during fan-out.', min_length=1, ), + NoExplicitNull, ] = None creative_id: Annotated[ str | None, @@ -61,6 +63,7 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): description='Receiver-scoped creative identifier when the assertion is creative-specific. The orchestrator maps its measurement-facing identifier to seller-local identifiers during fan-out.', min_length=1, ), + NoExplicitNull, ] = None measurement_period: Annotated[ datetime_range.DatetimeRange, @@ -78,12 +81,14 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): Field( description='Expectation represented by performance_index = 1.0. Compact-contract producers MUST populate this field; it remains optional in the schema so existing 3.x request payloads remain valid.' ), + NoExplicitNull, ] = None metric: Annotated[ performance_feedback_metric.PerformanceFeedbackMetric | None, Field( description='Metric this assertion describes. Omit only for holistic feedback that is not attributable to one metric.' ), + NoExplicitNull, ] = None metric_type: Annotated[ metric_type_1.MetricType | None, @@ -91,12 +96,14 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): deprecated=True, description='Deprecated legacy metric classification retained for existing request payloads. New implementations SHOULD use metric; when both are present consumers MUST use metric for dispatch.', ), + NoExplicitNull, ] = None producer: Annotated[ brand_ref.BrandReference | None, Field( description='Brand that produced the analysis. The receiving orchestrator gateway verifies this reference against authenticated caller identity and preserves it during seller fan-out.' ), + NoExplicitNull, ] = None vendor: Annotated[ brand_ref.BrandReference | None, @@ -104,10 +111,12 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): deprecated=True, description='Deprecated alias for producer retained for previously documented request payloads. When both are present consumers use producer.', ), + NoExplicitNull, ] = None feedback_source: Annotated[ feedback_source_1.FeedbackSource | None, Field(description='Legacy categorical source hint retained for existing request payloads.'), + NoExplicitNull, ] = None methodology: Annotated[ str | None, @@ -116,6 +125,7 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): max_length=100, min_length=1, ), + NoExplicitNull, ] = None methodology_version: Annotated[ str | None, @@ -124,6 +134,7 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): max_length=100, min_length=1, ), + NoExplicitNull, ] = None study_ref: Annotated[ str | None, @@ -132,27 +143,33 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None evidence: Annotated[ Evidence | None, Field( description='Small inline evidence summary used to weight the assertion without transporting the full study.' ), + NoExplicitNull, ] = None evidence_ref: Annotated[ AnyUrl | None, Field( description='Provider-hosted evidence or result reference for parties authorized to inspect the full study.' ), + NoExplicitNull, ] = None as_of: Annotated[ - AwareDatetime | None, Field(description='When the producer computed this assertion.') + AwareDatetime | None, + Field(description='When the producer computed this assertion.'), + NoExplicitNull, ] = None final: Annotated[ StrictBool | None, Field( description='Whether the producer expects this assertion to be revised as data matures.' ), + NoExplicitNull, ] = None supersedes_feedback_id: Annotated[ str | None, @@ -160,4 +177,5 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): description='Receiver-issued feedback_id of the earlier assertion this one replaces at the same hop.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/performance_feedback_metric.py b/src/adcp/types/domains/core/performance_feedback_metric.py index b349e74cc..e67e0af9f 100644 --- a/src/adcp/types/domains/core/performance_feedback_metric.py +++ b/src/adcp/types/domains/core/performance_feedback_metric.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/performance_feedback_metric.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import attribution_methodology as attribution_methodology_1 @@ -21,11 +21,15 @@ class Qualifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - viewability_standard: viewability_standard_1.ViewabilityStandard | None = None - completion_source: completion_source_1.CompletionSource | None = None - attribution_methodology: attribution_methodology_1.AttributionMethodology | None = None - attribution_window: duration.Duration | None = None - lift_dimension: lift_dimension_1.LiftDimension | None = None + viewability_standard: Annotated[ + viewability_standard_1.ViewabilityStandard | None, NoExplicitNull + ] = None + completion_source: Annotated[completion_source_1.CompletionSource | None, NoExplicitNull] = None + attribution_methodology: Annotated[ + attribution_methodology_1.AttributionMethodology | None, NoExplicitNull + ] = None + attribution_window: Annotated[duration.Duration | None, NoExplicitNull] = None + lift_dimension: Annotated[lift_dimension_1.LiftDimension | None, NoExplicitNull] = None class PerformanceFeedbackMetric1(AdCPBaseModel): @@ -39,6 +43,7 @@ class PerformanceFeedbackMetric1(AdCPBaseModel): Field( description='Identity-affecting metric qualifiers. Statistical evidence and feedback-producer identity do not belong here.' ), + NoExplicitNull, ] = None @@ -59,6 +64,7 @@ class PerformanceFeedbackMetric2(AdCPBaseModel): Field( description='Optional disambiguator mirroring the vendor-scope qualifier on `committed_metrics` — same closed key set as standard-scope entries.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/performance_standard.py b/src/adcp/types/domains/core/performance_standard.py index 9026fbe00..075a62128 100644 --- a/src/adcp/types/domains/core/performance_standard.py +++ b/src/adcp/types/domains/core/performance_standard.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/performance_standard.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import performance_standard_metric, viewability_standard @@ -34,6 +34,7 @@ class PerformanceStandard(AdCPBaseModel): Field( description="Measurement standard. Required when metric is 'viewability' (MRC and GroupM define materially different thresholds). Omit for other metrics." ), + NoExplicitNull, ] = None vendor: Annotated[ brand_ref.BrandReference, diff --git a/src/adcp/types/domains/core/placement.py b/src/adcp/types/domains/core/placement.py index 74e87e5fa..e5b3e7aaa 100644 --- a/src/adcp/types/domains/core/placement.py +++ b/src/adcp/types/domains/core/placement.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/placement.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from collections.abc import Sequence from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, model_validator from ..enums import ( @@ -66,6 +66,7 @@ class ProductDoohPlacementAttributes(AdCPBaseModel): description='Scheduled duration of one ad slot in seconds. This is an inventory fact used for loop and share calculations, not the creative-duration contract.', ge=1, ), + NoExplicitNull, ] = None loop_duration_seconds: Annotated[ SchemaInt | None, @@ -73,13 +74,15 @@ class ProductDoohPlacementAttributes(AdCPBaseModel): description='Duration of the full ad loop rotation in seconds and the canonical source for loop duration.', ge=1, ), + NoExplicitNull, ] = None - screen_resolution: ProductDoohScreenResolution | None = None + screen_resolution: Annotated[ProductDoohScreenResolution | None, NoExplicitNull] = None motion: Annotated[ dooh_motion_type.DoohMotionType | None, Field( description='Physical motion capability of a visual DOOH screen, not an accepted-format declaration. Omit for audio-only placements.' ), + NoExplicitNull, ] = None @@ -105,21 +108,26 @@ class Placement(AdCPBaseModel): description="For publisher_ref, the domain whose adagents.json declares the placement and part of canonical identity. For seller_inline, optional inventory-publisher attribution only; it does not grant the seller authority to mint IDs in that publisher's catalog namespace.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None seller_agent: Annotated[ seller_agent_ref.SellerAgentReference | None, Field( description='Sales agent that defines a seller_inline placement. Together with placement_id this is its self-contained identity. New 3.2 sellers SHOULD populate it; legacy product-context inline placements may omit it. Not used for publisher_ref.' ), + NoExplicitNull, ] = None name: Annotated[ str | None, Field( description='Human-readable name for the placement (e.g., \'Homepage Banner\', \'Article Sidebar\'). Required for `kind: "seller_inline"`. May be omitted for publisher-referenced placements because buyers resolve the name from the publisher declaration identified by `{publisher_domain, placement_id}`.' ), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Detailed description of where and how the placement appears') + str | None, + Field(description='Detailed description of where and how the placement appears'), + NoExplicitNull, ] = None mode: Annotated[ Mode, @@ -132,6 +140,7 @@ class Placement(AdCPBaseModel): Field( description="Optional tags for grouping placements within a product (e.g., 'homepage', 'native', 'premium'). When the placement_id comes from the publisher registry, these should align with the registry tags unless the product is narrowing scope." ), + NoExplicitNull, ] = None format_ids: Annotated[ Sequence[format_id.FormatReferenceStructuredObject] | None, @@ -140,6 +149,7 @@ class Placement(AdCPBaseModel): description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Legacy named-format placement narrowing. Can include concrete, template, or parameterized format IDs. When present on a product placement, this field narrows the product-level `format_ids` contract and MUST NOT introduce formats the product does not accept. Use canonical `format_options`.', min_length=1, ), + NoExplicitNull, ] = None format_options: Annotated[ list[product_format_declaration.ProductFormatDeclaration] | None, @@ -147,6 +157,7 @@ class Placement(AdCPBaseModel): description="Canonical seller-side narrowing for this product placement. When present, these declarations are intersected with the product-level format_options and MUST NOT introduce a format outside that product upper bound. For kind publisher_ref, buyers MUST also resolve {publisher_domain, placement_id} in the publisher's adagents.json and intersect the publisher catalog constraint: use the public placement's format_options when present (resolving bare format_option_id references against same-file top-level formats[]), otherwise use applicable top-level formats[] scoped to that placement's properties. Omitting this inline field removes only the seller-inline layer; it does not bypass a publisher placement or property-scoped narrowing. The placement inherits the full product-level set only when no applicable publisher catalog narrowing exists. Unresolved publisher placement or format-option references fail closed. Locale policy participates in the same intersection: when the product policy is absent, a placement may introduce any concrete policy as a narrowing of the unconstrained option; when both are present, every placement accepted_language_range must be contained by a product range under RFC 4647 Basic Filtering (`fr-CA` narrows `fr`; `fr` does not narrow `fr-CA`). Buyers compute effective locale eligibility independently for each placement. Any effective locale-constrained route is canonical-only and has no projecting product or placement format_id.", min_length=1, ), + NoExplicitNull, ] = None video_placement_types: Annotated[ list[video_placement_type.VideoPlacementType] | None, @@ -154,6 +165,7 @@ class Placement(AdCPBaseModel): description='Declared video placement types for this product placement, using IAB Tech Lab/OpenRTB 2.6 video.plcmt definitions with AdCP-native names. Most concrete placements SHOULD declare a single value; aggregate placements MAY declare multiple values. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None audio_distribution_types: Annotated[ list[audio_distribution_type.AudioDistributionType] | None, @@ -161,6 +173,7 @@ class Placement(AdCPBaseModel): description='Declared audio distribution types for this product placement, using IAB Tech Lab/OpenRTB 2.6 audio.feed definitions with AdCP-native names. Most concrete placements SHOULD declare a single value; aggregate placements MAY declare multiple values. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None sponsored_placement_types: Annotated[ list[sponsored_placement_type.SponsoredPlacementType] | None, @@ -168,6 +181,7 @@ class Placement(AdCPBaseModel): description='Declared sponsored-placement types for this product placement, distinguishing where the catalog-driven retail-media placement renders on the retailer surface. Most concrete placements SHOULD declare a single value; aggregate placements MAY declare multiple values. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None social_placement_surfaces: Annotated[ list[social_placement_surface.SocialPlacementSurface] | None, @@ -175,6 +189,7 @@ class Placement(AdCPBaseModel): description='Declared social-placement surfaces for this product placement, distinguishing the in-app surface where the social placement renders. Most concrete placements SHOULD declare a single value; aggregate placements MAY declare multiple values. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None identifiers: Annotated[ list[Identifier] | None, @@ -182,8 +197,11 @@ class Placement(AdCPBaseModel): description='Optional external inventory identifiers for this placement, using the same {type, value} shape as property identifiers. Externally governed IDs should be authority-prefixed (e.g., space:1234931339, geopath:30961, fcc:73953). Seller-local IDs are opaque values scoped by the surrounding publisher namespace. Useful for DOOH venue and installed-endpoint IDs, broadcast facility IDs, and any channel where placements map to externally registered inventory. For kind: publisher_ref, the effective identifier set is the union of the resolved publisher declaration and this product declaration, de-duplicated by exact (type, value); a product cannot suppress a publisher-declared identifier by omission.', min_length=1, ), + NoExplicitNull, ] = None - dooh_placement_attributes: ProductDoohPlacementAttributes | None = None + dooh_placement_attributes: Annotated[ProductDoohPlacementAttributes | None, NoExplicitNull] = ( + None + ) @model_validator(mode='after') def _require_schema_required_group(self) -> Placement: diff --git a/src/adcp/types/domains/core/placement_definition.py b/src/adcp/types/domains/core/placement_definition.py index 31bcb111a..1a42aab88 100644 --- a/src/adcp/types/domains/core/placement_definition.py +++ b/src/adcp/types/domains/core/placement_definition.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/placement_definition.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, model_validator from ..enums import audio_distribution_type @@ -59,6 +59,7 @@ class PublisherDoohPlacementAttributes(AdCPBaseModel): description='Default scheduled duration of one ad slot in seconds. This is an inventory fact used for loop and share calculations, not the creative-duration contract.', ge=1, ), + NoExplicitNull, ] = None loop_duration_seconds: Annotated[ SchemaInt | None, @@ -66,13 +67,15 @@ class PublisherDoohPlacementAttributes(AdCPBaseModel): description='Duration of the full ad loop rotation in seconds and the canonical source for loop duration.', ge=1, ), + NoExplicitNull, ] = None - screen_resolution: PublisherDoohScreenResolution | None = None + screen_resolution: Annotated[PublisherDoohScreenResolution | None, NoExplicitNull] = None motion: Annotated[ dooh_motion_type.DoohMotionType | None, Field( description='Physical motion capability of a visual DOOH screen, not an accepted-format declaration. Omit for audio-only placements.' ), + NoExplicitNull, ] = None @@ -88,6 +91,7 @@ class FormatOptions(AdCPBaseModel): Field( description='Optional placement-local locale policy. When the resolved top-level format declares a policy, every placement range must be contained by one of its ranges; otherwise this introduces a narrowing of the unconstrained format. The resolved effective route remains canonical-only.' ), + NoExplicitNull, ] = None @@ -105,13 +109,16 @@ class PlacementDefinition(AdCPBaseModel): ), ] description: Annotated[ - str | None, Field(description='Description of where and how this placement appears.') + str | None, + Field(description='Description of where and how this placement appears.'), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, Field( description="Tags for grouping and querying placements across properties and products (e.g., 'homepage', 'native', 'premium', 'pre_roll')." ), + NoExplicitNull, ] = None property_ids: Annotated[ list[property_id.PropertyId] | None, @@ -119,6 +126,7 @@ class PlacementDefinition(AdCPBaseModel): description='Property IDs in this adagents.json where this placement can appear.', min_length=1, ), + NoExplicitNull, ] = None property_tags: Annotated[ list[property_tag.PropertyTag] | None, @@ -126,6 +134,7 @@ class PlacementDefinition(AdCPBaseModel): description="Property tags in this adagents.json where this placement can appear. Useful for network-wide positions such as 'pre_roll' or 'homepage_native_feed'.", min_length=1, ), + NoExplicitNull, ] = None collection_ids: Annotated[ list[str] | None, @@ -133,6 +142,7 @@ class PlacementDefinition(AdCPBaseModel): description='Optional collection IDs in this adagents.json where this placement is valid. Use to narrow a placement to specific content programs carried on the selected properties.', min_length=1, ), + NoExplicitNull, ] = None channels: Annotated[ list[channels_1.MediaChannel] | None, @@ -140,18 +150,21 @@ class PlacementDefinition(AdCPBaseModel): description='Advertising channels where this placement can run. Products that reference the placement may narrow this set but should not broaden it.', min_length=1, ), + NoExplicitNull, ] = None presentation_ref: Annotated[ presentation_ref_1.PlacementPresentationReference | None, Field( description="Optional publisher-specific declarative frame for representing this placement's real chrome offline. It composes around the selected creative rendering unless the publisher-delegated preview_provider route explicitly covers placement presentation. It MUST NOT be promoted to or copied onto a shared format entry." ), + NoExplicitNull, ] = None preview_provider: Annotated[ preview_provider_1.PublisherDesignatedPreviewProvider | None, Field( description="Optional publisher delegation to a callable AdCP preview provider for specific format options on this placement. This publisher-origin route is the only mechanism that grants preview authority inside the placement's scope; an agent's own rendering_origin description does not." ), + NoExplicitNull, ] = None format_options: Annotated[ list[FormatOptions | product_format_declaration.ProductFormatDeclaration] | None, @@ -159,6 +172,7 @@ class PlacementDefinition(AdCPBaseModel): description="Optional 3.1+ canonical format-option declarations supported by this placement. This `adagents.json` placement surface supports two entry shapes: (1) reference an entry in the same file's top-level `formats[]` by `format_option_id` only — buyers resolve the full declaration from `formats[]` by matching `format_option_id` (recommended; avoids duplication). Top-level formats may be publisher-owned custom formats or narrowed canonical formats; their `format_kind` is the canonical anchor that the placement reference inherits. (2) carry an inline `ProductFormatDeclaration` directly — for placement-specific canonical narrowing that doesn't fit a reusable catalog entry. This bare-reference shape is placement-catalog specific; Product `format_options[]` entries are always full `ProductFormatDeclaration` objects with required `format_kind` and `params`.\n\nProduct-level formats remain the upper bound for a sellable product. Catalog placement formats describe placement support; when a product references the placement and also declares product-level formats, buyers use the intersection for that product placement. A catalog placement format that is absent from the product-level declaration is not accepted for that product unless the product explicitly includes it. When the product locale policy is absent, placement locale_policy may introduce any concrete narrowing; when both are present, every placement accepted_language_range must be contained by a product range under RFC 4647 Basic Filtering. An effective placement policy is canonical-only: its resolved catalog and matching product declarations cannot project through legacy format_ids.\n\n**Format-option reference shape.** A format-option reference entry SHOULD carry ONLY `format_option_id` — extra fields are allowed (`additionalProperties: true`) so adopters who want to attach a placement-local override like `display_name` or a narrower `locale_policy` don't get rejected by the branch boundary, but buyer SDKs MUST resolve the format from the top-level `formats[]` by `format_option_id` and apply additional fields on the entry as placement-level overrides (NOT as a partial inline declaration). If a publisher needs to materially narrow the format at the placement, use the inline-declaration form instead.\n\n**Resolution scope is same-file only.** `format_option_id` resolves only within this file's top-level `formats[]`; cross-file references are not supported by design because same-file resolution keeps validation bounded and prevents a file from squatting on or narrowing another publisher's format_option_id. When `format_options[]` references a `format_option_id` not declared in the file's top-level `formats[]`, validators MUST surface this as `FORMAT_OPTION_UNRESOLVED` on the response `errors[]`. Buyers MUST fail closed for that placement (drop the format from the placement's accepted set) rather than silently dropping the placement or guessing intent.", min_length=1, ), + NoExplicitNull, ] = None video_placement_types: Annotated[ list[video_placement_type.VideoPlacementType] | None, @@ -166,6 +180,7 @@ class PlacementDefinition(AdCPBaseModel): description='Declared video placement types for this publisher placement, using IAB Tech Lab/OpenRTB 2.6 video.plcmt definitions with AdCP-native names. Most concrete placements SHOULD declare a single value; aggregate placements MAY declare multiple values. Product-level placement declarations may narrow this set but SHOULD NOT broaden it. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None audio_distribution_types: Annotated[ list[audio_distribution_type.AudioDistributionType] | None, @@ -173,6 +188,7 @@ class PlacementDefinition(AdCPBaseModel): description='Declared audio distribution types for this publisher placement, using IAB Tech Lab/OpenRTB 2.6 audio.feed definitions with AdCP-native names. Most concrete placements SHOULD declare a single value; aggregate placements MAY declare multiple values. Product-level placement declarations may narrow this set but SHOULD NOT broaden it. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None sponsored_placement_types: Annotated[ list[sponsored_placement_type.SponsoredPlacementType] | None, @@ -180,6 +196,7 @@ class PlacementDefinition(AdCPBaseModel): description='Declared sponsored-placement types for this publisher placement, distinguishing where the catalog-driven retail-media placement renders on the retailer surface. Most concrete placements SHOULD declare a single value; aggregate placements MAY declare multiple values. Product-level placement declarations may narrow this set but SHOULD NOT broaden it. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None social_placement_surfaces: Annotated[ list[social_placement_surface.SocialPlacementSurface] | None, @@ -187,6 +204,7 @@ class PlacementDefinition(AdCPBaseModel): description='Declared social-placement surfaces for this publisher placement, distinguishing the in-app surface where the social placement renders. Most concrete placements SHOULD declare a single value; aggregate placements MAY declare multiple values. Product-level placement declarations may narrow this set but SHOULD NOT broaden it. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None identifiers: Annotated[ list[Identifier] | None, @@ -194,9 +212,12 @@ class PlacementDefinition(AdCPBaseModel): description='Optional external inventory identifiers for this placement, using the same {type, value} shape as property identifiers. Externally governed IDs should be authority-prefixed (e.g., space:1234931339, geopath:30961, fcc:73953). Seller-local IDs are opaque values scoped by the surrounding publisher namespace. Product-level placement declarations may carry additional identifiers but SHOULD NOT contradict publisher-declared identifiers for the same type.', min_length=1, ), + NoExplicitNull, ] = None - dooh_placement_attributes: PublisherDoohPlacementAttributes | None = None - ext: ext_1.ExtensionObject | None = None + dooh_placement_attributes: Annotated[ + PublisherDoohPlacementAttributes | None, NoExplicitNull + ] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> PlacementDefinition: diff --git a/src/adcp/types/domains/core/placement_delivery_metrics.py b/src/adcp/types/domains/core/placement_delivery_metrics.py index 6b908e6b6..b88891ba9 100644 --- a/src/adcp/types/domains/core/placement_delivery_metrics.py +++ b/src/adcp/types/domains/core/placement_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/placement_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import Field from . import placement_identity as placement_identity_1 @@ -18,12 +19,14 @@ class PlacementDeliveryMetrics(DeliveryMetrics): Field( description='Self-contained identity. publisher_ref resolves in adagents.json; seller_inline is scoped by seller_agent.' ), + NoExplicitNull, ] = None placement_name: Annotated[ str | None, Field( description='Current human-readable placement name. Convenience metadata only; placement_identity is stable identity.' ), + NoExplicitNull, ] = None placement_id: Annotated[ str, @@ -37,6 +40,7 @@ class PlacementDeliveryMetrics(DeliveryMetrics): description='Legacy publisher attribution. For publisher_ref identity it MUST equal placement_identity.publisher_domain. For seller_inline it identifies inventory context only and is not identity authority.', pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/placement_evidence.py b/src/adcp/types/domains/core/placement_evidence.py index 7ef6fcb9e..9897f25f4 100644 --- a/src/adcp/types/domains/core/placement_evidence.py +++ b/src/adcp/types/domains/core/placement_evidence.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/placement_evidence.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat @@ -21,17 +21,22 @@ class PlacementEvidence(AdCPBaseModel): ), ] captured_at: Annotated[ - AwareDatetime | None, Field(description='When the artifact was captured') + AwareDatetime | None, Field(description='When the artifact was captured'), NoExplicitNull ] = None latitude: Annotated[ - StrictFloat | None, Field(description='Capture location latitude', ge=-90.0, le=90.0) + StrictFloat | None, + Field(description='Capture location latitude', ge=-90.0, le=90.0), + NoExplicitNull, ] = None longitude: Annotated[ - StrictFloat | None, Field(description='Capture location longitude', ge=-180.0, le=180.0) + StrictFloat | None, + Field(description='Capture location longitude', ge=-180.0, le=180.0), + NoExplicitNull, ] = None notes: Annotated[ str | None, Field( description="Context that doesn't fit the structured fields (e.g., 'shot from the eastbound approach at 40mph equivalent')" ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/placement_presentation.py b/src/adcp/types/domains/core/placement_presentation.py index 39fb84e90..2a19c3262 100644 --- a/src/adcp/types/domains/core/placement_presentation.py +++ b/src/adcp/types/domains/core/placement_presentation.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/placement_presentation.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, ConfigDict, Field, field_validator @@ -18,7 +18,9 @@ class Canvas(AdCPBaseModel): ) width: Annotated[SchemaInt, Field(ge=1, le=8192)] height: Annotated[SchemaInt, Field(ge=1, le=8192)] - background_color: Annotated[str | None, Field(pattern='^#[0-9A-Fa-f]{6}$')] = '#ffffff' + background_color: Annotated[str | None, Field(pattern='^#[0-9A-Fa-f]{6}$'), NoExplicitNull] = ( + '#ffffff' + ) class Fit(StrEnum): @@ -138,5 +140,7 @@ class PlacementPresentationDocument(AdCPBaseModel): ), ] decorations: Annotated[ - list[BoxDecoration | TextDecoration | ImageDecoration] | None, Field(max_length=100) + list[BoxDecoration | TextDecoration | ImageDecoration] | None, + Field(max_length=100), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/placement_property_delivery_metrics.py b/src/adcp/types/domains/core/placement_property_delivery_metrics.py index 05f362f52..bed839cc2 100644 --- a/src/adcp/types/domains/core/placement_property_delivery_metrics.py +++ b/src/adcp/types/domains/core/placement_property_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/placement_property_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import Field from . import identifier as identifier_1 @@ -28,7 +29,7 @@ class PlacementPropertyDeliveryMetrics(DeliveryMetrics): ), ] placement_name: Annotated[ - str | None, Field(description='Current convenience name for the placement.') + str | None, Field(description='Current convenience name for the placement.'), NoExplicitNull ] = None publisher_domain: Annotated[ str, @@ -46,9 +47,10 @@ class PlacementPropertyDeliveryMetrics(DeliveryMetrics): Field( description="Canonical publisher-scoped property catalog identity when available. Its publisher_domain MUST equal the row's publisher_domain." ), + NoExplicitNull, ] = None property_name: Annotated[ - str | None, Field(description='Current convenience name for the property.') + str | None, Field(description='Current convenience name for the property.'), NoExplicitNull ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/placement_ref.py b/src/adcp/types/domains/core/placement_ref.py index 1a095e0dc..01e4e99e5 100644 --- a/src/adcp/types/domains/core/placement_ref.py +++ b/src/adcp/types/domains/core/placement_ref.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/placement_ref.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -20,6 +20,7 @@ class PlacementReference(AdCPBaseModel): description='Domain where the adagents.json declaring a publisher-catalog placement is hosted, or the inventory publisher associated with an inline placement. Omitted only for legacy single-publisher product-context references.', pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None placement_id: Annotated[ str, diff --git a/src/adcp/types/domains/core/placement_selection.py b/src/adcp/types/domains/core/placement_selection.py index 911c8ad8e..13305b557 100644 --- a/src/adcp/types/domains/core/placement_selection.py +++ b/src/adcp/types/domains/core/placement_selection.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/placement_selection.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -18,7 +18,7 @@ class PlacementSelection2(AdCPBaseModel): extra='forbid', ) mode: Literal['default'] = 'default' - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class PlacementSelection1(AdCPBaseModel): @@ -33,7 +33,7 @@ class PlacementSelection1(AdCPBaseModel): min_length=1, ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None PlacementSelection = Annotated[ diff --git a/src/adcp/types/domains/core/planned_delivery.py b/src/adcp/types/domains/core/planned_delivery.py index d30ef3fb2..3ef41da84 100644 --- a/src/adcp/types/domains/core/planned_delivery.py +++ b/src/adcp/types/domains/core/planned_delivery.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/planned_delivery.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..enums import channels as channels_1 @@ -24,9 +24,12 @@ class Geo(AdCPBaseModel): countries: Annotated[ list[str] | None, Field(description='ISO 3166-1 alpha-2 country codes where ads will deliver.'), + NoExplicitNull, ] = None regions: Annotated[ - list[str] | None, Field(description='ISO 3166-2 subdivision codes where ads will deliver.') + list[str] | None, + Field(description='ISO 3166-2 subdivision codes where ads will deliver.'), + NoExplicitNull, ] = None @@ -40,6 +43,7 @@ class PlannedDelivery(AdCPBaseModel): description='Seller-assigned media buy identifier. Optional on a purchase-phase prepare/check because the service may not assign the identifier until commit; required on modification and delivery lifecycle checks.', min_length=1, ), + NoExplicitNull, ] = None proposal_id: Annotated[ str | None, @@ -48,6 +52,7 @@ class PlannedDelivery(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None proposal_terms_digest: Annotated[ str | None, @@ -55,27 +60,35 @@ class PlannedDelivery(AdCPBaseModel): description='Digest of the proposal commercial_terms. The governance agent compares it to the digest bound during the intent check.', pattern='^sha256:[A-Za-z0-9_-]{43}$', ), + NoExplicitNull, + ] = None + geo: Annotated[ + Geo | None, Field(description='Geographic targeting the seller will apply.'), NoExplicitNull ] = None - geo: Annotated[Geo | None, Field(description='Geographic targeting the seller will apply.')] = ( - None - ) channels: Annotated[ list[channels_1.MediaChannel] | None, Field(description='Channels the seller will deliver on.'), + NoExplicitNull, ] = None start_time: Annotated[ - AwareDatetime | None, Field(description='Actual flight start the seller will use.') + AwareDatetime | None, + Field(description='Actual flight start the seller will use.'), + NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, Field(description='Actual flight end the seller will use.') + AwareDatetime | None, + Field(description='Actual flight end the seller will use.'), + NoExplicitNull, ] = None frequency_cap: Annotated[ frequency_cap_1.FrequencyCap | None, Field(description='Frequency cap the seller will apply.'), + NoExplicitNull, ] = None audience_summary: Annotated[ str | None, Field(description='Human-readable summary of the audience the seller will target.'), + NoExplicitNull, ] = None audience_targeting: Annotated[ list[audience_selector.AudienceSelector] | None, @@ -83,10 +96,12 @@ class PlannedDelivery(AdCPBaseModel): description='Structured audience targeting the seller will activate. Each entry is either a signal reference or a descriptive criterion. When present, governance agents MUST use this for bias/fairness validation and SHOULD ignore audience_summary for validation purposes. The audience_summary field is a human-readable rendering of this array, not an independent declaration.', min_length=1, ), + NoExplicitNull, ] = None total_budget: Annotated[ StrictFloat | None, Field(description='Total budget the seller will deliver against.', ge=0.0), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -94,12 +109,14 @@ class PlannedDelivery(AdCPBaseModel): description='Hard aggregate daily spend ceiling the seller will enforce. Governance checks compare it with the authorized execution controls; it does not allocate spend to packages.', ge=0.0, ), + NoExplicitNull, ] = None budget_cap_timezone: Annotated[ str | None, Field( description='IANA timezone defining the calendar-day boundary for every daily cap on the planned media buy.' ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -107,25 +124,30 @@ class PlannedDelivery(AdCPBaseModel): description='ISO 4217 currency code for the budget. Governance execution checks require it whenever total_budget is present and require it to match the intent-authorized currency.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None budget_allocation: Annotated[ budget_allocation_1.BudgetAllocation | None, Field( description='Seller-accepted cross-package allocation authority and goals. Presence with seller_optimized mode means automatic within-buy reallocations are part of the committed delivery, not separate modification actions.' ), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field(description='Aggregate pacing strategy the seller will apply to total_budget.'), + NoExplicitNull, ] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, Field( description='Seller-interpreted media-buy bidding policy used for governance and delivery transparency. Goal-bound controls follow budget-allocation scope semantics and monetary fields use the planned delivery currency. Package-authored overrides, including explicit automatic overrides, remain on packages rather than being copied into this aggregate field.' ), + NoExplicitNull, ] = None enforced_policies: Annotated[ list[str] | None, Field(description='Registry policy IDs the seller will enforce for this delivery.'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/positive_postal_area_support.py b/src/adcp/types/domains/core/positive_postal_area_support.py index 89e553e00..000bb58c3 100644 --- a/src/adcp/types/domains/core/positive_postal_area_support.py +++ b/src/adcp/types/domains/core/positive_postal_area_support.py @@ -1,25 +1,26 @@ # generated by datamodel-codegen: # filename: core/positive_postal_area_support.json -# timestamp: 2026-10-03T14:21:15+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations from typing import Annotated, Literal +from adcp.types.base import NoExplicitNull from pydantic import Field from .postal_area_support import PostalAreaSupport class PositivePostalAreaSupport(PostalAreaSupport): - us_zip: Annotated[Literal[True] | None, Field(deprecated=True)] = None - us_zip_plus_four: Annotated[Literal[True] | None, Field(deprecated=True)] = None - gb_outward: Annotated[Literal[True] | None, Field(deprecated=True)] = None - gb_full: Annotated[Literal[True] | None, Field(deprecated=True)] = None - ca_fsa: Annotated[Literal[True] | None, Field(deprecated=True)] = None - ca_full: Annotated[Literal[True] | None, Field(deprecated=True)] = None - de_plz: Annotated[Literal[True] | None, Field(deprecated=True)] = None - fr_code_postal: Annotated[Literal[True] | None, Field(deprecated=True)] = None - au_postcode: Annotated[Literal[True] | None, Field(deprecated=True)] = None - ch_plz: Annotated[Literal[True] | None, Field(deprecated=True)] = None - at_plz: Annotated[Literal[True] | None, Field(deprecated=True)] = None + us_zip: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + us_zip_plus_four: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + gb_outward: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + gb_full: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + ca_fsa: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + ca_full: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + de_plz: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + fr_code_postal: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + au_postcode: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + ch_plz: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None + at_plz: Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/postal_area_support.py b/src/adcp/types/domains/core/postal_area_support.py index 2dc9e2910..150f128bd 100644 --- a/src/adcp/types/domains/core/postal_area_support.py +++ b/src/adcp/types/domains/core/postal_area_support.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/postal_area_support.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Dict, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool @@ -38,25 +38,25 @@ class PostalAreaSupport(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - US: Annotated[list[ME] | None, Field(min_length=1)] = None - GB: Annotated[list[GBEnum] | None, Field(min_length=1)] = None - CA: Annotated[list[CAEnum] | None, Field(min_length=1)] = None - DE: Annotated[list[Literal['plz']] | None, Field(min_length=1)] = None - CH: Annotated[list[Literal['plz']] | None, Field(min_length=1)] = None - AT: Annotated[list[Literal['plz']] | None, Field(min_length=1)] = None - FR: Annotated[list[Literal['code_postal']] | None, Field(min_length=1)] = None - AU: Annotated[list[Literal['postcode']] | None, Field(min_length=1)] = None - BR: Annotated[list[Literal['cep']] | None, Field(min_length=1)] = None - IN: Annotated[list[Literal['pin']] | None, Field(min_length=1)] = None - ZA: Annotated[list[Literal['postal_code']] | None, Field(min_length=1)] = None - us_zip: Annotated[StrictBool | None, Field(deprecated=True)] = None - us_zip_plus_four: Annotated[StrictBool | None, Field(deprecated=True)] = None - gb_outward: Annotated[StrictBool | None, Field(deprecated=True)] = None - gb_full: Annotated[StrictBool | None, Field(deprecated=True)] = None - ca_fsa: Annotated[StrictBool | None, Field(deprecated=True)] = None - ca_full: Annotated[StrictBool | None, Field(deprecated=True)] = None - de_plz: Annotated[StrictBool | None, Field(deprecated=True)] = None - fr_code_postal: Annotated[StrictBool | None, Field(deprecated=True)] = None - au_postcode: Annotated[StrictBool | None, Field(deprecated=True)] = None - ch_plz: Annotated[StrictBool | None, Field(deprecated=True)] = None - at_plz: Annotated[StrictBool | None, Field(deprecated=True)] = None + US: Annotated[list[ME] | None, Field(min_length=1), NoExplicitNull] = None + GB: Annotated[list[GBEnum] | None, Field(min_length=1), NoExplicitNull] = None + CA: Annotated[list[CAEnum] | None, Field(min_length=1), NoExplicitNull] = None + DE: Annotated[list[Literal['plz']] | None, Field(min_length=1), NoExplicitNull] = None + CH: Annotated[list[Literal['plz']] | None, Field(min_length=1), NoExplicitNull] = None + AT: Annotated[list[Literal['plz']] | None, Field(min_length=1), NoExplicitNull] = None + FR: Annotated[list[Literal['code_postal']] | None, Field(min_length=1), NoExplicitNull] = None + AU: Annotated[list[Literal['postcode']] | None, Field(min_length=1), NoExplicitNull] = None + BR: Annotated[list[Literal['cep']] | None, Field(min_length=1), NoExplicitNull] = None + IN: Annotated[list[Literal['pin']] | None, Field(min_length=1), NoExplicitNull] = None + ZA: Annotated[list[Literal['postal_code']] | None, Field(min_length=1), NoExplicitNull] = None + us_zip: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + us_zip_plus_four: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + gb_outward: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + gb_full: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + ca_fsa: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + ca_full: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + de_plz: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + fr_code_postal: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + au_postcode: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + ch_plz: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None + at_plz: Annotated[StrictBool | None, Field(deprecated=True), NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/preview_provider.py b/src/adcp/types/domains/core/preview_provider.py index db2e2065a..e4317c416 100644 --- a/src/adcp/types/domains/core/preview_provider.py +++ b/src/adcp/types/domains/core/preview_provider.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/preview_provider.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool, field_validator @@ -33,6 +33,7 @@ class Route(AdCPBaseModel): Field( description="True only when the publisher delegates both creative rendering and the complete placement-specific frame to this route. When false or omitted, consumers compose any presentation_ref around the provider's creative render." ), + NoExplicitNull, ] = False diff --git a/src/adcp/types/domains/core/price.py b/src/adcp/types/domains/core/price.py index 2efecc705..2e28ed3e4 100644 --- a/src/adcp/types/domains/core/price.py +++ b/src/adcp/types/domains/core/price.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/price.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -36,4 +36,5 @@ class Price(AdCPBaseModel): Field( description="Billing period. 'night' for hotel rates, 'month' or 'year' for salaries and rentals, 'one_time' for purchase prices. Omit when the period is obvious from context (e.g., a vehicle price is always one-time)." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/principal_changed_webhook.py b/src/adcp/types/domains/core/principal_changed_webhook.py index d95c37469..18933a007 100644 --- a/src/adcp/types/domains/core/principal_changed_webhook.py +++ b/src/adcp/types/domains/core/principal_changed_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/principal_changed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from . import ext as ext_1 @@ -91,5 +91,6 @@ class PrincipalChangedWebhook(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$', ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/principal_declarations.py b/src/adcp/types/domains/core/principal_declarations.py index a80f31bad..9ba9e9f22 100644 --- a/src/adcp/types/domains/core/principal_declarations.py +++ b/src/adcp/types/domains/core/principal_declarations.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/principal_declarations.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -35,6 +35,7 @@ class AgentDeclarations(AdCPBaseModel): max_length=8, min_length=1, ), + NoExplicitNull, ] = None webhook_signing_algorithms: Annotated[ list[WebhookSigningAlgorithm] | None, @@ -42,6 +43,7 @@ class AgentDeclarations(AdCPBaseModel): description="RFC 9421 webhook-signing algorithms the caller can verify. The accepted intersection with the seller's webhook_signing.algorithms MUST be non-empty when the caller has any active webhook subscriber; an empty intersection fails the sync request with UNSUPPORTED_FEATURE because delivery would be unverifiable.", min_length=1, ), + NoExplicitNull, ] = None experimental_features: Annotated[ list[experimental_feature_id.ExperimentalFeatureId] | None, @@ -50,4 +52,5 @@ class AgentDeclarations(AdCPBaseModel): max_length=32, min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/principal_declarations_state.py b/src/adcp/types/domains/core/principal_declarations_state.py index 0160698bd..78e55ff0b 100644 --- a/src/adcp/types/domains/core/principal_declarations_state.py +++ b/src/adcp/types/domains/core/principal_declarations_state.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/principal_declarations_state.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import principal_declarations @@ -55,6 +55,7 @@ class PrincipalDeclarationsState(AdCPBaseModel): description='The single AdCP minor version the seller will use for asynchronous payload shapes toward this principal. MUST be a member of accepted.async_adcp_versions and MUST be present whenever that set is non-empty, so the buyer knows the exact payload contract rather than inferring it from the intersection.', pattern='^\\d+\\.\\d+$', ), + NoExplicitNull, ] = None exclusions: Annotated[ list[Exclusion] | None, @@ -62,4 +63,5 @@ class PrincipalDeclarationsState(AdCPBaseModel): description="Every declared value that is absent from the accepted intersection, with the seller's reason. Present whenever declared and accepted differ, so a buyer can see why a capability it relies on was not accepted instead of diffing the two sets.", max_length=64, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/principal_state.py b/src/adcp/types/domains/core/principal_state.py index e903c6ce9..01364216a 100644 --- a/src/adcp/types/domains/core/principal_state.py +++ b/src/adcp/types/domains/core/principal_state.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/principal_state.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -37,7 +37,7 @@ class RetiredDestination(AdCPBaseModel): min_length=1, ), ] - revoked_at: AwareDatetime | None = None + revoked_at: Annotated[AwareDatetime | None, NoExplicitNull] = None class PrincipalState(AdCPBaseModel): @@ -50,6 +50,7 @@ class PrincipalState(AdCPBaseModel): description='Current agent-level webhook subscribers. authentication.credentials is always omitted because it is write-only.', max_length=16, ), + NoExplicitNull, ] = None reporting_destinations: Annotated[ list[agent_reporting_destination_state.AgentReportingDestinationState] | None, @@ -57,12 +58,14 @@ class PrincipalState(AdCPBaseModel): description='Current reusable reporting destination bindings and setup states. destination_id and destination_ref values MUST each be unique within this caller-scoped array; superseded generations of a current destination appear in its prior_destination_refs.', max_length=64, ), + NoExplicitNull, ] = None declarations: Annotated[ principal_declarations_state.PrincipalDeclarationsState | None, Field( description='Declared consumption facts and the seller-computed accepted intersection. Present when and only when the seller supports the declarations section.' ), + NoExplicitNull, ] = None retired_destinations: Annotated[ list[RetiredDestination] | None, @@ -70,4 +73,5 @@ class PrincipalState(AdCPBaseModel): description='Destinations the caller revoked by omitting them from a submitted reporting_destinations section, retained while any generation remains resolvable for reporting history. Enumerable only by the owning principal. Reusing a retired destination_id requires fresh registration and proof and produces a new generation.', max_length=64, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/product.py b/src/adcp/types/domains/core/product.py index fadcdbbf5..b7bfb1bf2 100644 --- a/src/adcp/types/domains/core/product.py +++ b/src/adcp/types/domains/core/product.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/product.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat, ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, model_validator @@ -80,6 +80,7 @@ class PublisherProperty81(AdCPBaseModel): description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None publisher_domains: Annotated[ list[PublisherDomain] | None, @@ -87,6 +88,7 @@ class PublisherProperty81(AdCPBaseModel): description="Compact form for fanning the same selector across many publishers (e.g., a managed network listing every publisher it represents). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", min_length=1, ), + NoExplicitNull, ] = None selection_type: Annotated[ Literal['all'], @@ -145,6 +147,7 @@ class ConversionTracking(AdCPBaseModel): description="Action sources relevant to this product (e.g. a retail media product might have 'in_store' and 'website', while a display product might only have 'website')", min_length=1, ), + NoExplicitNull, ] = None supported_targets: Annotated[ list[SupportedTarget5] | None, @@ -152,12 +155,14 @@ class ConversionTracking(AdCPBaseModel): description='Target kinds available for event goals on this product. Values match target.kind on the optimization goal. cost_per: target cost per conversion event. per_ad_spend: target return on ad spend (requires value_field on event sources). maximize_value: maximize total conversion value without a specific ratio target (requires value_field). Only these target kinds are accepted — goals with unlisted target kinds will be rejected. A goal without a target implicitly maximizes conversion count within budget — no declaration needed for that mode. When omitted, buyers can still set target-less event goals.', min_length=1, ), + NoExplicitNull, ] = None platform_managed: Annotated[ StrictBool | None, Field( description="Whether the seller provides its own always-on measurement (e.g. Amazon sales attribution for Amazon advertisers). When true, sync_event_sources response will include seller-managed event sources with managed_by='seller'." ), + NoExplicitNull, ] = None @@ -172,16 +177,19 @@ class CatalogMatch(AdCPBaseModel): Field( description="GTINs from the buyer's catalog that are eligible on this product's inventory. Standard GTIN formats (GTIN-8 through GTIN-14). Only present for product-type catalogs with GTIN matching." ), + NoExplicitNull, ] = None matched_ids: Annotated[ list[str] | None, Field( description="Item IDs from the buyer's catalog that matched this product's inventory. The ID type depends on the catalog type and content_id_type (e.g., SKUs for product catalogs, job_ids for job catalogs, offering_ids for offering catalogs)." ), + NoExplicitNull, ] = None matched_count: Annotated[ SchemaInt | None, Field(description="Number of catalog items that matched this product's inventory.", ge=0), + NoExplicitNull, ] = None submitted_count: Annotated[ SchemaInt, @@ -209,9 +217,12 @@ class MaterialSubmission(AdCPBaseModel): url: Annotated[ AnyUrl | None, Field(description='HTTPS URL for uploading or submitting physical creative materials'), + NoExplicitNull, ] = None email: Annotated[ - EmailStr | None, Field(description='Email address for creative material submission') + EmailStr | None, + Field(description='Email address for creative material submission'), + NoExplicitNull, ] = None instructions: Annotated[ str | None, @@ -219,8 +230,9 @@ class MaterialSubmission(AdCPBaseModel): description='Human-readable instructions for material submission (file naming conventions, shipping address, etc.)', max_length=2000, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class PublisherProperty82(AdCPBaseModel): @@ -254,6 +266,7 @@ class PublisherProperty83(AdCPBaseModel): description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None publisher_domains: Annotated[ list[PublisherDomain] | None, @@ -261,6 +274,7 @@ class PublisherProperty83(AdCPBaseModel): description="Compact form for fanning the same tag predicate across many publishers (canonical managed-network shape). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", min_length=1, ), + NoExplicitNull, ] = None selection_type: Annotated[ Literal['by_tag'], Field(description='Discriminator indicating selection by property tags') @@ -302,12 +316,14 @@ class MetricOptimization(AdCPBaseModel): description="Reach units this product can optimize for. Required when supported_metrics includes 'reach'. Buyers must set reach_unit to a value in this list on reach optimization goals — sellers reject unsupported values.", min_length=1, ), + NoExplicitNull, ] = None supported_view_durations: Annotated[ list[SupportedViewDuration] | None, Field( description="Video view duration thresholds (in seconds) this product supports for completed_views goals. Only relevant when supported_metrics includes 'completed_views'. When absent, the seller uses their platform default. Buyers must set view_duration_seconds to a value in this list — sellers reject unsupported values." ), + NoExplicitNull, ] = None supported_viewability_standards: Annotated[ list[viewability_standard.ViewabilityStandard] | None, @@ -315,12 +331,14 @@ class MetricOptimization(AdCPBaseModel): description="Viewability standards this product can optimize viewable_rate goals against. Only relevant when supported_metrics includes 'viewable_rate'. When absent, buyers cannot assume a specific standard is supported and sellers reject unsupported values. Buyers must set the goal's standard to a value in this list when it is present.", min_length=1, ), + NoExplicitNull, ] = None supported_targets: Annotated[ list[SupportedTarget] | None, Field( description='Target kinds available for metric goals on this product. Values match target.kind on the optimization goal. Only these target kinds are accepted — goals with unlisted target kinds will be rejected. When omitted, buyers can set target-less metric goals (maximize volume within budget) but cannot set specific targets.' ), + NoExplicitNull, ] = None @@ -337,10 +355,12 @@ class Provider(AdCPBaseModel): context_match: Annotated[ StrictBool | None, Field(description='Whether this provider handles context match for this product.'), + NoExplicitNull, ] = False identity_match: Annotated[ StrictBool | None, Field(description='Whether this provider handles identity match for this product.'), + NoExplicitNull, ] = False countries: Annotated[ list[Country] | None, @@ -348,6 +368,7 @@ class Provider(AdCPBaseModel): description="ISO 3166-1 alpha-2 country codes this provider serves for identity match. The router uses this to select the correct regional provider based on the request's country field. Required when identity_match is true.", min_length=1, ), + NoExplicitNull, ] = None uid_types: Annotated[ list[uid_type.UidType] | None, @@ -355,6 +376,7 @@ class Provider(AdCPBaseModel): description="Identity types this regional provider can resolve. The router filters providers whose uid_types includes the request's uid_type. Required when identity_match is true.", min_length=1, ), + NoExplicitNull, ] = None @@ -373,16 +395,19 @@ class TrustedMatch(AdCPBaseModel): Field( description="Whether this product supports Identity Match requests. When true, the publisher's TMP router will send identity match requests to evaluate user eligibility." ), + NoExplicitNull, ] = False response_types: Annotated[ list[response_type.TmpResponseType] | None, Field(description='What the publisher can accept back from context match.', min_length=1), + NoExplicitNull, ] = [response_type.TmpResponseType.activation] dynamic_brands: Annotated[ StrictBool | None, Field( description="Whether the buyer can select a brand at match time. When false (default), the brand must be specified on the media buy/package. When true, the buyer's offer can include any brand — the publisher applies approval rules at match time. Enables multi-brand agreements where the holding company or buyer agent selects brand based on context." ), + NoExplicitNull, ] = False providers: Annotated[ list[Provider] | None, @@ -390,6 +415,7 @@ class TrustedMatch(AdCPBaseModel): description="TMP providers integrated with this product's inventory. Each entry identifies a provider by agent_url (from the registry) and declares what match types it supports for this product. The product-level context_match and identity_match booleans declare what the product supports overall; the per-provider booleans declare which provider handles each match type. Enables buyer discovery: 'find products where a specific provider does context matching.'", min_length=1, ), + NoExplicitNull, ] = None @@ -402,13 +428,17 @@ class ProductCard(AdCPBaseModel): Field( description='Hero image for the card. Recommended ~300x400 (4:3 portrait) for the standard card layout; receivers may scale.' ), + NoExplicitNull, ] = None title: Annotated[ - str | None, Field(description='Card title (typically the product name).', max_length=60) + str | None, + Field(description='Card title (typically the product name).', max_length=60), + NoExplicitNull, ] = None description: Annotated[ str | None, Field(description='Short descriptive blurb shown below the title.', max_length=200), + NoExplicitNull, ] = None price_label: Annotated[ str | None, @@ -416,6 +446,7 @@ class ProductCard(AdCPBaseModel): description="Formatted price or pricing summary (e.g., 'From $5 CPM', 'Auction floor $0.50 CPC'). Free-text — receivers render verbatim.", max_length=30, ), + NoExplicitNull, ] = None cta_label: Annotated[ str | None, @@ -423,6 +454,7 @@ class ProductCard(AdCPBaseModel): description="Call-to-action button label (e.g., 'View details', 'Get proposal').", max_length=25, ), + NoExplicitNull, ] = None @@ -433,6 +465,7 @@ class DeliveryMeasurement(AdCPBaseModel): description="Measurement vendors used for this product, as structured `BrandRef` identities. Multiple entries when multiple vendors play different roles (e.g., the ad server plus a separate viewability vendor like IAS or DV; or a retail-media seller plus a third-party retail measurement vendor like Circana or NielsenIQ). Each vendor's `brand.json` `agents[type='measurement']` is the discovery anchor; metric definitions live on the agent's `get_adcp_capabilities.measurement.metrics[]` block. Distinct from `performance_standards[].vendor` which carries vendor identity for *committed* metrics with thresholds — this field carries vendor identity for the overall measurement story, including non-committed-but-reported metrics.", min_length=1, ), + NoExplicitNull, ] = None provider: Annotated[ str | None, @@ -440,12 +473,14 @@ class DeliveryMeasurement(AdCPBaseModel): deprecated=True, description="**Deprecated as of this minor.** Free-form measurement provider description (e.g., 'Google Ad Manager with IAS viewability', 'Nielsen DAR', 'Geopath for DOOH impressions'). New implementations SHOULD use the structured `vendors` field instead. Retained for one-minor backwards compatibility; removed at the next major. When both `vendors` and `provider` are present, consumers MUST use `vendors` for vendor identity and treat `provider` as informational text.", ), + NoExplicitNull, ] = None notes: Annotated[ str | None, Field( description="Additional details about measurement methodology in plain language (e.g., 'MRC-accredited viewability. 50% in-view for 1s display / 2s video', 'Panel-based demographic measurement updated monthly'). Free-form prose for context that doesn't fit the structured `vendors` field." ), + NoExplicitNull, ] = None @@ -456,35 +491,42 @@ class ProductCardDetailed(AdCPBaseModel): hero_image: Annotated[ image_asset.ImageAsset | None, Field(description='Primary hero image at the top of the detailed view.'), + NoExplicitNull, ] = None carousel_images: Annotated[ list[image_asset.ImageAsset] | None, Field(description='Additional images for a swipeable carousel below the hero.'), + NoExplicitNull, + ] = None + title: Annotated[ + str | None, Field(description='Page title (typically the product name).'), NoExplicitNull ] = None - title: Annotated[str | None, Field(description='Page title (typically the product name).')] = ( - None - ) description: Annotated[ str | None, Field( description='Full descriptive copy. Markdown allowed in client renderers that support it; otherwise treat as plain text.' ), + NoExplicitNull, ] = None specifications: Annotated[ list[Specification] | None, Field( description="Structured key/value specifications (e.g., 'Aspect ratio: 9:16', 'Duration: 30s'). Each item is a labeled fact about the product." ), + NoExplicitNull, + ] = None + price_label: Annotated[ + str | None, Field(description='Formatted price or pricing summary.'), NoExplicitNull + ] = None + cta_label: Annotated[ + str | None, Field(description='Call-to-action button label.'), NoExplicitNull ] = None - price_label: Annotated[str | None, Field(description='Formatted price or pricing summary.')] = ( - None - ) - cta_label: Annotated[str | None, Field(description='Call-to-action button label.')] = None reference_assets: Annotated[ list[product_card_reference_asset.ProductCardReferenceAsset] | None, Field( description='Typed seller collateral for buyer planning — coverage maps, sample renders, environment photos, media kits. Distinct from hero_image/carousel_images, which are display-oriented.' ), + NoExplicitNull, ] = None @@ -504,12 +546,14 @@ class AudienceActivation(AdCPBaseModel): Field( description="The seller's preferred path when the buyer supports several. MUST also appear in methods." ), + NoExplicitNull, ] = None notes: Annotated[ str | None, Field( description='Free-text caveats (onboarding lead times, data-format constraints, regional restrictions).' ), + NoExplicitNull, ] = None @@ -557,6 +601,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: Field( description="Advertising channels this product is sold as. Products inherit from their properties' supported_channels but may narrow the scope. For example, a product covering YouTube properties might be sold as ['ctv'] even though those properties support ['olv', 'social', 'ctv']." ), + NoExplicitNull, ] = None format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -564,6 +609,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: deprecated=True, description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Legacy named-format compatibility path. Products MUST carry `format_ids`, `format_options`, or both during the 3.x migration window. New products MUST author canonical `format_options[]`; sellers MAY additionally project those declarations to `format_ids` for legacy buyers. When both fields are present they MUST describe the same underlying formats, and buyers MUST prefer `format_options`. Do not author a new product from `format_ids` alone.', ), + NoExplicitNull, ] = None format_options: Annotated[ list[product_format_declaration.ProductFormatDeclaration] | None, @@ -571,6 +617,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description="Canonical format-option path: one or more inline format declarations the product accepts. Each element narrows a canonical format with parameters, slots, platform_extensions, and optional locale_policy. New 3.2 products MUST carry format_options; a seller MAY additionally project the same declarations to deprecated format_ids for older 3.x peers. A declaration carrying locale_policy is canonical-only because legacy format_ids cannot preserve locale eligibility; no product or placement format_id may project to an effective locale-constrained option.\n\nWhen placements are published, product-level format_options are the union of formats deliverable somewhere in the product and the upper bound for every placement. A placement's effective accepted set is the intersection of every applicable layer: (1) the product format_options; (2) the product placement's inline format_options, when present; and (3) for kind publisher_ref, the named publisher's adagents.json catalog narrowing. Resolve layer 3 by locating the matching placements[] entry: use its format_options when present, resolving bare format_option_id references against that same file's top-level formats[]; otherwise use top-level formats[] applicable to the placement's property_ids/property_tags. An omitted optional layer is unconstrained, but an unresolved publisher placement or format-option reference MUST fail closed. A publisher-referenced placement without inline product format_options therefore does NOT inherit the full product union when the publisher catalog supplies narrower placement or property-scoped acceptance.\n\nMatch publisher-declared options by {publisher_domain, format_option_id}, match product-local options by format_option_id when publisher_domain is omitted, and otherwise match declarations with the same format_kind whose narrower parameters satisfy the broader declaration. A product- or placement-level declaration MUST NOT introduce a format outside the product upper bound. Locale policy follows the same intersection. If the product locale policy is absent, a placement may introduce any concrete policy as a narrowing of an unconstrained option; when both are present, every placement range must be contained by a product range under RFC 4647 Basic Filtering. Locale eligibility is checked independently for every placement where an assignment may serve.\n\nFor a product or package containing multiple included placements, a single creative intended for every placement MUST lie in the intersection of every selected placement's effective set. Distinct per-placement creatives MAY use the union, but the selected creative set MUST cover every included placement; uncovered inventory MUST be rejected or refined, never silently omitted. If a product spans multiple publishers but omits placements[], there is no public routing key for per-placement creatives: its format_options MUST therefore be the common intersection accepted across every selected publisher/property scope. A seller that needs a union of publisher-specific formats MUST publish placements[] with publisher-scoped identities and narrowing. Commercial terms such as price, floor, availability, and deal eligibility are product facts, not format parameters.", min_length=1, ), + NoExplicitNull, ] = None placements: Annotated[ list[placement.Placement] | None, @@ -578,6 +625,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description="Optional array of specific public placements within this product. Product placements declare `kind` to distinguish publisher-catalog placements (`publisher_ref`) from sales-agent-defined placements (`seller_inline`). Publisher references use canonical `{publisher_domain, placement_id}` identity and may omit name because adagents.json resolves it. New seller-inline placements SHOULD carry `seller_agent`; legacy rows without it remain scoped to the enclosing seller and product. A seller-inline publisher_domain is inventory attribution, not authority to mint an ID in that publisher's catalog namespace. Each placement MUST declare mode: targetable or included. Creative assignments route creatives only after placement inventory is purchased.", min_length=1, ), + NoExplicitNull, ] = None video_placement_types: Annotated[ list[video_placement_type.VideoPlacementType] | None, @@ -585,6 +633,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Declared video placement types that may be included in this product, using IAB Tech Lab/OpenRTB 2.6 video.plcmt definitions with AdCP-native names. Use on OLV, CTV, and other video products when buyers need to distinguish instream, accompanying-content, interstitial, and standalone/no-content inventory. Aggregate products and ad-network products MAY declare multiple values. When `placements[]` also carry `video_placement_types`, this product-level array SHOULD be the union of the placement-level declarations the seller may deliver under the product. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None audio_distribution_types: Annotated[ list[audio_distribution_type.AudioDistributionType] | None, @@ -592,6 +641,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Declared audio distribution types that may be included in this product, using IAB Tech Lab/OpenRTB 2.6 audio.feed definitions with AdCP-native names. Use on radio, streaming-audio, podcast, gaming, and other audio products when buyers need to distinguish music streaming services, FM/AM broadcast, podcasts, catch-up radio, web radio, video-game audio, and text-to-speech inventory without changing the buyer-facing channel or adagents.json property type. Aggregate products and ad-network products MAY declare multiple values. When `placements[]` also carry `audio_distribution_types`, this product-level array SHOULD be the union of the placement-level declarations the seller may deliver under the product. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None sponsored_placement_types: Annotated[ list[sponsored_placement_type.SponsoredPlacementType] | None, @@ -599,6 +649,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Declared sponsored-placement types that may be included in this product, distinguishing where catalog-driven retail-media placements render on the retailer surface (sponsored search, sponsored display, or sponsored native). Use on retail-media products when buyers need to distinguish search-keyed, display, and native in-grid sponsored inventory. Aggregate products and ad-network products MAY declare multiple values. When `placements[]` also carry `sponsored_placement_types`, this product-level array SHOULD be the union of the placement-level declarations the seller may deliver under the product. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None social_placement_surfaces: Annotated[ list[social_placement_surface.SocialPlacementSurface] | None, @@ -606,6 +657,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Declared social-placement surfaces that may be included in this product, distinguishing the in-app surface where social placements render (feed, stories, short_video, explore, or search). Use on social products when buyers need to distinguish feed, story, short-video, and discovery surfaces. Aggregate products and ad-network products MAY declare multiple values. When `placements[]` also carry `social_placement_surfaces`, this product-level array SHOULD be the union of the placement-level declarations the seller may deliver under the product. This is seller-declared discovery metadata, not independent verification of inventory quality or delivery context.', min_length=1, ), + NoExplicitNull, ] = None delivery_type: delivery_type_1.DeliveryType exclusivity: Annotated[ @@ -613,6 +665,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: Field( description="Whether this product offers exclusive access to its inventory. Defaults to 'none' when absent. Most relevant for guaranteed products tied to specific collections or placements." ), + NoExplicitNull, ] = None pricing_options: Annotated[ list[pricing_option.PricingOption], @@ -626,6 +679,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: Field( description="Forecasted delivery metrics for this product. Concrete discovery targeting scopes the forecast to those effective values. When discovery requested only required_overlay_support for a dimension, the forecast describes the product's discovery/default scope and is not a value-specific forecast for every later selection; buyers rediscover with concrete targeting_overlay values when they need that forecast." ), + NoExplicitNull, ] = None outcome_measurement: Annotated[ outcome_measurement_1.OutcomeMeasurement | None, @@ -633,18 +687,21 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: deprecated=True, description='**Deprecated as of this minor.** Outcome capabilities (incremental sales lift, brand lift, foot traffic, etc.) are now declared via `reporting_capabilities.available_metrics` (the same path used for impressions, conversions, ROAS) with `qualifier.attribution_methodology` and `qualifier.attribution_window` carrying the methodology and window on commit. New implementations SHOULD use the unified pattern; this field is retained for one-minor backwards compatibility and removed at the next major. See `outcome-measurement.json` description for migration guidance.', ), + NoExplicitNull, ] = None delivery_measurement: Annotated[ DeliveryMeasurement | None, Field( description='Measurement vendors and methodology for delivery metrics. The buyer accepts the declared vendors as the source of truth for the buy. When absent, buyers should apply their own measurement defaults. Senders SHOULD populate `vendors` (structured BrandRef array) for new implementations; the legacy `provider` string field is deprecated and retained for one-minor backwards compatibility.' ), + NoExplicitNull, ] = None measurement_terms: Annotated[ measurement_terms_1.MeasurementTerms | None, Field( description="Seller's default billing measurement and makegood terms. Declares who counts the billing metric and what remedies apply when thresholds are breached. Buyers may propose different terms at media buy creation — sellers accept, reject (TERMS_REJECTED), or adjust per their policy." ), + NoExplicitNull, ] = None performance_standards: Annotated[ list[performance_standard.PerformanceStandard] | None, @@ -652,12 +709,14 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description="Seller's default performance standards for this product: viewability, IVT, completion rate, brand safety, attention score. Buyers may propose different standards at media buy creation. When absent, no structured performance standards apply.", min_length=1, ), + NoExplicitNull, ] = None cancellation_policy: Annotated[ cancellation_policy_1.CancellationPolicy | None, Field( description='Cancellation terms for this product. Declares the minimum notice period required before cancellation takes effect and any penalties for insufficient notice. Relevant for guaranteed delivery products. Buyers accept these terms by creating a media buy against the product.' ), + NoExplicitNull, ] = None allowed_actions: Annotated[ list[product_allowed_action.ProductAllowedAction] | None, @@ -665,20 +724,23 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Actions buyers may perform on buys created against this product, scoped to statuses and modes. Advisory template — the authoritative per-buy capability is `available_actions[]` on the buy response, which resolves modes against current buy state, account tier, and negotiated terms. Buyers SHOULD use this for pre-flight product selection ("which products let me self-serve cancel within 72hr?") and read `available_actions[]` for runtime decisions. The array is uniquely keyed by `action` — sellers MUST NOT emit two entries with the same `action` value. Absence means the seller has not declared a structured action surface for this product — buyers fall back to `valid_actions[]` on buy responses for the flat string vocabulary.', min_length=1, ), + NoExplicitNull, ] = None reporting_capabilities: reporting_capabilities_1.ReportingCapabilities - creative_policy: creative_policy_1.CreativePolicy | None = None + creative_policy: Annotated[creative_policy_1.CreativePolicy | None, NoExplicitNull] = None is_custom: Annotated[ StrictBool | None, Field( description='Whether this product is a request-specific configured offer rather than a reusable baseline product. Sellers MUST set true when targeting, disclosed resolution, pricing, forecast assumptions, inventory, or terms are bound for a particular discovery/refinement lineage. Products issued through targeting-aware discovery include expires_at even when exact acceptance omits targeting_resolution. For backward compatibility, is_custom alone does not make expires_at schema-required.' ), + NoExplicitNull, ] = None property_targeting_allowed: Annotated[ StrictBool | None, Field( description="Whether buyers can select a subset of this product's publisher_properties through targeting_overlay.property_list. When false, the product is fixed inventory: it matches requested property targeting only when its inherent property set already satisfies the request, or when a configured product discloses additional inventory through targeting_resolution." ), + NoExplicitNull, ] = False data_provider_signals: Annotated[ list[data_provider_signal_selector.DataProviderSignalSelector] | None, @@ -686,6 +748,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: deprecated=True, description='Deprecated. Legacy/non-selectable metadata for data-provider signals already bundled into or associated with this product. This field does not provide buyer-selectable options, prices, or seller activation handles. Use included_signals for non-selectable product signal metadata, or signal_targeting_options for selectable package-level signal groups.', ), + NoExplicitNull, ] = None included_signals: Annotated[ list[signal_listing.SignalListing] | None, @@ -693,6 +756,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description="Non-selectable signal metadata for signals already included in, bundled with, or planned into this product. These signals describe what the product is; buyers do not select them in packages[].targeting_overlay.signal_targeting_groups and this field does not imply package-level signal targeting. Use signal_ref scope 'data_provider' or 'signal_source' to reference externally defined signals without redefining their name or value_type. Use signal_ref scope 'product' with name and value_type when the included signal is defined only by this product.", min_length=1, ), + NoExplicitNull, ] = None signal_targeting_options: Annotated[ list[product_signal_targeting_option.ProductSignalTargetingOption] | None, @@ -700,50 +764,58 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description="Inline seller-offered signals that may be applied to packages for this product at create_media_buy time. Each entry references a named signal definition with signal_ref scope 'product' for a product-local signal option, scope 'data_provider' for an external signal definition published in adagents.json signals[] that the seller is authorized to apply, or scope 'signal_source' for a source-native signal. Product-local options define name and value_type inline; data-provider and signal-source options may omit those fields when the referenced definition or source is authoritative. Use this field when the selectable menu is product-specific, has product-specific pricing or activation handles, is the relevant subset for a brief/refine result, or should be rendered without an additional get_signals call. Wholesale products may omit this field and rely on get_signals for the selectable signal feed. Buyers select eligible signals through packages[].targeting_overlay.signal_targeting_groups when signal_targeting_rules allow; fixed/default entries are applied by the seller and echoed on the package state. Sellers MUST set signal_targeting_allowed to true whenever this field is present. Bundled, non-selectable signal metadata belongs in included_signals; legacy data_provider_signals may appear only for backwards compatibility.", min_length=1, ), + NoExplicitNull, ] = None signal_targeting_rules: Annotated[ signal_targeting_rules_1.SignalTargetingRules | None, Field( description='Composition rules for selecting signals on this product. The selectable signal menu may come from inline signal_targeting_options or from get_signals when a wholesale product omits inline options. This is product-scoped because products may be backed by different ad servers with different Boolean targeting support and group limits.' ), + NoExplicitNull, ] = None signal_targeting_allowed: Annotated[ StrictBool | None, Field( description='Whether this product has a package-level signal_targeting_groups surface. When false (default), signals are bundled into the product terms and cannot be selected or explicitly echoed as package signal groups. When true, eligible signals from inline signal_targeting_options or from get_signals may be buyer-selected or seller-applied according to signal_targeting_rules and are represented through packages[].targeting_overlay.signal_targeting_groups. Editability is controlled by signal_targeting_rules; fixed/default-only products still set this to true when applied signal groups are echoed.' ), + NoExplicitNull, ] = False demographic_targeting: Annotated[ demographic_targeting_capability.DemographicTargetingCapability | None, Field( description='Exact demographic execution available for this product. Buyers MUST use this product-scoped declaration, not the seller-wide get_adcp_capabilities rollup, to preflight a demographic predicate.' ), + NoExplicitNull, ] = None overlay_support: Annotated[ targeting_overlay_support.TargetingOverlaySupport | None, Field( description='Binding product-scoped targeting dimensions the buyer may set independently on packages after discovery. Presence guarantees selectable capability subject to disclosed limits, not inventory or a forecast for every possible value. Targeting satisfied only through inherent product scope does not appear here. Returned products MUST cover every field requested through get_products.required_overlay_support. A later supported selection with no available inventory returns PRODUCT_UNAVAILABLE on create; an update outside the original priced envelope may return REQUOTE_REQUIRED.' ), + NoExplicitNull, ] = None media_buy_support: Annotated[ media_buy_support_1.ProductMediaBuySupport | None, Field( description='Binding product participation in shared MediaBuy-level controls. This is separate from overlay_support because a root frequency cap aggregates exposures across packages rather than targeting one package. Returned products MUST cover every field requested through required_media_buy_support.' ), + NoExplicitNull, ] = None - identity: product_identity.ProductIdentity | None = None + identity: Annotated[product_identity.ProductIdentity | None, NoExplicitNull] = None execution_requirements: Annotated[ list[product_execution_requirement.ProductExecutionRequirement] | None, Field( description="Experimental (`media_buy.execution_requirements`). Account resources a package on this product needs before `create_media_buy` succeeds. Every entry is required. Account-independent: it does not change `cache_scope` and carries no account resource IDs or names. When present, it plus the `required_connections` of the package's selected format declarations is complete for the kinds in `product-execution-requirement.json`: a seller MUST NOT reject a package that satisfies all of them for lacking an undeclared kind. Absence means undeclared. A declaring seller MUST reject an unmet `event_source` or `catalog` entry on a buyer-supplied `create_media_buy` `packages[]` or `update_media_buy` `new_packages[]` entry, or an update that removes a satisfying binding, with `VALIDATION_ERROR`, `error.field` at the binding, and `error.details` per `error-details/execution-requirement-unmet.json`.", min_length=1, ), + NoExplicitNull, ] = None targeting_resolution: Annotated[ product_targeting_resolution.ProductTargetingResolution | None, Field( description='Discovery-time targeting resolution bound to this configured product. modifications sparsely disclose product-specific differences from get_products.targeting_overlay. Request-level brief interpretation is returned once on GetProductsResponse.targeting_resolution. Exact structured overlay values are not repeated. Selecting product_id accepts the disclosed modifications; product forecast and pricing MUST reflect them.' ), + NoExplicitNull, ] = None audience_evidence: Annotated[ list[audience_evidence_1.AudienceEvidence] | None, @@ -751,6 +823,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Immutable population-level evidence explaining why this inventory may suit an audience. This supports discovery, comparison, and planning only. It does not imply exact demographic targeting, user-level signal membership, or legal-age verification. Sellers MUST publish each distinct snapshot with a new snapshot_id and content_digest.', min_length=1, ), + NoExplicitNull, ] = None audience_evidence_selections: Annotated[ list[audience_evidence_selection.AudienceEvidenceSelection] | None, @@ -758,6 +831,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Exact evidence snapshots that satisfied required eligibility or affected seller ranking for this get_products result. When audience_evidence_requirements was supplied and evidence influenced inclusion or rank, sellers MUST return the relevant selections; an absent-evidence match under evidence_presence when_available has no selection. Product selections use decision_use recommendation or eligibility.', min_length=1, ), + NoExplicitNull, ] = None catalog_types: Annotated[ list[catalog_type.CatalogType] | None, @@ -765,18 +839,21 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Catalog types this product supports for catalog-driven campaigns. A sponsored product listing declares ["product"], a job board declares ["job", "offering"]. Buyers match synced catalogs to products via this field.', min_length=1, ), + NoExplicitNull, ] = None metric_optimization: Annotated[ MetricOptimization | None, Field( description="Metric optimization capabilities for this product. Presence indicates the product supports optimization_goals with kind: 'metric'. No event source or conversion tracking setup required — the seller tracks these metrics natively." ), + NoExplicitNull, ] = None vendor_metric_optimization: Annotated[ vendor_metric_optimization_1.VendorMetricOptimization | None, Field( description="Vendor-attested metric optimization capabilities for this product. Presence indicates the product supports `optimization_goals` with `kind: 'vendor_metric'` — the seller's bidding stack can steer delivery toward a specific vendor's measurement (e.g., DV/IAS/Adelaide attention, Scope3 emissions, Kantar brand lift, retail-media partner metrics). Distinct from `metric_optimization` (seller-native metrics with no vendor binding) and from `reporting_capabilities.vendor_metrics` (which declares what the product can *report* rather than what it can *optimize against*). A product may report a vendor metric without being able to optimize for it. Buyers MUST verify the goal's `(vendor, metric_id)` is in `supported_metrics` AND that the package's `committed_metrics[]` includes a matching `{ scope: 'vendor', vendor, metric_id }` entry — optimization without committed reporting is unverifiable and is rejected at the wire level." ), + NoExplicitNull, ] = None max_optimization_goals: Annotated[ SchemaInt | None, @@ -784,48 +861,56 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Maximum number of optimization_goals this product accepts on a package. When absent, no limit is declared. Most social platforms accept only 1 goal — buyers sending arrays longer than this value should expect the seller to use only the highest-priority (lowest priority number) goal.', ge=1, ), + NoExplicitNull, ] = None measurement_readiness: Annotated[ measurement_readiness_1.MeasurementReadiness | None, Field( description="Assessment of whether the buyer's event source setup is sufficient for this product to optimize effectively. Only present when the seller can evaluate the buyer's account context. Buyers should check this before creating media buys with event-based optimization goals." ), + NoExplicitNull, ] = None conversion_tracking: Annotated[ ConversionTracking | None, Field( description="Conversion event tracking for this product. Presence indicates the product supports optimization_goals with kind: 'event'. Seller-level capabilities (supported event types, UID types, attribution windows) are declared in get_adcp_capabilities." ), + NoExplicitNull, ] = None catalog_match: Annotated[ CatalogMatch | None, Field( description='When the buyer provides a catalog on get_products, indicates which catalog items are eligible for this product. Only present for products where catalog matching is relevant (e.g., sponsored product listings, job boards, hotel ads).' ), + NoExplicitNull, ] = None brief_relevance: Annotated[ str | None, Field( description='Explanation of why this product matches the brief (only included when brief is provided)' ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='Expiration timestamp. Required for request-specific configured products whose targeting resolution, price, forecast, inventory, or terms are time-bound. After this time, a seller that still recognizes the issued configured ID within the authenticated account and referenced discovery/refinement lineage rejects create_media_buy with PRODUCT_EXPIRED and the buyer re-runs get_products. Once the seller no longer retains an expiry tombstone, or whenever the ID belongs to another account or lineage, PRODUCT_NOT_FOUND applies instead; sellers are not required to retain tombstones indefinitely and MUST NOT disclose cross-tenant existence through error choice.' ), + NoExplicitNull, ] = None product_card: Annotated[ ProductCard | None, Field( description='Optional standard visual card for displaying this product in user interfaces (catalog browsers, dashboards, agent UIs). Distinct from `format` — product_card describes the UI rendering of the product itself, not the ad creative the product accepts. Typed inline; no format_id indirection. Receivers render the card directly from these fields.' ), + NoExplicitNull, ] = None product_card_detailed: Annotated[ ProductCardDetailed | None, Field( description='Optional detailed card with hero + carousel + structured specifications, for rich product presentation (media-kit-style pages, full product detail views). Distinct from `format` — describes the UI rendering of the product itself, not the ad creative the product accepts. Typed inline; no format_id indirection.' ), + NoExplicitNull, ] = None collections: Annotated[ list[collection_selector.CollectionSelector] | None, @@ -833,12 +918,14 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Collections available in this product. Each entry references collections declared in an adagents.json by domain and collection ID. Buyers resolve full collection objects from the referenced adagents.json. Product selectors must name explicit collection_ids — the domain-only bulk-grant selector form is for authorization scoping, not product composition.', min_length=1, ), + NoExplicitNull, ] = None collection_targeting_allowed: Annotated[ StrictBool | None, Field( description="Whether buyers can select a subset of this product's collections through targeting_overlay.collection_list or targeting_overlay.collection_selection. When false, the product is a fixed bundle (a collection_selection that exactly restates the complete bundle remains an inherent match); when true, collection selection is a product-scoped overlay capability." ), + NoExplicitNull, ] = False list_applications: Annotated[ list[inventory_list_application.InventoryListApplication] | None, @@ -846,41 +933,47 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: description='Product-scoped receipts for every effective property- or collection-list targeting reference. Sellers MUST return one receipt per application regardless of response field projection; exclusion applications receive a receipt even when summary.matched is zero, while zero matches for any inclusion application make the product ineligible and it is not returned. Each receipt uses the same pre-list product inventory baseline; pricing and forecast reflect inventory remaining after all effective lists are composed.', min_length=1, ), + NoExplicitNull, ] = None installments: Annotated[ list[installment.Installment] | None, Field( description='Specific installments included in this product. Each installment references its parent through canonical collection_ref when the product spans multiple collections or publisher namespaces; collection_id remains a deprecated single-namespace shorthand. When absent with collections present, the product covers the collections broadly (run-of-collection).' ), + NoExplicitNull, ] = None enforced_policies: Annotated[ list[str] | None, Field( description='Registry policy IDs the seller enforces for this product. Enforcement level comes from the policy registry. Buyers can filter products by required policies.' ), + NoExplicitNull, + ] = None + acceptance_policy_profile_ids: Annotated[ + acceptance_policy_profile_ids_1.AcceptancePolicyProfileIds | None, NoExplicitNull ] = None - acceptance_policy_profile_ids: ( - acceptance_policy_profile_ids_1.AcceptancePolicyProfileIds | None - ) = None trusted_match: Annotated[ TrustedMatch | None, Field( description='Trusted Match Protocol capabilities for this product. When present, the product supports real-time contextual and/or identity matching via TMP. Buyers use this to determine what response types the publisher can accept and whether brands can be selected dynamically at match time.' ), + NoExplicitNull, ] = None audience_activation: Annotated[ AudienceActivation | None, Field( description="How buyer audience data can reach this product's targeting. Absence means undeclared — buyers SHOULD treat it as needs-clarification rather than non-support, except under an audience_activation_methods filter, where sellers MUST exclude undeclared products. Declare when the product accepts buyer audiences. Experimental: sellers declaring this MUST list media_buy.audience_activation in experimental_features on get_adcp_capabilities." ), + NoExplicitNull, ] = None material_submission: Annotated[ MaterialSubmission | None, Field( description="Instructions for submitting physical creative materials (print, static OOH, cinema). Present only for products requiring physical delivery outside the digital creative assignment flow. Buyer agents MUST validate url and email domains against the seller's known domains (from adagents.json) before submitting materials. Never auto-submit without human confirmation." ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> Product: diff --git a/src/adcp/types/domains/core/product_allocation.py b/src/adcp/types/domains/core/product_allocation.py index 92e36e431..1fe2e9dfc 100644 --- a/src/adcp/types/domains/core/product_allocation.py +++ b/src/adcp/types/domains/core/product_allocation.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/product_allocation.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..enums import pacing as pacing_1 @@ -28,6 +28,7 @@ class ProductAllocation(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None min_spend_target_percentage: Annotated[ StrictFloat | None, @@ -36,6 +37,7 @@ class ProductAllocation(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None max_spend_percentage: Annotated[ StrictFloat | None, @@ -44,44 +46,52 @@ class ProductAllocation(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Recommended subordinate package pacing. On proposal execution this becomes pacing on the derived package and MUST NOT cause delivery to exceed aggregate proposal/media-buy pacing. On a committed proposal it is a firm delivery term. On a seller-optimized proposal, sellers MUST NOT emit it unless they advertise media_buy.features.seller_optimized_package_pacing.' ), + NoExplicitNull, ] = None pricing_option_id: Annotated[ str | None, Field( description="Selected pricing option ID from the product's pricing_options array. Required when the containing proposal is committed so create_media_buy executes the exact disclosed commercial terms; optional on legacy draft proposals." ), + NoExplicitNull, ] = None rationale: Annotated[ str | None, Field(description='Explanation of why this product and allocation are recommended'), + NoExplicitNull, ] = None sequence: Annotated[ SchemaInt | None, Field(description='Optional ordering hint for multi-line-item plans (1-based)', ge=1), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, Field( description="Categorical tags for this allocation (e.g., 'desktop', 'german', 'mobile') - useful for grouping/filtering allocations by dimension" ), + NoExplicitNull, ] = None start_time: Annotated[ AwareDatetime | None, Field( description='Recommended flight start date/time for this allocation in ISO 8601 format. Allows publishers to propose per-flight scheduling within a proposal. When omitted, the allocation applies to the full campaign date range.' ), + NoExplicitNull, ] = None end_time: Annotated[ AwareDatetime | None, Field( description='Recommended flight end date/time for this allocation in ISO 8601 format. Allows publishers to propose per-flight scheduling within a proposal. When omitted, the allocation applies to the full campaign date range.' ), + NoExplicitNull, ] = None daypart_targets: Annotated[ list[daypart_target.DaypartTarget] | None, @@ -89,9 +99,11 @@ class ProductAllocation(AdCPBaseModel): description="Recommended time windows for this allocation in spot-plan proposals. Each entry's timezone defaults to inventory_local when omitted, and entries MAY use different clocks.", min_length=1, ), + NoExplicitNull, ] = None forecast: Annotated[ delivery_forecast.DeliveryForecast | None, Field(description='Forecasted delivery metrics for this allocation'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/product_allowed_action.py b/src/adcp/types/domains/core/product_allowed_action.py index 3f1c2c29c..5e2e79e00 100644 --- a/src/adcp/types/domains/core/product_allowed_action.py +++ b/src/adcp/types/domains/core/product_allowed_action.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/product_allowed_action.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import media_buy_action_mode, media_buy_status @@ -37,22 +37,26 @@ class ProductAllowedAction(AdCPBaseModel): description='Media buy statuses in which this action is permitted. When absent, the action is permitted in all non-terminal statuses (`pending_creatives`, `pending_start`, `active`, `paused`).', min_length=1, ), + NoExplicitNull, ] = None sla: Annotated[ sla_window.SlaWindow | None, Field( description='Optional SLA commitment for this action on this product. Absence means no commitment.' ), + NoExplicitNull, ] = None constraints: Annotated[ change_term_constraints.MediaBuyChangeTermConstraints | None, Field( description='Optional advisory machine-readable bounds buyers can use during product selection. The proposal must restate any binding bounds in commercial_terms.change_terms[].constraints.' ), + NoExplicitNull, ] = None terms_ref: Annotated[ str | None, Field( description='Optional advisory pointer to published commercial terms governing this product action. It is not a proposal change-term identity and never grants a binding change right; a proposal materializes binding rights under commercial_terms.change_terms[].term_id.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/product_audience_evidence_requirements.py b/src/adcp/types/domains/core/product_audience_evidence_requirements.py index d3421157a..4687cb3e1 100644 --- a/src/adcp/types/domains/core/product_audience_evidence_requirements.py +++ b/src/adcp/types/domains/core/product_audience_evidence_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/product_audience_evidence_requirements.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type @@ -45,6 +45,7 @@ class MaximumAge(Duration): Field( description="Time unit. 'seconds' for sub-minute precision. 'campaign' spans the full campaign flight." ), + NoExplicitNull, ] = None @@ -54,7 +55,7 @@ class AcceptedAttestationIssuers1(AdCPBaseModel): ) type: Literal['brand'] = 'brand' brand: brand_key.BrandKey - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AcceptedAttestationIssuers2(AdCPBaseModel): @@ -63,7 +64,7 @@ class AcceptedAttestationIssuers2(AdCPBaseModel): ) type: Literal['agent'] = 'agent' agent_url: AnyUrl - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AcceptedAttestationIssuers3(AdCPBaseModel): @@ -72,7 +73,7 @@ class AcceptedAttestationIssuers3(AdCPBaseModel): ) type: Literal['origin'] = 'origin' origin: AnyUrl - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None AcceptedAttestationIssuers = Annotated[ @@ -88,28 +89,40 @@ class ProductAudienceEvidenceRequirements(AdCPBaseModel): requirement_mode: RequirementMode evidence_presence: EvidencePresence accepted_methodologies: Annotated[ - list[audience_evidence_methodology.AudienceEvidenceMethodology] | None, Field(min_length=1) + list[audience_evidence_methodology.AudienceEvidenceMethodology] | None, + Field(min_length=1), + NoExplicitNull, ] = None excluded_methodologies: Annotated[ - list[audience_evidence_methodology.AudienceEvidenceMethodology] | None, Field(min_length=1) + list[audience_evidence_methodology.AudienceEvidenceMethodology] | None, + Field(min_length=1), + NoExplicitNull, + ] = None + accepted_evidence_types: Annotated[ + list[AcceptedEvidenceType] | None, Field(min_length=1), NoExplicitNull + ] = None + accepted_providers: Annotated[ + list[brand_key.BrandKey] | None, Field(min_length=1), NoExplicitNull + ] = None + excluded_providers: Annotated[ + list[brand_key.BrandKey] | None, Field(min_length=1), NoExplicitNull ] = None - accepted_evidence_types: Annotated[list[AcceptedEvidenceType] | None, Field(min_length=1)] = ( - None - ) - accepted_providers: Annotated[list[brand_key.BrandKey] | None, Field(min_length=1)] = None - excluded_providers: Annotated[list[brand_key.BrandKey] | None, Field(min_length=1)] = None accepted_subject_types: Annotated[ - list[audience_subject_type.AudienceSubjectType] | None, Field(min_length=1) + list[audience_subject_type.AudienceSubjectType] | None, Field(min_length=1), NoExplicitNull ] = None accepted_resolution_methods: Annotated[ - list[audience_resolution_method.AudienceResolutionMethod] | None, Field(min_length=1) + list[audience_resolution_method.AudienceResolutionMethod] | None, + Field(min_length=1), + NoExplicitNull, ] = None - minimum_confidence: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] = None - maximum_age: MaximumAge | None = None - methodology_documentation_required: StrictBool | None = False - independent_attestation_required: StrictBool | None = False + minimum_confidence: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0), NoExplicitNull] = None + maximum_age: Annotated[MaximumAge | None, NoExplicitNull] = None + methodology_documentation_required: Annotated[StrictBool | None, NoExplicitNull] = False + independent_attestation_required: Annotated[StrictBool | None, NoExplicitNull] = False accepted_attestation_issuers: Annotated[ - list[AcceptedAttestationIssuers] | None, Field(min_length=1) + list[AcceptedAttestationIssuers] | None, Field(min_length=1), NoExplicitNull + ] = None + accepted_attestation_claim_types: Annotated[ + list[AnyUrl] | None, Field(min_length=1), NoExplicitNull ] = None - accepted_attestation_claim_types: Annotated[list[AnyUrl] | None, Field(min_length=1)] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/product_card_reference_asset.py b/src/adcp/types/domains/core/product_card_reference_asset.py index b3586e47e..2249e8301 100644 --- a/src/adcp/types/domains/core/product_card_reference_asset.py +++ b/src/adcp/types/domains/core/product_card_reference_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/product_card_reference_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from .assets import image_asset, markdown_asset, url_asset, video_asset @@ -37,6 +37,7 @@ class ProductCardReferenceAsset(AdCPBaseModel): Field( description="Human-readable label for the asset role. Required when role is 'other'; optional otherwise." ), + NoExplicitNull, ] = None asset: Annotated[ image_asset.ImageAsset @@ -48,5 +49,7 @@ class ProductCardReferenceAsset(AdCPBaseModel): ), ] description: Annotated[ - str | None, Field(description='Optional human-readable context about this asset.') + str | None, + Field(description='Optional human-readable context about this asset.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/product_execution_requirement.py b/src/adcp/types/domains/core/product_execution_requirement.py index 71063d91a..db7c16bcc 100644 --- a/src/adcp/types/domains/core/product_execution_requirement.py +++ b/src/adcp/types/domains/core/product_execution_requirement.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/product_execution_requirement.json -# timestamp: 2026-10-04T00:53:27+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -27,8 +27,9 @@ class ProductExecutionRequirement1(AdCPBaseModel): description='Event types that satisfy the requirement (any-of): the satisfying `event_sources[]` entry MUST use one of these `event_type` values. Omit when any event type satisfies it.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ProductExecutionRequirement2(AdCPBaseModel): @@ -43,7 +44,7 @@ class ProductExecutionRequirement2(AdCPBaseModel): min_length=1, ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Status(StrEnum): @@ -76,6 +77,7 @@ class Connection(DownstreamConnectionRequirement): Field( description='Concrete AdCP protocol operation names that require this downstream connection. Sellers SHOULD include this in product declarations when the requirement is known ahead of time, and in AUTHORIZATION_REQUIRED details when it explains the failed operation. Prefer specific operation names such as `list_creatives`, `sync_creatives`, `create_media_buy`, `get_media_buy_delivery`, or `get_creative_delivery` over broad category labels such as `reporting`.' ), + NoExplicitNull, ] = None @@ -90,7 +92,7 @@ class ProductExecutionRequirement3(AdCPBaseModel): description='The required connection. Account-independent: `status` is omitted or `unknown`; `resource_ref`, `connection_id`, and `expires_at` MUST be omitted; `required_for`, when present, includes `create_media_buy`.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None ProductExecutionRequirement = Annotated[ diff --git a/src/adcp/types/domains/core/product_filters.py b/src/adcp/types/domains/core/product_filters.py index b10a9860f..b96781d40 100644 --- a/src/adcp/types/domains/core/product_filters.py +++ b/src/adcp/types/domains/core/product_filters.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/product_filters.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat @@ -48,8 +48,12 @@ class BudgetRange(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - min: Annotated[StrictFloat | None, Field(description='Minimum budget amount', ge=0.0)] = None - max: Annotated[StrictFloat | None, Field(description='Maximum budget amount', ge=0.0)] = None + min: Annotated[ + StrictFloat | None, Field(description='Minimum budget amount', ge=0.0), NoExplicitNull + ] = None + max: Annotated[ + StrictFloat | None, Field(description='Maximum budget amount', ge=0.0), NoExplicitNull + ] = None currency: Annotated[ str, Field( @@ -88,10 +92,12 @@ class Provider(AdCPBaseModel): context_match: Annotated[ StrictBool | None, Field(description='When true, require this provider to support context match.'), + NoExplicitNull, ] = None identity_match: Annotated[ StrictBool | None, Field(description='When true, require this provider to support identity match.'), + NoExplicitNull, ] = None @@ -119,6 +125,7 @@ class AudienceActivationMethods1(AdCPBaseModel): Field( description='Require this buyer agent on tmp_identity_match entries. Buyers typically filter on their own agent_url.' ), + NoExplicitNull, ] = None @@ -141,12 +148,14 @@ class RequiredGeoTargetingItem(AdCPBaseModel): description='ISO 3166-1 alpha-2 country code. Required for native postal_area system filters; not applicable to country, region, or metro filters.', pattern='^[A-Z]{2}$', ), + NoExplicitNull, ] = None system: Annotated[ str | None, Field( description="Optional classification system within the level. Use for a specific metro system (e.g., 'nielsen_dma'), native postal_area system (e.g., 'zip' with country 'US'), or deprecated legacy postal alias (e.g., 'us_zip'). Not applicable for country/region which use ISO standards." ), + NoExplicitNull, ] = None @@ -161,6 +170,7 @@ class SignalTargetingItem4(AdCPBaseModel): Field( description="Desired use of this signal on the product. 'include' requires the product option to allow use in an any group. 'exclude' requires the product option to allow use in a none group." ), + NoExplicitNull, ] = TargetingMode.include @@ -190,7 +200,9 @@ class Keyword(AdCPBaseModel): extra='forbid', ) keyword: Annotated[str, Field(description='The keyword to target', min_length=1)] - match_type: match_type_1.MatchType | None = match_type_1.MatchType.broad + match_type: Annotated[match_type_1.MatchType | None, NoExplicitNull] = ( + match_type_1.MatchType.broad + ) class TrustedMatch(AdCPBaseModel): @@ -203,6 +215,7 @@ class TrustedMatch(AdCPBaseModel): description='Filter to products with specific TMP providers and match types. Each entry identifies a provider by agent_url and optionally requires specific match capabilities. Products must match at least one entry.', min_length=1, ), + NoExplicitNull, ] = None response_types: Annotated[ list[response_type.TmpResponseType] | None, @@ -210,6 +223,7 @@ class TrustedMatch(AdCPBaseModel): description="Filter to products supporting specific TMP response types (e.g., 'activation', 'creative', 'catalog_items'). Products must support at least one of the listed types.", min_length=1, ), + NoExplicitNull, ] = None @@ -220,6 +234,7 @@ class SignalTargetingItem1(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New targeting constraints SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -227,6 +242,7 @@ class SignalTargetingItem1(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[Literal['binary'], Field(description='Discriminator for binary signals')] = 'binary' value: Annotated[ @@ -244,6 +260,7 @@ class SignalTargetingItem2(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New targeting constraints SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -251,6 +268,7 @@ class SignalTargetingItem2(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[ Literal['categorical'], Field(description='Discriminator for categorical signals') @@ -271,6 +289,7 @@ class SignalTargetingItem3(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New targeting constraints SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -278,6 +297,7 @@ class SignalTargetingItem3(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[ Literal['numeric'], Field(description='Discriminator for numeric signals') @@ -287,12 +307,14 @@ class SignalTargetingItem3(AdCPBaseModel): Field( description="Minimum value (inclusive). Omit for no minimum. Must be <= max_value when both are provided. Should be >= signal's range.min if defined." ), + NoExplicitNull, ] = None max_value: Annotated[ StrictFloat | None, Field( description="Maximum value (inclusive). Omit for no maximum. Must be >= min_value when both are provided. Should be <= signal's range.max if defined." ), + NoExplicitNull, ] = None @@ -302,6 +324,7 @@ class SignalTargetingItem5(SignalTargetingItem1, SignalTargetingItem4): Field( description="Desired use of this signal on the product. 'include' requires the product option to allow use in an any group. 'exclude' requires the product option to allow use in a none group." ), + NoExplicitNull, ] = TargetingMode.include @@ -311,6 +334,7 @@ class SignalTargetingItem6(SignalTargetingItem2, SignalTargetingItem4): Field( description="Desired use of this signal on the product. 'include' requires the product option to allow use in an any group. 'exclude' requires the product option to allow use in a none group." ), + NoExplicitNull, ] = TargetingMode.include @@ -320,6 +344,7 @@ class SignalTargetingItem7(SignalTargetingItem3, SignalTargetingItem4): Field( description="Desired use of this signal on the product. 'include' requires the product option to allow use in an any group. 'exclude' requires the product option to allow use in a none group." ), + NoExplicitNull, ] = TargetingMode.include @@ -341,28 +366,37 @@ class GeoProximityItem(AdCPBaseModel): lat: Annotated[ StrictFloat | None, Field(description='Latitude in decimal degrees (WGS 84)', ge=-90.0, le=90.0), + NoExplicitNull, ] = None lng: Annotated[ StrictFloat | None, Field(description='Longitude in decimal degrees (WGS 84)', ge=-180.0, le=180.0), + NoExplicitNull, ] = None label: Annotated[ str | None, Field(description="Human-readable label (e.g., 'Düsseldorf', 'Heathrow Airport')"), + NoExplicitNull, ] = None travel_time: Annotated[ - TravelTime | None, Field(description='Travel time limit for isochrone calculation') + TravelTime | None, + Field(description='Travel time limit for isochrone calculation'), + NoExplicitNull, ] = None transport_mode: Annotated[ transport_mode_1.TransportMode | None, Field( description='Transportation mode for isochrone calculation. Required when travel_time is provided.' ), + NoExplicitNull, + ] = None + radius: Annotated[ + Radius | None, Field(description='Simple radius from the point'), NoExplicitNull ] = None - radius: Annotated[Radius | None, Field(description='Simple radius from the point')] = None geometry: Annotated[ Geometry | None, Field(description='Pre-computed GeoJSON geometry defining the proximity boundary'), + NoExplicitNull, ] = None @@ -374,6 +408,7 @@ class AudienceActivationMethods2(AdCPBaseModel): transport: Annotated[ cloud_storage_protocol.CloudStorageProtocol | None, Field(description='Require this storage protocol (file_transfer only).'), + NoExplicitNull, ] = None directions: Annotated[ list[Direction] | None, @@ -381,10 +416,12 @@ class AudienceActivationMethods2(AdCPBaseModel): description="Require a non-empty intersection with the method's declared directions (file_transfer only).", min_length=1, ), + NoExplicitNull, ] = None vendor: Annotated[ brand_ref.BrandReference | None, Field(description="Require the method's vendor to match this reference."), + NoExplicitNull, ] = None @@ -396,6 +433,7 @@ class AudienceActivationMethods3(AdCPBaseModel): vendor: Annotated[ brand_ref.BrandReference | None, Field(description="Require the method's vendor to match this reference."), + NoExplicitNull, ] = None @@ -407,6 +445,7 @@ class AudienceActivationMethods4(AdCPBaseModel): vendor: Annotated[ brand_ref.BrandReference | None, Field(description="Require the method's vendor to match this reference."), + NoExplicitNull, ] = None @@ -418,6 +457,7 @@ class AudienceActivationMethods5(AdCPBaseModel): vendor: Annotated[ brand_ref.BrandReference | None, Field(description="Require the method's vendor to match this reference."), + NoExplicitNull, ] = None @@ -430,12 +470,14 @@ class RequiredVendorMetric(AdCPBaseModel): Field( description='Pin to a specific vendor. Optional — omit to match any vendor offering this `metric_id`.' ), + NoExplicitNull, ] = None metric_id: Annotated[ vendor_metric_id.VendorMetricId | None, Field( description="Pin to an exact metric identifier within a vendor's vocabulary. Optional — omit to match any metric from the pinned vendor. Cross-vendor identifier matching only works after vendors converge on identifiers (typically post-standardization)." ), + NoExplicitNull, ] = None @@ -443,18 +485,20 @@ class ProductFilters(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - delivery_type: delivery_type_1.DeliveryType | None = None + delivery_type: Annotated[delivery_type_1.DeliveryType | None, NoExplicitNull] = None exclusivity: Annotated[ exclusivity_1.Exclusivity | None, Field( description="Filter by exclusivity level. Returns products matching the specified exclusivity (e.g., 'exclusive' returns only sole-sponsorship products)." ), + NoExplicitNull, ] = None is_fixed_price: Annotated[ StrictBool | None, Field( description='Legacy filter for fixed versus auction pricing availability. true returns options with fixed_price; false returns auction options whose price is established through bid_price. Contingent options such as revenue_share match neither value and MUST be omitted whenever this filter is present. Use pricing_structures to discover contingent pricing. Products with both fixed and auction options match both true and false, but sellers MUST return only entries matching the requested structure.' ), + NoExplicitNull, ] = None pricing_structures: Annotated[ list[pricing_structure.PricingStructure] | None, @@ -462,6 +506,7 @@ class ProductFilters(AdCPBaseModel): description='Filter by how the payable price is determined. fixed selects options with fixed_price, auction selects options established through bid_price, and contingent selects options calculated from a measured business outcome after delivery (currently revenue_share). Products match when at least one pricing option has a requested structure. Sellers MUST return only matching pricing_options entries. When combined with is_fixed_price, both filters apply and the returned entries must satisfy both.', min_length=1, ), + NoExplicitNull, ] = None pricing_currencies: Annotated[ list[PricingCurrency] | None, @@ -469,6 +514,7 @@ class ProductFilters(AdCPBaseModel): description='Filter by currencies the buyer can use for the media product transaction, using ISO 4217 currency codes. Products match when they offer at least one product-level pricing_options entry in one of the requested currencies and any seller-applied or otherwise mandatory product-scoped signal charges are satisfiable in one of those currencies or have no incremental price. Mandatory custom signal pricing without currency is not satisfiable for this filter unless the seller can truthfully treat it as having no incremental price. Sellers MUST return only product pricing_options entries whose currency is in this list so buyers can select deterministically from discovery. This filter does not require pruning optional signal or vendor add-on pricing; buyers should avoid optional add-ons priced only in unsupported currencies.', min_length=1, ), + NoExplicitNull, ] = None format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -477,6 +523,7 @@ class ProductFilters(AdCPBaseModel): description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Filter by legacy named-format references. Use `format_kinds` or `format_option_refs`.', min_length=1, ), + NoExplicitNull, ] = None format_kinds: Annotated[ list[str] | None, @@ -484,6 +531,7 @@ class ProductFilters(AdCPBaseModel): description='Filter to products accepting any of these canonical format kinds.', min_length=1, ), + NoExplicitNull, ] = None format_option_refs: Annotated[ list[format_option_ref.FormatOptionReference] | None, @@ -491,28 +539,36 @@ class ProductFilters(AdCPBaseModel): description='Filter to products accepting any of these exact publisher- or product-scoped canonical format options.', min_length=1, ), + NoExplicitNull, ] = None standard_formats_only: Annotated[ - StrictBool | None, Field(description='Only return products accepting IAB standard formats') + StrictBool | None, + Field(description='Only return products accepting IAB standard formats'), + NoExplicitNull, ] = None min_exposures: Annotated[ SchemaInt | None, Field(description='Minimum exposures/impressions needed for measurement validity', ge=1), + NoExplicitNull, ] = None start_date: Annotated[ date | None, Field( description='Campaign start date (ISO 8601 date format: YYYY-MM-DD) for availability checks' ), + NoExplicitNull, ] = None end_date: Annotated[ date | None, Field( description='Campaign end date (ISO 8601 date format: YYYY-MM-DD) for availability checks' ), + NoExplicitNull, ] = None budget_range: Annotated[ - BudgetRange | None, Field(description='Budget range to filter appropriate products') + BudgetRange | None, + Field(description='Budget range to filter appropriate products'), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -521,6 +577,7 @@ class ProductFilters(AdCPBaseModel): description='DEPRECATED legacy coverage filter. On compact discovery tasks use criteria.offer_filters.countries. Return products whose inventory covers at least one requested area; this does not impose delivery targeting or require selectable targeting support. Retained get_products handlers MUST preserve the original coverage predicate.', min_length=1, ), + NoExplicitNull, ] = None regions: Annotated[ list[Region] | None, @@ -529,6 +586,7 @@ class ProductFilters(AdCPBaseModel): description='DEPRECATED legacy coverage filter. On compact discovery tasks use criteria.offer_filters.regions. Return products whose inventory covers at least one requested area; this does not impose delivery targeting or require selectable targeting support. Retained get_products handlers MUST preserve the original coverage predicate.', min_length=1, ), + NoExplicitNull, ] = None metros: Annotated[ list[Metro] | None, @@ -537,6 +595,7 @@ class ProductFilters(AdCPBaseModel): description='DEPRECATED legacy coverage filter. On compact discovery tasks use criteria.offer_filters.metros. Return products whose inventory covers at least one requested area; this does not impose delivery targeting or require selectable targeting support. Retained get_products handlers MUST preserve the original coverage predicate.', min_length=1, ), + NoExplicitNull, ] = None channels: Annotated[ list[channels_1.MediaChannel] | None, @@ -544,6 +603,7 @@ class ProductFilters(AdCPBaseModel): description="Filter by advertising channels (e.g., ['display', 'ctv', 'dooh'])", min_length=1, ), + NoExplicitNull, ] = None video_placement_types: Annotated[ list[video_placement_type.VideoPlacementType] | None, @@ -551,6 +611,7 @@ class ProductFilters(AdCPBaseModel): description='Filter product metadata by declared video placement types, using IAB Tech Lab/OpenRTB 2.6 video.plcmt definitions with AdCP-native names. A product matches when its declared array intersects the requested array. This is discovery classification only and does not promise delivery exclusively on a requested type; buyers needing exact placement inventory use targeting_overlay.placement_selection against targetable placements. This filter has set semantics for wholesale feed canonicalization.', min_length=1, ), + NoExplicitNull, ] = None audio_distribution_types: Annotated[ list[audio_distribution_type.AudioDistributionType] | None, @@ -558,6 +619,7 @@ class ProductFilters(AdCPBaseModel): description='Filter product metadata by declared audio distribution types, using IAB Tech Lab/OpenRTB 2.6 audio.feed definitions with AdCP-native names. A product matches when its declared array intersects the requested array. This is discovery classification only and does not promise delivery exclusively on a requested type. This filter has set semantics for wholesale feed canonicalization.', min_length=1, ), + NoExplicitNull, ] = None sponsored_placement_types: Annotated[ list[sponsored_placement_type.SponsoredPlacementType] | None, @@ -565,6 +627,7 @@ class ProductFilters(AdCPBaseModel): description='Filter retail-media product metadata by declared sponsored-placement types (sponsored search, sponsored display, or sponsored native). A product matches when its declared array intersects the requested array. This is discovery classification only and does not promise delivery exclusively on a requested type. This filter has set semantics for wholesale feed canonicalization.', min_length=1, ), + NoExplicitNull, ] = None social_placement_surfaces: Annotated[ list[social_placement_surface.SocialPlacementSurface] | None, @@ -572,6 +635,7 @@ class ProductFilters(AdCPBaseModel): description='Filter social-product metadata by declared placement surfaces (feed, stories, short_video, explore, or search). A product matches when its declared array intersects the requested array. This is discovery classification only and does not promise delivery exclusively on a requested surface; buyers needing an exact public placement use targeting_overlay.placement_selection. This filter has set semantics for wholesale feed canonicalization.', min_length=1, ), + NoExplicitNull, ] = None required_axe_integrations: Annotated[ list[AnyUrl] | None, @@ -580,12 +644,14 @@ class ProductFilters(AdCPBaseModel): description='Deprecated: Use trusted_match filter instead. Filter to products executable through specific agentic ad exchanges. URLs are canonical identifiers.', min_length=1, ), + NoExplicitNull, ] = None trusted_match: Annotated[ TrustedMatch | None, Field( description='Filter products by Trusted Match Protocol capabilities. Only products with matching TMP support are returned.' ), + NoExplicitNull, ] = None audience_activation_methods: Annotated[ list[ @@ -601,12 +667,14 @@ class ProductFilters(AdCPBaseModel): description="Filter to products whose audience_activation.methods matches at least one requested entry (OR across entries). Within an entry every specified field must match (AND); omitted optional fields are wildcards; directions matches on non-empty intersection, and a method that omits directions matches any requested directions; vendor matches on domain, plus brand_id when specified. Fields inapplicable to the requested pattern (e.g. transport on a clean_room entry) make that entry unsatisfiable. Sellers MUST exclude products with no audience_activation declaration when this filter is present; exclusions MAY be reported via filter_diagnostics.excluded_by. Experimental: part of the media_buy.audience_activation surface — buyers SHOULD check the seller's experimental_features before filtering on it; sellers that do not list the feature ignore this filter.", min_length=1, ), + NoExplicitNull, ] = None required_features: Annotated[ media_buy_features.MediaBuyFeatures | None, Field( description='Filter to products from sellers supporting specific protocol features. Only features set to true are used for filtering.' ), + NoExplicitNull, ] = None required_geo_targeting: Annotated[ list[RequiredGeoTargetingItem] | None, @@ -615,6 +683,7 @@ class ProductFilters(AdCPBaseModel): description='DEPRECATED. Use get_products.required_overlay_support, which is product-scoped and applies consistently to geographic and non-geographic targeting dimensions.', min_length=1, ), + NoExplicitNull, ] = None signal_targeting: Annotated[ list[SignalTargetingItem] | None, @@ -623,6 +692,7 @@ class ProductFilters(AdCPBaseModel): description='DEPRECATED legacy signal-option eligibility filter. Retained get_products handlers MUST preserve the signal identity, value predicate, and requested include/exclude capability. This does not activate delivery targeting. Native buyers use criteria.targeting_overlay.signal_targeting_groups only for concrete delivery selections, preserving exclusion through group operators; copying targeting_mode into targeting_overlay.signal_targeting does not preserve it.', min_length=1, ), + NoExplicitNull, ] = None postal_areas: Annotated[ list[postal_area.PostalArea] | None, @@ -631,6 +701,7 @@ class ProductFilters(AdCPBaseModel): description='DEPRECATED legacy coverage filter. On compact discovery tasks use criteria.offer_filters.postal_areas. Return products whose inventory covers at least one requested area; this does not impose delivery targeting or require selectable targeting support. Retained get_products handlers MUST preserve the original coverage predicate.', min_length=1, ), + NoExplicitNull, ] = None geo_proximity: Annotated[ list[GeoProximityItem] | None, @@ -639,6 +710,7 @@ class ProductFilters(AdCPBaseModel): description='DEPRECATED legacy coverage filter. On compact discovery tasks use criteria.offer_filters.geo_proximity. Return products whose inventory covers at least one requested area; this does not impose delivery targeting or require selectable targeting support. Retained get_products handlers MUST preserve the original coverage predicate.', min_length=1, ), + NoExplicitNull, ] = None required_performance_standards: Annotated[ list[performance_standard.PerformanceStandard] | None, @@ -646,6 +718,7 @@ class ProductFilters(AdCPBaseModel): description="Filter to products that can meet the buyer's performance standard requirements. Each entry specifies a metric, minimum threshold, and optionally a required vendor and standard. Products that cannot meet these thresholds or do not support the specified vendors are excluded. Use this to tell the seller upfront: 'I need DoubleVerify for viewability at 70% MRC.'", min_length=1, ), + NoExplicitNull, ] = None required_metrics: Annotated[ list[available_metric.AvailableMetric] | None, @@ -658,6 +731,7 @@ class ProductFilters(AdCPBaseModel): ], min_length=1, ), + NoExplicitNull, ] = None required_vendor_metrics: Annotated[ list[RequiredVendorMetric] | None, @@ -678,6 +752,7 @@ class ProductFilters(AdCPBaseModel): ], min_length=1, ), + NoExplicitNull, ] = None keywords: Annotated[ list[Keyword] | None, @@ -686,16 +761,19 @@ class ProductFilters(AdCPBaseModel): description='DEPRECATED legacy product eligibility filter. Retained get_products handlers MUST preserve the requested keyword eligibility and match_type (default broad). This is not an instruction to add package keyword targeting. Native buyers use criteria.targeting_overlay.keyword_targets only when they intend a delivery constraint, or criteria.required_overlay_support.keyword_targets for future selectability.', min_length=1, ), + NoExplicitNull, ] = None audience_evidence_requirements: Annotated[ audience_evidence_requirements_1.AudienceEvidenceRequirements | None, Field( description='Buyer policy for evaluating Product.audience_evidence. In required mode, sellers MUST apply the evidence_presence and admissibility semantics and exclude non-matching products; they MUST NOT ignore an unsupported hard requirement. In preferred mode, sellers use matches for ranking and explain the evidence selected. Buyers SHOULD inspect media_buy.audience_evidence capabilities before sending this object.' ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Vendor-namespaced extension parameters for seller-specific filter criteria not covered by standard fields. Keys MUST be namespaced under a vendor or platform key (e.g., ext.gam, ext.platform_x). Sellers MUST treat all values as untrusted buyer input; do not interpolate into LLM prompts, SQL queries, or system commands without sanitization. Persistent use of an extension key across multiple buyers is a signal to propose standardization.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/product_format_declaration.py b/src/adcp/types/domains/core/product_format_declaration.py index 7f0f65645..47f37d788 100644 --- a/src/adcp/types/domains/core/product_format_declaration.py +++ b/src/adcp/types/domains/core/product_format_declaration.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/product_format_declaration.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, Field, StrictBool from ..enums import channels @@ -44,6 +44,7 @@ class ProductFormatDeclaration1(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -51,12 +52,14 @@ class ProductFormatDeclaration1(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -64,60 +67,70 @@ class ProductFormatDeclaration1(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -125,12 +138,14 @@ class ProductFormatDeclaration1(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['image'] = 'image' params: image.CanonicalFormatImage @@ -142,6 +157,7 @@ class ProductFormatDeclaration2(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -149,12 +165,14 @@ class ProductFormatDeclaration2(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -162,60 +180,70 @@ class ProductFormatDeclaration2(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -223,12 +251,14 @@ class ProductFormatDeclaration2(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['html5'] = 'html5' params: html5.CanonicalFormatHtml5Banner @@ -240,6 +270,7 @@ class ProductFormatDeclaration3(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -247,12 +278,14 @@ class ProductFormatDeclaration3(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -260,60 +293,70 @@ class ProductFormatDeclaration3(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -321,12 +364,14 @@ class ProductFormatDeclaration3(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['display_tag'] = 'display_tag' params: display_tag.CanonicalFormatDisplayTag @@ -338,6 +383,7 @@ class ProductFormatDeclaration4(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -345,12 +391,14 @@ class ProductFormatDeclaration4(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -358,60 +406,70 @@ class ProductFormatDeclaration4(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -419,12 +477,14 @@ class ProductFormatDeclaration4(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['image_carousel'] = 'image_carousel' params: image_carousel.CanonicalFormatImageCarousel @@ -436,6 +496,7 @@ class ProductFormatDeclaration5(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -443,12 +504,14 @@ class ProductFormatDeclaration5(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -456,60 +519,70 @@ class ProductFormatDeclaration5(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -517,12 +590,14 @@ class ProductFormatDeclaration5(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['video_hosted'] = 'video_hosted' params: video_hosted.CanonicalFormatHostedVideo @@ -534,6 +609,7 @@ class ProductFormatDeclaration6(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -541,12 +617,14 @@ class ProductFormatDeclaration6(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -554,60 +632,70 @@ class ProductFormatDeclaration6(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -615,12 +703,14 @@ class ProductFormatDeclaration6(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['video_vast'] = 'video_vast' params: video_vast.CanonicalFormatVastVideo @@ -632,6 +722,7 @@ class ProductFormatDeclaration7(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -639,12 +730,14 @@ class ProductFormatDeclaration7(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -652,60 +745,70 @@ class ProductFormatDeclaration7(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -713,12 +816,14 @@ class ProductFormatDeclaration7(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_hosted'] = 'audio_hosted' params: audio_hosted.CanonicalFormatHostedAudio @@ -730,6 +835,7 @@ class ProductFormatDeclaration8(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -737,12 +843,14 @@ class ProductFormatDeclaration8(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -750,60 +858,70 @@ class ProductFormatDeclaration8(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -811,12 +929,14 @@ class ProductFormatDeclaration8(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_vast'] = 'audio_vast' params: audio_vast.CanonicalFormatVastAudio @@ -828,6 +948,7 @@ class ProductFormatDeclaration9(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -835,12 +956,14 @@ class ProductFormatDeclaration9(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -848,60 +971,70 @@ class ProductFormatDeclaration9(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -909,12 +1042,14 @@ class ProductFormatDeclaration9(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_daast'] = 'audio_daast' params: audio_daast.CanonicalFormatDaastAudio @@ -926,6 +1061,7 @@ class ProductFormatDeclaration10(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -933,12 +1069,14 @@ class ProductFormatDeclaration10(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -946,60 +1084,70 @@ class ProductFormatDeclaration10(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1007,12 +1155,14 @@ class ProductFormatDeclaration10(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['sponsored_placement'] = 'sponsored_placement' params: sponsored_placement.CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven @@ -1024,6 +1174,7 @@ class ProductFormatDeclaration11(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1031,12 +1182,14 @@ class ProductFormatDeclaration11(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1044,60 +1197,70 @@ class ProductFormatDeclaration11(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1105,12 +1268,14 @@ class ProductFormatDeclaration11(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['native_in_feed'] = 'native_in_feed' params: native_in_feed.CanonicalFormatNativeInFeed @@ -1122,6 +1287,7 @@ class ProductFormatDeclaration12(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1129,12 +1295,14 @@ class ProductFormatDeclaration12(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1142,60 +1310,70 @@ class ProductFormatDeclaration12(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1203,12 +1381,14 @@ class ProductFormatDeclaration12(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['responsive_creative'] = 'responsive_creative' params: responsive_creative.CanonicalFormatResponsiveCreative @@ -1220,6 +1400,7 @@ class ProductFormatDeclaration13(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1227,12 +1408,14 @@ class ProductFormatDeclaration13(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1240,60 +1423,70 @@ class ProductFormatDeclaration13(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1301,12 +1494,14 @@ class ProductFormatDeclaration13(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['agent_placement'] = 'agent_placement' params: agent_placement.CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement @@ -1318,6 +1513,7 @@ class ProductFormatDeclaration14(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1325,12 +1521,14 @@ class ProductFormatDeclaration14(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1338,60 +1536,70 @@ class ProductFormatDeclaration14(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1399,12 +1607,14 @@ class ProductFormatDeclaration14(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['seller_rendered_stateful_display'] = 'seller_rendered_stateful_display' params: seller_rendered_stateful_display.CanonicalFormatSellerRenderedStatefulDisplay @@ -1416,6 +1626,7 @@ class ProductFormatDeclaration15(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1423,12 +1634,14 @@ class ProductFormatDeclaration15(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1436,60 +1649,70 @@ class ProductFormatDeclaration15(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1497,12 +1720,14 @@ class ProductFormatDeclaration15(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['coordinated_placements'] = 'coordinated_placements' params: coordinated_placements.CanonicalFormatCoordinatedPlacements @@ -1514,6 +1739,7 @@ class ProductFormatDeclaration16(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1521,12 +1747,14 @@ class ProductFormatDeclaration16(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1534,60 +1762,70 @@ class ProductFormatDeclaration16(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1595,12 +1833,14 @@ class ProductFormatDeclaration16(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['custom'] = 'custom' params: Annotated[ diff --git a/src/adcp/types/domains/core/product_identity.py b/src/adcp/types/domains/core/product_identity.py index 1741ddb12..9f5164981 100644 --- a/src/adcp/types/domains/core/product_identity.py +++ b/src/adcp/types/domains/core/product_identity.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/product_identity.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool @@ -26,4 +26,5 @@ class ProductIdentity(AdCPBaseModel): description="Human-readable methodology for this product's custom reach unit. Required when the product reports reach or frequency with reach_unit custom, including frequency-only reporting.", min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/product_offer_filters.py b/src/adcp/types/domains/core/product_offer_filters.py index da9a509ab..0227ced72 100644 --- a/src/adcp/types/domains/core/product_offer_filters.py +++ b/src/adcp/types/domains/core/product_offer_filters.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/product_offer_filters.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat @@ -114,8 +114,8 @@ class Provider(AdCPBaseModel): extra='allow', ) agent_url: AnyUrl - context_match: StrictBool | None = None - identity_match: StrictBool | None = None + context_match: Annotated[StrictBool | None, NoExplicitNull] = None + identity_match: Annotated[StrictBool | None, NoExplicitNull] = None class TravelTime(AdCPBaseModel): @@ -137,6 +137,7 @@ class GeoProximityItem(AdCPBaseModel): ge=-90.0, le=90.0, ), + NoExplicitNull, ] = None lng: Annotated[ StrictFloat | None, @@ -145,48 +146,54 @@ class GeoProximityItem(AdCPBaseModel): ge=-180.0, le=180.0, ), + NoExplicitNull, ] = None label: Annotated[ str | None, Field( description="Human-readable label for this entry (e.g., 'Düsseldorf', 'Heathrow Airport', 'Primary trade area')." ), + NoExplicitNull, ] = None travel_time: Annotated[ TravelTime | None, Field( description='Travel time limit for isochrone calculation. The platform resolves this to a geographic boundary based on actual transportation networks.' ), + NoExplicitNull, ] = None transport_mode: Annotated[ transport_mode_1.TransportMode | None, Field( description='Transportation mode for isochrone calculation. Required when travel_time is provided.' ), + NoExplicitNull, ] = None radius: Annotated[ Radius | None, Field( description='Simple radius from the point. The platform draws a circle of this distance around the coordinates.' ), + NoExplicitNull, ] = None geometry: Annotated[ Geometry | None, Field( description='Pre-computed GeoJSON geometry defining the proximity boundary. Use when the buyer has already calculated isochrones (via TravelTime, Mapbox, etc.) or has custom boundaries. When geometry is provided, lat/lng are not required.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class TrustedMatch(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - providers: Annotated[list[Provider] | None, Field(min_length=1)] = None - response_types: Annotated[list[response_type.TmpResponseType] | None, Field(min_length=1)] = ( - None - ) + providers: Annotated[list[Provider] | None, Field(min_length=1), NoExplicitNull] = None + response_types: Annotated[ + list[response_type.TmpResponseType] | None, Field(min_length=1), NoExplicitNull + ] = None class RequiredPerformanceStandard(AdCPBaseModel): @@ -195,7 +202,7 @@ class RequiredPerformanceStandard(AdCPBaseModel): ) metric: performance_standard_metric.PerformanceStandardMetric threshold: Annotated[StrictFloat, Field(ge=0.0, le=1.0)] - standard: viewability_standard.ViewabilityStandard | None = None + standard: Annotated[viewability_standard.ViewabilityStandard | None, NoExplicitNull] = None vendor: brand_key.BrandKey @@ -203,57 +210,64 @@ class RequiredVendorMetric(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - vendor: brand_key.BrandKey | None = None - metric_id: vendor_metric_id.VendorMetricId | None = None + vendor: Annotated[brand_key.BrandKey | None, NoExplicitNull] = None + metric_id: Annotated[vendor_metric_id.VendorMetricId | None, NoExplicitNull] = None class ProductOfferFilters(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - delivery_type: delivery_type_1.DeliveryType | None = None - exclusivity: exclusivity_1.Exclusivity | None = None + delivery_type: Annotated[delivery_type_1.DeliveryType | None, NoExplicitNull] = None + exclusivity: Annotated[exclusivity_1.Exclusivity | None, NoExplicitNull] = None is_fixed_price: Annotated[ StrictBool | None, Field( description='Filter fixed-price versus auction offers. Contingent pricing matches neither value.' ), + NoExplicitNull, ] = None pricing_structures: Annotated[ - list[pricing_structure.PricingStructure] | None, Field(min_length=1) + list[pricing_structure.PricingStructure] | None, Field(min_length=1), NoExplicitNull ] = None - pricing_currencies: Annotated[list[PricingCurrency] | None, Field(min_length=1)] = None - format_kinds: Annotated[list[str] | None, Field(min_length=1)] = None + pricing_currencies: Annotated[ + list[PricingCurrency] | None, Field(min_length=1), NoExplicitNull + ] = None + format_kinds: Annotated[list[str] | None, Field(min_length=1), NoExplicitNull] = None format_option_refs: Annotated[ - list[format_option_ref.FormatOptionReference] | None, Field(min_length=1) + list[format_option_ref.FormatOptionReference] | None, Field(min_length=1), NoExplicitNull ] = None - standard_formats_only: StrictBool | None = None - min_exposures: Annotated[SchemaInt | None, Field(ge=1)] = None + standard_formats_only: Annotated[StrictBool | None, NoExplicitNull] = None + min_exposures: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None start_date: Annotated[ date | None, Field( description='Fixed-flight availability filter: with end_date, declares the exact flight the buyer intends to run. Returned products MUST be able to serve that flight, and pricing and forecasts are scoped to it. Mutually exclusive with availability_horizon.' ), + NoExplicitNull, ] = None end_date: Annotated[ date | None, Field( description='Fixed-flight availability filter end. See start_date. Mutually exclusive with availability_horizon.' ), + NoExplicitNull, ] = None availability_horizon: Annotated[ AvailabilityHorizon | None, Field( description="Flexible-window availability discovery: the buyer is open to any bookable window inside [start_time, end_time) and asks the seller to describe when the returned inventory can run, instead of filtering to one exact flight. Sellers that support this field partition the horizon into time-dimensioned forecast rows (forecast-dimension-time) carrying availability_status; sellers that cannot cover the full horizon signal the gap via the response's incomplete[] mechanism. Unlike start_date/end_date this is not an eligibility filter — products remain returnable when only part of the horizon is open. The resulting availability is a snapshot bounded by the forecast's valid_until, never a hold. Mutually exclusive with start_date and end_date, which declare a fixed flight; buyers that already know their dates use those instead." ), + NoExplicitNull, ] = None - budget_range: budget_range_1.BudgetRange | None = None + budget_range: Annotated[budget_range_1.BudgetRange | None, NoExplicitNull] = None countries: Annotated[ list[Country] | None, Field( description="Filter by country coverage using ISO 3166-1 alpha-2 codes (e.g., ['US', 'CA', 'GB']). Returns products whose geographic coverage includes at least one of the specified countries. This is a product attribute filter, not a delivery-targeting instruction.", min_length=1, ), + NoExplicitNull, ] = None regions: Annotated[ list[Region] | None, @@ -261,6 +275,7 @@ class ProductOfferFilters(AdCPBaseModel): description='Inventory coverage in the specified ISO 3166-2 subdivisions: OR within this field, AND across offer filters. Does not add delivery targeting, require targeting support, or rescope pricing or forecasts.', min_length=1, ), + NoExplicitNull, ] = None metros: Annotated[ list[Metro] | None, @@ -268,6 +283,7 @@ class ProductOfferFilters(AdCPBaseModel): description='Inventory coverage in the specified metro areas (system and code): OR within this field, AND across offer filters. Does not add delivery targeting, require targeting support, or rescope pricing or forecasts.', min_length=1, ), + NoExplicitNull, ] = None postal_areas: Annotated[ list[postal_area.PostalArea] | None, @@ -275,6 +291,7 @@ class ProductOfferFilters(AdCPBaseModel): description='Inventory coverage in the specified postal areas: OR within this field, AND across offer filters. Does not add delivery targeting, require targeting support, or rescope pricing or forecasts.', min_length=1, ), + NoExplicitNull, ] = None geo_proximity: Annotated[ list[GeoProximityItem] | None, @@ -282,41 +299,53 @@ class ProductOfferFilters(AdCPBaseModel): description='Inventory coverage in the specified proximity boundaries: OR within this field, AND across offer filters. Does not add delivery targeting, require targeting support, or rescope pricing or forecasts.', min_length=1, ), + NoExplicitNull, ] = None property_list: Annotated[ property_list_ref.PropertyListReference | None, Field( description='Reference to an externally managed property list. When provided, the seller filters products to only those available on properties in the list. This narrows which publisher inventory is returned; it is a product attribute filter, not a delivery-targeting instruction.' ), + NoExplicitNull, + ] = None + channels: Annotated[ + list[channels_1.MediaChannel] | None, Field(min_length=1), NoExplicitNull ] = None - channels: Annotated[list[channels_1.MediaChannel] | None, Field(min_length=1)] = None video_placement_types: Annotated[ - list[video_placement_type.VideoPlacementType] | None, Field(min_length=1) + list[video_placement_type.VideoPlacementType] | None, Field(min_length=1), NoExplicitNull ] = None audio_distribution_types: Annotated[ - list[audio_distribution_type.AudioDistributionType] | None, Field(min_length=1) + list[audio_distribution_type.AudioDistributionType] | None, + Field(min_length=1), + NoExplicitNull, ] = None sponsored_placement_types: Annotated[ - list[sponsored_placement_type.SponsoredPlacementType] | None, Field(min_length=1) + list[sponsored_placement_type.SponsoredPlacementType] | None, + Field(min_length=1), + NoExplicitNull, ] = None social_placement_surfaces: Annotated[ - list[social_placement_surface.SocialPlacementSurface] | None, Field(min_length=1) + list[social_placement_surface.SocialPlacementSurface] | None, + Field(min_length=1), + NoExplicitNull, ] = None - trusted_match: TrustedMatch | None = None + trusted_match: Annotated[TrustedMatch | None, NoExplicitNull] = None required_features: Annotated[ canonical_media_buy_features.CanonicalMediaBuyFeatures | None, Field(description='Canonical protocol features the seller must support.'), + NoExplicitNull, ] = None required_performance_standards: Annotated[ - list[RequiredPerformanceStandard] | None, Field(min_length=1) + list[RequiredPerformanceStandard] | None, Field(min_length=1), NoExplicitNull ] = None required_metrics: Annotated[ - list[available_metric.AvailableMetric] | None, Field(min_length=1) + list[available_metric.AvailableMetric] | None, Field(min_length=1), NoExplicitNull ] = None - required_vendor_metrics: Annotated[list[RequiredVendorMetric] | None, Field(min_length=1)] = ( - None - ) - audience_evidence_requirements: ( - product_audience_evidence_requirements.ProductAudienceEvidenceRequirements | None - ) = None - ext: ext_1.ExtensionObject | None = None + required_vendor_metrics: Annotated[ + list[RequiredVendorMetric] | None, Field(min_length=1), NoExplicitNull + ] = None + audience_evidence_requirements: Annotated[ + product_audience_evidence_requirements.ProductAudienceEvidenceRequirements | None, + NoExplicitNull, + ] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/product_signal_targeting_option.py b/src/adcp/types/domains/core/product_signal_targeting_option.py index fe0551364..ca76f3938 100644 --- a/src/adcp/types/domains/core/product_signal_targeting_option.py +++ b/src/adcp/types/domains/core/product_signal_targeting_option.py @@ -1,12 +1,13 @@ # generated by datamodel-codegen: # filename: core/product_signal_targeting_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from . import signal_ref, vendor_pricing_option @@ -32,12 +33,14 @@ class ProductSignalTargetingOption(SignalListing): Field( description='Optional opaque resolved-segment or seller execution handle for this signal. Omit when signal_ref plus the value expression is sufficient for the seller to resolve the signal. Include when the seller exposes a distinct runtime or activation handle that buyers must echo in packages[].targeting_overlay.signal_targeting_groups.groups[].signals[].signal_agent_segment_id. Buyers SHOULD echo this handle verbatim rather than reconstructing identity from categorical values; providers MAY namespace handles so cross-provider identity stays legible without a shared taxonomy registry.' ), + NoExplicitNull, ] = None activation_status: Annotated[ ActivationStatus | None, Field( description="Whether this signal option is ready to select on create_media_buy for the requesting account. 'ready' means the buyer can select it directly. 'requires_activation' means the buyer must activate the signal first or include an activation_key the seller accepts." ), + NoExplicitNull, ] = ActivationStatus.ready allowed_targeting_modes: Annotated[ list[AllowedTargetingMode] | None, @@ -45,18 +48,21 @@ class ProductSignalTargetingOption(SignalListing): description="How this signal may be used when composing package-level signal targeting groups. 'include' means the signal may appear in an 'any' child group. 'exclude' means the signal may appear in a 'none' child group. Omit when the signal is include-only. This field declares the allowed buy-time group operator; binary package signal entries still use value=true in both include and exclude groups.", min_length=1, ), + NoExplicitNull, ] = [AllowedTargetingMode.include] default_selected: Annotated[ StrictBool | None, Field( description="Whether the seller recommends or preselects this signal when composing this product. Buyers may remove it unless signal_targeting_rules.selection_mode is 'fixed'. When selection_mode is 'fixed', sellers apply default_selected signals even if the buyer omits signal_targeting_groups and MUST echo the applied entries on the resulting package state." ), + NoExplicitNull, ] = False selection_group: Annotated[ str | None, Field( description='Optional product-defined composability bucket for signal options, such as alternative audience tiers, a key-value targeting plane, or an audience-segment targeting plane. Signals in the same selection_group are expected to be OR-combinable inside one child group for a given targeting mode, subject to signal_targeting_rules. Use different selection_group values when the product requires separate ANDed clauses, such as signal sets backed by different platform targeting primitives that cannot be collapsed into one child group. selection_group is a product-option grouping key, not a reference to one child object in packages[].targeting_overlay.signal_targeting_groups.groups[]. Sellers can use signal_targeting_rules.max_selected_per_group and signal_targeting_rules.selection_group_rules with selection_group to guide and validate storefront composition.' ), + NoExplicitNull, ] = None pricing_options: Annotated[ list[vendor_pricing_option.VendorPricingOption] | None, @@ -64,6 +70,7 @@ class ProductSignalTargetingOption(SignalListing): description='Signal pricing options available when this signal is selected on this product. Product-scoped pricing is authoritative for this product; if get_signals exposes a different default rate card, use this product-scoped price when composing the buy. Buyers pass the selected pricing_option_id in packages[].targeting_overlay.signal_targeting_groups.groups[].signals[].pricing_option_id. Omit when the signal is bundled into the product price or has no incremental cost.', min_length=1, ), + NoExplicitNull, ] = None signal_ref: Annotated[ signal_ref.SignalRef, diff --git a/src/adcp/types/domains/core/product_targeting_resolution.py b/src/adcp/types/domains/core/product_targeting_resolution.py index 43d742ffc..1c0819610 100644 --- a/src/adcp/types/domains/core/product_targeting_resolution.py +++ b/src/adcp/types/domains/core/product_targeting_resolution.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/product_targeting_resolution.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -30,5 +30,6 @@ class ProductTargetingResolution(AdCPBaseModel): description='Optional digest of the canonical effective targeting overlay. Until AdCP defines a cross-implementation canonicalization algorithm, this value is an opaque seller audit handle and buyers MUST NOT attempt to reproduce or compare it across sellers.', pattern='^sha256:[0-9a-f]{64}$', ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/property.py b/src/adcp/types/domains/core/property.py index 76dbc6284..073d4f3b3 100644 --- a/src/adcp/types/domains/core/property.py +++ b/src/adcp/types/domains/core/property.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/property.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import channels, identifier_types @@ -40,6 +40,7 @@ class Property(AdCPBaseModel): Field( description='Unique identifier for this property (optional). Enables referencing properties by ID instead of repeating full objects.' ), + NoExplicitNull, ] = None property_type: Annotated[ property_type_1.PropertyType, Field(description='Type of advertising property') @@ -53,16 +54,19 @@ class Property(AdCPBaseModel): Field( description='Tags for categorization and grouping (e.g., network membership, content categories)' ), + NoExplicitNull, ] = None supported_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Advertising channels this property supports (e.g., ['display', 'olv', 'social']). Publishers declare which channels their inventory aligns with. Properties may support multiple channels. See the Media Channel Taxonomy for definitions." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, Field( description='Domain where adagents.json should be checked for authorization validation. Optional in adagents.json (file location implies domain).' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/property_delivery_metrics.py b/src/adcp/types/domains/core/property_delivery_metrics.py index c6987ce92..66782be00 100644 --- a/src/adcp/types/domains/core/property_delivery_metrics.py +++ b/src/adcp/types/domains/core/property_delivery_metrics.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/property_delivery_metrics.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any +from adcp.types.base import NoExplicitNull from pydantic import Field from . import identifier as identifier_1 @@ -32,12 +33,14 @@ class PropertyDeliveryMetrics(DeliveryMetrics): Field( description="Canonical publisher-scoped catalog identity when the delivered property resolves to adagents.json. Its publisher_domain MUST equal the row's publisher_domain." ), + NoExplicitNull, ] = None property_name: Annotated[ str | None, Field( description='Current human-readable property name. Convenience metadata only; property_ref is stable identity.' ), + NoExplicitNull, ] = None impressions: Any spend: Any diff --git a/src/adcp/types/domains/core/property_list_ref.py b/src/adcp/types/domains/core/property_list_ref.py index 9373a7a11..9c2de6935 100644 --- a/src/adcp/types/domains/core/property_list_ref.py +++ b/src/adcp/types/domains/core/property_list_ref.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/property_list_ref.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -23,4 +23,5 @@ class PropertyListReference(AdCPBaseModel): Field( description='JWT or other authorization token for accessing the list. Optional if the list is public or caller has implicit access.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/proposal.py b/src/adcp/types/domains/core/proposal.py index 04b10b2aa..c88896b85 100644 --- a/src/adcp/types/domains/core/proposal.py +++ b/src/adcp/types/domains/core/proposal.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/proposal.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..enums import pacing as pacing_1 @@ -22,16 +22,22 @@ class TotalBudgetGuidance(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - min: Annotated[StrictFloat | None, Field(description='Minimum recommended budget', ge=0.0)] = ( - None - ) + min: Annotated[ + StrictFloat | None, Field(description='Minimum recommended budget', ge=0.0), NoExplicitNull + ] = None recommended: Annotated[ - StrictFloat | None, Field(description='Recommended budget for optimal performance', ge=0.0) + StrictFloat | None, + Field(description='Recommended budget for optimal performance', ge=0.0), + NoExplicitNull, ] = None max: Annotated[ - StrictFloat | None, Field(description='Maximum budget before diminishing returns', ge=0.0) + StrictFloat | None, + Field(description='Maximum budget before diminishing returns', ge=0.0), + NoExplicitNull, ] = None - currency: Annotated[str | None, Field(description='ISO 4217 currency code')] = None + currency: Annotated[str | None, Field(description='ISO 4217 currency code'), NoExplicitNull] = ( + None + ) class Proposal(AdCPBaseModel): @@ -53,6 +59,7 @@ class Proposal(AdCPBaseModel): Field( description='Explanation of the proposal strategy and what it achieves', max_length=2000 ), + NoExplicitNull, ] = None allocations: Annotated[ list[product_allocation.ProductAllocation], @@ -66,39 +73,47 @@ class Proposal(AdCPBaseModel): Field( description='How the executed total budget is allocated across proposal products. Omit for legacy fixed proposals.' ), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Recommended aggregate pacing for the executed media-buy budget. On a committed proposal this is part of the firm delivery terms.' ), + NoExplicitNull, ] = None frequency_cap: Annotated[ media_buy_frequency_cap.MediaBuyFrequencyCap | None, Field( description='Aggregate cap bound into this legacy proposal. It is authoritative when the proposal is executed and uses one counter across its packages.' ), + NoExplicitNull, ] = None proposal_status: Annotated[ proposal_status_1.ProposalStatus | None, Field( description="Lifecycle status of this proposal and the per-proposal source of truth for whether finalization is required before create_media_buy. When absent, the proposal is ready to buy (backward compatible). 'draft' means indicative pricing — finalize via refine before purchasing. 'committed' means firm pricing with inventory reserved until expires_at and executable via create_media_buy." ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='When this proposal expires and can no longer be executed. For draft proposals, indicates when indicative pricing becomes stale. For committed proposals, indicates when the inventory hold lapses — the buyer must call create_media_buy before this time.' ), + NoExplicitNull, ] = None insertion_order: Annotated[ insertion_order_1.InsertionOrder | None, Field( description='Formal insertion order attached to a committed proposal. Present when the seller requires a signed agreement before the media buy can proceed. The buyer references the io_id in io_acceptance on create_media_buy.' ), + NoExplicitNull, ] = None total_budget_guidance: Annotated[ - TotalBudgetGuidance | None, Field(description='Optional budget guidance for this proposal') + TotalBudgetGuidance | None, + Field(description='Optional budget guidance for this proposal'), + NoExplicitNull, ] = None brief_alignment: Annotated[ str | None, @@ -106,11 +121,13 @@ class Proposal(AdCPBaseModel): description='Explanation of how this proposal aligns with the campaign brief', max_length=2000, ), + NoExplicitNull, ] = None forecast: Annotated[ delivery_forecast.DeliveryForecast | None, Field( description='Aggregate forecasted delivery metrics for the entire proposal. When both proposal-level and allocation-level forecasts are present, the proposal-level forecast is authoritative for total delivery estimation.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/protocol_envelope.py b/src/adcp/types/domains/core/protocol_envelope.py index 7f75dfd17..35498ba36 100644 --- a/src/adcp/types/domains/core/protocol_envelope.py +++ b/src/adcp/types/domains/core/protocol_envelope.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/protocol_envelope.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..enums import task_status @@ -24,18 +24,21 @@ class ProtocolEnvelope(AdCPBaseModel): Field( description='Transport-managed conversation identifier. On A2A, this maps to the native Message/Task `contextId` used to associate messages with a conversation; it is not carried inside the AdCP DataPart. On MCP, a request-body `context_id`, where admitted by the selected request schema, is a compatibility-only field: servers MUST ignore it, callers MUST NOT rely on it for continuity, and it MUST NOT select session state, identity, account, authorization, task continuation, or idempotency scope. MCP continuity, if provided, comes from the transport session. Distinct from `context` (per-request opaque echo, see below) and from `task_id` (AdCP operation tracking).' ), + NoExplicitNull, ] = None context: Annotated[ context_1.ContextObject | None, Field( description='Per-request opaque caller-supplied correlation object echoed unchanged in the response. Used for buyer-side tracking (UI session IDs, trace IDs, custom metadata) that the agent MUST preserve byte-for-byte without parsing. Distinct from `context_id` (transport-managed A2A conversation correlation or MCP compatibility metadata) — `context` is caller-owned echo and never selects transport state. Both MAY appear on the same response.\n\n**Relationship to per-task body-level `context` declarations.** Many task request/response schemas (147 as of 3.1) already declare a body-level `context` field that `$ref`s `/schemas/core/context.json` at the body root. Under the flat-on-the-wire MCP serialization (see `notes` below), envelope-level `context` and body-level `context` occupy the same key on the response root — they are NOT separate fields, they MUST share the same value, and they MUST both `$ref` `core/context.json`. The envelope declaration is **authoritative** for the schema definition; per-task body declarations are mirrors retained for tooling reasons (SDK codegen completeness, per-task validation against the response schema in isolation). Future versions MAY drop body-level `context` declarations from per-task schemas; conformance does not require either declaration to be present, only that the wire value `$ref`s `core/context.json`.' ), + NoExplicitNull, ] = None task_id: Annotated[ str | None, Field( description='Unique identifier for tracking asynchronous operations. Present when a task requires extended processing time. Used to query task status and retrieve results when complete.' ), + NoExplicitNull, ] = None status: Annotated[ task_status.TaskStatus, @@ -48,30 +51,35 @@ class ProtocolEnvelope(AdCPBaseModel): Field( description='Human-readable summary of the task result. Provides natural language explanation of what happened, suitable for display to end users or for AI agent comprehension. Generated by the protocol layer based on the task response.' ), + NoExplicitNull, ] = None timestamp: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp when the response was generated. Useful for debugging, logging, cache validation, and tracking async operation progress.' ), + NoExplicitNull, ] = None replayed: Annotated[ StrictBool | None, Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False adcp_error: Annotated[ error.Error | None, Field( description="Transport-envelope error signal for fatal task failures. Per the two-layer model in `error-handling.mdx#envelope-vs-payload-errors-the-two-layer-model`, a fatal task failure SHOULD populate both this envelope-level field AND the payload's `errors[]` array — the envelope carries a typed, extractable error so MCP/A2A clients can dispatch without re-parsing the payload, while the payload's structured `errors[]` remains the canonical normative shape. Non-fatal warnings populate ONLY `payload.errors[]` with `severity: warning` — the envelope MUST NOT carry `adcp_error` for non-failures." ), + NoExplicitNull, ] = None push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='AdCP application-layer webhook configuration for async task updates over MCP, A2A, or REST. Echoed from the request to confirm webhook settings. It is distinct from transport-native progress or A2A TaskPushNotificationConfig delivery and can outlive the originating transport session.' ), + NoExplicitNull, ] = None governance_context: Annotated[ str | None, @@ -81,10 +89,12 @@ class ProtocolEnvelope(AdCPBaseModel): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None payload: Annotated[ dict[str, Any] | None, Field( description='Conceptual grouping for the task-specific response data defined by individual task response schemas (e.g., get-products-response.json, create-media-buy-response.json). `payload` is a documentary construct — it is NOT a required wire field, and its on-the-wire shape depends on transport (see Transport serialization below). Task response schemas declare body fields without wrapping them in a `payload` object; the wire representation places those body fields per transport convention. On MCP the body fields appear as siblings of envelope fields at the root of the tool response; on A2A they appear inside `task.artifacts[0].parts[].DataPart`; on REST they appear at the root of the JSON body.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/provenance.py b/src/adcp/types/domains/core/provenance.py index c02f2c0b4..b22fee04a 100644 --- a/src/adcp/types/domains/core/provenance.py +++ b/src/adcp/types/domains/core/provenance.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/provenance.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..enums import c2pa_watermark_action @@ -33,12 +33,14 @@ class AiTool(AdCPBaseModel): Field( description="Version identifier for the AI tool or model (e.g., '25.1', '0125', '2.1'). For generative models, use the model version rather than the API version." ), + NoExplicitNull, ] = None provider: Annotated[ str | None, Field( description="Organization that provides the AI tool (e.g., 'OpenAI', 'Stability AI', 'Google')" ), + NoExplicitNull, ] = None @@ -62,6 +64,7 @@ class DeclaredBy(AdCPBaseModel): agent_url: Annotated[ AnyUrl | None, Field(description='URL of the agent or service that declared this provenance'), + NoExplicitNull, ] = None role: Annotated[Role, Field(description='Role of the declaring party in the supply chain')] @@ -87,6 +90,7 @@ class VerifyAgent(AdCPBaseModel): Field( description="Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `encypher.markers_present_v2`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." ), + NoExplicitNull, ] = None @@ -100,6 +104,7 @@ class EmbeddedProvenanceItem(AdCPBaseModel): Field( description="Standard the embedding conforms to, if any (e.g., 'c2pa' for C2PA Section A.7 text manifest embedding)" ), + NoExplicitNull, ] = None provider: Annotated[ str, @@ -112,9 +117,12 @@ class EmbeddedProvenanceItem(AdCPBaseModel): Field( description="Buyer's representation that this embedding can be verified by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist). MAY be omitted for self-verifiable embeddings (e.g., a C2PA text manifest with a public key the seller already trusts)." ), + NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, Field(description='When the provenance data was embedded (ISO 8601)') + AwareDatetime | None, + Field(description='When the provenance data was embedded (ISO 8601)'), + NoExplicitNull, ] = None @@ -133,6 +141,7 @@ class VerifyAgent10(AdCPBaseModel): Field( description="Optional `feature_id` the buyer represents the seller should request via `get_creative_features` (e.g., `imatag.watermark_detected`). SHOULD match the `feature_id` declared on the matching `accepted_verifiers[]` entry, or be omitted to defer the selector to the seller. When the seller's entry pins a `feature_id`, that value wins; when neither side pins, the seller selects from the agent's `governance.creative_features` catalog." ), + NoExplicitNull, ] = None @@ -142,6 +151,7 @@ class RenderGuidance(AdCPBaseModel): Field( description='How long the disclosure must persist during content playback or display' ), + NoExplicitNull, ] = None min_duration_ms: Annotated[ SchemaInt | None, @@ -149,6 +159,7 @@ class RenderGuidance(AdCPBaseModel): description="Minimum display duration in milliseconds for initial persistence. Recommended when persistence is initial — without it, the duration is at the publisher's discretion. At serve time the publisher reads this from provenance since the brief is not available.", ge=1, ), + NoExplicitNull, ] = None positions: Annotated[ list[disclosure_position.DisclosurePosition] | None, @@ -156,8 +167,9 @@ class RenderGuidance(AdCPBaseModel): description='Preferred disclosure positions in priority order. The first position a format supports should be used.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Jurisdiction(AdCPBaseModel): @@ -167,6 +179,7 @@ class Jurisdiction(AdCPBaseModel): region: Annotated[ str | None, Field(description="Sub-national region code (e.g., 'CA' for California, 'BY' for Bavaria)"), + NoExplicitNull, ] = None regulation: Annotated[ str, @@ -179,12 +192,14 @@ class Jurisdiction(AdCPBaseModel): Field( description='Required disclosure label text for this jurisdiction, in the local language' ), + NoExplicitNull, ] = None render_guidance: Annotated[ RenderGuidance | None, Field( description="How the disclosure should be rendered for this jurisdiction. Expresses the declaring party's intent for persistence and position based on regulatory requirements. Publishers control actual rendering but governance agents can audit whether guidance was followed." ), + NoExplicitNull, ] = None @@ -198,6 +213,7 @@ class Disclosure(AdCPBaseModel): jurisdictions: Annotated[ list[Jurisdiction] | None, Field(description='Jurisdictions where disclosure obligations apply', min_length=1), + NoExplicitNull, ] = None @@ -216,7 +232,9 @@ class VerificationItem(AdCPBaseModel): ), ] verified_time: Annotated[ - AwareDatetime | None, Field(description='When the verification was performed (ISO 8601)') + AwareDatetime | None, + Field(description='When the verification was performed (ISO 8601)'), + NoExplicitNull, ] = None result: Annotated[Result, Field(description='Verification outcome')] confidence: Annotated[ @@ -224,9 +242,10 @@ class VerificationItem(AdCPBaseModel): Field( description='Confidence score of the verification result (0.0 to 1.0)', ge=0.0, le=1.0 ), + NoExplicitNull, ] = None details_url: Annotated[ - AnyUrl | None, Field(description='URL to the full verification report') + AnyUrl | None, Field(description='URL to the full verification report'), NoExplicitNull ] = None @@ -246,13 +265,17 @@ class Watermark(AdCPBaseModel): Field( description="Buyer's representation that this watermark can be detected by a governance agent on the seller's `creative_policy.accepted_verifiers` list. The `agent_url` MUST match (canonicalized) one of the seller's published `accepted_verifiers[].agent_url` entries; sellers reject `sync_creatives` submissions whose `verify_agent.agent_url` is off-list with `PROVENANCE_VERIFIER_NOT_ACCEPTED`. This is buyer-supplied evidence, not buyer-driven routing — the seller is the verifier-of-record and the seller controls which agent it actually calls (the seller MAY use a different on-list agent if it determines this is more appropriate; the seller does not call buyer-asserted endpoints outside its allowlist)." ), + NoExplicitNull, ] = None c2pa_action: Annotated[ c2pa_watermark_action.C2PaWatermarkAction | None, Field(description='C2PA action classification for this watermark'), + NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, Field(description='When the watermark was applied (ISO 8601)') + AwareDatetime | None, + Field(description='When the watermark was applied (ISO 8601)'), + NoExplicitNull, ] = None @@ -262,46 +285,54 @@ class Provenance(AdCPBaseModel): Field( description='IPTC-aligned classification of AI involvement in producing this content' ), + NoExplicitNull, ] = None synthetic_depiction: Annotated[ StrictBool | None, Field( description='Assessed declaration of whether the content synthetically depicts a real or fictional person performing or appearing in a way that was generated or materially manipulated rather than captured as depicted. `true` covers both a fully synthetic performer and material manipulation of a real performer; `false` is an assessed declaration that the content does not contain such a depiction. Absence means the content has not been assessed for synthetic depiction. This field does not claim consent, legality, or independent verification, and receivers MUST NOT derive it solely from `digital_source_type`.' ), + NoExplicitNull, ] = None ai_tool: Annotated[ AiTool | None, Field( description='AI system used to generate or modify this content. Aligns with IPTC 2025.1 AI metadata fields and C2PA claim_generator.' ), + NoExplicitNull, ] = None human_oversight: Annotated[ HumanOversight | None, Field( description='Level of human involvement in the AI-assisted creation process. Independent of `disclosure.required` — the protocol does not derive disclosure obligations from oversight level. Some regulations include carve-outs for human-edited or human-directed AI output, but those carve-outs have factual prerequisites the schema cannot evaluate. Asserting `edited` or `directed` does not by itself justify `disclosure.required: false`.' ), + NoExplicitNull, ] = None declared_by: Annotated[ DeclaredBy | None, Field( description='Party declaring this provenance. Identifies who attached the provenance claim, enabling receiving parties to assess trust.' ), + NoExplicitNull, ] = None declared_at: Annotated[ AwareDatetime | None, Field( description='When this provenance claim was made (ISO 8601). Distinct from created_time, which records when the content itself was produced. A provenance claim may be attached well after content creation, for example when retroactively declaring AI involvement for regulatory compliance.' ), + NoExplicitNull, ] = None created_time: Annotated[ AwareDatetime | None, Field(description='When this content was created or generated (ISO 8601)'), + NoExplicitNull, ] = None c2pa: Annotated[ C2pa | None, Field( description='C2PA sidecar manifest reference. Links to a detached cryptographic provenance manifest for this content. Note: file-level C2PA bindings break when ad servers transcode, resize, or re-encode assets. For pipelines with intermediaries, consider embedded_provenance as the primary provenance mechanism.' ), + NoExplicitNull, ] = None embedded_provenance: Annotated[ list[EmbeddedProvenanceItem] | None, @@ -309,6 +340,7 @@ class Provenance(AdCPBaseModel): description='Provenance metadata embedded within the content stream. Each entry declares one embedding layer: structured provenance data carried inside the content itself, as distinct from sidecar references (c2pa.manifest_url). Embedded provenance survives operations that break sidecar and file-level bindings: ad-server transcoding, CMS ingestion, copy-paste, reformatting, and CDN re-encoding. For ad-tech pipelines where content passes through multiple intermediaries, embedded provenance is the reliable path for provenance that persists from declaration through delivery. This is a declaration by the embedding party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).', min_length=1, ), + NoExplicitNull, ] = None watermarks: Annotated[ list[Watermark] | None, @@ -316,12 +348,14 @@ class Provenance(AdCPBaseModel): description='Content watermarks applied to this asset. Each entry declares one watermarking layer: a content modification that encodes an identifier or fingerprint within the asset. Watermarks differ from embedded provenance: a watermark encodes an identifier (who generated it, who owns it), while embedded provenance carries or references a structured provenance record (the full chain of custody). A single asset may carry both. Aligns with C2PA action taxonomy: c2pa.watermarked.bound (watermark linked to a C2PA manifest) and c2pa.watermarked.unbound (watermark independent of any manifest). This is a declaration by the watermarking party. The receiving party (the seller) is the verifier-of-record: it confirms the claim by calling a governance agent it trusts (typically one published in `creative_policy.accepted_verifiers`).', min_length=1, ), + NoExplicitNull, ] = None disclosure: Annotated[ Disclosure | None, Field( description='Regulatory disclosure requirements for this content. Indicates whether AI disclosure is required and under which jurisdictions.' ), + NoExplicitNull, ] = None verification: Annotated[ list[VerificationItem] | None, @@ -329,5 +363,6 @@ class Provenance(AdCPBaseModel): description='Third-party verification or detection results for this content. Multiple services may independently evaluate the same content. Provenance is a claim — verification results attached by the declaring party are supplementary. The enforcing party (e.g., seller/publisher) should run its own verification via get_creative_features or calibrate_content.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/publisher_property_selector.py b/src/adcp/types/domains/core/publisher_property_selector.py index 5b4cc5862..141cb6f7d 100644 --- a/src/adcp/types/domains/core/publisher_property_selector.py +++ b/src/adcp/types/domains/core/publisher_property_selector.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/publisher_property_selector.json -# timestamp: 2026-10-04T00:53:27+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -30,6 +30,7 @@ class PublisherPropertySelector1(AdCPBaseModel): description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None publisher_domains: Annotated[ list[PublisherDomain] | None, @@ -37,6 +38,7 @@ class PublisherPropertySelector1(AdCPBaseModel): description="Compact form for fanning the same selector across many publishers (e.g., a managed network listing every publisher it represents). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", min_length=1, ), + NoExplicitNull, ] = None selection_type: Annotated[ Literal['all'], @@ -77,6 +79,7 @@ class PublisherPropertySelector3(AdCPBaseModel): description="Domain where publisher's adagents.json is hosted (e.g., 'cnn.com'). XOR with `publisher_domains` — exactly one MUST be present on each `publisher_properties[]` entry; both-present and neither-present both fail validation.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None publisher_domains: Annotated[ list[PublisherDomain] | None, @@ -84,6 +87,7 @@ class PublisherPropertySelector3(AdCPBaseModel): description="Compact form for fanning the same tag predicate across many publishers (canonical managed-network shape). Each entry is the domain where that publisher's adagents.json is hosted. Each listed domain MUST be canonicalized to lowercase (the `pattern` already rejects uppercase). Mutually exclusive with `publisher_domain`. Each listed domain counts as explicitly scoped for the `managerdomain` fallback safety rule.", min_length=1, ), + NoExplicitNull, ] = None selection_type: Annotated[ Literal['by_tag'], Field(description='Discriminator indicating selection by property tags') diff --git a/src/adcp/types/domains/core/push_notification_config.py b/src/adcp/types/domains/core/push_notification_config.py index 1dd3b1cd7..11d7fbde8 100644 --- a/src/adcp/types/domains/core/push_notification_config.py +++ b/src/adcp/types/domains/core/push_notification_config.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/push_notification_config.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..enums import auth_scheme @@ -48,6 +48,7 @@ class PushNotificationConfig(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None token: Annotated[ str | None, @@ -56,6 +57,7 @@ class PushNotificationConfig(AdCPBaseModel): max_length=4096, min_length=16, ), + NoExplicitNull, ] = None authentication: Annotated[ Authentication | None, @@ -63,4 +65,5 @@ class PushNotificationConfig(AdCPBaseModel): deprecated=True, description='Legacy authentication configuration (A2A-compatible). Opts the seller into Bearer or HMAC-SHA256 signing instead of the default RFC 9421 webhook profile. Deprecated; removed in AdCP 4.0. **Precedence is a switch, not a fallback:** presence of this block selects the legacy scheme; absence selects 9421. A seller MUST NOT sign the same webhook both ways, and a buyer MUST NOT attempt \'try 9421 first, fall back to HMAC\' verification — signature mode is determined solely by whether this block was present at registration time. The seller\'s baseline 9421 webhook key is published at its brand.json `agents[]` `jwks_uri` using `adcp_use: "request-signing"` (deprecated `webhook-signing` keys remain accepted during the compatibility window); it does not override this selector and is only used when `authentication` is omitted. See docs/building/by-layer/L1/security.mdx#webhook-callbacks for the full precedence and downgrade-resistance rules (including the `webhook_mode_mismatch` rejection a buyer MUST apply when a received webhook\'s signing mode does not match the registered mode).', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/real_estate_item.py b/src/adcp/types/domains/core/real_estate_item.py index 338963531..45b5ed6e3 100644 --- a/src/adcp/types/domains/core/real_estate_item.py +++ b/src/adcp/types/domains/core/real_estate_item.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/real_estate_item.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -19,12 +19,18 @@ class Address(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - street: Annotated[str | None, Field(description='Street address.')] = None - city: Annotated[str | None, Field(description='City name.')] = None - region: Annotated[str | None, Field(description='State, province, or region.')] = None - postal_code: Annotated[str | None, Field(description='Postal or ZIP code.')] = None + street: Annotated[str | None, Field(description='Street address.'), NoExplicitNull] = None + city: Annotated[str | None, Field(description='City name.'), NoExplicitNull] = None + region: Annotated[ + str | None, Field(description='State, province, or region.'), NoExplicitNull + ] = None + postal_code: Annotated[str | None, Field(description='Postal or ZIP code.'), NoExplicitNull] = ( + None + ) country: Annotated[ - str | None, Field(description='ISO 3166-1 alpha-2 country code.', pattern='^[A-Z]{2}$') + str | None, + Field(description='ISO 3166-1 alpha-2 country code.', pattern='^[A-Z]{2}$'), + NoExplicitNull, ] = None @@ -80,35 +86,52 @@ class RealEstateItem(AdCPBaseModel): price: Annotated[ price_1.Price | None, Field(description="Property price or rental rate. Use period 'month' for rentals."), + NoExplicitNull, + ] = None + property_type: Annotated[ + PropertyType | None, Field(description='Type of property.'), NoExplicitNull ] = None - property_type: Annotated[PropertyType | None, Field(description='Type of property.')] = None listing_type: Annotated[ - ListingType | None, Field(description='Whether the property is for sale or rent.') + ListingType | None, + Field(description='Whether the property is for sale or rent.'), + NoExplicitNull, + ] = None + bedrooms: Annotated[ + SchemaInt | None, Field(description='Number of bedrooms.', ge=0), NoExplicitNull ] = None - bedrooms: Annotated[SchemaInt | None, Field(description='Number of bedrooms.', ge=0)] = None bathrooms: Annotated[ StrictFloat | None, Field( description='Number of bathrooms (e.g., 2.5 for two full and one half bath).', ge=0.0 ), + NoExplicitNull, + ] = None + area: Annotated[Area | None, Field(description='Property size.'), NoExplicitNull] = None + description: Annotated[ + str | None, Field(description='Property description.'), NoExplicitNull ] = None - area: Annotated[Area | None, Field(description='Property size.')] = None - description: Annotated[str | None, Field(description='Property description.')] = None location: Annotated[ - Location | None, Field(description='Geographic coordinates of the property.') + Location | None, + Field(description='Geographic coordinates of the property.'), + NoExplicitNull, + ] = None + image_url: Annotated[ + AnyUrl | None, Field(description='Primary property image URL.'), NoExplicitNull + ] = None + url: Annotated[AnyUrl | None, Field(description='Listing page URL.'), NoExplicitNull] = None + neighborhood: Annotated[ + str | None, Field(description='Neighborhood or area name.'), NoExplicitNull + ] = None + year_built: Annotated[ + SchemaInt | None, Field(description='Year the property was built.'), NoExplicitNull ] = None - image_url: Annotated[AnyUrl | None, Field(description='Primary property image URL.')] = None - url: Annotated[AnyUrl | None, Field(description='Listing page URL.')] = None - neighborhood: Annotated[str | None, Field(description='Neighborhood or area name.')] = None - year_built: Annotated[SchemaInt | None, Field(description='Year the property was built.')] = ( - None - ) tags: Annotated[ list[str] | None, Field( description="Tags for filtering (e.g., 'garden', 'parking', 'renovated', 'waterfront').", min_length=1, ), + NoExplicitNull, ] = None assets: Annotated[ list[offering_asset_group.OfferingAssetGroup] | None, @@ -116,5 +139,6 @@ class RealEstateItem(AdCPBaseModel): description="Typed creative asset pools for this property listing. Uses the same OfferingAssetGroup structure as offering-type catalogs. Standard group IDs: 'images_landscape' (exterior/interior hero), 'images_vertical' (9:16 for Stories), 'images_square' (1:1). Enables formats to declare typed image requirements that map unambiguously to the right asset regardless of platform.", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reference_asset.py b/src/adcp/types/domains/core/reference_asset.py index 877e9bfa4..9c952e0c4 100644 --- a/src/adcp/types/domains/core/reference_asset.py +++ b/src/adcp/types/domains/core/reference_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reference_asset.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -39,4 +39,5 @@ class ReferenceAsset(AdCPBaseModel): Field( description='Human-readable description of the asset and how it should inform creative generation' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reference_renderer.py b/src/adcp/types/domains/core/reference_renderer.py index f9b6a454d..da0ab1e98 100644 --- a/src/adcp/types/domains/core/reference_renderer.py +++ b/src/adcp/types/domains/core/reference_renderer.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/reference_renderer.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, field_validator @@ -75,6 +75,7 @@ class ReferenceRenderer(AdCPBaseModel): description="Deprecated compatibility annotation retained for previously published registry entries. Renderer package versions and community format revisions have independent lifecycles, so consumers MUST NOT require this value to equal the enclosing entry's format_revision or use matching values as evidence of compatibility. Registry review and contract fixtures bind the named export to the enclosing format's input contract. New entries SHOULD omit this field.", pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$', ), + NoExplicitNull, ] = None integrity: Annotated[ str, diff --git a/src/adcp/types/domains/core/registry_event.py b/src/adcp/types/domains/core/registry_event.py index 6dcdbb0cd..5943fa871 100644 --- a/src/adcp/types/domains/core/registry_event.py +++ b/src/adcp/types/domains/core/registry_event.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/registry_event.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Any, Literal from uuid import UUID -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel, StrictBool @@ -66,6 +66,7 @@ class Payload(AdCPBaseModel): Field( description='Registry evidence source for the merge, such as adagents_json or manual_review.' ), + NoExplicitNull, ] = None @@ -114,8 +115,8 @@ class Payload1(AdCPBaseModel): extra='allow', ) property_rid: UUID - last_resolved_at: AwareDatetime | None = None - reason: str | None = None + last_resolved_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + reason: Annotated[str | None, NoExplicitNull] = None class RegistryEvent4(AdCPBaseModel): @@ -173,6 +174,7 @@ class Payload3(AdCPBaseModel): Field( description='Registry evidence source for the merge, such as adagents_json or manual_review.' ), + NoExplicitNull, ] = None @@ -329,13 +331,13 @@ class AgentProfilePayload(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - agent_url: AnyUrl | None = None - name: str | None = None - type: Type | None = None - channels: StringArray | None = None - property_types: StringArray | None = None - markets: list[Market] | None = None - categories: StringArray | None = None + agent_url: Annotated[AnyUrl | None, NoExplicitNull] = None + name: Annotated[str | None, NoExplicitNull] = None + type: Annotated[Type | None, NoExplicitNull] = None + channels: Annotated[StringArray | None, NoExplicitNull] = None + property_types: Annotated[StringArray | None, NoExplicitNull] = None + markets: Annotated[list[Market] | None, NoExplicitNull] = None + categories: Annotated[StringArray | None, NoExplicitNull] = None category_taxonomy: str | None = None format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -343,14 +345,15 @@ class AgentProfilePayload(AdCPBaseModel): deprecated=True, description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Named-format profile projection. Use canonical `format_kinds` and `format_option_refs`.', ), + NoExplicitNull, ] = None - format_kinds: list[str] | None = None - tags: StringArray | None = None - delivery_types: StringArray | None = None - property_count: Annotated[SchemaInt | None, Field(ge=0)] = None - publisher_count: Annotated[SchemaInt | None, Field(ge=0)] = None - has_tmp: StrictBool | None = None - updated_at: AwareDatetime | None = None + format_kinds: Annotated[list[str] | None, NoExplicitNull] = None + tags: Annotated[StringArray | None, NoExplicitNull] = None + delivery_types: Annotated[StringArray | None, NoExplicitNull] = None + property_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + publisher_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + has_tmp: Annotated[StrictBool | None, NoExplicitNull] = None + updated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None class Country(Market): @@ -423,7 +426,9 @@ class Payload5(AdCPBaseModel): extra='allow', ) agent_url: AnyUrl - removed_from_publishers: Annotated[list[Domain] | None, Field(min_length=1)] = None + removed_from_publishers: Annotated[list[Domain] | None, Field(min_length=1), NoExplicitNull] = ( + None + ) class RegistryEvent11(AdCPBaseModel): @@ -467,7 +472,7 @@ class RegistryEvent11(AdCPBaseModel): class Payload6(AgentProfilePayload): - changed_fields: ChangedFields | None = None + changed_fields: Annotated[ChangedFields | None, NoExplicitNull] = None agent_url: Any @@ -517,8 +522,8 @@ class Storyboard(AdCPBaseModel): ) storyboard_id: str status: StoryboardStatus - steps_passed: Annotated[SchemaInt | None, Field(ge=0)] = None - steps_total: Annotated[SchemaInt | None, Field(ge=0)] = None + steps_passed: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + steps_total: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None class Payload7(AdCPBaseModel): @@ -534,7 +539,7 @@ class Payload7(AdCPBaseModel): ] storyboards_passing: Annotated[SchemaInt, Field(ge=0)] storyboards_total: Annotated[SchemaInt, Field(ge=0)] - storyboards: list[Storyboard] | None = None + storyboards: Annotated[list[Storyboard] | None, NoExplicitNull] = None class RegistryEvent13(AdCPBaseModel): @@ -584,12 +589,13 @@ class Payload8(AdCPBaseModel): agent_url: AnyUrl role: BadgeRole verified_specialisms: Annotated[list[str], Field(min_length=1)] - adcp_version: str | None = None + adcp_version: Annotated[str | None, NoExplicitNull] = None grading_profile: Annotated[ GradingProfile | None, Field( description='Grading profile that produced the badge. Historical events emitted before profile selection may omit this field and are Legacy.' ), + NoExplicitNull, ] = None @@ -640,12 +646,13 @@ class Payload9(AdCPBaseModel): agent_url: AnyUrl role: BadgeRole reason: str - adcp_version: str | None = None + adcp_version: Annotated[str | None, NoExplicitNull] = None grading_profile: Annotated[ GradingProfile | None, Field( description='Grading profile in force when the badge was lost. Historical events emitted before profile selection may omit this field and are Legacy.' ), + NoExplicitNull, ] = None @@ -693,35 +700,38 @@ class Payload10(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - publisher_domain: Domain | None = None + publisher_domain: Annotated[Domain | None, NoExplicitNull] = None domain: Annotated[ Domain | None, Field( deprecated=True, description='Legacy alias for publisher_domain retained for early feed examples.', ), + NoExplicitNull, ] = None - properties_added: Annotated[SchemaInt | None, Field(ge=0)] = None - properties_removed: Annotated[SchemaInt | None, Field(ge=0)] = None - agents_added: list[AnyUrl] | None = None - agents_removed: list[AnyUrl] | None = None - agent_count: Annotated[SchemaInt | None, Field(ge=0)] = None - property_count: Annotated[SchemaInt | None, Field(ge=0)] = None - collection_count: Annotated[SchemaInt | None, Field(ge=0)] = None + properties_added: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + properties_removed: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + agents_added: Annotated[list[AnyUrl] | None, NoExplicitNull] = None + agents_removed: Annotated[list[AnyUrl] | None, NoExplicitNull] = None + agent_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + property_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + collection_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None format_count: Annotated[ SchemaInt | None, Field(description='Number of top-level formats[] declarations after this revision.', ge=0), + NoExplicitNull, ] = None placement_count: Annotated[ SchemaInt | None, Field( description='Number of top-level placements[] declarations after this revision.', ge=0 ), + NoExplicitNull, ] = None - changed_fields: ChangedFields | None = None - discovery_method: str | None = None + changed_fields: Annotated[ChangedFields | None, NoExplicitNull] = None + discovery_method: Annotated[str | None, NoExplicitNull] = None manager_domain: str | None = None - source: str | None = None + source: Annotated[str | None, NoExplicitNull] = None class RegistryEvent16(AdCPBaseModel): @@ -773,11 +783,13 @@ class Payload12(AdCPBaseModel): Field( description='Registry authorization row id when the event is backed by a materialized effective authorization row.' ), + NoExplicitNull, ] = None agent_url: AnyUrl agent_url_canonical: Annotated[ str | None, Field(description='Registry-canonicalized form of agent_url for equality checks.'), + NoExplicitNull, ] = None publisher_domain: Domain authorization_type: Annotated[ @@ -785,13 +797,22 @@ class Payload12(AdCPBaseModel): Field( description='When present, identifies the adagents.json authorization variant represented by this payload.' ), + NoExplicitNull, ] = None authorized_for: str | None = None - property_ids: Annotated[list[property_id.PropertyId] | None, Field(min_length=1)] = None - property_tags: Annotated[list[property_tag.PropertyTag] | None, Field(min_length=1)] = None - properties: Annotated[list[property_1.Property] | None, Field(min_length=1)] = None + property_ids: Annotated[ + list[property_id.PropertyId] | None, Field(min_length=1), NoExplicitNull + ] = None + property_tags: Annotated[ + list[property_tag.PropertyTag] | None, Field(min_length=1), NoExplicitNull + ] = None + properties: Annotated[list[property_1.Property] | None, Field(min_length=1), NoExplicitNull] = ( + None + ) publisher_properties: Annotated[ - list[publisher_property_selector.PublisherPropertySelector] | None, Field(min_length=1) + list[publisher_property_selector.PublisherPropertySelector] | None, + Field(min_length=1), + NoExplicitNull, ] = None property_rid: Annotated[ UUID | None, @@ -805,34 +826,36 @@ class Payload12(AdCPBaseModel): description='Publisher-local property id for materialized per-property authorization rows.' ), ] = None - placement_ids: Annotated[list[str] | None, Field(min_length=1)] = None - placement_tags: Annotated[list[str] | None, Field(min_length=1)] = None + placement_ids: Annotated[list[str] | None, Field(min_length=1), NoExplicitNull] = None + placement_tags: Annotated[list[str] | None, Field(min_length=1), NoExplicitNull] = None collections: Annotated[ list[collection_selector.CollectionSelector] | None, Field( description='Collection constraints from authorized_agents[*].collections. When set, the event does NOT authorize the property unqualified — consumers building a local authorization index MUST scope the grant to these selectors and fail closed when a query carries no collection scope. A selector without collection_ids is a bulk grant for all collections declared at that publisher_domain.', min_length=1, ), + NoExplicitNull, ] = None - countries: Countries | None = None - delegation_type: DelegationType | None = None - exclusive: StrictBool | None = None + countries: Annotated[Countries | None, NoExplicitNull] = None + delegation_type: Annotated[DelegationType | None, NoExplicitNull] = None + exclusive: Annotated[StrictBool | None, NoExplicitNull] = None signing_keys: Annotated[ list[agent_signing_key.AgentSigningKey] | None, Field( description='Publisher-attested signing keys copied from adagents.json when the registry has them. Advisory in feed events; verifiers MUST re-fetch the authoritative publisher artifact before treating keys as a trust anchor.', min_length=1, ), + NoExplicitNull, ] = None - effective_from: AwareDatetime | None = None - effective_until: AwareDatetime | None = None - evidence: Evidence | None = None - disputed: StrictBool | None = None + effective_from: Annotated[AwareDatetime | None, NoExplicitNull] = None + effective_until: Annotated[AwareDatetime | None, NoExplicitNull] = None + evidence: Annotated[Evidence | None, NoExplicitNull] = None + disputed: Annotated[StrictBool | None, NoExplicitNull] = None created_by: str | None = None expires_at: AwareDatetime | None = None created_at: AwareDatetime | None = None updated_at: AwareDatetime | None = None - override_applied: StrictBool | None = None + override_applied: Annotated[StrictBool | None, NoExplicitNull] = None override_reason: str | None = None @@ -972,16 +995,19 @@ class PropertyPayload(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - property_rid: UUID | None = None - classification: Classification | None = None - source: PropertySource | None = None - identifiers: Annotated[list[identifier.Identifier] | None, Field(min_length=1)] = None - publisher_domain: Domain | None = None + property_rid: Annotated[UUID | None, NoExplicitNull] = None + classification: Annotated[Classification | None, NoExplicitNull] = None + source: Annotated[PropertySource | None, NoExplicitNull] = None + identifiers: Annotated[ + list[identifier.Identifier] | None, Field(min_length=1), NoExplicitNull + ] = None + publisher_domain: Annotated[Domain | None, NoExplicitNull] = None property: Annotated[ property_1.Property | None, Field(description='Optional full post-change property object when available.'), + NoExplicitNull, ] = None - changed_fields: ChangedFields | None = None + changed_fields: Annotated[ChangedFields | None, NoExplicitNull] = None class AuthorizationPayload(Payload12): @@ -1073,7 +1099,7 @@ class RegistryEvent2(AdCPBaseModel): class Payload2(PropertyPayload): - reactivated_at: AwareDatetime | None = None + reactivated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None property_rid: Any @@ -1118,11 +1144,11 @@ class RegistryEvent5(AdCPBaseModel): class Payload11(PublisherAdagentsPayload): - properties_added: Annotated[SchemaInt | None, Field(ge=0)] = None - properties_removed: Annotated[SchemaInt | None, Field(ge=0)] = None - agents_added: list[AnyUrl] | None = None - agents_removed: list[AnyUrl] | None = None - changed_fields: ChangedFields | None = None + properties_added: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + properties_removed: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + agents_added: Annotated[list[AnyUrl] | None, NoExplicitNull] = None + agents_removed: Annotated[list[AnyUrl] | None, NoExplicitNull] = None + changed_fields: Annotated[ChangedFields | None, NoExplicitNull] = None class RegistryEvent17(AdCPBaseModel): @@ -1169,25 +1195,27 @@ class CollectionPayload(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - collection_rid: UUID | None = None - publisher_domain: Domain | None = None + collection_rid: Annotated[UUID | None, NoExplicitNull] = None + publisher_domain: Annotated[Domain | None, NoExplicitNull] = None collection_id: Annotated[ str | None, Field(description='Publisher-local collection_id from the authoritative adagents.json.'), ] = None name: str | None = None kind: collection_kind.CollectionKind | None = None - source: PropertySource | None = None - status: Status | None = None + source: Annotated[PropertySource | None, NoExplicitNull] = None + status: Annotated[Status | None, NoExplicitNull] = None identifiers: Annotated[ list[CollectionIdentifier] | None, Field(description='Distribution identifiers that alias this collection across platforms.'), + NoExplicitNull, ] = None collection: Annotated[ collection_1.Collection | None, Field(description='Optional full post-change collection object when available.'), + NoExplicitNull, ] = None - changed_fields: ChangedFields | None = None + changed_fields: Annotated[ChangedFields | None, NoExplicitNull] = None class RegistryEvent6(AdCPBaseModel): diff --git a/src/adcp/types/domains/core/reporting_adjustment.py b/src/adcp/types/domains/core/reporting_adjustment.py index b0bc57966..071cac2d2 100644 --- a/src/adcp/types/domains/core/reporting_adjustment.py +++ b/src/adcp/types/domains/core/reporting_adjustment.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_adjustment.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from . import reporting_control_total @@ -57,6 +57,7 @@ class ReportingAdjustment(AdCPBaseModel): max_length=1024, min_length=1, ), + NoExplicitNull, ] = None accounting_period: Annotated[ AccountingPeriod, @@ -77,6 +78,7 @@ class ReportingAdjustment(AdCPBaseModel): description='SHA-256 of the RFC 8785 JCS serialization of this adjustment with canonical_adjustment_sha256 omitted. Reconciled Billing consumers recompute this digest before accepting or rejecting the adjustment.', pattern='^[A-Fa-f0-9]{64}$', ), + NoExplicitNull, ] = None correction_observed_at: AwareDatetime created_at: AwareDatetime diff --git a/src/adcp/types/domains/core/reporting_adjustment_receipt.py b/src/adcp/types/domains/core/reporting_adjustment_receipt.py index 4efbdd996..371f3a110 100644 --- a/src/adcp/types/domains/core/reporting_adjustment_receipt.py +++ b/src/adcp/types/domains/core/reporting_adjustment_receipt.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_adjustment_receipt.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -43,6 +43,7 @@ class ReportingAdjustmentReceipt(AdCPBaseModel): min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$', ), + NoExplicitNull, ] = None status: Status observed_adjustment_sha256: Annotated[ @@ -53,7 +54,7 @@ class ReportingAdjustmentReceipt(AdCPBaseModel): ), ] rejection_codes: Annotated[ - list[ReportingAdjustmentRejectionCode] | None, Field(min_length=1) + list[ReportingAdjustmentRejectionCode] | None, Field(min_length=1), NoExplicitNull ] = None observed_at: AwareDatetime - received_at: AwareDatetime | None = None + received_at: Annotated[AwareDatetime | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reporting_canonicalization_contract.py b/src/adcp/types/domains/core/reporting_canonicalization_contract.py index c33e43797..b75c69882 100644 --- a/src/adcp/types/domains/core/reporting_canonicalization_contract.py +++ b/src/adcp/types/domains/core/reporting_canonicalization_contract.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/reporting_canonicalization_contract.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -56,7 +56,7 @@ class GoldenVectors(AdCPBaseModel): ) empty_report: Annotated[EmptyReport, Field(title='EmptyReportGoldenVector')] ordering_encoding: Annotated[OrderingEncoding, Field(title='OrderingEncodingGoldenVector')] - additional: list[AdditionalItem] | None = None + additional: Annotated[list[AdditionalItem] | None, NoExplicitNull] = None class ReportingPrimaryKey(ScalarStr): diff --git a/src/adcp/types/domains/core/reporting_capabilities.py b/src/adcp/types/domains/core/reporting_capabilities.py index 36f5a4fa2..ba5467593 100644 --- a/src/adcp/types/domains/core/reporting_capabilities.py +++ b/src/adcp/types/domains/core/reporting_capabilities.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_capabilities.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ..enums import available_metric, reporting_frequency @@ -49,6 +49,7 @@ class VendorMetric(AdCPBaseModel): Field( description="Relationship between this product's seller and `vendor` — `first_party` (the seller or its own measurement agent produced the number), `affiliated` (distinct legal entity under common ownership or control: parent/subsidiary, controlling stake, joint venture, disclosed board overlap), or `third_party` (independent vendor). Defined on structural facts only — a commercial dependency does not make a vendor `affiliated`; see the enum. A property of the (seller, vendor) pair, which is why it is declared here rather than on the vendor's catalog. REQUIRED whenever `vendor` resolves to the seller's own `brand.json` or to an entity under common ownership or control with the seller — the self-measurement case is permitted only when it is disclosed, so a seller MUST NOT leave the field absent and let a buyer guess. RECOMMENDED for unrelated third-party vendors. Absence means *undeclared*: buyers MUST treat an absent value as unknown, never as `third_party`, and MAY exclude undeclared rows from reconciliation. This is a relationship disposition, not a trust ranking — quality and independence-of-incentive questions belong to the vendor catalog's `accreditations[]` and `standard_reference`, not to this field. Methodology (sensor, panel, model, instrument) is NOT declared here — it lives at the vendor catalog's `description`, `methodology_url`, `methodology_version`, `standard_reference`, and `accreditations[]`. MAY be echoed on `vendor_metric_values[].vendor_relationship` in delivery." ), + NoExplicitNull, ] = None @@ -84,6 +85,7 @@ class ReportingCapabilities(AdCPBaseModel): Field( description='Product-scoped subset of get_adcp_capabilities.media_buy.reporting_delivery.offerings[].offering_id that packages using this product can satisfy. This binds seller-wide managed-delivery offerings to product/package eligibility. An empty array explicitly declares no managed offering; absence means product-level applicability is unknown and MUST NOT be inferred from the seller-wide list. Account, seat, credential, or provider constraints may narrow support further during sync_accounts validation.' ), + NoExplicitNull, ] = None available_metrics: Annotated[ list[available_metric.AvailableMetric], @@ -100,102 +102,119 @@ class ReportingCapabilities(AdCPBaseModel): Field( description="Vendor-defined metrics this product can report, beyond the closed `available_metrics` enum. Each entry is a pointer (`{ vendor, metric_id }`) into the vendor's metric catalog — the canonical definition (standard alignment, accreditations, methodology, unit, human-readable description) lives at the vendor's `get_adcp_capabilities.measurement.metrics[]`, queried once per vendor when needed. Use this for proprietary metrics like attention scores, emissions, panel-based demographics, or platform-native social metrics not yet in the standard enum. Sellers populate values in delivery via `delivery-metrics.json#/properties/vendor_metric_values`. The metric is identified by the tuple `(vendor, metric_id)`; identifiers are namespaced by the vendor, so the same `metric_id` may mean different things in different vendors' vocabularies. Semantic uniqueness key is `(vendor.domain, vendor.brand_id, metric_id)`; sellers MUST de-duplicate before emission and MUST NOT declare the same vendor metric twice. Buyers MAY treat duplicate `(vendor, metric_id)` rows as a seller-side conformance bug. (JSON Schema `uniqueItems` is not used here because BrandRef carries optional fields whose absence/presence would defeat deep-equal — uniqueness is on the semantic key, enforced at build/validation time on the seller side.) Promotion path: when the industry converges on a metric via a published standard, the spec adds it to the closed `available_metrics` enum and the vendor extensions become historical aliases. The `vendor` MAY resolve to the selling party's own `brand.json` — a seller MAY be its own measurement vendor (DOOH sensor networks, retail-media closed loops, walled gardens) provided it publishes the metric in an `agents[type='measurement']` catalog like any other vendor and declares the relationship via `vendor_relationship`; the catalog contract is not relaxed for first-party measurement." ), + NoExplicitNull, ] = None supports_creative_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports creative-level metric breakdowns in delivery reporting (by_creative within by_package)' ), + NoExplicitNull, ] = None supports_format_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports canonical creative-format breakdowns in GET delivery reporting (by_format within by_package, keyed by format_kind). This is independent from supports_creative_breakdown because a seller may expose aggregate format-grain reporting without exposing individual creative performance.' ), + NoExplicitNull, ] = None supports_keyword_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports keyword-level metric breakdowns in delivery reporting (by_keyword within by_package)' ), + NoExplicitNull, ] = None supports_geo_breakdown: Annotated[ geo_breakdown_support.GeographicBreakdownSupport | None, Field( description='Geographic breakdown support for this product. Declares which geo levels and systems are available for by_geo reporting within by_package.' ), + NoExplicitNull, ] = None supports_device_type_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports device type breakdowns in delivery reporting (by_device_type within by_package)' ), + NoExplicitNull, ] = None supports_device_platform_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports device platform breakdowns in delivery reporting (by_device_platform within by_package)' ), + NoExplicitNull, ] = None supports_audience_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports audience segment breakdowns in delivery reporting (by_audience within by_package)' ), + NoExplicitNull, ] = None supports_demographic_breakdown: Annotated[ demographic_reporting_capability.DemographicReportingCapability | None, Field( description='Product-scoped demographic breakdown support for by_demographic reporting. Declares reportable age ranges and measurement systems independently from demographic targeting execution.' ), + NoExplicitNull, ] = None supports_placement_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports placement breakdowns in delivery reporting (by_placement within by_package)' ), + NoExplicitNull, ] = None supports_property_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports property breakdowns in delivery reporting (by_property within by_package).' ), + NoExplicitNull, ] = None supports_collection_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports collection breakdowns in delivery reporting (by_collection within by_package).' ), + NoExplicitNull, ] = None supports_installment_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports installment breakdowns in delivery reporting (by_installment within by_package).' ), + NoExplicitNull, ] = None supports_collection_property_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports collection × property intersection reporting (by_collection_property within by_package).' ), + NoExplicitNull, ] = None supports_installment_property_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports installment × property intersection reporting (by_installment_property within by_package).' ), + NoExplicitNull, ] = None supports_placement_property_breakdown: Annotated[ StrictBool | None, Field( description='Whether this product supports placement × property intersection reporting (by_placement_property within by_package).' ), + NoExplicitNull, ] = None supports_spot_breakdown: Annotated[ spot_reporting_capability.SpotReportingCapability | None, Field( description='Spot-level as-run airing-log support and metrics available at spot grain for broadcast TV, radio, and other scheduled inventory.' ), + NoExplicitNull, ] = None date_range_support: Annotated[ DateRangeSupport, @@ -209,6 +228,7 @@ class ReportingCapabilities(AdCPBaseModel): description='Granularities at which this product honors per-window pulls on get_media_buy_delivery (via request `time_granularity` + `include_window_breakdown: true`). Closes the GET-side half of the snapshot/log two-paths-parity contract for data-bearing events: a buyer who missed a webhook fire at any granularity listed here can reconstruct an identical payload by polling. Capability-scoped MUST — sellers MUST honor pulls at any granularity declared here, and MUST return UNSUPPORTED_GRANULARITY for pulls outside the set. Sellers MAY emit higher-frequency webhooks than they expose for pull (common where the webhook is a Kafka tap and historical reads go through a warehouse with coarser granularity); buyers see the gap up front via this capability and treat the webhook as primary for those frequencies. Absent or empty means the product only supports cumulative date-range pulls and full per-window recovery via GET is unavailable — see snapshot-and-log Rule 4.', examples=[['daily'], ['hourly', 'daily'], ['hourly', 'daily', 'monthly']], ), + NoExplicitNull, ] = None measurement_windows: Annotated[ list[measurement_window.MeasurementWindow] | None, @@ -216,4 +236,5 @@ class ReportingCapabilities(AdCPBaseModel): description='Measurement maturation stages available for this product. Used by any channel where billing-grade data is produced in phases rather than arriving final on day one. Examples: broadcast/linear TV (Live → C3 → C7 DVR accumulation), DOOH (tentative plays → post-IVT/fraud-check final), digital with IVT filtering (raw → GIVT filtered → SIVT filtered), podcast (7-day downloads → 30-day downloads). Each window defines an accumulation period and expected data availability. When present, delivery reports reference a specific window_id. Sellers whose data is final on first delivery typically omit this.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_consumer_status.py b/src/adcp/types/domains/core/reporting_consumer_status.py index b8f56908b..fde8eed4e 100644 --- a/src/adcp/types/domains/core/reporting_consumer_status.py +++ b/src/adcp/types/domains/core/reporting_consumer_status.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_consumer_status.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field @@ -66,6 +66,7 @@ class ReportingConsumerStatus(AdCPBaseModel): min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$', ), + NoExplicitNull, ] = None delivery_config_id: Annotated[ str, Field(max_length=64, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$') @@ -94,6 +95,7 @@ class ReportingConsumerStatus(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None reporting_revision_id: Annotated[ str | None, @@ -103,6 +105,7 @@ class ReportingConsumerStatus(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None observed_revision_content_sha256: Annotated[ str | None, @@ -110,6 +113,7 @@ class ReportingConsumerStatus(AdCPBaseModel): description='revision_content_sha256 independently recomputed from the exact consumed Core revision binding. Required for received and content_mismatch, where it proves which exact revision content the consumer read; unlike a Reconciled Billing receipt it carries no materialization evidence, row totals, canonical digest, or billing acceptance.', pattern='^[A-Fa-f0-9]{64}$', ), + NoExplicitNull, ] = None consumer_status: Annotated[ ConsumerStatus, @@ -128,12 +132,14 @@ class ReportingConsumerStatus(AdCPBaseModel): Field( description="Closed reason the consumed revision contradicts the accepted configuration generation. Each value is decidable from the obligation, the pinned report definition, and the revision itself, with no reference to either party's own measurement. scope_media_buy_missing: a media buy frozen in the obligation's media_buy_ids denominator is absent from the revision and is not represented by an explicit zero row, so the revision cannot distinguish zero delivery from an omitted buy. coverage_short: the revision covers fewer packages than the obligation's frozen coverage.covered_package_ids claims. metric_missing: a metric named in the pinned report definition's metrics[].name is absent from the revision. schema_nonconformant: rows do not validate against the reporting profile's pinned schema_uri and schema_sha256. currency_mismatch: a value's unit disagrees with the unit the pinned report definition fixed for that metric, or a control total's unit disagrees with the profile-defined unit for that name. period_mismatch: the revision carries a time dimension declared by the pinned grain whose values fall outside the obligation's half-open period. Precedence when more than one applies: schema_nonconformant is used only when the failure is structural validation against the pinned schema; a metric that is simply absent uses metric_missing even when the pinned schema declares it required. Each names a contract fact already fixed by the accepted generation, never a difference of opinion about counts. Agents dispatch on this value, not on prose." ), + NoExplicitNull, ] = None failure_code: Annotated[ FailureCode | None, Field( description='Typed reason a named revision was unreadable. Agents dispatch on this value, not prose or provider response bodies.' ), + NoExplicitNull, ] = None consumer_commit_ref: Annotated[ str | None, @@ -143,6 +149,7 @@ class ReportingConsumerStatus(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,512}$', ), + NoExplicitNull, ] = None seller_ledger_snapshot_id: Annotated[ str | None, @@ -151,14 +158,17 @@ class ReportingConsumerStatus(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None seller_ledger_as_of: Annotated[ AwareDatetime | None, Field( description='ledger_as_of echoed from seller_ledger_snapshot_id. Present if and only if seller_ledger_snapshot_id is present.' ), + NoExplicitNull, ] = None recorded_at: Annotated[ AwareDatetime | None, Field(description='When the seller durably recorded this immutable statement.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_control_total.py b/src/adcp/types/domains/core/reporting_control_total.py index fdbd69365..70e9602ec 100644 --- a/src/adcp/types/domains/core/reporting_control_total.py +++ b/src/adcp/types/domains/core/reporting_control_total.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/reporting_control_total.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -32,6 +32,7 @@ class ReportingControlTotal1(AdCPBaseModel): max_length=32, min_length=1, ), + NoExplicitNull, ] = None @@ -57,6 +58,7 @@ class ReportingControlTotal2(AdCPBaseModel): max_length=32, min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_coverage.py b/src/adcp/types/domains/core/reporting_coverage.py index b3ada0b0c..9491e1b06 100644 --- a/src/adcp/types/domains/core/reporting_coverage.py +++ b/src/adcp/types/domains/core/reporting_coverage.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_coverage.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -45,7 +45,9 @@ class Limitation(AdCPBaseModel): ) reason: Reason media_buy_id: ReportingMediaBuyId - package_ids: Annotated[list[ReportingPackageId] | None, Field(min_length=1)] = None + package_ids: Annotated[list[ReportingPackageId] | None, Field(min_length=1), NoExplicitNull] = ( + None + ) class ReportingCoverage(AdCPBaseModel): diff --git a/src/adcp/types/domains/core/reporting_dataset_share_destination.py b/src/adcp/types/domains/core/reporting_dataset_share_destination.py index c67138f1b..b8b6c6fb4 100644 --- a/src/adcp/types/domains/core/reporting_dataset_share_destination.py +++ b/src/adcp/types/domains/core/reporting_dataset_share_destination.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_dataset_share_destination.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -46,8 +46,8 @@ class Recipient(AdCPBaseModel): extra='forbid', ) identity: Annotated[str, Field(max_length=512, min_length=1)] - cloud: ReportingCloud | None = None - region: Annotated[str | None, Field(max_length=128, min_length=1)] = None + cloud: Annotated[ReportingCloud | None, NoExplicitNull] = None + region: Annotated[str | None, Field(max_length=128, min_length=1), NoExplicitNull] = None class ReportingDatasetShareDestination2(AdCPBaseModel): diff --git a/src/adcp/types/domains/core/reporting_delivery_capabilities.py b/src/adcp/types/domains/core/reporting_delivery_capabilities.py index 6770cc64f..11a95495f 100644 --- a/src/adcp/types/domains/core/reporting_delivery_capabilities.py +++ b/src/adcp/types/domains/core/reporting_delivery_capabilities.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_capabilities.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, EmailStr, Field, StrictBool from . import reporting_delivery_offering, reporting_reliability_statistics @@ -25,6 +25,7 @@ class OperationsContact(AdCPBaseModel): Field( description='HTTPS page a human uses to open or track a reporting issue, such as a support portal or status page. Same hardened origin shape as the offering document URIs: never an IP literal, userinfo URL, loopback host, AdCP task endpoint, webhook target, or credentialed link.' ), + NoExplicitNull, ] = None email: Annotated[ EmailStr | None, @@ -32,6 +33,7 @@ class OperationsContact(AdCPBaseModel): description='Monitored operations mailbox for reporting escalations. A role address, not an individual.', max_length=254, ), + NoExplicitNull, ] = None @@ -51,12 +53,14 @@ class ReportingDeliveryCapabilities(AdCPBaseModel): Field( description='Tier flag: this seller supports managed file, dataset-share, or warehouse delivery. Offerings whose method names a delivery pattern require this tier. When false or absent, every offering is API-delivered and Core-only.' ), + NoExplicitNull, ] = None reconciled_billing: Annotated[ StrictBool | None, Field( description='Tier flag: this seller supports canonical-digest verification and authenticated consumer receipts for both report materializations and post-official adjustments through receipt_task. Offerings with reconciliation_mode consumer_receipt and billing-grade canonicalization require this tier.' ), + NoExplicitNull, ] = None configuration_task: Literal['sync_accounts'] | None = None status_task: Literal['get_reporting_status'] | None = None @@ -123,18 +127,21 @@ class ReportingDeliveryCapabilities(AdCPBaseModel): description="Maximum interval after a CONSUMER_STATUS_MISMATCH issue's opened_at during which the seller may keep that issue at a non-escalated recommended_action. After it, the issue MUST be action_required with a contact_ recommended_action naming the diagnosed responsible_party. Declaring it requires operations_contact so the escalation has a destination. Absence means the seller publishes no escalation commitment; it never means an unbounded one.", ge=0, ), + NoExplicitNull, ] = None operations_contact: Annotated[ OperationsContact | None, Field( description='Optional non-secret human escalation path for reporting issues the protocol cannot resolve. It is display metadata for an operator, not an AdCP endpoint: agents MUST NOT dereference, probe, or send protocol traffic to these values, and they carry no authorization. Required when consumer_mismatch_escalation_seconds is advertised.' ), + NoExplicitNull, ] = None reliability_statistics: Annotated[ list[reporting_reliability_statistics.ReportingReliabilityStatistics] | None, Field( description='Optional evidence-scoped observed performance for advertised offerings. offering_id values MUST be unique and name offerings in this capability block.' ), + NoExplicitNull, ] = None resource_retention_days: Annotated[ SchemaInt | None, @@ -142,14 +149,16 @@ class ReportingDeliveryCapabilities(AdCPBaseModel): description='Minimum period after publication for which at least one verified exact materialization remains readable to every still-authorized intended consumer.', ge=1, ), + NoExplicitNull, ] = None - supports_webhook_activity: StrictBool | None = None + supports_webhook_activity: Annotated[StrictBool | None, NoExplicitNull] = None authorization_revocation_seconds: Annotated[ SchemaInt | None, Field( description="Maximum delay after caller/account authorization ends before seller-controlled transport access, provider grants, and write credentials are revoked. It cannot revoke a buyer's access to data already written into a buyer-owned destination.", ge=0, ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/reporting_delivery_config.py b/src/adcp/types/domains/core/reporting_delivery_config.py index 44ab8c951..04a60c6b7 100644 --- a/src/adcp/types/domains/core/reporting_delivery_config.py +++ b/src/adcp/types/domains/core/reporting_delivery_config.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_config.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -11,7 +11,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..enums import reporting_finality @@ -28,9 +28,9 @@ class Scope(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - all_media_buys: Literal[True] | None = None + all_media_buys: Annotated[Literal[True] | None, NoExplicitNull] = None media_buy_ids: Annotated[ - list[reporting_coverage.ReportingMediaBuyId] | None, Field(min_length=1) + list[reporting_coverage.ReportingMediaBuyId] | None, Field(min_length=1), NoExplicitNull ] = None @model_validator(mode='before') @@ -153,12 +153,16 @@ class ReportingDeliveryConfiguration(AdCPBaseModel): Field( description="Reserved: which party's count of this feed is authoritative. seller (the default, and the only value any 3.2 seller accepts) means the seller produces every revision and the consumer may only attest to what it consumed. consumer is reserved for the buyer-deposited billing revision task scoped to a later minor; until that task exists sellers MUST reject it with UNSUPPORTED_FEATURE. Reserving the field now keeps a future buyer-basis billing feed additive instead of breaking the billing-feed constraints. See https://github.com/adcontextprotocol/adcp/issues/7440." ), + NoExplicitNull, ] = AuthoritativeParty.seller schedule: reporting_schedule.ReportingSchedule - method: reporting_delivery_method.ReportingDeliveryMethod | None = None + method: Annotated[reporting_delivery_method.ReportingDeliveryMethod | None, NoExplicitNull] = ( + None + ) revocation_effective_at: Annotated[ AwareDatetime | None, Field( description='Optional requested cutoff for deactivation. No new publication may begin after the applied cutoff; historical access is limited to the contracted recovery window.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_delivery_config_state.py b/src/adcp/types/domains/core/reporting_delivery_config_state.py index dfe7b6cd4..4209ddd31 100644 --- a/src/adcp/types/domains/core/reporting_delivery_config_state.py +++ b/src/adcp/types/domains/core/reporting_delivery_config_state.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_config_state.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from . import reporting_coverage, reporting_delivery_config, reporting_status_issue @@ -33,8 +33,8 @@ class Setup(AdCPBaseModel): min_length=1, ), ] - url: AnyUrl | None = None - expires_at: AwareDatetime | None = None + url: Annotated[AnyUrl | None, NoExplicitNull] = None + expires_at: Annotated[AwareDatetime | None, NoExplicitNull] = None class ReportingDeliveryConfigLifecycleState(StrEnum): @@ -58,34 +58,41 @@ class ReportingDeliveryConfigurationState(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None - validated_at: AwareDatetime | None = None - activated_at: AwareDatetime | None = None - deactivated_at: AwareDatetime | None = None + validated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + activated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + deactivated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None publication_stopped_at: Annotated[ AwareDatetime | None, Field( description='Applied schedule boundary at or after deactivation. No obligation whose period starts at or after this cutoff is created; earlier obligations remain owed through their SLA and recovery lifecycle.' ), + NoExplicitNull, ] = None seller_managed_access_ends_at: Annotated[ AwareDatetime | None, Field( description='End of historical access to a producer-hosted share/resource for a still-authorized principal after voluntary deactivation. Inapplicable to data already written into a buyer-owned destination.' ), + NoExplicitNull, ] = None current_coverage: Annotated[ reporting_coverage.ReportingCoverage | None, Field( description='Current effective product/package coverage for the selected offering and resolved account. This setup-time view may change as media buys or provider capabilities change; each period obligation later freezes its own authoritative coverage.' ), + NoExplicitNull, ] = None setup: Annotated[ Setup | None, Field( description='Secret-free next step when provider-side authorization or recipient activation cannot be completed automatically.' ), + NoExplicitNull, ] = None issues: Annotated[ - list[reporting_status_issue.ReportingStatusIssue] | None, Field(min_length=1) + list[reporting_status_issue.ReportingStatusIssue] | None, + Field(min_length=1), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_delivery_offering.py b/src/adcp/types/domains/core/reporting_delivery_offering.py index 02ad6a866..8123b8d84 100644 --- a/src/adcp/types/domains/core/reporting_delivery_offering.py +++ b/src/adcp/types/domains/core/reporting_delivery_offering.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_offering.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..enums import reporting_finality @@ -71,6 +71,7 @@ class ReportingProfile(AdCPBaseModel): max_length=128, min_length=1, ), + NoExplicitNull, ] = None canonicalization_contract_version: Literal['1.0'] = '1.0' canonicalization_media_type: Literal['application/vnd.adcp.reporting-canonicalization+json'] = ( @@ -81,6 +82,7 @@ class ReportingProfile(AdCPBaseModel): Field( description='Retrievable exact canonicalization contract on the authenticated seller/provider or AdCP-registry origin. SDKs apply the same bounded, redirect-free SSRF controls as schema_uri and verify canonicalization_sha256 before use.' ), + NoExplicitNull, ] = None canonicalization_sha256: Annotated[ str | None, @@ -88,6 +90,7 @@ class ReportingProfile(AdCPBaseModel): description='Digest of the exact canonicalization contract identified by canonicalization_id.', pattern='^[A-Fa-f0-9]{64}$', ), + NoExplicitNull, ] = None @@ -119,8 +122,10 @@ class ProducerIdentity(AdCPBaseModel): ) provider: Provider identity: Annotated[str, Field(max_length=512, min_length=1)] - cloud: reporting_dataset_share_destination.ReportingCloud | None = None - region: Annotated[str | None, Field(max_length=128, min_length=1)] = None + cloud: Annotated[reporting_dataset_share_destination.ReportingCloud | None, NoExplicitNull] = ( + None + ) + region: Annotated[str | None, Field(max_length=128, min_length=1), NoExplicitNull] = None class ReportingFeedPurpose(StrEnum): @@ -143,18 +148,23 @@ class Method(AdCPBaseModel): transport: Annotated[str, Field(max_length=64, min_length=1, pattern='^[a-z][a-z0-9_.-]*$')] orchestration: reporting_delivery_method.ReportingOrchestration destination_modes: Annotated[list[DestinationMode], Field(min_length=1)] - provider: Provider | None = None - format: Format | None = None + provider: Annotated[Provider | None, NoExplicitNull] = None + format: Annotated[Format | None, NoExplicitNull] = None access_mode: Annotated[ - str | None, Field(max_length=64, min_length=1, pattern='^[a-z][a-z0-9_.-]*$') + str | None, + Field(max_length=64, min_length=1, pattern='^[a-z][a-z0-9_.-]*$'), + NoExplicitNull, ] = None producer_identity: Annotated[ ProducerIdentity | None, Field( description='Seller principal a buyer grants access to for this exact buyer-hosted destination offering.' ), + NoExplicitNull, + ] = None + reader_compatibility: Annotated[ + list[reporting_resource.ReportingReaderCompatibilityItem] | None, NoExplicitNull ] = None - reader_compatibility: list[reporting_resource.ReportingReaderCompatibilityItem] | None = None class ReportingDeliveryOffering(AdCPBaseModel): @@ -218,4 +228,5 @@ class ReportingDeliveryOffering(AdCPBaseModel): Field( description='Managed delivery method for this offering. Omit for a Core (API-delivered) offering: rows flow through existing get_media_buy_delivery and reporting_webhook transports and no destination is involved. Present only when the seller advertises managed_delivery.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_file_entry.py b/src/adcp/types/domains/core/reporting_file_entry.py index e8e092cc5..6aa299b80 100644 --- a/src/adcp/types/domains/core/reporting_file_entry.py +++ b/src/adcp/types/domains/core/reporting_file_entry.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/reporting_file_entry.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from . import reporting_file_object_ref, reporting_native_version_ref @@ -17,8 +17,10 @@ class ReportingFileEntry(AdCPBaseModel): extra='forbid', ) object_ref: reporting_file_object_ref.ReportingFileObjectReference - native_version_ref: reporting_native_version_ref.ReportingNativeVersionReference | None = None + native_version_ref: Annotated[ + reporting_native_version_ref.ReportingNativeVersionReference | None, NoExplicitNull + ] = None size_bytes: Annotated[SchemaInt, Field(ge=0)] sha256: Annotated[str, Field(pattern='^[A-Fa-f0-9]{64}$')] row_count: Annotated[SchemaInt, Field(ge=0)] - partition: Annotated[dict[str, str] | None, Field(max_length=32)] = None + partition: Annotated[dict[str, str] | None, Field(max_length=32), NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py b/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py index 8976a6482..55fb958a4 100644 --- a/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py +++ b/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_ledger_changed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import reporting_finality @@ -49,16 +49,24 @@ class ReportingLedgerChangedWebhook(AdCPBaseModel): account_id: Annotated[str, Field(min_length=1)] change_kind: ChangeKind reporting_revision_id: Annotated[ - str | None, Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$') + str | None, + Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$'), + NoExplicitNull, ] = None supersedes_reporting_revision_id: Annotated[ - str | None, Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$') + str | None, + Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$'), + NoExplicitNull, ] = None - finality: reporting_finality.ReportingFinality | None = None + finality: Annotated[reporting_finality.ReportingFinality | None, NoExplicitNull] = None reporting_adjustment_id: Annotated[ - str | None, Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$') + str | None, + Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$'), + NoExplicitNull, ] = None adjusts_reporting_revision_id: Annotated[ - str | None, Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$') + str | None, + Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reporting_materialization.py b/src/adcp/types/domains/core/reporting_materialization.py index 995f13884..85762a0c6 100644 --- a/src/adcp/types/domains/core/reporting_materialization.py +++ b/src/adcp/types/domains/core/reporting_materialization.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_materialization.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from . import reporting_delivery_offering, reporting_resource, reporting_verification @@ -66,7 +66,9 @@ class ReportingMaterialization(AdCPBaseModel): feed_purpose: reporting_delivery_offering.ReportingFeedPurpose method: Method transport: Annotated[ - str | None, Field(max_length=64, min_length=1, pattern='^[a-z][a-z0-9_.-]*$') + str | None, + Field(max_length=64, min_length=1, pattern='^[a-z][a-z0-9_.-]*$'), + NoExplicitNull, ] = None attempt: Annotated[SchemaInt, Field(ge=1)] status: Annotated[ @@ -78,8 +80,9 @@ class ReportingMaterialization(AdCPBaseModel): ready_at: Annotated[ AwareDatetime | None, Field(description='When consumer-path or destination verification completed.'), + NoExplicitNull, ] = None - failed_at: AwareDatetime | None = None + failed_at: Annotated[AwareDatetime | None, NoExplicitNull] = None failure_code: Annotated[ str | None, Field( @@ -88,7 +91,10 @@ class ReportingMaterialization(AdCPBaseModel): min_length=1, pattern='^[A-Z][A-Z0-9_]*$', ), + NoExplicitNull, ] = None - resource: reporting_resource.ReportingResource | None = None - verification: reporting_verification.ReportingVerification | None = None + resource: Annotated[reporting_resource.ReportingResource | None, NoExplicitNull] = None + verification: Annotated[reporting_verification.ReportingVerification | None, NoExplicitNull] = ( + None + ) created_at: AwareDatetime diff --git a/src/adcp/types/domains/core/reporting_obligation.py b/src/adcp/types/domains/core/reporting_obligation.py index bc2a9fa25..22993ed06 100644 --- a/src/adcp/types/domains/core/reporting_obligation.py +++ b/src/adcp/types/domains/core/reporting_obligation.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_obligation.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from ..enums import reporting_finality, reporting_health @@ -91,6 +91,7 @@ class ReportingObligation(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None required_finality: reporting_finality.ReportingFinality reconciliation_mode: reporting_reconciliation_mode.ReportingReconciliationMode @@ -120,6 +121,7 @@ class ReportingObligation(AdCPBaseModel): description='Complete number of immutable authenticated consumer status statements associated with this obligation in the ledger snapshot, whether originally joined by reporting_obligation_id or by the exact configuration-generation, report-definition, and period key before the obligation existed. Core status-sync history is counted independently from Reconciled Billing receipts.', ge=0, ), + NoExplicitNull, ] = None current_consumer_status_id: Annotated[ str | None, @@ -129,6 +131,7 @@ class ReportingObligation(AdCPBaseModel): min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$', ), + NoExplicitNull, ] = None adjustment_count: Annotated[ SchemaInt | None, @@ -136,6 +139,7 @@ class ReportingObligation(AdCPBaseModel): description='Number of immutable post-official reporting adjustment records for this obligation in the consistent ledger snapshot.', ge=0, ), + NoExplicitNull, ] = None materialization_count: Annotated[ SchemaInt | None, @@ -143,6 +147,7 @@ class ReportingObligation(AdCPBaseModel): description="Number of materialization records for this obligation's revisions in the consistent ledger snapshot. Present iff the obligation is managed-delivery (destination_ref present).", ge=0, ), + NoExplicitNull, ] = None successful_materialization_count: Annotated[ SchemaInt | None, @@ -150,6 +155,7 @@ class ReportingObligation(AdCPBaseModel): description='Number of available/delivered verified materializations in the consistent ledger snapshot. Present iff the obligation is managed-delivery (destination_ref present).', ge=0, ), + NoExplicitNull, ] = None receipt_count: Annotated[ SchemaInt | None, @@ -157,6 +163,7 @@ class ReportingObligation(AdCPBaseModel): description='Complete number of authenticated receipts associated with this obligation in the ledger snapshot. Present iff reconciliation_mode is consumer_receipt.', ge=0, ), + NoExplicitNull, ] = None accepted_receipt_count: Annotated[ SchemaInt | None, @@ -164,6 +171,7 @@ class ReportingObligation(AdCPBaseModel): description='Number of accepted receipts. At most one current accepted receipt per consumer and revision contributes to reconciliation_status. Present iff reconciliation_mode is consumer_receipt.', ge=0, ), + NoExplicitNull, ] = None adjustment_receipt_count: Annotated[ SchemaInt | None, @@ -171,6 +179,7 @@ class ReportingObligation(AdCPBaseModel): description="Number of authenticated receipts for adjustments targeting this obligation's official revision. Reconciled Billing only.", ge=0, ), + NoExplicitNull, ] = None accepted_adjustment_receipt_count: Annotated[ SchemaInt | None, @@ -178,6 +187,7 @@ class ReportingObligation(AdCPBaseModel): description='Number of accepted adjustment receipts. Reconciled Billing buyers do not post an adjustment until their exact digest is accepted.', ge=0, ), + NoExplicitNull, ] = None pending_adjustment_count: Annotated[ SchemaInt | None, @@ -185,6 +195,7 @@ class ReportingObligation(AdCPBaseModel): description='Number of applicable adjustments without an accepted receipt. Reconciled Billing complete/healthy requires zero.', ge=0, ), + NoExplicitNull, ] = None issues: list[reporting_status_issue.ReportingStatusIssue] resource_retained_until: Annotated[ @@ -192,4 +203,5 @@ class ReportingObligation(AdCPBaseModel): Field( description='Minimum time through which at least one verified materialization for a completed obligation remains readable. Managed-delivery only; Core revisions are retained per status_retention_days and readable through the existing API transports.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_receipt.py b/src/adcp/types/domains/core/reporting_receipt.py index 4deba8528..17703e45c 100644 --- a/src/adcp/types/domains/core/reporting_receipt.py +++ b/src/adcp/types/domains/core/reporting_receipt.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_receipt.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -53,18 +53,21 @@ class ReportingReceipt(AdCPBaseModel): min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$', ), + NoExplicitNull, ] = None status: Status verification_profile: reporting_verification_profile.ReportingVerificationProfile observed_row_count: Annotated[SchemaInt, Field(ge=0)] observed_control_totals: list[reporting_control_total.ReportingControlTotal] - observed_canonical_content_digest: ( - reporting_canonical_content_digest.ReportingCanonicalContentDigest | None - ) = None - observed_manifest_sha256: Annotated[str | None, Field(pattern='^[A-Fa-f0-9]{64}$')] = None - observed_native_version_ref: ( - reporting_native_version_ref.ReportingNativeVersionReference | None - ) = None + observed_canonical_content_digest: Annotated[ + reporting_canonical_content_digest.ReportingCanonicalContentDigest | None, NoExplicitNull + ] = None + observed_manifest_sha256: Annotated[ + str | None, Field(pattern='^[A-Fa-f0-9]{64}$'), NoExplicitNull + ] = None + observed_native_version_ref: Annotated[ + reporting_native_version_ref.ReportingNativeVersionReference | None, NoExplicitNull + ] = None consumer_commit_ref: Annotated[ str | None, Field( @@ -72,7 +75,10 @@ class ReportingReceipt(AdCPBaseModel): max_length=512, min_length=1, ), + NoExplicitNull, ] = None - rejection_codes: Annotated[list[RejectionCode] | None, Field(min_length=1)] = None + rejection_codes: Annotated[list[RejectionCode] | None, Field(min_length=1), NoExplicitNull] = ( + None + ) observed_at: AwareDatetime - received_at: AwareDatetime | None = None + received_at: Annotated[AwareDatetime | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reporting_reliability_statistics.py b/src/adcp/types/domains/core/reporting_reliability_statistics.py index 53b9e1e70..8faa6a66a 100644 --- a/src/adcp/types/domains/core/reporting_reliability_statistics.py +++ b/src/adcp/types/domains/core/reporting_reliability_statistics.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_reliability_statistics.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field @@ -34,8 +34,10 @@ class Evidence(AdCPBaseModel): ) basis: Basis generated_at: AwareDatetime - attestation_uri: AnyUrl | None = None - attestation_sha256: Annotated[str | None, Field(pattern='^[A-Fa-f0-9]{64}$')] = None + attestation_uri: Annotated[AnyUrl | None, NoExplicitNull] = None + attestation_sha256: Annotated[ + str | None, Field(pattern='^[A-Fa-f0-9]{64}$'), NoExplicitNull + ] = None class LatencyPercentiles(AdCPBaseModel): @@ -93,11 +95,13 @@ class ReportingReliabilityStatistics(AdCPBaseModel): Field( description='Observed seconds from official finalized_at to the first adjustment, required when official_revisions_adjusted is nonzero.' ), + NoExplicitNull, ] = None adjustment_magnitude: Annotated[ list[AdjustmentMagnitudeItem] | None, Field( description='Optional absolute adjustment-magnitude percentiles for comparable control totals such as spend.' ), + NoExplicitNull, ] = None evidence: Evidence diff --git a/src/adcp/types/domains/core/reporting_report_definition.py b/src/adcp/types/domains/core/reporting_report_definition.py index 29d118e15..527b9128e 100644 --- a/src/adcp/types/domains/core/reporting_report_definition.py +++ b/src/adcp/types/domains/core/reporting_report_definition.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_report_definition.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -59,7 +59,7 @@ class Metric(AdCPBaseModel): name: Annotated[str, Field(max_length=128, min_length=1)] source_expression: Annotated[str, Field(max_length=2048, min_length=1)] aggregation: Aggregation - unit: Annotated[str | None, Field(max_length=64, min_length=1)] = None + unit: Annotated[str | None, Field(max_length=64, min_length=1), NoExplicitNull] = None class Dimension(ScalarStr): @@ -150,7 +150,7 @@ class Calendar(AdCPBaseModel): extra='forbid', ) timezone_basis: ReportCalendarTimezoneBasis - timezone: Annotated[str | None, Field(max_length=255, min_length=1)] = None + timezone: Annotated[str | None, Field(max_length=255, min_length=1), NoExplicitNull] = None class ReportingReportDefinition(AdCPBaseModel): diff --git a/src/adcp/types/domains/core/reporting_resource.py b/src/adcp/types/domains/core/reporting_resource.py index 8cd4072f7..0393eaa3c 100644 --- a/src/adcp/types/domains/core/reporting_resource.py +++ b/src/adcp/types/domains/core/reporting_resource.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_resource.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -55,7 +55,9 @@ class ReportingResource(AdCPBaseModel): min_length=1, ), ] - native_version_ref: reporting_native_version_ref.ReportingNativeVersionReference | None = None + native_version_ref: Annotated[ + reporting_native_version_ref.ReportingNativeVersionReference | None, NoExplicitNull + ] = None manifest_version: Annotated[ Literal['1.0'], Field(description='Version of reporting-file-manifest.json used by a manifest resource.'), @@ -66,6 +68,7 @@ class ReportingResource(AdCPBaseModel): description='SHA-256 over the exact manifest bytes. Consumers verify this before parsing the manifest.', pattern='^[A-Fa-f0-9]{64}$', ), + NoExplicitNull, ] = None immutability: Annotated[ Immutability, @@ -82,4 +85,5 @@ class ReportingResource(AdCPBaseModel): Field( description='Reader features or format constraints required to consume this resource. Readiness verification MUST use a representative supported reader.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_revision.py b/src/adcp/types/domains/core/reporting_revision.py index fd3e216c6..3114f0d3a 100644 --- a/src/adcp/types/domains/core/reporting_revision.py +++ b/src/adcp/types/domains/core/reporting_revision.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_revision.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from ..enums import reporting_finality @@ -114,6 +114,7 @@ class ReportingRevision(AdCPBaseModel): Field( description='Why an official revision is considered final: an authoritative source signal, a versioned contractual cutoff, or a versioned stabilization rule.' ), + NoExplicitNull, ] = None finality_policy_id: Annotated[ str | None, @@ -123,12 +124,14 @@ class ReportingRevision(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None finalized_at: Annotated[ AwareDatetime | None, Field( description='When the producer applied the declared finality basis to this official revision.' ), + NoExplicitNull, ] = None observed_at: Annotated[ AwareDatetime, @@ -149,6 +152,7 @@ class ReportingRevision(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None row_count: Annotated[ SchemaInt, @@ -163,7 +167,7 @@ class ReportingRevision(AdCPBaseModel): description='Profile-defined totals computed from the canonical logical revision. Names MUST be unique.' ), ] - canonical_content_digest: ( - reporting_canonical_content_digest.ReportingCanonicalContentDigest | None - ) = None + canonical_content_digest: Annotated[ + reporting_canonical_content_digest.ReportingCanonicalContentDigest | None, NoExplicitNull + ] = None created_at: AwareDatetime diff --git a/src/adcp/types/domains/core/reporting_schedule.py b/src/adcp/types/domains/core/reporting_schedule.py index 8226a2ba3..28a05ae60 100644 --- a/src/adcp/types/domains/core/reporting_schedule.py +++ b/src/adcp/types/domains/core/reporting_schedule.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_schedule.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field @@ -36,6 +36,7 @@ class ReportingSchedule(AdCPBaseModel): Field( description='Required for billing_cycle alignment. This immutable instant anchors the recurring half-open billing periods so producer and consumer derive the same month, quarter, or other contractual cycle.' ), + NoExplicitNull, ] = None period_timezone: Annotated[ str | None, @@ -44,6 +45,7 @@ class ReportingSchedule(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None delivery_sla: Annotated[ str, diff --git a/src/adcp/types/domains/core/reporting_schedule_offering.py b/src/adcp/types/domains/core/reporting_schedule_offering.py index 26d0a633a..b52b0aced 100644 --- a/src/adcp/types/domains/core/reporting_schedule_offering.py +++ b/src/adcp/types/domains/core/reporting_schedule_offering.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_schedule_offering.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from . import reporting_schedule @@ -40,15 +40,19 @@ class ReportingScheduleOffering(AdCPBaseModel): Field( description='For billing_cycle only. fixed requires the advertised anchor and timezone; configurable lets each authorized account configuration select them.' ), + NoExplicitNull, ] = None period_timezone_policy: Annotated[ PeriodTimezonePolicy | None, Field( description="For source_timezone only. fixed advertises one exact upstream IANA timezone; account_resolved requires the seller to resolve and echo the account's upstream reporting timezone during configuration." ), + NoExplicitNull, ] = None - period_anchor: AwareDatetime | None = None - period_timezone: Annotated[str | None, Field(max_length=255, min_length=1)] = None + period_anchor: Annotated[AwareDatetime | None, NoExplicitNull] = None + period_timezone: Annotated[str | None, Field(max_length=255, min_length=1), NoExplicitNull] = ( + None + ) delivery_sla: Annotated[ str, Field( diff --git a/src/adcp/types/domains/core/reporting_status_changed_webhook.py b/src/adcp/types/domains/core/reporting_status_changed_webhook.py index 3378c62cc..cc98bb9d0 100644 --- a/src/adcp/types/domains/core/reporting_status_changed_webhook.py +++ b/src/adcp/types/domains/core/reporting_status_changed_webhook.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/reporting_status_changed_webhook.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -67,17 +67,19 @@ class ReportingStatusChangedWebhook(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None health: Annotated[ reporting_health.ReportingHealth, Field(description='The health state after this transition.'), ] - previous_health: reporting_health.ReportingHealth | None = None + previous_health: Annotated[reporting_health.ReportingHealth | None, NoExplicitNull] = None issue_ids: Annotated[ list[IssueId] | None, Field( description='Stable identifiers of the open issues that caused or survived this transition, matching issues[].issue_id on get_reporting_status, so consumers can project AdCP reporting issues into durable work items. Empty or absent on recovery transitions.', max_length=16, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reporting_status_issue.py b/src/adcp/types/domains/core/reporting_status_issue.py index 129fd2cbf..873a23820 100644 --- a/src/adcp/types/domains/core/reporting_status_issue.py +++ b/src/adcp/types/domains/core/reporting_status_issue.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_status_issue.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from . import reporting_coverage, reporting_delivery_offering @@ -80,12 +80,14 @@ class ReportingStatusIssue(AdCPBaseModel): Field( description='When the seller first observed this logical condition, carried unchanged across every re-emission until the issue is retired. It anchors the escalation clock advertised as consumer_mismatch_escalation_seconds and lets a consumer age an issue without keeping its own first-seen table. Required when code is CONSUMER_STATUS_MISMATCH.' ), + NoExplicitNull, ] = None issue_state: Annotated[ IssueState | None, Field( description='Optional seller-maintained lifecycle for this issue_id. open is the default when omitted. acknowledged means a human on responsible_party has taken it up but the condition persists. resolved means the underlying condition no longer holds; a recurrence uses a new issue_id. waived means the parties agreed off-protocol to disregard this exact issue even though its underlying condition may still hold. Only open and acknowledged issues appear in issues[]; retiring an issue removes it from the projection rather than publishing it at resolved or waived, so a reader that treats a nonempty issues[] as degradation stays correct. A CONSUMER_STATUS_MISMATCH waiver follows the bilateral, exact-scope requirements in consumer_mismatch_lifecycle.' ), + NoExplicitNull, ] = None external_ref: Annotated[ str | None, @@ -95,6 +97,7 @@ class ReportingStatusIssue(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,128}$', ), + NoExplicitNull, ] = None responsible_party: ResponsibleParty recommended_action: RecommendedAction @@ -104,9 +107,12 @@ class ReportingStatusIssue(AdCPBaseModel): description='Untrusted display text only. SDKs and agents dispatch exclusively on closed code/recommended_action values and never execute embedded links or instructions.', max_length=500, ), + NoExplicitNull, ] = None reporting_obligation_id: Annotated[ - str | None, Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$') + str | None, + Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$'), + NoExplicitNull, ] = None reporting_revision_id: Annotated[ str | None, @@ -116,6 +122,7 @@ class ReportingStatusIssue(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None reporting_status_id: Annotated[ str | None, @@ -125,18 +132,23 @@ class ReportingStatusIssue(AdCPBaseModel): min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$', ), + NoExplicitNull, ] = None delivery_config_id: Annotated[ - str | None, Field(max_length=64, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$') + str | None, + Field(max_length=64, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$'), + NoExplicitNull, + ] = None + delivery_config_version: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + feed_purpose: Annotated[ + reporting_delivery_offering.ReportingFeedPurpose | None, NoExplicitNull ] = None - delivery_config_version: Annotated[SchemaInt | None, Field(ge=1)] = None - feed_purpose: reporting_delivery_offering.ReportingFeedPurpose | None = None media_buy_ids: Annotated[ - list[reporting_coverage.ReportingMediaBuyId] | None, Field(min_length=1) + list[reporting_coverage.ReportingMediaBuyId] | None, Field(min_length=1), NoExplicitNull ] = None package_ids: Annotated[ - list[reporting_coverage.ReportingPackageId] | None, Field(min_length=1) + list[reporting_coverage.ReportingPackageId] | None, Field(min_length=1), NoExplicitNull ] = None - period_start: AwareDatetime | None = None - period_end: AwareDatetime | None = None - expected_at: AwareDatetime | None = None + period_start: Annotated[AwareDatetime | None, NoExplicitNull] = None + period_end: Annotated[AwareDatetime | None, NoExplicitNull] = None + expected_at: Annotated[AwareDatetime | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reporting_verification.py b/src/adcp/types/domains/core/reporting_verification.py index d10af955d..2aad46853 100644 --- a/src/adcp/types/domains/core/reporting_verification.py +++ b/src/adcp/types/domains/core/reporting_verification.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_verification.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from . import ( @@ -86,19 +86,21 @@ class ReportingVerification(AdCPBaseModel): description='Profile-defined totals recomputed through verification_path. Names MUST be unique.' ), ] - canonical_content_digest: ( - reporting_canonical_content_digest.ReportingCanonicalContentDigest | None - ) = None + canonical_content_digest: Annotated[ + reporting_canonical_content_digest.ReportingCanonicalContentDigest | None, NoExplicitNull + ] = None physical_checksums: Annotated[ list[PhysicalChecksums | PhysicalChecksums1] | None, Field( description='Method-specific byte/object checksums. Different encodings of the same logical revision normally have different values.', min_length=1, ), + NoExplicitNull, ] = None native_commit_evidence: Annotated[ NativeCommitEvidence | None, Field( description='Provider-native immutable version evidence observed through the named consumer or destination path.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_webhook.py b/src/adcp/types/domains/core/reporting_webhook.py index 75a031218..58d23feac 100644 --- a/src/adcp/types/domains/core/reporting_webhook.py +++ b/src/adcp/types/domains/core/reporting_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_webhook.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..enums import auth_scheme, available_metric @@ -51,6 +51,7 @@ class ReportingWebhook(AdCPBaseModel): description='Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.', min_length=16, ), + NoExplicitNull, ] = None authentication: Annotated[ Authentication, @@ -70,4 +71,5 @@ class ReportingWebhook(AdCPBaseModel): Field( description="Optional list of metrics to include in webhook notifications. If omitted, all available metrics are included; an empty array has the same meaning as omission (it does not narrow to impressions and spend only). impressions and spend are always included regardless of this list. Must be a subset of the product's available_metrics. Subset evaluation and leaf resolution follow `enums/available-metric.json`: a numeric leaf may be covered by its container, while each structured distribution must be explicitly available. Requesting any nested identity selects its canonical carrier inside the payload's nested object. Same narrowing semantics as get_media_buy_delivery's requested_metrics (which additionally requires at least one entry when present)." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_write_destination.py b/src/adcp/types/domains/core/reporting_write_destination.py index e14248287..d6c58d90c 100644 --- a/src/adcp/types/domains/core/reporting_write_destination.py +++ b/src/adcp/types/domains/core/reporting_write_destination.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/reporting_write_destination.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -56,6 +56,7 @@ class ReportingWriteDestination2(AdCPBaseModel): min_length=1, pattern='^[a-z][a-z0-9_.-]*$', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/representation_destination.py b/src/adcp/types/domains/core/representation_destination.py index b811381a9..e68ca9ac8 100644 --- a/src/adcp/types/domains/core/representation_destination.py +++ b/src/adcp/types/domains/core/representation_destination.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/representation_destination.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import daast_version, vast_version @@ -32,16 +32,19 @@ class RepresentationDestination(AdCPBaseModel): description='Optional placements the derived manifest must satisfy. Omission means every placement in the intended product route, not an unconstrained destination.', min_length=1, ), + NoExplicitNull, ] = None execution_vast_version: Annotated[ vast_version.VastVersion | None, Field( description='Exact VAST execution version selected for seller-assembled first-class VAST trackers when no sibling exact-version VAST document supplies it. Must be accepted by format_option and is preserved in selection lineage.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST execution version selected for seller-assembled first-class DAAST trackers when no sibling exact-version DAAST document supplies it. Must be accepted by format_option and is preserved in selection lineage.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/representation_rejection.py b/src/adcp/types/domains/core/representation_rejection.py index 8af9d760f..c25dc64aa 100644 --- a/src/adcp/types/domains/core/representation_rejection.py +++ b/src/adcp/types/domains/core/representation_rejection.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/representation_rejection.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -29,4 +29,4 @@ class RepresentationRejection(AdCPBaseModel): representation_id: Annotated[str, Field(min_length=1)] code: Code message: Annotated[str, Field(min_length=1)] - details: dict[str, Any] | None = None + details: Annotated[dict[str, Any] | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/representation_selection.py b/src/adcp/types/domains/core/representation_selection.py index 0778984a0..f7b92afd6 100644 --- a/src/adcp/types/domains/core/representation_selection.py +++ b/src/adcp/types/domains/core/representation_selection.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/representation_selection.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import daast_version, representation_selection_strategy, vast_version @@ -49,12 +49,14 @@ class RepresentationSelection(AdCPBaseModel): Field( description='Exact VAST version verified for seller-assembled first-class VAST trackers. This is selection lineage and part of the execution review identity; it is not included in selected_output_digest.' ), + NoExplicitNull, ] = None execution_daast_version: Annotated[ daast_version.DaastVersion | None, Field( description='Exact DAAST version verified for seller-assembled first-class DAAST trackers. This is selection lineage and part of the execution review identity; it is not included in selected_output_digest.' ), + NoExplicitNull, ] = None resolved_by: Annotated[ ResolvedBy, diff --git a/src/adcp/types/domains/core/requirements/audio_asset_requirements.py b/src/adcp/types/domains/core/requirements/audio_asset_requirements.py index 673dd0b39..f181e7fa0 100644 --- a/src/adcp/types/domains/core/requirements/audio_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/audio_asset_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/requirements/audio_asset_requirements.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarInt from pydantic import ConfigDict, Field, StrictFloat @@ -35,38 +35,53 @@ class AudioAssetRequirements(AdCPBaseModel): extra='allow', ) min_duration_ms: Annotated[ - SchemaInt | None, Field(description='Minimum duration in milliseconds', ge=1) + SchemaInt | None, + Field(description='Minimum duration in milliseconds', ge=1), + NoExplicitNull, ] = None max_duration_ms: Annotated[ - SchemaInt | None, Field(description='Maximum duration in milliseconds', ge=1) + SchemaInt | None, + Field(description='Maximum duration in milliseconds', ge=1), + NoExplicitNull, + ] = None + formats: Annotated[ + list[Format] | None, Field(description='Accepted audio file formats'), NoExplicitNull ] = None - formats: Annotated[list[Format] | None, Field(description='Accepted audio file formats')] = None max_file_size_kb: Annotated[ - SchemaInt | None, Field(description='Maximum file size in kilobytes', ge=1) + SchemaInt | None, Field(description='Maximum file size in kilobytes', ge=1), NoExplicitNull ] = None sample_rates: Annotated[ list[SampleRate] | None, Field(description='Accepted sample rates in Hz (e.g., [44100, 48000])'), + NoExplicitNull, ] = None channels: Annotated[ - list[Channel] | None, Field(description='Accepted audio channel configurations') + list[Channel] | None, + Field(description='Accepted audio channel configurations'), + NoExplicitNull, ] = None min_bitrate_kbps: Annotated[ - SchemaInt | None, Field(description='Minimum audio bitrate in kilobits per second', ge=1) + SchemaInt | None, + Field(description='Minimum audio bitrate in kilobits per second', ge=1), + NoExplicitNull, ] = None max_bitrate_kbps: Annotated[ - SchemaInt | None, Field(description='Maximum audio bitrate in kilobits per second', ge=1) + SchemaInt | None, + Field(description='Maximum audio bitrate in kilobits per second', ge=1), + NoExplicitNull, ] = None loudness_lufs: Annotated[ StrictFloat | None, Field( description='Target integrated loudness in LUFS. LKFS is the equivalent unit name under ITU-R BS.1770; values expressed in LUFS and LKFS are directly comparable without conversion.' ), + NoExplicitNull, ] = None loudness_tolerance_db: Annotated[ StrictFloat | None, Field(description='Acceptable deviation from the loudness_lufs target in dB.', ge=0.0), + NoExplicitNull, ] = None true_peak_dbfs: Annotated[ - StrictFloat | None, Field(description='Maximum true-peak level in dBFS.') + StrictFloat | None, Field(description='Maximum true-peak level in dBFS.'), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/core/requirements/catalog_field_binding.py b/src/adcp/types/domains/core/requirements/catalog_field_binding.py index c3d6bfcd6..94c2757bb 100644 --- a/src/adcp/types/domains/core/requirements/catalog_field_binding.py +++ b/src/adcp/types/domains/core/requirements/catalog_field_binding.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/requirements/catalog_field_binding.json -# timestamp: 2026-10-03T00:36:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from .. import ext as ext_1 @@ -29,7 +29,7 @@ class ScalarBinding(AdCPBaseModel): description="Dot-notation path to the field on the catalog item (e.g., 'name', 'price.amount', 'location.city')." ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AssetPoolBinding(AdCPBaseModel): @@ -49,7 +49,7 @@ class AssetPoolBinding(AdCPBaseModel): description="The asset_group_id on the catalog item's assets array to pull from (e.g., 'images_landscape', 'images_vertical', 'logo')." ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None PerItemBindings = Annotated[ @@ -79,8 +79,9 @@ class CatalogFieldBinding1(AdCPBaseModel): description='Scalar and asset pool bindings that apply within each repetition of the group. Nested catalog_group bindings are not permitted.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None CatalogFieldBinding = Annotated[ diff --git a/src/adcp/types/domains/core/requirements/catalog_requirements.py b/src/adcp/types/domains/core/requirements/catalog_requirements.py index 81471366d..86505c335 100644 --- a/src/adcp/types/domains/core/requirements/catalog_requirements.py +++ b/src/adcp/types/domains/core/requirements/catalog_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/requirements/catalog_requirements.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ...enums import catalog_type as catalog_type_1 @@ -27,6 +27,7 @@ class CatalogRequirements(AdCPBaseModel): Field( description='Whether this catalog type must be present. When true, creatives using this format must reference a synced catalog of this type.' ), + NoExplicitNull, ] = True min_items: Annotated[ SchemaInt | None, @@ -34,6 +35,7 @@ class CatalogRequirements(AdCPBaseModel): description='Minimum number of items the catalog must contain for this format to render properly (e.g., a carousel might require at least 3 products)', ge=1, ), + NoExplicitNull, ] = None max_items: Annotated[ SchemaInt | None, @@ -41,6 +43,7 @@ class CatalogRequirements(AdCPBaseModel): description='Maximum number of items the format can render. Items beyond this limit are ignored. Useful for fixed-slot layouts (e.g., a 3-product card) or feed-size constraints.', ge=1, ), + NoExplicitNull, ] = None required_fields: Annotated[ list[str] | None, @@ -48,6 +51,7 @@ class CatalogRequirements(AdCPBaseModel): description="Fields that must be present and non-empty on every item in the catalog. Field names are catalog-type-specific (e.g., 'title', 'price', 'image_url' for product catalogs; 'store_id', 'quantity' for inventory feeds).", min_length=1, ), + NoExplicitNull, ] = None feed_formats: Annotated[ list[feed_format.FeedFormat] | None, @@ -55,6 +59,7 @@ class CatalogRequirements(AdCPBaseModel): description='Accepted feed formats for this catalog type. When specified, the synced catalog must use one of these formats. When omitted, any format is accepted.', min_length=1, ), + NoExplicitNull, ] = None offering_asset_constraints: Annotated[ list[offering_asset_constraint.OfferingAssetConstraint] | None, @@ -62,6 +67,7 @@ class CatalogRequirements(AdCPBaseModel): description="Per-item creative asset requirements. Declares what asset groups (headlines, images, videos) each catalog item must provide in its assets array, along with count bounds and per-asset technical constraints. Applicable to 'offering' and all vertical catalog types (hotel, flight, job, etc.) whose items carry typed assets.", min_length=1, ), + NoExplicitNull, ] = None field_bindings: Annotated[ list[catalog_field_binding.CatalogFieldBinding] | None, @@ -69,4 +75,5 @@ class CatalogRequirements(AdCPBaseModel): description='Explicit mappings from format template slots to catalog item fields or typed asset pools. Optional — creative agents can infer mappings without them, but bindings make the relationship self-describing and enable validation. Covers scalar fields (asset_id → catalog_field), asset pools (asset_id → asset_group_id on the catalog item), and repeatable groups that iterate over catalog items.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/requirements/css_asset_requirements.py b/src/adcp/types/domains/core/requirements/css_asset_requirements.py index 96bdaef76..488eb60c1 100644 --- a/src/adcp/types/domains/core/requirements/css_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/css_asset_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/requirements/css_asset_requirements.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field @@ -15,5 +15,5 @@ class CssAssetRequirements(AdCPBaseModel): extra='allow', ) max_file_size_kb: Annotated[ - SchemaInt | None, Field(description='Maximum file size in kilobytes', ge=1) + SchemaInt | None, Field(description='Maximum file size in kilobytes', ge=1), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/core/requirements/daast_asset_requirements.py b/src/adcp/types/domains/core/requirements/daast_asset_requirements.py index 1b591a10b..75d7da0ae 100644 --- a/src/adcp/types/domains/core/requirements/daast_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/daast_asset_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/requirements/daast_asset_requirements.json -# timestamp: 2026-08-28T20:03:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ...enums import daast_version as daast_version_1 @@ -23,8 +23,10 @@ class DaastAssetRequirements(AdCPBaseModel): deprecated=True, description='Deprecated one-element alias for `daast_versions`. Producers use either the singular legacy alias or the plural 3.2 field, never both.', ), + NoExplicitNull, ] = None daast_versions: Annotated[ daast_tracker_constraints.DaastVersions | None, Field(description='Accepted DAAST version set for this asset requirement.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/requirements/html_asset_requirements.py b/src/adcp/types/domains/core/requirements/html_asset_requirements.py index 303810f4c..b2fd842fd 100644 --- a/src/adcp/types/domains/core/requirements/html_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/html_asset_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/requirements/html_asset_requirements.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -25,22 +25,26 @@ class HtmlAssetRequirements(AdCPBaseModel): max_file_size_kb: Annotated[ SchemaInt | None, Field(description='Maximum file size in kilobytes for the HTML asset', ge=1), + NoExplicitNull, ] = None sandbox: Annotated[ Sandbox | None, Field( description="Sandbox environment the HTML must be compatible with. 'none' = direct DOM access, 'iframe' = standard iframe isolation, 'safeframe' = IAB SafeFrame container, 'fencedframe' = Privacy Sandbox fenced frame" ), + NoExplicitNull, ] = Sandbox.none external_resources_allowed: Annotated[ StrictBool | None, Field( description='Whether the HTML creative can load external resources (scripts, images, fonts, etc.). When false, all resources must be inlined or bundled.' ), + NoExplicitNull, ] = None allowed_external_domains: Annotated[ list[str] | None, Field( description='List of domains the HTML creative may reference for external resources. Only applicable when external_resources_allowed is true.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/requirements/image_asset_requirements.py b/src/adcp/types/domains/core/requirements/image_asset_requirements.py index f0a643f4e..9ea7e77d0 100644 --- a/src/adcp/types/domains/core/requirements/image_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/image_asset_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/requirements/image_asset_requirements.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat from pydantic import ConfigDict, Field, StrictBool, StrictFloat @@ -65,6 +65,7 @@ class ImageAssetRequirements(AdCPBaseModel): description='Minimum width. Interpretation depends on unit (default: pixels). For exact dimensions, set min_width = max_width.', gt=0.0, ), + NoExplicitNull, ] = None max_width: Annotated[ StrictFloat | None, @@ -72,6 +73,7 @@ class ImageAssetRequirements(AdCPBaseModel): description='Maximum width. Interpretation depends on unit (default: pixels). For exact dimensions, set min_width = max_width.', gt=0.0, ), + NoExplicitNull, ] = None min_height: Annotated[ StrictFloat | None, @@ -79,6 +81,7 @@ class ImageAssetRequirements(AdCPBaseModel): description='Minimum height. Interpretation depends on unit (default: pixels). For exact dimensions, set min_height = max_height.', gt=0.0, ), + NoExplicitNull, ] = None max_height: Annotated[ StrictFloat | None, @@ -86,6 +89,7 @@ class ImageAssetRequirements(AdCPBaseModel): description='Maximum height. Interpretation depends on unit (default: pixels). For exact dimensions, set min_height = max_height.', gt=0.0, ), + NoExplicitNull, ] = None pixel_ratios: Annotated[ list[PixelRatio] | None, @@ -93,18 +97,21 @@ class ImageAssetRequirements(AdCPBaseModel): description='Accepted intrinsic-pixel densities for this image slot. One submitted image matching any listed ratio satisfies the slot; the array does not require one asset per ratio. Absence imposes no density constraint beyond the existing dimension requirements.', min_length=1, ), + NoExplicitNull, ] = None parameters_from_format_id: Annotated[ StrictBool | None, Field( description="When true on a legacy template format, logical width, logical height, and pixel ratio are supplied by the parameterized format_id. The image asset's intrinsic dimensions MUST equal logical dimensions multiplied by pixel_ratio (which defaults to 1 when omitted)." ), + NoExplicitNull, ] = None unit: Annotated[ dimension_unit.DimensionUnit | None, Field( description="Unit of measurement for width/height values. Defaults to 'px' when absent. Print formats use 'inches' or 'cm'." ), + NoExplicitNull, ] = None aspect_ratio: Annotated[ str | None, @@ -112,36 +119,45 @@ class ImageAssetRequirements(AdCPBaseModel): description="Required aspect ratio (e.g., '16:9', '1:1', '1.91:1')", pattern='^\\d+(\\.\\d+)?:\\d+(\\.\\d+)?$', ), + NoExplicitNull, + ] = None + formats: Annotated[ + list[Format] | None, Field(description='Accepted image file formats'), NoExplicitNull ] = None - formats: Annotated[list[Format] | None, Field(description='Accepted image file formats')] = None min_dpi: Annotated[ SchemaInt | None, Field( description='Minimum resolution in dots per inch. Always in DPI regardless of the dimension unit. Standard print requires 300 DPI, newspaper 150 DPI.', ge=1, ), + NoExplicitNull, ] = None bleed: Annotated[ Bleed | Bleed1 | None, Field( description='Required bleed area beyond the trim size. The submitted image must be larger than the declared dimensions: total width = trim width + left bleed + right bleed, total height = trim height + top bleed + bottom bleed. For uniform bleed: total = trim + (2 * uniform). Uses the same unit as the parent dimensions.' ), + NoExplicitNull, ] = None color_space: Annotated[ - ColorSpace | None, Field(description='Required color space. Print typically requires CMYK.') + ColorSpace | None, + Field(description='Required color space. Print typically requires CMYK.'), + NoExplicitNull, ] = None max_file_size_kb: Annotated[ - SchemaInt | None, Field(description='Maximum file size in kilobytes', ge=1) + SchemaInt | None, Field(description='Maximum file size in kilobytes', ge=1), NoExplicitNull ] = None transparency_required: Annotated[ StrictBool | None, Field( description='Whether the image must support transparency (requires PNG, WebP, or GIF)' ), + NoExplicitNull, ] = None animation_allowed: Annotated[ StrictBool | None, Field(description='Whether animated images (GIF, animated WebP) are accepted'), + NoExplicitNull, ] = None max_animation_duration_ms: Annotated[ SchemaInt | None, @@ -149,6 +165,7 @@ class ImageAssetRequirements(AdCPBaseModel): description='Maximum animation duration in milliseconds (if animation_allowed is true)', ge=0, ), + NoExplicitNull, ] = None max_weight_grams: Annotated[ SchemaInt | None, @@ -156,4 +173,5 @@ class ImageAssetRequirements(AdCPBaseModel): description='Maximum weight in grams for the finished physical piece (print inserts, flyers). Affects postage calculations and production constraints. Only applicable to print channels.', gt=0, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/requirements/javascript_asset_requirements.py b/src/adcp/types/domains/core/requirements/javascript_asset_requirements.py index a2757e7e9..f0f6a5efe 100644 --- a/src/adcp/types/domains/core/requirements/javascript_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/javascript_asset_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/requirements/javascript_asset_requirements.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -24,23 +24,29 @@ class JavascriptAssetRequirements(AdCPBaseModel): max_file_size_kb: Annotated[ SchemaInt | None, Field(description='Maximum file size in kilobytes for the JavaScript asset', ge=1), + NoExplicitNull, ] = None module_type: Annotated[ ModuleType | None, Field( description="Required JavaScript module format. 'script' = classic script, 'module' = ES modules, 'iife' = immediately invoked function expression" ), + NoExplicitNull, ] = None strict_mode_required: Annotated[ - StrictBool | None, Field(description='Whether the JavaScript must use strict mode') + StrictBool | None, + Field(description='Whether the JavaScript must use strict mode'), + NoExplicitNull, ] = None external_resources_allowed: Annotated[ StrictBool | None, Field(description='Whether the JavaScript can load external resources dynamically'), + NoExplicitNull, ] = None allowed_external_domains: Annotated[ list[str] | None, Field( description='List of domains the JavaScript may reference for external resources. Only applicable when external_resources_allowed is true.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/requirements/markdown_asset_requirements.py b/src/adcp/types/domains/core/requirements/markdown_asset_requirements.py index ff057afdc..d842d30b1 100644 --- a/src/adcp/types/domains/core/requirements/markdown_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/markdown_asset_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/requirements/markdown_asset_requirements.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field @@ -14,6 +14,6 @@ class MarkdownAssetRequirements(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - max_length: Annotated[SchemaInt | None, Field(description='Maximum character length', ge=1)] = ( - None - ) + max_length: Annotated[ + SchemaInt | None, Field(description='Maximum character length', ge=1), NoExplicitNull + ] = None diff --git a/src/adcp/types/domains/core/requirements/offering_asset_constraint.py b/src/adcp/types/domains/core/requirements/offering_asset_constraint.py index 752c31af6..f9392afeb 100644 --- a/src/adcp/types/domains/core/requirements/offering_asset_constraint.py +++ b/src/adcp/types/domains/core/requirements/offering_asset_constraint.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/requirements/offering_asset_constraint.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ...enums import asset_content_type @@ -33,17 +33,23 @@ class OfferingAssetConstraint(AdCPBaseModel): Field( description='Whether this asset group must be present in each offering. Defaults to true.' ), + NoExplicitNull, ] = True min_count: Annotated[ - SchemaInt | None, Field(description='Minimum number of items required in this group.', ge=1) + SchemaInt | None, + Field(description='Minimum number of items required in this group.', ge=1), + NoExplicitNull, ] = None max_count: Annotated[ - SchemaInt | None, Field(description='Maximum number of items allowed in this group.', ge=1) + SchemaInt | None, + Field(description='Maximum number of items allowed in this group.', ge=1), + NoExplicitNull, ] = None asset_requirements: Annotated[ asset_requirements_1.AssetRequirements | None, Field( description='Technical requirements for each item in this group (e.g., max_length for text, min_width/aspect_ratio for images). Applies uniformly to all items in the group.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/requirements/text_asset_requirements.py b/src/adcp/types/domains/core/requirements/text_asset_requirements.py index 9ae90b27e..071a7965e 100644 --- a/src/adcp/types/domains/core/requirements/text_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/text_asset_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/requirements/text_asset_requirements.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field @@ -14,26 +14,27 @@ class TextAssetRequirements(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - min_length: Annotated[SchemaInt | None, Field(description='Minimum character length', ge=0)] = ( - None - ) - max_length: Annotated[SchemaInt | None, Field(description='Maximum character length', ge=1)] = ( - None - ) - min_lines: Annotated[SchemaInt | None, Field(description='Minimum number of lines', ge=1)] = ( - None - ) - max_lines: Annotated[SchemaInt | None, Field(description='Maximum number of lines', ge=1)] = ( - None - ) + min_length: Annotated[ + SchemaInt | None, Field(description='Minimum character length', ge=0), NoExplicitNull + ] = None + max_length: Annotated[ + SchemaInt | None, Field(description='Maximum character length', ge=1), NoExplicitNull + ] = None + min_lines: Annotated[ + SchemaInt | None, Field(description='Minimum number of lines', ge=1), NoExplicitNull + ] = None + max_lines: Annotated[ + SchemaInt | None, Field(description='Maximum number of lines', ge=1), NoExplicitNull + ] = None character_pattern: Annotated[ str | None, Field( description="Regex pattern defining allowed characters (e.g., '^[a-zA-Z0-9 .,!?-]+$')" ), + NoExplicitNull, ] = None prohibited_terms: Annotated[ - list[str] | None, Field(description='List of prohibited words or phrases') + list[str] | None, Field(description='List of prohibited words or phrases'), NoExplicitNull ] = None allowed_values: Annotated[ list[str] | None, @@ -41,4 +42,5 @@ class TextAssetRequirements(AdCPBaseModel): description='Closed set of permitted string values for this text slot. When present, a conformant implementation MUST reject any submitted content not in this list with `CREATIVE_VALUE_NOT_ALLOWED` (echoing the offending field path in `error.field` and the allowed list in `error.details.allowed_values`). Matching is case-sensitive; producers MUST supply exact casing. If the submitted value contains unresolved template tokens (e.g., {{product_name}}), validation against allowed_values MUST be deferred until interpolation is complete. All declared constraints (allowed_values, character_pattern, max_length, etc.) are conjunctive — submitted content must satisfy every applicable constraint simultaneously.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/requirements/url_asset_requirements.py b/src/adcp/types/domains/core/requirements/url_asset_requirements.py index 32f541574..4b1c06daf 100644 --- a/src/adcp/types/domains/core/requirements/url_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/url_asset_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/requirements/url_asset_requirements.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -34,20 +34,25 @@ class UrlAssetRequirements(AdCPBaseModel): Field( description="Purpose this URL slot serves in the format — distinct from `url_type` on the manifest-side asset (which declares the receiver's invocation mechanism). A slot can be `click_tracker` (purpose) and accept a `tracker_pixel` (mechanism) URL, or `clickthrough` (purpose) and accept a `clickthrough` (mechanism) URL. Complements `asset_role` (human-readable label) by providing a machine-readable enum and serves as the receiver's fallback signal when a manifest URL asset omits `url_type`." ), + NoExplicitNull, ] = None protocols: Annotated[ list[Protocol] | None, Field(description='Allowed URL protocols. HTTPS is recommended for all ad URLs.'), + NoExplicitNull, ] = None allowed_domains: Annotated[ - list[str] | None, Field(description='List of allowed domains for the URL') + list[str] | None, Field(description='List of allowed domains for the URL'), NoExplicitNull ] = None max_length: Annotated[ - SchemaInt | None, Field(description='Maximum URL length in characters', ge=1) + SchemaInt | None, + Field(description='Maximum URL length in characters', ge=1), + NoExplicitNull, ] = None macro_support: Annotated[ StrictBool | None, Field( description="Coarse slot gate for macro-bearing URLs. `false` is a hard prohibition on macro tokens. `true` permits tokens but does not prove any dialect semantic, resolver, context, or encoding capability; exact support comes from the selected format option's macro_resolution_capabilities." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/requirements/vast_asset_requirements.py b/src/adcp/types/domains/core/requirements/vast_asset_requirements.py index f778813a2..23e7d5d89 100644 --- a/src/adcp/types/domains/core/requirements/vast_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/vast_asset_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/requirements/vast_asset_requirements.json -# timestamp: 2026-08-28T20:03:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ...enums import vast_version as vast_version_1 @@ -23,14 +23,17 @@ class VastAssetRequirements(AdCPBaseModel): deprecated=True, description='Deprecated one-element alias for `vast_versions`. Producers use either the singular legacy alias or the plural 3.2 field, never both.', ), + NoExplicitNull, ] = None vast_versions: Annotated[ list[vast_version_1.VastVersion] | None, Field(description='Accepted VAST version set for this asset requirement.', min_length=1), + NoExplicitNull, ] = None media_file_requirements: Annotated[ vast_media_file_requirements.VastMediafileRequirements | None, Field( description='Technical acceptance constraints for the alternative MediaFile renditions in each applicable resolved InLine linear creative.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/requirements/video_asset_requirements.py b/src/adcp/types/domains/core/requirements/video_asset_requirements.py index 9bb6b1477..730be18fa 100644 --- a/src/adcp/types/domains/core/requirements/video_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/video_asset_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/requirements/video_asset_requirements.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat, ScalarInt from pydantic import ConfigDict, Field, StrictBool, StrictFloat @@ -60,67 +60,89 @@ class VideoAssetRequirements(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - min_width: Annotated[SchemaInt | None, Field(description='Minimum width in pixels', ge=1)] = ( - None - ) - max_width: Annotated[SchemaInt | None, Field(description='Maximum width in pixels', ge=1)] = ( - None - ) - min_height: Annotated[SchemaInt | None, Field(description='Minimum height in pixels', ge=1)] = ( - None - ) - max_height: Annotated[SchemaInt | None, Field(description='Maximum height in pixels', ge=1)] = ( - None - ) + min_width: Annotated[ + SchemaInt | None, Field(description='Minimum width in pixels', ge=1), NoExplicitNull + ] = None + max_width: Annotated[ + SchemaInt | None, Field(description='Maximum width in pixels', ge=1), NoExplicitNull + ] = None + min_height: Annotated[ + SchemaInt | None, Field(description='Minimum height in pixels', ge=1), NoExplicitNull + ] = None + max_height: Annotated[ + SchemaInt | None, Field(description='Maximum height in pixels', ge=1), NoExplicitNull + ] = None aspect_ratio: Annotated[ str | None, Field(description="Required aspect ratio (e.g., '16:9', '9:16')", pattern='^\\d+:\\d+$'), + NoExplicitNull, ] = None min_duration_ms: Annotated[ - SchemaInt | None, Field(description='Minimum duration in milliseconds', ge=1) + SchemaInt | None, + Field(description='Minimum duration in milliseconds', ge=1), + NoExplicitNull, ] = None max_duration_ms: Annotated[ - SchemaInt | None, Field(description='Maximum duration in milliseconds', ge=1) + SchemaInt | None, + Field(description='Maximum duration in milliseconds', ge=1), + NoExplicitNull, ] = None containers: Annotated[ - list[Container] | None, Field(description='Accepted video container formats') + list[Container] | None, + Field(description='Accepted video container formats'), + NoExplicitNull, + ] = None + codecs: Annotated[ + list[Codec] | None, Field(description='Accepted video codecs'), NoExplicitNull ] = None - codecs: Annotated[list[Codec] | None, Field(description='Accepted video codecs')] = None max_file_size_kb: Annotated[ SchemaInt | None, Field(description='Maximum file size, where 1 KB is exactly 1,000 bytes', ge=1), + NoExplicitNull, ] = None min_bitrate_kbps: Annotated[ - SchemaInt | None, Field(description='Minimum video bitrate in kilobits per second', ge=1) + SchemaInt | None, + Field(description='Minimum video bitrate in kilobits per second', ge=1), + NoExplicitNull, ] = None max_bitrate_kbps: Annotated[ - SchemaInt | None, Field(description='Maximum video bitrate in kilobits per second', ge=1) + SchemaInt | None, + Field(description='Maximum video bitrate in kilobits per second', ge=1), + NoExplicitNull, ] = None frame_rates: Annotated[ list[FrameRate] | None, Field(description='Accepted frame rates in frames per second (e.g., [24, 30, 60])'), + NoExplicitNull, ] = None audio_required: Annotated[ - StrictBool | None, Field(description='Whether the video must include an audio track') + StrictBool | None, + Field(description='Whether the video must include an audio track'), + NoExplicitNull, ] = None frame_rate_type: Annotated[ frame_rate_type_1.FrameRateType | None, Field( description='Required frame rate type. Broadcast and SSAI require constant frame rate for seamless splicing.' ), + NoExplicitNull, ] = None scan_type: Annotated[ scan_type_1.ScanType | None, Field(description='Required scan type. Modern delivery requires progressive scan.'), + NoExplicitNull, ] = None gop_type: Annotated[ gop_type_1.GopType | None, Field( description='Required GOP structure. SSAI and broadcast require closed GOPs for clean splice points.' ), + NoExplicitNull, ] = None min_gop_interval_seconds: Annotated[ - StrictFloat | None, Field(description='Minimum keyframe interval in seconds', ge=0.0) + StrictFloat | None, + Field(description='Minimum keyframe interval in seconds', ge=0.0), + NoExplicitNull, ] = None max_gop_interval_seconds: Annotated[ StrictFloat | None, @@ -128,30 +150,36 @@ class VideoAssetRequirements(AdCPBaseModel): description='Maximum keyframe interval in seconds. SSAI typically requires 1-2 second intervals.', ge=0.0, ), + NoExplicitNull, ] = None moov_atom_position: Annotated[ moov_atom_position_1.MoovAtomPosition | None, Field( description="Required moov atom position in MP4 container. 'start' enables progressive download without buffering the entire file." ), + NoExplicitNull, ] = None audio_codecs: Annotated[ list[AudioCodec] | None, Field(description="Accepted audio codecs (e.g., ['aac', 'pcm', 'ac3'])"), + NoExplicitNull, ] = None audio_sample_rates: Annotated[ list[AudioSampleRate] | None, Field(description='Accepted audio sample rates in Hz (e.g., [44100, 48000])'), + NoExplicitNull, ] = None audio_channels: Annotated[ list[audio_channel_layout.AudioChannelLayout] | None, Field(description='Accepted audio channel configurations'), + NoExplicitNull, ] = None loudness_lufs: Annotated[ StrictFloat | None, Field( description='Target integrated loudness in LUFS (e.g., -24 for broadcast, -16 for streaming)' ), + NoExplicitNull, ] = None loudness_tolerance_db: Annotated[ StrictFloat | None, @@ -159,8 +187,10 @@ class VideoAssetRequirements(AdCPBaseModel): description='Acceptable deviation from loudness_lufs target in dB (e.g., 2 means -22 to -26 LUFS for a -24 target)', ge=0.0, ), + NoExplicitNull, ] = None true_peak_dbfs: Annotated[ StrictFloat | None, Field(description='Maximum true peak level in dBFS (e.g., -2 for broadcast)'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/requirements/webhook_asset_requirements.py b/src/adcp/types/domains/core/requirements/webhook_asset_requirements.py index bd1e92bab..9095cc63c 100644 --- a/src/adcp/types/domains/core/requirements/webhook_asset_requirements.py +++ b/src/adcp/types/domains/core/requirements/webhook_asset_requirements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/requirements/webhook_asset_requirements.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -20,4 +20,6 @@ class WebhookAssetRequirements(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - methods: Annotated[list[Method] | None, Field(description='Allowed HTTP methods')] = None + methods: Annotated[ + list[Method] | None, Field(description='Allowed HTTP methods'), NoExplicitNull + ] = None diff --git a/src/adcp/types/domains/core/response.py b/src/adcp/types/domains/core/response.py index 4898fb1a2..9ec163c18 100644 --- a/src/adcp/types/domains/core/response.py +++ b/src/adcp/types/domains/core/response.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/response.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -20,6 +20,7 @@ class ProtocolResponse(AdCPBaseModel): Field( description='Transport-managed conversation identifier. Maps to native contextId on A2A; compatibility metadata only on MCP and not a continuation or authorization mechanism.' ), + NoExplicitNull, ] = None data: Annotated[ Any | None, diff --git a/src/adcp/types/domains/core/rights_attestation_evaluation.py b/src/adcp/types/domains/core/rights_attestation_evaluation.py index ebd0bc596..74c7452d4 100644 --- a/src/adcp/types/domains/core/rights_attestation_evaluation.py +++ b/src/adcp/types/domains/core/rights_attestation_evaluation.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/rights_attestation_evaluation.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from . import brand_ref @@ -24,7 +24,7 @@ class Subject(AdCPBaseModel): agent_url: Annotated[ AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject19(AdCPBaseModel): @@ -59,7 +59,7 @@ class Subject19(AdCPBaseModel): pattern='^sha256:[a-f0-9]{64}$', ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ActionBinding(AdCPBaseModel): @@ -106,7 +106,7 @@ class Issuer(AdCPBaseModel): Literal['brand'], Field(description='The issuer is identified by an AdCP BrandRef.') ] = 'brand' brand: brand_ref.BrandReference - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Issuer5(AdCPBaseModel): @@ -126,7 +126,7 @@ class Issuer5(AdCPBaseModel): description='Canonical HTTPS endpoint of the issuing agent. Evaluators compare it using AdCP URL canonicalization rules.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Issuer6(AdCPBaseModel): @@ -146,7 +146,7 @@ class Issuer6(AdCPBaseModel): description='Canonical HTTPS origin with no path, query, fragment, or userinfo. This identifies the issuer; it does not authorize a fetch.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject10(AdCPBaseModel): @@ -156,7 +156,7 @@ class Subject10(AdCPBaseModel): type: Literal['brand'] = 'brand' resource_type: Literal['https://adcontextprotocol.org/claims/subjects/rights-grant'] = 'https://adcontextprotocol.org/claims/subjects/rights-grant' brand: brand_ref.BrandReference - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Reference(AttestationReference): @@ -175,6 +175,7 @@ class Reference(AttestationReference): ], title='Attestation Issuer', ), + NoExplicitNull, ] = None claim_type: Annotated[ Literal['https://adcontextprotocol.org/claims/rights/grant'], @@ -201,6 +202,7 @@ class Reference(AttestationReference): ], title='Attestation Subject', ), + NoExplicitNull, ] = None @@ -224,4 +226,4 @@ class RightsAttestationEvaluation(AdCPBaseModel): ] reference: Reference evaluation: Evaluation - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/rights_constraint.py b/src/adcp/types/domains/core/rights_constraint.py index d5838dd94..6f335c198 100644 --- a/src/adcp/types/domains/core/rights_constraint.py +++ b/src/adcp/types/domains/core/rights_constraint.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/rights_constraint.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool @@ -54,7 +54,7 @@ class Disclosure(AdCPBaseModel): extra='forbid', ) required: StrictBool - text: Annotated[str | None, Field(min_length=1)] = None + text: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class Subject(AdCPBaseModel): @@ -66,7 +66,7 @@ class Subject(AdCPBaseModel): agent_url: Annotated[ AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject29(AdCPBaseModel): @@ -101,7 +101,7 @@ class Subject29(AdCPBaseModel): pattern='^sha256:[a-f0-9]{64}$', ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Issuer(AdCPBaseModel): @@ -112,7 +112,7 @@ class Issuer(AdCPBaseModel): Literal['brand'], Field(description='The issuer is identified by an AdCP BrandRef.') ] = 'brand' brand: brand_ref.BrandReference - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Issuer8(AdCPBaseModel): @@ -132,7 +132,7 @@ class Issuer8(AdCPBaseModel): description='Canonical HTTPS endpoint of the issuing agent. Evaluators compare it using AdCP URL canonicalization rules.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Issuer9(AdCPBaseModel): @@ -152,7 +152,7 @@ class Issuer9(AdCPBaseModel): description='Canonical HTTPS origin with no path, query, fragment, or userinfo. This identifies the issuer; it does not authorize a fetch.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject20(AdCPBaseModel): @@ -162,7 +162,7 @@ class Subject20(AdCPBaseModel): type: Literal['brand'] = 'brand' resource_type: Literal['https://adcontextprotocol.org/claims/subjects/rights-grant'] = 'https://adcontextprotocol.org/claims/subjects/rights-grant' brand: brand_ref.BrandReference - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class AttestationRef(AttestationReference): @@ -181,6 +181,7 @@ class AttestationRef(AttestationReference): ], title='Attestation Issuer', ), + NoExplicitNull, ] = None claim_type: Annotated[ Literal['https://adcontextprotocol.org/claims/rights/grant'], @@ -207,6 +208,7 @@ class AttestationRef(AttestationReference): ], title='Attestation Subject', ), + NoExplicitNull, ] = None @@ -223,15 +225,19 @@ class RightsConstraint(AdCPBaseModel): Field( description='Canonical BrandRef for the rights holder that issued the grant through an authorized rights agent. Required whenever attestation_refs is present. Evaluators first match this exact BrandRef against local issuer policy, then resolve the authoritative brand.json and bind the credential verification key to exactly one matching agents[] entry with type rights. Sibling-brand agents are never authority. Legacy unattested constraints may omit this field but remain machine-unverified.' ), + NoExplicitNull, ] = None valid_from: Annotated[ - AwareDatetime | None, Field(description='Start of the rights validity period') + AwareDatetime | None, + Field(description='Start of the rights validity period'), + NoExplicitNull, ] = None valid_until: Annotated[ AwareDatetime | None, Field( description='End of the rights validity period. Creative should not be served after this time.' ), + NoExplicitNull, ] = None uses: Annotated[ list[right_use.RightUse], @@ -242,12 +248,14 @@ class RightsConstraint(AdCPBaseModel): Field( description='Countries where this creative may be served under these rights (ISO 3166-1 alpha-2). If omitted, no country restriction. When both countries and excluded_countries are present, the effective set is countries minus excluded_countries.' ), + NoExplicitNull, ] = None excluded_countries: Annotated[ list[ExcludedCountry] | None, Field( description='Countries excluded from rights availability (ISO 3166-1 alpha-2). Use when the grant is worldwide except specific markets.' ), + NoExplicitNull, ] = None impression_cap: Annotated[ SchemaInt | None, @@ -255,24 +263,28 @@ class RightsConstraint(AdCPBaseModel): description='Maximum total impressions allowed for the full validity period (valid_from to valid_until). This is the absolute cap across all creatives using this rights grant, not a per-creative or per-period limit.', ge=1, ), + NoExplicitNull, ] = None right_type: Annotated[ right_type_1.RightType | None, Field( description='Type of rights (talent, music, etc.). Helps identify constraints when a creative combines multiple rights types.' ), + NoExplicitNull, ] = None approval_status: Annotated[ ApprovalStatus | None, Field( description='Approval status from the rights holder at manifest creation time (snapshot, not a live value)' ), + NoExplicitNull, ] = None grant_status: Annotated[ GrantStatus | None, Field( description='Authoritative lifecycle state bound into the grant digest and credential status evidence. Only active can evaluate to verified. paused and revoked credentials are ineligible for serving; resumption issues a fresh active credential and reference.' ), + NoExplicitNull, ] = None restrictions: Annotated[ list[Restriction] | None, @@ -280,18 +292,21 @@ class RightsConstraint(AdCPBaseModel): description='Normalized enforceable content and usage restrictions issued with the grant. When present these values are part of content_digest and cannot be omitted or changed without invalidating the attestation.', min_length=1, ), + NoExplicitNull, ] = None disclosure: Annotated[ Disclosure | None, Field( description='Disclosure obligation issued with the grant and bound by content_digest.' ), + NoExplicitNull, ] = None creative_approval_required: Annotated[ StrictBool | None, Field( description='Whether each creative produced under this grant requires holder approval before distribution. This enforceable term is bound by content_digest.' ), + NoExplicitNull, ] = None verification_url: Annotated[ AnyUrl | None, @@ -299,6 +314,7 @@ class RightsConstraint(AdCPBaseModel): deprecated=True, description='DEPRECATED legacy informational locator. It is not proof of grant status, a revocation source, a credential locator, or permission to make a network request. Receivers MUST NOT fetch it during rights evaluation, and HTTP status codes have no AdCP authorization meaning. Rights authorization uses attestation_refs and a verifier-of-record evaluation under evaluator-owned policy.', ), + NoExplicitNull, ] = None content_digest: Annotated[ str | None, @@ -306,6 +322,7 @@ class RightsConstraint(AdCPBaseModel): description='SHA-256 of UTF8(JCS(rights_constraint minus content_digest, attestation_refs, and verification_url)), formatted as sha256:. This binds all serving-relevant grant fields, including future extension fields, while deliberately excluding the legacy non-authoritative URL.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None attestation_refs: Annotated[ list[AttestationRef] | None, @@ -314,5 +331,6 @@ class RightsConstraint(AdCPBaseModel): max_length=4, min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/seller_agent_ref.py b/src/adcp/types/domains/core/seller_agent_ref.py index a7cf517a5..c1b0e0e1a 100644 --- a/src/adcp/types/domains/core/seller_agent_ref.py +++ b/src/adcp/types/domains/core/seller_agent_ref.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/seller_agent_ref.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -27,4 +27,5 @@ class SellerAgentReference(AdCPBaseModel): min_length=1, pattern='^[a-zA-Z0-9_-]+$', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/signal_coverage_forecast.py b/src/adcp/types/domains/core/signal_coverage_forecast.py index 41a01b7c3..5a1b4ea92 100644 --- a/src/adcp/types/domains/core/signal_coverage_forecast.py +++ b/src/adcp/types/domains/core/signal_coverage_forecast.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_coverage_forecast.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Dict, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat @@ -21,14 +21,17 @@ class CoverageRate(forecast_point.CoverageRate): low: Annotated[ StrictFloat | None, Field(description='Conservative (low-end) forecast value', ge=0.0, le=1.0), + NoExplicitNull, ] = None mid: Annotated[ StrictFloat | None, Field(description='Expected (most likely) forecast value', ge=0.0, le=1.0), + NoExplicitNull, ] = None high: Annotated[ StrictFloat | None, Field(description='Optimistic (high-end) forecast value', ge=0.0, le=1.0), + NoExplicitNull, ] = None @@ -37,22 +40,22 @@ class Metrics(forecast_point.Metrics): model_config = ConfigDict( extra='allow', ) - audience_size: forecast_range.ForecastRange | None = None - reach: forecast_range.ForecastRange | None = None - frequency: forecast_range.ForecastRange | None = None - impressions: forecast_range.ForecastRange | None = None - clicks: forecast_range.ForecastRange | None = None - spend: forecast_range.ForecastRange | None = None - views: forecast_range.ForecastRange | None = None - completed_views: forecast_range.ForecastRange | None = None - grps: forecast_range.ForecastRange | None = None - engagements: forecast_range.ForecastRange | None = None - follows: forecast_range.ForecastRange | None = None - saves: forecast_range.ForecastRange | None = None - profile_visits: forecast_range.ForecastRange | None = None - measured_impressions: forecast_range.ForecastRange | None = None - downloads: forecast_range.ForecastRange | None = None - plays: forecast_range.ForecastRange | None = None + audience_size: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + reach: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + frequency: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + impressions: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + clicks: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + spend: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + views: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + completed_views: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + grps: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + engagements: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + follows: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + saves: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + profile_visits: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + measured_impressions: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + downloads: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None + plays: Annotated[forecast_range.ForecastRange | None, NoExplicitNull] = None coverage_rate: Annotated[ CoverageRate, Field( @@ -87,7 +90,7 @@ class Scope(AdCPBaseModel): ), ] product_id: Annotated[ - str | None, Field(description="Product denominator when kind is 'product'.") + str | None, Field(description="Product denominator when kind is 'product'."), NoExplicitNull ] = None countries: Annotated[ list[Country] | None, @@ -95,6 +98,7 @@ class Scope(AdCPBaseModel): description='Countries included in the denominator, as ISO 3166-1 alpha-2 codes.', min_length=1, ), + NoExplicitNull, ] = None line_item_types: Annotated[ list[str] | None, @@ -102,10 +106,12 @@ class Scope(AdCPBaseModel): description='Seller or ad-server line item types included in the denominator.', min_length=1, ), + NoExplicitNull, ] = None date_range: Annotated[ date_range_1.DateRange | None, Field(description='Historical or planned date window used to compute the denominator.'), + NoExplicitNull, ] = None @@ -174,9 +180,13 @@ class SignalCoverageForecast(AdCPBaseModel): ), ] generated_at: Annotated[ - AwareDatetime | None, Field(description='When this coverage forecast was computed.') + AwareDatetime | None, + Field(description='When this coverage forecast was computed.'), + NoExplicitNull, ] = None valid_until: Annotated[ - AwareDatetime | None, Field(description='When this coverage forecast expires.') + AwareDatetime | None, + Field(description='When this coverage forecast expires.'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/signal_definition.py b/src/adcp/types/domains/core/signal_definition.py index 1aedabd51..68ccb87eb 100644 --- a/src/adcp/types/domains/core/signal_definition.py +++ b/src/adcp/types/domains/core/signal_definition.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_definition.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, StrictFloat @@ -30,7 +30,9 @@ class Range(AdCPBaseModel): min: Annotated[StrictFloat, Field(description='Minimum value')] max: Annotated[StrictFloat, Field(description='Maximum value')] unit: Annotated[ - str | None, Field(description="Unit of measurement (e.g., 'score', 'dollars', 'years')") + str | None, + Field(description="Unit of measurement (e.g., 'score', 'dollars', 'years')"), + NoExplicitNull, ] = None @@ -44,10 +46,12 @@ class Value(AdCPBaseModel): Field( description='Optional human-readable or taxonomy-native path for display and review.' ), + NoExplicitNull, ] = None modifiers: Annotated[ list[str] | None, Field(description='Optional taxonomy-specific modifiers that qualify the node.'), + NoExplicitNull, ] = None @@ -64,10 +68,12 @@ class ValueMapping(AdCPBaseModel): Field( description='Optional human-readable or taxonomy-native path for display and review.' ), + NoExplicitNull, ] = None modifiers: Annotated[ list[str] | None, Field(description='Optional taxonomy-specific modifiers that qualify the mapped value.'), + NoExplicitNull, ] = None @@ -89,6 +95,7 @@ class Taxonomy(AdCPBaseModel): Field( description='Version identifier for the taxonomy when the taxonomy has versioned definitions.' ), + NoExplicitNull, ] = None segtax: Annotated[ SchemaInt | None, @@ -96,12 +103,14 @@ class Taxonomy(AdCPBaseModel): description='OpenRTB segtax code when the taxonomy maps to an OpenRTB segment taxonomy.', ge=1, ), + NoExplicitNull, ] = None etag: Annotated[ str | None, Field( description='Optional validator for custom taxonomy definitions so consumers can detect drift between signal publication and taxonomy resolution.' ), + NoExplicitNull, ] = None values: Annotated[ list[Value], @@ -116,12 +125,14 @@ class Taxonomy(AdCPBaseModel): description="For categorical signals, maps package-targeting allowed_values[] strings to stable taxonomy node identifiers. The allowed_values[] strings remain the wire values buyers submit; this mapping explains how those strings resolve into the published taxonomy. Each value_mappings[].value SHOULD match one of the signal's allowed_values[] entries; JSON Schema draft-07 cannot enforce this cross-array constraint.", min_length=1, ), + NoExplicitNull, ] = None parent_match_behavior: Annotated[ ParentMatchBehavior | None, Field( description="Whether this signal definition supports treating a parent taxonomy node as matching descendant nodes for discovery/filtering or seller-side expansion. 'exact_only' means only explicitly listed node ids match. 'descendants_supported' means the seller can expand known, version-pinned parent nodes to descendants internally, typically by ORing the children in its execution system. 'unknown' means the provider has not declared parent-node behavior. This field is metadata about discovery/translation behavior, not a package targeting operator." ), + NoExplicitNull, ] = None @@ -189,9 +200,11 @@ class Onboarder(AdCPBaseModel): extra='forbid', ) match_keys: Annotated[list[MatchKey], Field(min_length=1)] - pre_onboarding_audience_expansion: StrictBool | None = None - pre_onboarding_device_expansion: StrictBool | None = None - pre_onboarding_precision_level: PreOnboardingPrecisionLevel | None = None + pre_onboarding_audience_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + pre_onboarding_device_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + pre_onboarding_precision_level: Annotated[ + PreOnboardingPrecisionLevel | None, NoExplicitNull + ] = None class IdType(StrEnum): @@ -274,19 +287,26 @@ class Channel(AdCPBaseModel): list[Right], Field(description='Rights supported by this channel.', min_length=1) ] url: Annotated[ - AnyUrl | None, Field(description='HTTPS URL for submitting the rights request.') + AnyUrl | None, + Field(description='HTTPS URL for submitting the rights request.'), + NoExplicitNull, ] = None email: Annotated[ - EmailStr | None, Field(description='Email address for submitting the rights request.') + EmailStr | None, + Field(description='Email address for submitting the rights request.'), + NoExplicitNull, ] = None languages: Annotated[ - list[str] | None, Field(description='BCP 47 language tags supported by this channel.') + list[str] | None, + Field(description='BCP 47 language tags supported by this channel.'), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, Field( description='ISO 3166-1 alpha-2 countries this channel serves, when the provider routes rights by country.' ), + NoExplicitNull, ] = None @@ -301,6 +321,7 @@ class DataSubjectRights(AdCPBaseModel): max_length=253, pattern='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])$', ), + NoExplicitNull, ] = None channels: Annotated[ list[Channel], @@ -316,10 +337,12 @@ class DataSubjectRights(AdCPBaseModel): ge=1, le=90, ), + NoExplicitNull, ] = None ccpa_opt_out_url: Annotated[ AnyUrl | None, Field(description="US-specific 'Do Not Sell or Share' opt-out URL where required."), + NoExplicitNull, ] = None @@ -347,6 +370,7 @@ class Modeling(AdCPBaseModel): Field( description='Signal/modeling-specific disclosure requirements and jurisdictional notes. This is not creative provenance render guidance.' ), + NoExplicitNull, ] = None @@ -370,6 +394,7 @@ class SignalDefinition(AdCPBaseModel): description="Detailed description of what this signal represents and how it's derived", max_length=2000, ), + NoExplicitNull, ] = None value_type: Annotated[ signal_value_type.SignalValueType, @@ -380,6 +405,7 @@ class SignalDefinition(AdCPBaseModel): Field( description="Tags for grouping and filtering this domain's published signal definitions" ), + NoExplicitNull, ] = None allowed_values: Annotated[ list[str] | None, @@ -387,6 +413,7 @@ class SignalDefinition(AdCPBaseModel): description='For categorical signals, the valid values users can be assigned', min_length=1, ), + NoExplicitNull, ] = None restricted_attributes: Annotated[ list[restricted_attribute.RestrictedAttribute] | None, @@ -394,12 +421,14 @@ class SignalDefinition(AdCPBaseModel): description="Restricted attribute categories this signal touches. Data providers SHOULD declare these so governance agents can structurally match signals against a plan's restricted_attributes without relying on semantic inference from the signal name or description.", min_length=1, ), + NoExplicitNull, ] = None demographic_predicate: Annotated[ demographic_predicate_1.DemographicPredicate | None, Field( description='Authoritative machine-readable demographic meaning for this signal. Signal names and taxonomy labels alone never establish exact demographic equivalence. Signals carrying this field MUST also declare restricted_attributes including age.' ), + NoExplicitNull, ] = None policy_categories: Annotated[ list[str] | None, @@ -407,15 +436,19 @@ class SignalDefinition(AdCPBaseModel): description="Policy categories this signal is sensitive for (e.g., a children's interest signal declares ['children_directed']). Governance agents match these against a plan's policy_categories to flag sensitive data usage.", min_length=1, ), + NoExplicitNull, ] = None range: Annotated[ - Range | None, Field(description='For numeric signals, the valid value range') + Range | None, + Field(description='For numeric signals, the valid value range'), + NoExplicitNull, ] = None taxonomy: Annotated[ Taxonomy | None, Field( description='Optional taxonomy metadata describing what this signal means in an external audience, content, retail-media, or provider-owned taxonomy. Taxonomy metadata does not create a new value_type and does not change package targeting grammar: buyers still target the named signal according to value_type. When a taxonomy value is a parent node, parent/descendant expansion is seller behavior and must be declared through parent_match_behavior rather than assumed.' ), + NoExplicitNull, ] = None segmentation_criteria: Annotated[ str | None, @@ -423,12 +456,14 @@ class SignalDefinition(AdCPBaseModel): description='Rules governing inclusion of identifiers in the segment. Aligns with IAB Data Transparency Standard audience criteria disclosure.', max_length=500, ), + NoExplicitNull, ] = None criteria_url: Annotated[ AnyUrl | None, Field( description='Optional URL to a longer-form methodology or criteria document. This is a disclosure pointer; buyers should not branch programmatically on the linked content.' ), + NoExplicitNull, ] = None data_sources: Annotated[ list[DataSource] | None, @@ -436,48 +471,57 @@ class SignalDefinition(AdCPBaseModel): description="Origin categories of the raw data used to compile the signal, aligned with IAB Data Transparency Standard source disclosure. Use 'panel' for respondent-panel or JIC-style audience sources. Offline and public-record sources require onboarder disclosure. Co-viewing projection and reconciliation of seller claims against a JIC or measurement vendor belong in measurement reporting/vendor metrics rather than package signal targeting.", min_length=1, ), + NoExplicitNull, ] = None methodology: Annotated[ Methodology | None, Field( description="How the signal's audience membership or attribute was determined. 'modeled' requires the modeling block." ), + NoExplicitNull, ] = None audience_expansion: Annotated[ StrictBool | None, Field( description='Whether look-alike or similar-audience expansion was used to include additional identifiers. When true, modeling is required.' ), + NoExplicitNull, ] = None device_expansion: Annotated[ StrictBool | None, Field( description="Whether the signal was expanded deterministically across devices of the same user, household, or business. Probabilistic cross-device expansion is modeling and should use methodology 'modeled' or the modeling block." ), + NoExplicitNull, ] = None refresh_cadence: Annotated[ RefreshCadence | None, Field( description="Cadence at which the signal definition's underlying segment membership is refreshed." ), + NoExplicitNull, ] = None lookback_window: Annotated[ RefreshCadence | None, Field(description='Time window in which a qualifying event can occur for inclusion.'), + NoExplicitNull, ] = None onboarder: Annotated[ Onboarder | None, Field( description='Onboarder disclosure. Required when data_sources includes an offline_* or public_record_* source.' ), + NoExplicitNull, ] = None subject_type: Annotated[ audience_subject_type.AudienceSubjectType | None, Field(description='What kind of subject this signal characterizes.'), + NoExplicitNull, ] = None resolution_method: Annotated[ audience_resolution_method.AudienceResolutionMethod | None, Field(description='How the subject is resolved at decision time.'), + NoExplicitNull, ] = None id_types: Annotated[ list[IdType] | None, @@ -485,12 +529,14 @@ class SignalDefinition(AdCPBaseModel): description='Identifier currencies analyzed to determine audience membership or attributes.', min_length=1, ), + NoExplicitNull, ] = None audience_scope: Annotated[ AudienceScope | None, Field( description="Context within which the audience attribute was determined. 'single_domain' requires originating_domain." ), + NoExplicitNull, ] = None originating_domain: Annotated[ str | None, @@ -498,6 +544,7 @@ class SignalDefinition(AdCPBaseModel): description="Domain of the digital property where the audience originates. Required when audience_scope is 'single_domain'.", pattern='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])$', ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -505,6 +552,7 @@ class SignalDefinition(AdCPBaseModel): description="ISO 3166-1 alpha-2 country codes where the signal is applicable. Sellers must not expose a signal for media buys in countries outside this list. Federating agents that surface a peer's signal MUST treat the peer-published list as an upper bound, re-check it against the buyer's intended deployment countries, and may apply only narrower local policy.", min_length=1, ), + NoExplicitNull, ] = None consent_basis: Annotated[ list[consent_basis_1.ConsentBasis] | None, @@ -512,34 +560,40 @@ class SignalDefinition(AdCPBaseModel): description="Declared GDPR Article 6 lawful basis or consent basis under which this signal's data is processed. For non-GDPR regimes, use countries, policy_categories, and disclosure fields to describe jurisdiction-specific obligations unless a future enum value applies.", min_length=1, ), + NoExplicitNull, ] = None art9_basis: Annotated[ Art9Basis | None, Field( description='GDPR Article 9 basis when restricted_attributes is non-empty and the signal is used in jurisdictions where Article 9 applies. Required by policy for applicable use cases rather than universally required at schema level because sensitivity and lawful basis are jurisdiction-relative.' ), + NoExplicitNull, ] = None modeling: Annotated[ Modeling | None, Field( description="Modeling disclosure for modeled data signals. Required when methodology is 'modeled' or audience_expansion is true. This describes data modeling and intentionally does not reuse creative provenance, which is content/render oriented." ), + NoExplicitNull, ] = None data_subject_rights: Annotated[ DataSubjectRights | None, Field( description='Per-signal data-subject-rights routing. Inline on the signal because upstream source, rights routing, and response commitments can differ by segment or may be unavailable from a public provider document for custom/private signals. This is a contact/routing reference, not a machine-callable AdCP API.' ), + NoExplicitNull, ] = None last_updated: Annotated[ AwareDatetime | None, Field( description='When this definition record was last updated. This indicates freshness of the definition record, not an attestation that the underlying data or model was refreshed at that time.' ), + NoExplicitNull, ] = None dts_compliant_version: Annotated[ str | None, Field( description='IAB Data Transparency Standard version this signal definition self-attests as satisfying, when applicable.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/signal_definition_enrichment.py b/src/adcp/types/domains/core/signal_definition_enrichment.py index 2151a3a24..62b7af392 100644 --- a/src/adcp/types/domains/core/signal_definition_enrichment.py +++ b/src/adcp/types/domains/core/signal_definition_enrichment.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_definition_enrichment.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool @@ -22,8 +22,8 @@ class Value(AdCPBaseModel): extra='forbid', ) id: Annotated[str, Field(min_length=1)] - path: str | None = None - modifiers: list[str] | None = None + path: Annotated[str | None, NoExplicitNull] = None + modifiers: Annotated[list[str] | None, NoExplicitNull] = None class ValueMapping(AdCPBaseModel): @@ -32,8 +32,8 @@ class ValueMapping(AdCPBaseModel): ) value: str taxonomy_value_id: str - path: str | None = None - modifiers: list[str] | None = None + path: Annotated[str | None, NoExplicitNull] = None + modifiers: Annotated[list[str] | None, NoExplicitNull] = None class ParentMatchBehavior(StrEnum): @@ -47,12 +47,12 @@ class Taxonomy(AdCPBaseModel): extra='forbid', ) ref: AnyUrl - version: str | None = None - segtax: Annotated[SchemaInt | None, Field(ge=1)] = None - etag: str | None = None + version: Annotated[str | None, NoExplicitNull] = None + segtax: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + etag: Annotated[str | None, NoExplicitNull] = None values: Annotated[list[Value], Field(min_length=1)] - value_mappings: Annotated[list[ValueMapping] | None, Field(min_length=1)] = None - parent_match_behavior: ParentMatchBehavior | None = None + value_mappings: Annotated[list[ValueMapping] | None, Field(min_length=1), NoExplicitNull] = None + parent_match_behavior: Annotated[ParentMatchBehavior | None, NoExplicitNull] = None class DataSource(StrEnum): @@ -119,9 +119,11 @@ class Onboarder(AdCPBaseModel): extra='forbid', ) match_keys: Annotated[list[MatchKey], Field(min_length=1)] - pre_onboarding_audience_expansion: StrictBool | None = None - pre_onboarding_device_expansion: StrictBool | None = None - pre_onboarding_precision_level: PreOnboardingPrecisionLevel | None = None + pre_onboarding_audience_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + pre_onboarding_device_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + pre_onboarding_precision_level: Annotated[ + PreOnboardingPrecisionLevel | None, NoExplicitNull + ] = None class Country(ScalarStr): @@ -187,10 +189,10 @@ class Channel(AdCPBaseModel): extra='forbid', ) rights: Annotated[list[Right], Field(min_length=1)] - url: AnyUrl | None = None - email: EmailStr | None = None - languages: list[str] | None = None - countries: list[Country] | None = None + url: Annotated[AnyUrl | None, NoExplicitNull] = None + email: Annotated[EmailStr | None, NoExplicitNull] = None + languages: Annotated[list[str] | None, NoExplicitNull] = None + countries: Annotated[list[Country] | None, NoExplicitNull] = None class DataSubjectRights(AdCPBaseModel): @@ -203,10 +205,11 @@ class DataSubjectRights(AdCPBaseModel): max_length=253, pattern='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])$', ), + NoExplicitNull, ] = None channels: Annotated[list[Channel], Field(min_length=1)] - response_sla_days: Annotated[SchemaInt | None, Field(ge=1, le=90)] = None - ccpa_opt_out_url: AnyUrl | None = None + response_sla_days: Annotated[SchemaInt | None, Field(ge=1, le=90), NoExplicitNull] = None + ccpa_opt_out_url: Annotated[AnyUrl | None, NoExplicitNull] = None class Modeling(AdCPBaseModel): @@ -217,7 +220,9 @@ class Modeling(AdCPBaseModel): seed_source: SeedSource training_data_jurisdictions: Annotated[list[TrainingDataJurisdiction], Field(min_length=1)] ai_act_risk_class: AiActRiskClass - disclosure: signal_modeling_disclosure.SignalModelingDisclosure | None = None + disclosure: Annotated[ + signal_modeling_disclosure.SignalModelingDisclosure | None, NoExplicitNull + ] = None class SignalDefinitionEnrichment(AdCPBaseModel): @@ -227,57 +232,65 @@ class SignalDefinitionEnrichment(AdCPBaseModel): restricted_attributes: Annotated[ list[restricted_attribute.RestrictedAttribute] | None, Field(description='Restricted attribute categories this signal touches.', min_length=1), + NoExplicitNull, ] = None demographic_predicate: Annotated[ demographic_predicate_1.DemographicPredicate | None, Field( description="Projected authoritative demographic meaning for the signal. When projected from another provider, this MUST match the provider's definition exactly. Signal names alone never establish demographic semantics." ), + NoExplicitNull, ] = None policy_categories: Annotated[ list[str] | None, Field(description='Policy categories this signal is sensitive for.', min_length=1), + NoExplicitNull, ] = None taxonomy: Annotated[ Taxonomy | None, Field( description='Optional taxonomy metadata describing what this signal means in an external audience, content, retail-media, or provider-owned taxonomy.' ), + NoExplicitNull, ] = None - segmentation_criteria: Annotated[str | None, Field(max_length=500)] = None - criteria_url: AnyUrl | None = None - data_sources: Annotated[list[DataSource] | None, Field(min_length=1)] = None - methodology: Methodology | None = None - audience_expansion: StrictBool | None = None - device_expansion: StrictBool | None = None - refresh_cadence: RefreshCadence | None = None - lookback_window: RefreshCadence | None = None - onboarder: Onboarder | None = None - countries: Annotated[list[Country] | None, Field(min_length=1)] = None + segmentation_criteria: Annotated[str | None, Field(max_length=500), NoExplicitNull] = None + criteria_url: Annotated[AnyUrl | None, NoExplicitNull] = None + data_sources: Annotated[list[DataSource] | None, Field(min_length=1), NoExplicitNull] = None + methodology: Annotated[Methodology | None, NoExplicitNull] = None + audience_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + device_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + refresh_cadence: Annotated[RefreshCadence | None, NoExplicitNull] = None + lookback_window: Annotated[RefreshCadence | None, NoExplicitNull] = None + onboarder: Annotated[Onboarder | None, NoExplicitNull] = None + countries: Annotated[list[Country] | None, Field(min_length=1), NoExplicitNull] = None consent_basis: Annotated[ list[consent_basis_1.ConsentBasis] | None, Field( description="Data provider's declared GDPR Article 6 lawful basis or consent basis for the underlying signal definition, projected into this get_signals response row when requested. Sellers and federating agents that pass through another provider's signal MUST NOT substitute their own processing basis for the provider-declared basis.", min_length=1, ), + NoExplicitNull, ] = None art9_basis: Annotated[ Art9Basis | None, Field( description="Data provider's declared GDPR Article 9 basis for the underlying signal definition when special-category data is involved and Article 9 applies, projected into this get_signals response row when requested. Sellers and federating agents that pass through another provider's signal MUST NOT substitute their own Article 9 basis for the provider-declared basis." ), + NoExplicitNull, ] = None - modeling: Modeling | None = None + modeling: Annotated[Modeling | None, NoExplicitNull] = None data_subject_rights: Annotated[ DataSubjectRights | None, Field( description='Per-signal data-subject-rights routing. This is a contact/routing reference, not a machine-callable AdCP API.' ), + NoExplicitNull, ] = None last_updated: Annotated[ AwareDatetime | None, Field( description='When this definition record was last updated. This indicates freshness of the definition record, not an attestation that the underlying data or model was refreshed at that time.' ), + NoExplicitNull, ] = None - dts_compliant_version: str | None = None + dts_compliant_version: Annotated[str | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/signal_filters.py b/src/adcp/types/domains/core/signal_filters.py index 1e07697cd..deac4d24c 100644 --- a/src/adcp/types/domains/core/signal_filters.py +++ b/src/adcp/types/domains/core/signal_filters.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/signal_filters.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import signal_catalog_type @@ -20,13 +20,17 @@ class SignalFilters(AdCPBaseModel): catalog_types: Annotated[ list[signal_catalog_type.SignalAvailabilityType] | None, Field(description='Filter by catalog type', min_length=1), + NoExplicitNull, ] = None data_providers: Annotated[ - list[str] | None, Field(description='Filter by specific data providers', min_length=1) + list[str] | None, + Field(description='Filter by specific data providers', min_length=1), + NoExplicitNull, ] = None max_cpm: Annotated[ StrictFloat | None, Field(description="Maximum CPM filter. Applies only to signals with model='cpm'.", ge=0.0), + NoExplicitNull, ] = None max_percent: Annotated[ StrictFloat | None, @@ -35,13 +39,17 @@ class SignalFilters(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None min_coverage_percentage: Annotated[ - StrictFloat | None, Field(description='Minimum coverage requirement', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Minimum coverage requirement', ge=0.0, le=100.0), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Vendor-namespaced extension parameters for seller- or platform-specific signal filter criteria not covered by standard fields. Keys MUST be namespaced under a vendor or platform key (e.g., ext.gam, ext.platform_x). Sellers MUST treat all values as untrusted buyer input; avoid unbounded logging or labels, and do not interpolate values into caller-visible error strings, LLM prompts, SQL queries, or system commands without sanitization. Persistent use of an extension key across multiple buyers is a signal to propose standardization.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/signal_listing.py b/src/adcp/types/domains/core/signal_listing.py index c2c4db2db..6e65b568a 100644 --- a/src/adcp/types/domains/core/signal_listing.py +++ b/src/adcp/types/domains/core/signal_listing.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/signal_listing.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..enums import restricted_attribute, signal_value_type @@ -35,6 +35,7 @@ class SignalListing(AdCPBaseModel): Field( description="Canonical signal reference. Use scope 'product' for a product-local signal defined by this listing; use scope 'data_provider' with data_provider_domain for a signal defined in a data provider's published adagents.json signals[]; use scope 'signal_source' with signal_source_url for a source-native signal." ), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -42,36 +43,42 @@ class SignalListing(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older Signals Protocol clients.', ), + NoExplicitNull, ] = None name: Annotated[ str | None, Field( description="Human-readable signal name. Required when signal_ref.scope is 'product'. For data_provider and signal_source refs, this is optional contextual display text; the referenced definition or source remains authoritative." ), + NoExplicitNull, ] = None description: Annotated[ str | None, Field( description='Detailed signal description. For data_provider and signal_source refs, this is optional contextual display text and MUST NOT replace the referenced definition.' ), + NoExplicitNull, ] = None methodology_url: Annotated[ AnyUrl | None, Field( description='Optional link to published methodology, media-kit, or data documentation. For data_provider and signal_source refs, this SHOULD match or supplement the referenced definition.' ), + NoExplicitNull, ] = None last_updated: Annotated[ AwareDatetime | None, Field( description='When this listing record was last updated. This indicates freshness of the listing record, not an attestation that the underlying data or model was refreshed at that time.' ), + NoExplicitNull, ] = None value_type: Annotated[ signal_value_type.SignalValueType | None, Field( description="The data type of this signal's values. Required when signal_ref.scope is 'product'." ), + NoExplicitNull, ] = None categories: Annotated[ list[str] | None, @@ -79,10 +86,12 @@ class SignalListing(AdCPBaseModel): description="Valid values for categorical signals. Present when value_type is 'categorical'.", min_length=1, ), + NoExplicitNull, ] = None range: Annotated[ Range | None, Field(description="Valid range for numeric signals. Present when value_type is 'numeric'."), + NoExplicitNull, ] = None restricted_attributes: Annotated[ list[restricted_attribute.RestrictedAttribute] | None, @@ -90,12 +99,14 @@ class SignalListing(AdCPBaseModel): description='Restricted attribute categories this listing touches. Required with demographic_predicate and must include age. For referenced provider/source signals, any projected values must match the authoritative definition.', min_length=1, ), + NoExplicitNull, ] = None demographic_predicate: Annotated[ demographic_predicate_1.DemographicPredicate | None, Field( description='Machine-readable demographic meaning. For product-local signals this listing is authoritative; for data-provider and signal-source refs, any projected value MUST match the referenced authoritative definition. Signal names alone never establish demographic semantics.' ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/signal_modeling_disclosure.py b/src/adcp/types/domains/core/signal_modeling_disclosure.py index 31be34976..ea50f557b 100644 --- a/src/adcp/types/domains/core/signal_modeling_disclosure.py +++ b/src/adcp/types/domains/core/signal_modeling_disclosure.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_modeling_disclosure.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool @@ -30,6 +30,7 @@ class Jurisdiction(AdCPBaseModel): Field( description='Provider-defined sub-national region code or name when the obligation is regional. No global canonical format is implied.' ), + NoExplicitNull, ] = None regulation: Annotated[ str, @@ -40,15 +41,19 @@ class Jurisdiction(AdCPBaseModel): Field( description='Human-readable disclosure text or summary the provider expects buyers or reviewers to see.' ), + NoExplicitNull, ] = None disclosure_url: Annotated[ AnyUrl | None, Field( description="Optional URL to the provider's canonical disclosure or methodology page for this jurisdiction." ), + NoExplicitNull, ] = None audience: Annotated[ - Audience | None, Field(description='Primary audience for this disclosure entry.') + Audience | None, + Field(description='Primary audience for this disclosure entry.'), + NoExplicitNull, ] = None @@ -67,6 +72,7 @@ class SignalModelingDisclosure(AdCPBaseModel): Field( description='Jurisdictions where a modeling or AI-use disclosure applies.', min_length=1 ), + NoExplicitNull, ] = None notes: Annotated[ str | None, @@ -74,4 +80,5 @@ class SignalModelingDisclosure(AdCPBaseModel): description='Optional provider notes on how the disclosure should be interpreted. Informational only; buyers should not branch programmatically on this text.', max_length=2000, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/signal_pricing.py b/src/adcp/types/domains/core/signal_pricing.py index 2c35b6cc7..ad0f66885 100644 --- a/src/adcp/types/domains/core/signal_pricing.py +++ b/src/adcp/types/domains/core/signal_pricing.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_pricing.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -20,7 +20,7 @@ class VendorPricing1(AdCPBaseModel): model: Literal['cpm'] = 'cpm' cpm: Annotated[StrictFloat, Field(description='Cost per thousand impressions', ge=0.0)] currency: Annotated[str, Field(description='ISO 4217 currency code', pattern='^[A-Z]{3}$')] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class VendorPricing2(AdCPBaseModel): @@ -37,12 +37,13 @@ class VendorPricing2(AdCPBaseModel): description='Optional CPM cap. When set, the effective charge is min(percent × media_spend_per_mille, max_cpm).', ge=0.0, ), + NoExplicitNull, ] = None currency: Annotated[ str, Field(description='ISO 4217 currency code for the resulting charge', pattern='^[A-Z]{3}$'), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Period(StrEnum): @@ -60,7 +61,7 @@ class VendorPricing3(AdCPBaseModel): amount: Annotated[StrictFloat, Field(description='Fixed charge for the billing period', ge=0.0)] period: Annotated[Period, Field(description='Billing period for the flat fee.')] currency: Annotated[str, Field(description='ISO 4217 currency code', pattern='^[A-Z]{3}$')] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class VendorPricing4(AdCPBaseModel): @@ -76,7 +77,7 @@ class VendorPricing4(AdCPBaseModel): ] unit_price: Annotated[StrictFloat, Field(description='Cost per one unit', ge=0.0)] currency: Annotated[str, Field(description='ISO 4217 currency code', pattern='^[A-Z]{3}$')] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Metadata(AdCPBaseModel): @@ -89,6 +90,7 @@ class Metadata(AdCPBaseModel): description="One or two sentences describing the pricing construct in plain language, displayed to the buyer's operator when requesting approval. Should not repeat the top-level `description` verbatim — summarize the charge mechanic instead (e.g., 'Base $12 CPM plus $0.50 per qualifying post-view conversion, capped at $45 CPM').", min_length=1, ), + NoExplicitNull, ] = None @@ -116,8 +118,9 @@ class VendorPricing5(AdCPBaseModel): description='ISO 4217 currency code. Present when the pricing resolves to a monetary charge in a specific currency.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None VendorPricing = Annotated[ diff --git a/src/adcp/types/domains/core/signal_selection_group_rule.py b/src/adcp/types/domains/core/signal_selection_group_rule.py index 830ae4f84..01dbec9d4 100644 --- a/src/adcp/types/domains/core/signal_selection_group_rule.py +++ b/src/adcp/types/domains/core/signal_selection_group_rule.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_selection_group_rule.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field @@ -37,12 +37,14 @@ class SignalSelectionGroupRule(AdCPBaseModel): Field( description="How options in this selection_group are intended to be used in signal_targeting_groups. 'include' maps to child groups with operator 'any'. 'exclude' maps to child groups with operator 'none'. Omit when options in the group may be used according to each option's allowed_targeting_modes." ), + NoExplicitNull, ] = None selection_mode: Annotated[ SelectionMode | None, Field( description="Selection behavior for this selection_group. 'required' means at least min_selected_signals, or 1 when omitted. 'fixed' means default_selected options in this group are seller-applied and read-only." ), + NoExplicitNull, ] = None min_selected_signals: Annotated[ SchemaInt | None, @@ -50,8 +52,10 @@ class SignalSelectionGroupRule(AdCPBaseModel): description="Minimum selected options from this selection_group. If selection_mode is 'required' and omitted, sellers MUST treat the minimum as 1.", ge=0, ), + NoExplicitNull, ] = None max_selected_signals: Annotated[ SchemaInt | None, Field(description='Maximum selected options from this selection_group.', ge=1), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/signal_targeting.py b/src/adcp/types/domains/core/signal_targeting.py index d9de5babe..f6830a0d7 100644 --- a/src/adcp/types/domains/core/signal_targeting.py +++ b/src/adcp/types/domains/core/signal_targeting.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/signal_targeting.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat from . import signal_id as signal_id_1 @@ -20,6 +20,7 @@ class SignalTargeting1(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New targeting constraints SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -27,6 +28,7 @@ class SignalTargeting1(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[Literal['binary'], Field(description='Discriminator for binary signals')] = 'binary' value: Annotated[ @@ -44,6 +46,7 @@ class SignalTargeting2(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New targeting constraints SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -51,6 +54,7 @@ class SignalTargeting2(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[ Literal['categorical'], Field(description='Discriminator for categorical signals') @@ -71,6 +75,7 @@ class SignalTargeting3(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New targeting constraints SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -78,6 +83,7 @@ class SignalTargeting3(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[ Literal['numeric'], Field(description='Discriminator for numeric signals') @@ -87,12 +93,14 @@ class SignalTargeting3(AdCPBaseModel): Field( description="Minimum value (inclusive). Omit for no minimum. Must be <= max_value when both are provided. Should be >= signal's range.min if defined." ), + NoExplicitNull, ] = None max_value: Annotated[ StrictFloat | None, Field( description="Maximum value (inclusive). Omit for no maximum. Must be >= min_value when both are provided. Should be <= signal's range.max if defined." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/signal_targeting_expression.py b/src/adcp/types/domains/core/signal_targeting_expression.py index 93a9e8048..5b9035922 100644 --- a/src/adcp/types/domains/core/signal_targeting_expression.py +++ b/src/adcp/types/domains/core/signal_targeting_expression.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/signal_targeting_expression.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from . import signal_ref as signal_ref_1 @@ -56,12 +56,14 @@ class SignalTargetingExpression3(AdCPBaseModel): Field( description="Minimum value, inclusive. Omit for no minimum. Should be within the signal definition's range when declared." ), + NoExplicitNull, ] = None max_value: Annotated[ StrictFloat | None, Field( description="Maximum value, inclusive. Omit for no maximum. Should be within the signal definition's range when declared." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/signal_targeting_rules.py b/src/adcp/types/domains/core/signal_targeting_rules.py index 4d9d6d7be..ce06453e9 100644 --- a/src/adcp/types/domains/core/signal_targeting_rules.py +++ b/src/adcp/types/domains/core/signal_targeting_rules.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_targeting_rules.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from . import signal_selection_group_rule @@ -33,12 +33,14 @@ class SignalTargetingRules(AdCPBaseModel): Field( description="How selected signal_targeting_options are resolved against the product's inventory. 'direct_targeting' means selected signals are applied as targeting predicates to the package inventory. 'seller_planned' means selected signals are planning inputs that the seller resolves against product-specific inventory, timing, availability, reach, or pacing constraints; buyers SHOULD NOT attempt to decompose the signal selection into lower-level inventory or schedule decisions. Use 'seller_planned' for products such as linear broadcast schedules where the audience definition may be portable but the audience-to-avails plan is seller-resolved." ), + NoExplicitNull, ] = ResolutionModel.direct_targeting selection_mode: Annotated[ SelectionMode | None, Field( description="Default selection behavior for selectable signals on this product. 'optional' means the buyer may select zero or more signals. 'required' means the buyer must select at least min_selected_signals, or 1 when min_selected_signals is omitted. 'fixed' means the seller applies the default_selected signals and the buyer cannot add or remove them; buyers SHOULD render those entries as read-only and sellers MUST echo them in package targeting_overlay.signal_targeting_groups. Use selection_group_rules for product-scoped products that need different behavior for different groups, such as fixed suppressions plus a required include tier." ), + NoExplicitNull, ] = SelectionMode.optional min_selected_signals: Annotated[ SchemaInt | None, @@ -46,6 +48,7 @@ class SignalTargetingRules(AdCPBaseModel): description="Minimum number of signals the buyer must select when selection_mode is 'required'. If selection_mode is 'required' and this field is omitted, sellers MUST treat the minimum as 1. Defaults to 0 for optional selection.", ge=0, ), + NoExplicitNull, ] = None max_selected_signals: Annotated[ SchemaInt | None, @@ -53,6 +56,7 @@ class SignalTargetingRules(AdCPBaseModel): description='Maximum number of signals the buyer may select for a package. Omit when there is no declared limit beyond the available options.', ge=1, ), + NoExplicitNull, ] = None max_selected_per_group: Annotated[ SchemaInt | None, @@ -60,6 +64,7 @@ class SignalTargetingRules(AdCPBaseModel): description='Maximum number of signal_targeting_options the buyer may select from the same ProductSignalTargetingOption.selection_group. Use 1 for mutually exclusive alternatives within each option group. This limit applies to product option grouping, not to the number of child groups in packages[].targeting_overlay.signal_targeting_groups.', ge=1, ), + NoExplicitNull, ] = None max_signal_targeting_groups: Annotated[ SchemaInt | None, @@ -67,6 +72,7 @@ class SignalTargetingRules(AdCPBaseModel): description='Maximum number of child groups allowed in packages[].targeting_overlay.signal_targeting_groups.groups. Omit when the seller has no declared limit beyond product terms.', ge=1, ), + NoExplicitNull, ] = None max_signals_per_targeting_group: Annotated[ SchemaInt | None, @@ -74,6 +80,7 @@ class SignalTargetingRules(AdCPBaseModel): description='Maximum number of signals allowed in each packages[].targeting_overlay.signal_targeting_groups.groups[].signals array. Omit when the seller has no declared limit beyond product terms.', ge=1, ), + NoExplicitNull, ] = None selection_group_rules: Annotated[ list[signal_selection_group_rule.SignalSelectionGroupRule] | None, @@ -81,4 +88,5 @@ class SignalTargetingRules(AdCPBaseModel): description='Optional product-scoped overrides for specific ProductSignalTargetingOption.selection_group values. Use this when one product has mixed behavior, such as fixed seller-applied suppressions, a required pick-one include tier, optional buyer-selected exclusions, or heterogeneous targeting planes that must be represented as separate ANDed clauses. Rules apply only to options whose selection_group matches. When selection_group_rules are present, each packages[].targeting_overlay.signal_targeting_groups child group MUST contain signals from exactly one selection_group and one targeting_mode, and buyers MUST send at most one child group for each (selection_group, targeting_mode) pair. Sellers MUST reject duplicate, mixed, or collapsed groups that combine distinct selection_group_rules into the same child group.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/sla_window.py b/src/adcp/types/domains/core/sla_window.py index f6545809d..7a73cc862 100644 --- a/src/adcp/types/domains/core/sla_window.py +++ b/src/adcp/types/domains/core/sla_window.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/sla_window.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -22,6 +22,7 @@ class SlaWindow(AdCPBaseModel): examples=['PT5M', 'PT4H', 'P1D'], pattern='^P(?!$)(\\d+Y)?(\\d+M)?(\\d+D)?(T(\\d+H)?(\\d+M)?(\\d+S)?)?$', ), + NoExplicitNull, ] = None completion_max: Annotated[ str | None, @@ -30,4 +31,5 @@ class SlaWindow(AdCPBaseModel): examples=['PT1H', 'PT24H', 'P2D'], pattern='^P(?!$)(\\d+Y)?(\\d+M)?(\\d+D)?(T(\\d+H)?(\\d+M)?(\\d+S)?)?$', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/special.py b/src/adcp/types/domains/core/special.py index c2b96e54d..2ec0ed2b6 100644 --- a/src/adcp/types/domains/core/special.py +++ b/src/adcp/types/domains/core/special.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/special.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..enums import special_category @@ -20,12 +20,15 @@ class Special(AdCPBaseModel): str, Field(description="Name of the event (e.g., 'Olympics 2028', 'Super Bowl LXI')") ] category: Annotated[ - special_category.SpecialCategory | None, Field(description='Category of the event') + special_category.SpecialCategory | None, + Field(description='Category of the event'), + NoExplicitNull, ] = None starts: Annotated[ - AwareDatetime | None, Field(description='When the event starts (ISO 8601)') + AwareDatetime | None, Field(description='When the event starts (ISO 8601)'), NoExplicitNull ] = None ends: Annotated[ AwareDatetime | None, Field(description='When the event ends (ISO 8601). Omit for single-day events.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/store_item.py b/src/adcp/types/domains/core/store_item.py index a93a4eb49..2b1999cbe 100644 --- a/src/adcp/types/domains/core/store_item.py +++ b/src/adcp/types/domains/core/store_item.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/store_item.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from . import catchment @@ -30,17 +30,24 @@ class Address(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - street: Annotated[str | None, Field(description="Street address (e.g., '123 Main St').")] = None - city: Annotated[str | None, Field(description='City name.')] = None + street: Annotated[ + str | None, Field(description="Street address (e.g., '123 Main St')."), NoExplicitNull + ] = None + city: Annotated[str | None, Field(description='City name.'), NoExplicitNull] = None region: Annotated[ str | None, Field( description="State, province, or region. ISO 3166-2 subdivision code preferred (e.g., 'NL-NH', 'US-CA')." ), + NoExplicitNull, ] = None - postal_code: Annotated[str | None, Field(description='Postal or ZIP code.')] = None + postal_code: Annotated[str | None, Field(description='Postal or ZIP code.'), NoExplicitNull] = ( + None + ) country: Annotated[ - str | None, Field(description='ISO 3166-1 alpha-2 country code.', pattern='^[A-Z]{2}$') + str | None, + Field(description='ISO 3166-1 alpha-2 country code.', pattern='^[A-Z]{2}$'), + NoExplicitNull, ] = None @@ -62,7 +69,9 @@ class StoreItem(AdCPBaseModel): ] location: Annotated[Location, Field(description='Geographic coordinates of the store.')] address: Annotated[ - Address | None, Field(description='Structured address for display and geocoding fallback.') + Address | None, + Field(description='Structured address for display and geocoding fallback.'), + NoExplicitNull, ] = None catchments: Annotated[ list[catchment.Catchment] | None, @@ -70,13 +79,17 @@ class StoreItem(AdCPBaseModel): description='Catchment areas for this store. Each defines a reachable area using travel time (isochrone), simple radius, or pre-computed GeoJSON. Multiple catchments allow different modes — e.g., 15-minute drive AND 10-minute walk.', min_length=1, ), + NoExplicitNull, ] = None phone: Annotated[ - str | None, Field(description="Store phone number in E.164 format (e.g., '+31201234567').") + str | None, + Field(description="Store phone number in E.164 format (e.g., '+31201234567')."), + NoExplicitNull, ] = None url: Annotated[ AnyUrl | None, Field(description='Store-specific page URL (e.g., store locator detail page).'), + NoExplicitNull, ] = None hours: Annotated[ dict[ @@ -87,6 +100,7 @@ class StoreItem(AdCPBaseModel): Field( description='Operating hours. Keys are ISO day names (monday–sunday), values are time ranges.' ), + NoExplicitNull, ] = None tags: Annotated[ list[str] | None, @@ -94,5 +108,6 @@ class StoreItem(AdCPBaseModel): description="Tags for filtering stores in targeting and creative selection (e.g., 'flagship', 'pickup', 'pharmacy').", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/talent.py b/src/adcp/types/domains/core/talent.py index 5607dfb08..41bffc402 100644 --- a/src/adcp/types/domains/core/talent.py +++ b/src/adcp/types/domains/core/talent.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/talent.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..enums import talent_role @@ -26,4 +26,5 @@ class Talent(AdCPBaseModel): Field( description="URL to this person's brand.json entry. Enables buyer agents to evaluate the talent's brand identity and associations." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/targeting.py b/src/adcp/types/domains/core/targeting.py index 17707ed15..7308d40f7 100644 --- a/src/adcp/types/domains/core/targeting.py +++ b/src/adcp/types/domains/core/targeting.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/targeting.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from collections.abc import Sequence from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool, StrictFloat @@ -80,6 +80,7 @@ class AgeRestriction(AdCPBaseModel): verification_required: Annotated[ StrictBool | None, Field(description='Whether verified age (not inferred) is required for compliance'), + NoExplicitNull, ] = False accepted_methods: Annotated[ list[age_verification_method.AgeVerificationMethod] | None, @@ -87,6 +88,7 @@ class AgeRestriction(AdCPBaseModel): description='Accepted verification methods. If omitted, any method the platform supports is acceptable.', min_length=1, ), + NoExplicitNull, ] = None @@ -103,6 +105,7 @@ class StoreCatchment(AdCPBaseModel): description='Filter to specific stores within the catalog. Omit to target all stores.', min_length=1, ), + NoExplicitNull, ] = None catchment_ids: Annotated[ list[str] | None, @@ -110,6 +113,7 @@ class StoreCatchment(AdCPBaseModel): description="Catchment zone IDs to target (e.g., 'walk', 'drive'). Omit to target all catchment zones.", min_length=1, ), + NoExplicitNull, ] = None @@ -151,6 +155,7 @@ class KeywordTarget(AdCPBaseModel): description="Per-keyword bid price, denominated in the same currency as the package's pricing option. Overrides the package-level bid_price for this keyword. Inherits the max_bid interpretation from the pricing option: when max_bid is true, this is the keyword's bid ceiling; when false, this is the exact bid. If omitted, the package bid_price applies.", ge=0.0, ), + NoExplicitNull, ] = None @@ -173,6 +178,7 @@ class GeoProximityItem(AdCPBaseModel): ge=-90.0, le=90.0, ), + NoExplicitNull, ] = None lng: Annotated[ StrictFloat | None, @@ -181,38 +187,44 @@ class GeoProximityItem(AdCPBaseModel): ge=-180.0, le=180.0, ), + NoExplicitNull, ] = None label: Annotated[ str | None, Field( description="Human-readable label for this entry (e.g., 'Düsseldorf', 'Heathrow Airport', 'Primary trade area')." ), + NoExplicitNull, ] = None travel_time: Annotated[ TravelTime | None, Field( description='Travel time limit for isochrone calculation. The platform resolves this to a geographic boundary based on actual transportation networks.' ), + NoExplicitNull, ] = None transport_mode: Annotated[ transport_mode_1.TransportMode | None, Field( description='Transportation mode for isochrone calculation. Required when travel_time is provided.' ), + NoExplicitNull, ] = None radius: Annotated[ Radius | None, Field( description='Simple radius from the point. The platform draws a circle of this distance around the coordinates.' ), + NoExplicitNull, ] = None geometry: Annotated[ Geometry | None, Field( description='Pre-computed GeoJSON geometry defining the proximity boundary. Use when the buyer has already calculated isochrones (via TravelTime, Mapbox, etc.) or has custom boundaries. When geometry is provided, lat/lng are not required.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class TargetingOverlay(AdCPBaseModel): @@ -225,6 +237,7 @@ class TargetingOverlay(AdCPBaseModel): description="Restrict delivery to specific countries. ISO 3166-1 alpha-2 codes (e.g., 'US', 'GB', 'DE').", min_length=1, ), + NoExplicitNull, ] = None geo_countries_exclude: Annotated[ Sequence[GeoCountriesExcludeItem] | None, @@ -232,6 +245,7 @@ class TargetingOverlay(AdCPBaseModel): description="Exclude specific countries from delivery. ISO 3166-1 alpha-2 codes (e.g., 'US', 'GB', 'DE').", min_length=1, ), + NoExplicitNull, ] = None geo_regions: Annotated[ list[GeoRegion] | None, @@ -239,6 +253,7 @@ class TargetingOverlay(AdCPBaseModel): description='Restrict delivery to exact canonical ISO 3166-2 subdivisions (states, provinces, regions, departments, or other subdivision categories). Unknown identifiers are invalid. At create or update, sellers MUST reject unsupported identifiers and MUST NOT silently widen, drop, or partially apply the list. During get_products, a seller may instead return a sparse, buyer-reviewable targeting_resolution modification for a valid but unsupported requested outcome. Exact internal translation preserves accepted identifiers in package readback.', min_length=1, ), + NoExplicitNull, ] = None geo_regions_exclude: Annotated[ Sequence[GeoRegionsExcludeItem] | None, @@ -246,6 +261,7 @@ class TargetingOverlay(AdCPBaseModel): description='Exclude exact canonical ISO 3166-2 subdivisions. Support is independent from geo_regions inclusion support. Unknown identifiers and values also present in geo_regions are invalid. At create or update, sellers MUST reject unsupported identifiers and partial application; during get_products, a seller may instead return a sparse, buyer-reviewable targeting_resolution modification for a valid but unsupported requested outcome.', min_length=1, ), + NoExplicitNull, ] = None geo_metros: Annotated[ list[geo_metro.GeoMetro] | None, @@ -254,6 +270,7 @@ class TargetingOverlay(AdCPBaseModel): min_length=1, title='Targeting Geo Metros', ), + NoExplicitNull, ] = None geo_metros_exclude: Annotated[ Sequence[GeoMetrosExcludeItem] | None, @@ -261,6 +278,7 @@ class TargetingOverlay(AdCPBaseModel): description='Exclude specific metro areas from delivery. Each entry specifies the classification system and excluded values. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, ), + NoExplicitNull, ] = None geo_postal_areas: Annotated[ list[postal_area.PostalArea] | None, @@ -268,6 +286,7 @@ class TargetingOverlay(AdCPBaseModel): description='Restrict delivery to specific postal areas. Prefer the native country + postal system form. The deprecated legacy country-fused postal-system tokens remain accepted for compatibility. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, ), + NoExplicitNull, ] = None geo_postal_areas_exclude: Annotated[ Sequence[postal_area.PostalArea] | None, @@ -275,6 +294,7 @@ class TargetingOverlay(AdCPBaseModel): description='Exclude specific postal areas from delivery. Prefer the native country + postal system form. The deprecated legacy country-fused postal-system tokens remain accepted for compatibility. Seller must declare supported systems in get_adcp_capabilities.', min_length=1, ), + NoExplicitNull, ] = None geo_places: Annotated[ list[geo_place_area.GeographicPlaceArea] | None, @@ -282,6 +302,7 @@ class TargetingOverlay(AdCPBaseModel): description='Restrict delivery to catalog-backed named places. Values MUST be stable identifiers in the declared system, not display names. Sellers must declare supported systems, countries, and place types in get_adcp_capabilities and reject unsupported entries rather than silently dropping them.', min_length=1, ), + NoExplicitNull, ] = None geo_places_exclude: Annotated[ list[geo_place_area.GeographicPlaceArea] | None, @@ -289,6 +310,7 @@ class TargetingOverlay(AdCPBaseModel): description='Exclude catalog-backed named places. Uses the same identifier-based shape as geo_places. Sellers MUST reject overlap with geo_places for the same country, system, place_type, and value.', min_length=1, ), + NoExplicitNull, ] = None daypart_targets: Annotated[ list[daypart_target.DaypartTarget] | None, @@ -296,6 +318,7 @@ class TargetingOverlay(AdCPBaseModel): description='Restrict delivery to specific time windows. Each entry specifies days of week, an hour range, and an optional timezone that defaults to inventory_local. A concrete IANA zone uses one shared civil-time clock, while inventory_local evaluates each inventory unit in its seller-assigned local timezone. Entries are independent and MAY use different clocks.', min_length=1, ), + NoExplicitNull, ] = None axe_include_segment: Annotated[ str | None, @@ -303,6 +326,7 @@ class TargetingOverlay(AdCPBaseModel): deprecated=True, description='Deprecated: Use TMP provider fields instead. AXE segment ID to include for targeting.', ), + NoExplicitNull, ] = None axe_exclude_segment: Annotated[ str | None, @@ -310,6 +334,7 @@ class TargetingOverlay(AdCPBaseModel): deprecated=True, description='Deprecated: Use TMP provider fields instead. AXE segment ID to exclude from targeting.', ), + NoExplicitNull, ] = None audience_include: Annotated[ list[str] | None, @@ -317,6 +342,7 @@ class TargetingOverlay(AdCPBaseModel): description='Restrict delivery to members of these first-party CRM audiences. Only users present in the uploaded lists are eligible. References audience_id values from sync_audiences on the same seller account — audience IDs are not portable across sellers. Not for lookalike expansion — express that intent in the campaign brief. Seller must declare support in get_adcp_capabilities.', min_length=1, ), + NoExplicitNull, ] = None audience_exclude: Annotated[ list[str] | None, @@ -324,6 +350,7 @@ class TargetingOverlay(AdCPBaseModel): description='Suppress delivery to members of these first-party CRM audiences. Matched users are excluded regardless of other targeting. References audience_id values from sync_audiences on the same seller account — audience IDs are not portable across sellers. Seller must declare support in get_adcp_capabilities.', min_length=1, ), + NoExplicitNull, ] = None signal_targeting_groups: Annotated[ package_signal_targeting_groups.PackageSignalTargetingGroups | None, @@ -331,6 +358,7 @@ class TargetingOverlay(AdCPBaseModel): description="Basic Boolean grouping for seller-offered signals. v1 supports a required top-level operator 'all' and child groups with operator 'any' for include groups or 'none' for exclusion groups. Example semantics: group 1 any(A, B) plus group 2 none(C, D) means (A OR B) AND NOT (C OR D). Signal entries reference named signal definitions with signal_ref scope 'product' for product-local signal options or scope 'data_provider' for external signals published in adagents.json signals[]. For simple include-only targeting, send one child group with operator 'any'. Sellers SHOULD reject entries that are not available for the product through inline signal_targeting_options or get_signals, are not active for the account, or exceed the product's signal_targeting_allowed/signal_targeting_rules/product terms. Signal targeting limits are product-scoped, not declared in get_adcp_capabilities, because products may be backed by different ad servers. Sellers MUST echo applied signal_targeting_groups on the resulting package state, including fixed/default selections. Sellers MAY return REQUOTE_REQUIRED when a targeting mutation changes commercial terms.", title='Targeting Signal Groups', ), + NoExplicitNull, ] = None signal_targeting: Annotated[ list[signal_targeting_1.SignalTargeting] | None, @@ -339,15 +367,17 @@ class TargetingOverlay(AdCPBaseModel): description='DEPRECATED. Use signal_targeting_groups for package-level signal targeting. Legacy flat signal_targeting remains accepted during the SignalRef migration window but cannot express grouped include/exclude composition or product-scoped pricing.', min_length=1, ), + NoExplicitNull, ] = None demographics: Annotated[ demographic_targeting_intent.DemographicTargetingIntent | None, Field( description='Canonical demographic audience targeting intent with optional constraints on how age may be determined. This is distinct from age_restriction: demographics selects an audience, while age_restriction expresses a legal eligibility or verification floor. Fresh create/update targeting MUST compile exactly or be rejected. During get_products, a seller may offer a different configured predicate only through sparse targeting_resolution modifications on a distinguishable product_id; selecting that product accepts the alternative. Sellers never silently broaden, narrow, default, drop, or substitute the basis.' ), + NoExplicitNull, ] = None frequency_cap: Annotated[ - frequency_cap_1.FrequencyCap | None, Field(title='Targeting Frequency Cap') + frequency_cap_1.FrequencyCap | None, Field(title='Targeting Frequency Cap'), NoExplicitNull ] = None property_list: Annotated[ property_list_ref.PropertyListReference | None, @@ -355,12 +385,14 @@ class TargetingOverlay(AdCPBaseModel): description="Reference to a property list for targeting specific properties within this product. The package runs on the intersection of the product's publisher_properties and this list. Sellers SHOULD return a validation error if the product has property_targeting_allowed: false.", title='Targeting Property List', ), + NoExplicitNull, ] = None property_list_exclude: Annotated[ property_list_ref.PropertyListReference | None, Field( description="Reference to a property list whose properties must not carry the buyer's ads. Matched properties are removed from delivery. Use for brand-safety do-not-run lists (apps, sites). Exclude wins on overlap with property_list, and applies regardless of the product's property_targeting_allowed flag. Seller must declare support in get_adcp_capabilities." ), + NoExplicitNull, ] = None collection_list: Annotated[ collection_list_ref.CollectionListReference | None, @@ -368,18 +400,21 @@ class TargetingOverlay(AdCPBaseModel): description='Reference to a collection list for including specific collections (programs, publications, channels) within this product. The package runs on the intersection of matched collections and this list. Use for inclusion-based collection targeting. Seller must declare support in get_adcp_capabilities.', title='Targeting Collection List', ), + NoExplicitNull, ] = None collection_list_exclude: Annotated[ collection_list_ref.CollectionListReference | None, Field( description="Reference to a collection list for excluding specific collections (programs, publications, channels) from this product. Matched collections must not carry the buyer's ads. Use for brand safety do-not-air lists. Seller must declare support in get_adcp_capabilities." ), + NoExplicitNull, ] = None placement_selection: Annotated[ placement_selection_1.PlacementSelection | None, Field( description='Purchased placement selection within the product. This constrains package inventory; it is distinct from creative_assignments[].placement_refs, which only route individual creatives within the purchased set. On create, mode selected supplies the complete selected set and mode default uses the product default. In request-side Targeting Input, a non-null value replaces this dimension, omission preserves or inherits it, and null clears it when the product permits that broader inventory set.' ), + NoExplicitNull, ] = None collection_selection: Annotated[ collection_selection_1.CollectionSelection | None, @@ -387,12 +422,14 @@ class TargetingOverlay(AdCPBaseModel): description="Purchased collection selection within the product. On create, mode selected supplies the complete selected set and mode default uses the product's full bundle. On package readback this is the committed selection sellers MUST echo as concrete selectors, materializing any collection_list composition; collection_list fields remain the buyer-managed list mechanism. In request-side Targeting Input, a non-null value replaces this dimension, omission preserves or inherits it, and null clears it when the product permits that broader inventory set.", title='Targeting Collection Selection', ), + NoExplicitNull, ] = None age_restriction: Annotated[ AgeRestriction | None, Field( description='Age restriction for compliance. Use for legal requirements (alcohol, gambling), not audience targeting.' ), + NoExplicitNull, ] = None device_platform: Annotated[ list[device_platform_1.DevicePlatform] | None, @@ -400,6 +437,7 @@ class TargetingOverlay(AdCPBaseModel): description='Restrict to specific platforms. Use for technical compatibility (app only works on iOS). Values from Sec-CH-UA-Platform standard, extended for CTV.', min_length=1, ), + NoExplicitNull, ] = None device_platform_exclude: Annotated[ list[device_platform_1.DevicePlatform] | None, @@ -407,6 +445,7 @@ class TargetingOverlay(AdCPBaseModel): description='Exclude specific operating-system platforms from delivery. When a platform appears in both device_platform and device_platform_exclude, exclusion wins. Sellers MUST reject a request they cannot enforce rather than silently dropping the exclusion.', min_length=1, ), + NoExplicitNull, ] = None device_type: Annotated[ list[device_type_1.DeviceType] | None, @@ -414,6 +453,7 @@ class TargetingOverlay(AdCPBaseModel): description='Restrict to specific device form factors. Use for campaigns targeting hardware categories rather than operating systems (e.g., mobile-only promotions, CTV campaigns).', min_length=1, ), + NoExplicitNull, ] = None device_type_exclude: Annotated[ list[device_type_1.DeviceType] | None, @@ -421,6 +461,7 @@ class TargetingOverlay(AdCPBaseModel): description='Exclude specific device form factors from delivery (e.g., exclude CTV for app-install campaigns).', min_length=1, ), + NoExplicitNull, ] = None browser: Annotated[ list[browser_family.BrowserFamily] | None, @@ -428,6 +469,7 @@ class TargetingOverlay(AdCPBaseModel): description='Restrict delivery to specific canonical browser families in the impression delivery and rendering environment, not the post-click landing-page browser. Values MUST NOT be inferred solely from operating system, device, web/mobile-web inventory, or placement. Values in this array use OR semantics. When browser is supplied, families not listed are ineligible: other includes a seller-recognized family that is not explicitly enumerated, while unknown includes a browser the seller cannot classify into a recognized family. When the same family appears in browser and browser_exclude, exclusion wins. Browser and device constraints intersect; a seller that cannot enforce the exact combination MUST exclude or explicitly reconfigure the product during discovery and MUST reject it at create or update rather than silently widening delivery. Browser versions and seller-native IDs are intentionally unsupported.', min_length=1, ), + NoExplicitNull, ] = None browser_exclude: Annotated[ list[browser_family.BrowserFamily] | None, @@ -435,6 +477,7 @@ class TargetingOverlay(AdCPBaseModel): description='Exclude specific canonical browser families from delivery. other excludes seller-recognized families that are not explicitly enumerated; unknown excludes browsers the seller cannot classify into a recognized family. When the same family appears in browser and browser_exclude, exclusion wins. Sellers MUST reject a request they cannot enforce rather than silently dropping the exclusion.', min_length=1, ), + NoExplicitNull, ] = None store_catchments: Annotated[ list[StoreCatchment] | None, @@ -442,6 +485,7 @@ class TargetingOverlay(AdCPBaseModel): description='Target users within store catchment areas from a synced store catalog. Each entry references a store-type catalog and optionally narrows to specific stores or catchment zones.', min_length=1, ), + NoExplicitNull, ] = None geo_proximity: Annotated[ list[GeoProximityItem] | None, @@ -449,6 +493,7 @@ class TargetingOverlay(AdCPBaseModel): description='Target users within travel time, distance, or a custom boundary around arbitrary geographic points. Multiple entries use OR semantics — a user within range of any listed point is eligible. For campaigns targeting 10+ locations, consider using store_catchments with a location catalog instead. Seller must declare support in get_adcp_capabilities.', min_length=1, ), + NoExplicitNull, ] = None language: Annotated[ list[locale_tag.LanguageTag] | None, @@ -457,6 +502,7 @@ class TargetingOverlay(AdCPBaseModel): min_length=1, title='Targeting Languages', ), + NoExplicitNull, ] = None keyword_targets: Annotated[ list[KeywordTarget] | None, @@ -465,6 +511,7 @@ class TargetingOverlay(AdCPBaseModel): min_length=1, title='Targeting Keywords', ), + NoExplicitNull, ] = None negative_keywords: Annotated[ list[negative_keyword.NegativeKeyword] | None, @@ -473,4 +520,5 @@ class TargetingOverlay(AdCPBaseModel): min_length=1, title='Targeting Negative Keywords', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/targeting_input.py b/src/adcp/types/domains/core/targeting_input.py index a86a60833..3c3def92e 100644 --- a/src/adcp/types/domains/core/targeting_input.py +++ b/src/adcp/types/domains/core/targeting_input.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/targeting_input.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictFloat @@ -85,6 +85,7 @@ class StoreCatchment(AdCPBaseModel): description='Filter to specific stores within the catalog. Omit to target all stores.', min_length=1, ), + NoExplicitNull, ] = None catchment_ids: Annotated[ list[str] | None, @@ -92,6 +93,7 @@ class StoreCatchment(AdCPBaseModel): description="Catchment zone IDs to target (e.g., 'walk', 'drive'). Omit to target all catchment zones.", min_length=1, ), + NoExplicitNull, ] = None @@ -133,6 +135,7 @@ class KeywordTarget(AdCPBaseModel): description="Per-keyword bid price, denominated in the same currency as the package's pricing option. Overrides the package-level bid_price for this keyword. Inherits the max_bid interpretation from the pricing option: when max_bid is true, this is the keyword's bid ceiling; when false, this is the exact bid. If omitted, the package bid_price applies.", ge=0.0, ), + NoExplicitNull, ] = None @@ -155,6 +158,7 @@ class GeoProximityItem(AdCPBaseModel): ge=-90.0, le=90.0, ), + NoExplicitNull, ] = None lng: Annotated[ StrictFloat | None, @@ -163,38 +167,44 @@ class GeoProximityItem(AdCPBaseModel): ge=-180.0, le=180.0, ), + NoExplicitNull, ] = None label: Annotated[ str | None, Field( description="Human-readable label for this entry (e.g., 'Düsseldorf', 'Heathrow Airport', 'Primary trade area')." ), + NoExplicitNull, ] = None travel_time: Annotated[ TravelTime | None, Field( description='Travel time limit for isochrone calculation. The platform resolves this to a geographic boundary based on actual transportation networks.' ), + NoExplicitNull, ] = None transport_mode: Annotated[ transport_mode_1.TransportMode | None, Field( description='Transportation mode for isochrone calculation. Required when travel_time is provided.' ), + NoExplicitNull, ] = None radius: Annotated[ Radius | None, Field( description='Simple radius from the point. The platform draws a circle of this distance around the coordinates.' ), + NoExplicitNull, ] = None geometry: Annotated[ Geometry | None, Field( description='Pre-computed GeoJSON geometry defining the proximity boundary. Use when the buyer has already calculated isochrones (via TravelTime, Mapbox, etc.) or has custom boundaries. When geometry is provided, lat/lng are not required.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class TargetingOverlayInput(AdCPBaseModel): diff --git a/src/adcp/types/domains/core/targeting_modification.py b/src/adcp/types/domains/core/targeting_modification.py index 086fbe0e2..84160f97f 100644 --- a/src/adcp/types/domains/core/targeting_modification.py +++ b/src/adcp/types/domains/core/targeting_modification.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/targeting_modification.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -32,7 +32,7 @@ class TargetingModification1(AdCPBaseModel): ), ] reason: Annotated[str, Field(min_length=1)] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Path(StrEnum): @@ -77,6 +77,7 @@ class TargetingModification2(AdCPBaseModel): Field( description='Stable identity of exactly one postal-area group in the requested array. Required only for postal-area paths. Zero or multiple matches make the modification invalid.' ), + NoExplicitNull, ] = None values: Annotated[ list[str], @@ -86,7 +87,7 @@ class TargetingModification2(AdCPBaseModel): ), ] reason: Annotated[str, Field(min_length=1)] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None TargetingModification = Annotated[ diff --git a/src/adcp/types/domains/core/targeting_overlay_requirements.py b/src/adcp/types/domains/core/targeting_overlay_requirements.py index 9ae9b0230..84add91f3 100644 --- a/src/adcp/types/domains/core/targeting_overlay_requirements.py +++ b/src/adcp/types/domains/core/targeting_overlay_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/targeting_overlay_requirements.json -# timestamp: 2026-10-03T00:35:15+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, RootModel from ..enums import browser_family, daypart_timezone_mode, match_type, metro_system, transport_mode @@ -86,6 +86,7 @@ class DaypartRequirement1(AdCPBaseModel): description='Exact concrete IANA timezone identifiers that must remain selectable after discovery. Valid only when timezone_modes includes iana.', min_length=1, ), + NoExplicitNull, ] = None @@ -103,12 +104,12 @@ class GeoProximity(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - radius: Required | None = None - travel_time: Required | None = None - geometry: Required | None = None - transport_modes: Annotated[list[transport_mode.TransportMode] | None, Field(min_length=1)] = ( - None - ) + radius: Annotated[Required | None, NoExplicitNull] = None + travel_time: Annotated[Required | None, NoExplicitNull] = None + geometry: Annotated[Required | None, NoExplicitNull] = None + transport_modes: Annotated[ + list[transport_mode.TransportMode] | None, Field(min_length=1), NoExplicitNull + ] = None class Demographics(AdCPBaseModel): @@ -122,47 +123,56 @@ class TargetingOverlayRequirements(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - geo_countries: Required | None = None - geo_countries_exclude: Required | None = None - geo_regions: Required | geo_region_requirement.GeographicRegionRequirement | None = None - geo_regions_exclude: Required | geo_region_requirement.GeographicRegionRequirement | None = None - geo_metros: MetroRequirement | None = None - geo_metros_exclude: MetroRequirement | None = None - geo_places: geo_place_requirement.GeographicPlaceRequirement | None = None - geo_places_exclude: geo_place_requirement.GeographicPlaceRequirement | None = None - geo_postal_areas: Required | positive_postal_area_support.PositivePostalAreaSupport | None = ( - None - ) - geo_postal_areas_exclude: ( - Required | positive_postal_area_support.PositivePostalAreaSupport | None - ) = None - geo_proximity: Required | GeoProximity | None = None - daypart_targets: DaypartRequirement | None = None - audience_include: Required | None = None - audience_exclude: Required | None = None - signal_targeting_groups: Required | None = None - demographics: Required | Demographics | None = None - frequency_cap: Required | None = None + geo_countries: Annotated[Required | None, NoExplicitNull] = None + geo_countries_exclude: Annotated[Required | None, NoExplicitNull] = None + geo_regions: Annotated[ + Required | geo_region_requirement.GeographicRegionRequirement | None, NoExplicitNull + ] = None + geo_regions_exclude: Annotated[ + Required | geo_region_requirement.GeographicRegionRequirement | None, NoExplicitNull + ] = None + geo_metros: Annotated[MetroRequirement | None, NoExplicitNull] = None + geo_metros_exclude: Annotated[MetroRequirement | None, NoExplicitNull] = None + geo_places: Annotated[ + geo_place_requirement.GeographicPlaceRequirement | None, NoExplicitNull + ] = None + geo_places_exclude: Annotated[ + geo_place_requirement.GeographicPlaceRequirement | None, NoExplicitNull + ] = None + geo_postal_areas: Annotated[ + Required | positive_postal_area_support.PositivePostalAreaSupport | None, NoExplicitNull + ] = None + geo_postal_areas_exclude: Annotated[ + Required | positive_postal_area_support.PositivePostalAreaSupport | None, NoExplicitNull + ] = None + geo_proximity: Annotated[Required | GeoProximity | None, NoExplicitNull] = None + daypart_targets: Annotated[DaypartRequirement | None, NoExplicitNull] = None + audience_include: Annotated[Required | None, NoExplicitNull] = None + audience_exclude: Annotated[Required | None, NoExplicitNull] = None + signal_targeting_groups: Annotated[Required | None, NoExplicitNull] = None + demographics: Annotated[Required | Demographics | None, NoExplicitNull] = None + frequency_cap: Annotated[Required | None, NoExplicitNull] = None frequency_cap_support: Annotated[ frequency_cap_requirements.FrequencyCapRequirements | None, Field( description='Minimum structured package-cap support. Matches a broad legacy frequency_cap: true or a containing frequency_cap_support object. Mutually exclusive with a frequency_cap: true requirement, which would exclude every constrained product.' ), + NoExplicitNull, ] = None - property_list: Required | None = None - property_list_exclude: Required | None = None - collection_list: Required | None = None - collection_list_exclude: Required | None = None - placement_selection: Required | None = None - age_restriction: Required | None = None - device_platform: Required | None = None - device_platform_exclude: Required | None = None - device_type: Required | None = None - device_type_exclude: Required | None = None - browser: BrowserRequirement | None = None - browser_exclude: BrowserRequirement | None = None - store_catchments: Required | None = None - language: Required | None = None - keyword_targets: KeywordRequirement | None = None - negative_keywords: KeywordRequirement | None = None - ext: ext_1.ExtensionObject | None = None + property_list: Annotated[Required | None, NoExplicitNull] = None + property_list_exclude: Annotated[Required | None, NoExplicitNull] = None + collection_list: Annotated[Required | None, NoExplicitNull] = None + collection_list_exclude: Annotated[Required | None, NoExplicitNull] = None + placement_selection: Annotated[Required | None, NoExplicitNull] = None + age_restriction: Annotated[Required | None, NoExplicitNull] = None + device_platform: Annotated[Required | None, NoExplicitNull] = None + device_platform_exclude: Annotated[Required | None, NoExplicitNull] = None + device_type: Annotated[Required | None, NoExplicitNull] = None + device_type_exclude: Annotated[Required | None, NoExplicitNull] = None + browser: Annotated[BrowserRequirement | None, NoExplicitNull] = None + browser_exclude: Annotated[BrowserRequirement | None, NoExplicitNull] = None + store_catchments: Annotated[Required | None, NoExplicitNull] = None + language: Annotated[Required | None, NoExplicitNull] = None + keyword_targets: Annotated[KeywordRequirement | None, NoExplicitNull] = None + negative_keywords: Annotated[KeywordRequirement | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/targeting_overlay_support.py b/src/adcp/types/domains/core/targeting_overlay_support.py index 0e7756166..98d1f5da2 100644 --- a/src/adcp/types/domains/core/targeting_overlay_support.py +++ b/src/adcp/types/domains/core/targeting_overlay_support.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/targeting_overlay_support.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, SchemaInt +from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, RootModel, StringConstraints @@ -26,9 +26,9 @@ class PlacementSelection(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - max_values_per_package: Annotated[SchemaInt | None, Field(ge=1)] = None - max_packages: Annotated[SchemaInt | None, Field(ge=1)] = None - ext: ext_1.ExtensionObject | None = None + max_values_per_package: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max_packages: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Supported(RootModel[Literal[True]]): @@ -46,7 +46,7 @@ class CountrySupport1(AdCPBaseModel): ge=1, ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class CountrySupport(RootModel[Supported | CountrySupport1]): @@ -64,15 +64,16 @@ class MetroSupport1(AdCPBaseModel): extra='forbid', ) systems: Annotated[list[metro_system.MetroAreaSystem], Field(min_length=1)] - max_values_per_package: Annotated[SchemaInt | None, Field(ge=1)] = None + max_values_per_package: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None max_packages: Annotated[ SchemaInt | None, Field( description='Optional maximum number of independently targeted packages the seller will create from this configured product.', ge=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class MetroSupport(RootModel[Supported | MetroSupport1]): @@ -111,7 +112,7 @@ class PlaceCatalogSupport(AdCPBaseModel): description='Exact catalog versions selectable on later package overlays.', min_length=1 ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class PlaceSupport(AdCPBaseModel): @@ -122,15 +123,16 @@ class PlaceSupport(AdCPBaseModel): dict[GeoPlaceSystemKey, PlaceCatalogSupport], Field(min_length=1), ] - max_values_per_package: Annotated[SchemaInt | None, Field(ge=1)] = None + max_values_per_package: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None max_packages: Annotated[ SchemaInt | None, Field( description='Optional maximum number of independently place-targeted packages the seller will create from this configured product.', ge=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class KeywordSupport1(AdCPBaseModel): @@ -138,7 +140,7 @@ class KeywordSupport1(AdCPBaseModel): extra='forbid', ) supported_match_types: Annotated[list[match_type.MatchType], Field(min_length=1)] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class KeywordSupport(RootModel[Supported | KeywordSupport1]): @@ -156,7 +158,7 @@ class BrowserSupport1(AdCPBaseModel): extra='forbid', ) families: Annotated[list[browser_family.BrowserFamily], Field(min_length=1)] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class BrowserSupport(RootModel[Supported | BrowserSupport1]): @@ -189,8 +191,9 @@ class DaypartSupport1(AdCPBaseModel): Field( description="Concrete IANA timezone identifiers accepted by this product. true means every identifier valid in the seller's supported IANA TZDB; an array is the exact supported subset. Required when timezone_modes includes iana and forbidden otherwise." ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class DaypartSupport(RootModel[Literal[True] | DaypartSupport1]): @@ -207,17 +210,18 @@ class GeoProximity(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - radius: Supported | None = None - travel_time: Supported | None = None - geometry: Supported | None = None - transport_modes: Annotated[list[transport_mode.TransportMode] | None, Field(min_length=1)] = ( - None - ) + radius: Annotated[Supported | None, NoExplicitNull] = None + travel_time: Annotated[Supported | None, NoExplicitNull] = None + geometry: Annotated[Supported | None, NoExplicitNull] = None + transport_modes: Annotated[ + list[transport_mode.TransportMode] | None, Field(min_length=1), NoExplicitNull + ] = None max_values_per_package: Annotated[ SchemaInt | None, Field(description='Maximum number of proximity entries accepted on one package.', ge=1), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Demographics(AdCPBaseModel): @@ -225,54 +229,59 @@ class Demographics(AdCPBaseModel): extra='allow', ) age: Supported - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class TargetingOverlaySupport(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - geo_countries: CountrySupport | None = None - geo_countries_exclude: CountrySupport | None = None - geo_regions: Supported | geo_region_support.GeographicRegionSupport | None = None - geo_regions_exclude: Supported | geo_region_support.GeographicRegionSupport | None = None - geo_metros: MetroSupport | None = None - geo_metros_exclude: MetroSupport | None = None - geo_places: PlaceSupport | None = None - geo_places_exclude: PlaceSupport | None = None - geo_postal_areas: Supported | positive_postal_area_support.PositivePostalAreaSupport | None = ( - None - ) - geo_postal_areas_exclude: ( - Supported | positive_postal_area_support.PositivePostalAreaSupport | None - ) = None - geo_proximity: Supported | GeoProximity | None = None - daypart_targets: DaypartSupport | None = None - audience_include: Supported | None = None - audience_exclude: Supported | None = None - signal_targeting_groups: Supported | None = None - demographics: Supported | Demographics | None = None - frequency_cap: Supported | None = None + geo_countries: Annotated[CountrySupport | None, NoExplicitNull] = None + geo_countries_exclude: Annotated[CountrySupport | None, NoExplicitNull] = None + geo_regions: Annotated[ + Supported | geo_region_support.GeographicRegionSupport | None, NoExplicitNull + ] = None + geo_regions_exclude: Annotated[ + Supported | geo_region_support.GeographicRegionSupport | None, NoExplicitNull + ] = None + geo_metros: Annotated[MetroSupport | None, NoExplicitNull] = None + geo_metros_exclude: Annotated[MetroSupport | None, NoExplicitNull] = None + geo_places: Annotated[PlaceSupport | None, NoExplicitNull] = None + geo_places_exclude: Annotated[PlaceSupport | None, NoExplicitNull] = None + geo_postal_areas: Annotated[ + Supported | positive_postal_area_support.PositivePostalAreaSupport | None, NoExplicitNull + ] = None + geo_postal_areas_exclude: Annotated[ + Supported | positive_postal_area_support.PositivePostalAreaSupport | None, NoExplicitNull + ] = None + geo_proximity: Annotated[Supported | GeoProximity | None, NoExplicitNull] = None + daypart_targets: Annotated[DaypartSupport | None, NoExplicitNull] = None + audience_include: Annotated[Supported | None, NoExplicitNull] = None + audience_exclude: Annotated[Supported | None, NoExplicitNull] = None + signal_targeting_groups: Annotated[Supported | None, NoExplicitNull] = None + demographics: Annotated[Supported | Demographics | None, NoExplicitNull] = None + frequency_cap: Annotated[Supported | None, NoExplicitNull] = None frequency_cap_support: Annotated[ frequency_cap_constraints.FrequencyCapConstraints | None, Field( description='Independently positive constrained package-cap support. Mutually exclusive with legacy frequency_cap: a product declares one form or the other.' ), + NoExplicitNull, ] = None - property_list: Supported | None = None - property_list_exclude: Supported | None = None - collection_list: Supported | None = None - collection_list_exclude: Supported | None = None - placement_selection: Supported | PlacementSelection | None = None - age_restriction: Supported | None = None - device_platform: Supported | None = None - device_platform_exclude: Supported | None = None - device_type: Supported | None = None - device_type_exclude: Supported | None = None - browser: BrowserSupport | None = None - browser_exclude: BrowserSupport | None = None - store_catchments: Supported | None = None - language: Supported | None = None - keyword_targets: KeywordSupport | None = None - negative_keywords: KeywordSupport | None = None - ext: ext_1.ExtensionObject | None = None + property_list: Annotated[Supported | None, NoExplicitNull] = None + property_list_exclude: Annotated[Supported | None, NoExplicitNull] = None + collection_list: Annotated[Supported | None, NoExplicitNull] = None + collection_list_exclude: Annotated[Supported | None, NoExplicitNull] = None + placement_selection: Annotated[Supported | PlacementSelection | None, NoExplicitNull] = None + age_restriction: Annotated[Supported | None, NoExplicitNull] = None + device_platform: Annotated[Supported | None, NoExplicitNull] = None + device_platform_exclude: Annotated[Supported | None, NoExplicitNull] = None + device_type: Annotated[Supported | None, NoExplicitNull] = None + device_type_exclude: Annotated[Supported | None, NoExplicitNull] = None + browser: Annotated[BrowserSupport | None, NoExplicitNull] = None + browser_exclude: Annotated[BrowserSupport | None, NoExplicitNull] = None + store_catchments: Annotated[Supported | None, NoExplicitNull] = None + language: Annotated[Supported | None, NoExplicitNull] = None + keyword_targets: Annotated[KeywordSupport | None, NoExplicitNull] = None + negative_keywords: Annotated[KeywordSupport | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/tasks_get_request.py b/src/adcp/types/domains/core/tasks_get_request.py index 59654efce..bc0046fb3 100644 --- a/src/adcp/types/domains/core/tasks_get_request.py +++ b/src/adcp/types/domains/core/tasks_get_request.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: core/tasks_get_request.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from . import account_ref @@ -24,18 +25,21 @@ class TasksGetRequest(AdcpVersionEnvelope): Field( description='Account scope for the task lookup. Sellers MUST return REFERENCE_NOT_FOUND for a task_id that exists only under a different account or principal. When omitted, the seller MAY use the credential-bound singleton account, but multi-account credentials SHOULD require an explicit account.' ), + NoExplicitNull, ] = None include_history: Annotated[ StrictBool | None, Field( description='Include full conversation history for this task (may increase response size)' ), + NoExplicitNull, ] = False include_result: Annotated[ StrictBool | None, Field( description="Include the task's canonical terminal result payload when one exists. Defaults to false for lightweight status-only polls. When true, sellers MUST include result for completed, failed, or rejected terminal tasks when that task produced a terminal artifact; canceled tasks may have no result. The legacy singular error field remains a convenience for failed tasks but does not replace the canonical terminal result." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/tasks_get_response.py b/src/adcp/types/domains/core/tasks_get_response.py index 99e8c8f85..6a0bfee66 100644 --- a/src/adcp/types/domains/core/tasks_get_response.py +++ b/src/adcp/types/domains/core/tasks_get_response.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/tasks_get_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..enums import adcp_protocol, task_status @@ -23,15 +23,21 @@ class Progress(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ - str | None, Field(description='Current step or phase of the operation') + str | None, Field(description='Current step or phase of the operation'), NoExplicitNull ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, + ] = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None class Details(AdCPBaseModel): @@ -39,11 +45,15 @@ class Details(AdCPBaseModel): extra='allow', ) protocol: Annotated[ - adcp_protocol.AdcpProtocol | None, Field(description='AdCP protocol where error occurred') + adcp_protocol.AdcpProtocol | None, + Field(description='AdCP protocol where error occurred'), + NoExplicitNull, + ] = None + operation: Annotated[ + str | None, Field(description='Specific operation that failed'), NoExplicitNull ] = None - operation: Annotated[str | None, Field(description='Specific operation that failed')] = None specific_context: Annotated[ - dict[str, Any] | None, Field(description='Domain-specific error context') + dict[str, Any] | None, Field(description='Domain-specific error context'), NoExplicitNull ] = None @@ -53,7 +63,9 @@ class Error(AdCPBaseModel): ) code: Annotated[str, Field(description='Error code for programmatic handling')] message: Annotated[str, Field(description='Detailed error message')] - details: Annotated[Details | None, Field(description='Additional error context')] = None + details: Annotated[ + Details | None, Field(description='Additional error context'), NoExplicitNull + ] = None class Type(StrEnum): @@ -93,30 +105,38 @@ class TasksGetResponse(AdcpVersionEnvelope, ProtocolEnvelope): Field( description='When the task completed (ISO 8601, only for completed/failed/canceled tasks)' ), + NoExplicitNull, ] = None has_webhook: Annotated[ - StrictBool | None, Field(description='Whether this task has webhook configuration') + StrictBool | None, + Field(description='Whether this task has webhook configuration'), + NoExplicitNull, ] = None progress: Annotated[ - Progress | None, Field(description='Progress information for long-running tasks') + Progress | None, + Field(description='Progress information for long-running tasks'), + NoExplicitNull, ] = None error: Annotated[ Error | None, Field( description='Convenience summary for failed tasks. When include_result was true and the canonical terminal result is also present, this error MUST agree with the canonical fatal error in result. A legacy poll carrying only this singular summary proves failure status but not equivalence to a richer terminal webhook artifact.' ), + NoExplicitNull, ] = None history: Annotated[ list[HistoryItem] | None, Field( description='Complete conversation history for this task (only included if include_history was true in request)' ), + NoExplicitNull, ] = None result: Annotated[ dict[str, Any] | None, Field( description='Canonical task-specific terminal payload. Present when include_result was true and a completed, failed, or rejected task produced a terminal artifact; canceled tasks may omit it. For failed tasks, the singular error field is a convenience summary and MUST agree with the canonical fatal error represented here. Consumers and sellers MUST resolve and validate the exact schema through manifest.task_result_resolution: use terminal_schema_overrides[task_type] when present, otherwise tools[task_type].response_schema. The polling envelope keeps this field generic so tasks/get does not embed every task response schema.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/tasks_list_request.py b/src/adcp/types/domains/core/tasks_list_request.py index 2cb1b573d..2b2dc2036 100644 --- a/src/adcp/types/domains/core/tasks_list_request.py +++ b/src/adcp/types/domains/core/tasks_list_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/tasks_list_request.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..enums import adcp_protocol, sort_direction, task_status @@ -24,53 +24,69 @@ class Filters(AdCPBaseModel): extra='allow', ) protocol: Annotated[ - adcp_protocol.AdcpProtocol | None, Field(description='Filter by single AdCP protocol') + adcp_protocol.AdcpProtocol | None, + Field(description='Filter by single AdCP protocol'), + NoExplicitNull, ] = None protocols: Annotated[ list[adcp_protocol.AdcpProtocol] | None, Field(description='Filter by multiple AdCP protocols', min_length=1), + NoExplicitNull, ] = None status: Annotated[ - task_status.TaskStatus | None, Field(description='Filter by single task status') + task_status.TaskStatus | None, + Field(description='Filter by single task status'), + NoExplicitNull, ] = None statuses: Annotated[ list[task_status.TaskStatus] | None, Field(description='Filter by multiple task statuses', min_length=1), + NoExplicitNull, ] = None task_type: Annotated[ - task_type_1.TaskType | None, Field(description='Filter by single task type') + task_type_1.TaskType | None, Field(description='Filter by single task type'), NoExplicitNull ] = None task_types: Annotated[ list[task_type_1.TaskType] | None, Field(description='Filter by multiple task types', min_length=1), + NoExplicitNull, ] = None created_after: Annotated[ - AwareDatetime | None, Field(description='Filter tasks created after this date (ISO 8601)') + AwareDatetime | None, + Field(description='Filter tasks created after this date (ISO 8601)'), + NoExplicitNull, ] = None created_before: Annotated[ - AwareDatetime | None, Field(description='Filter tasks created before this date (ISO 8601)') + AwareDatetime | None, + Field(description='Filter tasks created before this date (ISO 8601)'), + NoExplicitNull, ] = None updated_after: Annotated[ AwareDatetime | None, Field(description='Filter tasks last updated after this date (ISO 8601)'), + NoExplicitNull, ] = None updated_before: Annotated[ AwareDatetime | None, Field(description='Filter tasks last updated before this date (ISO 8601)'), + NoExplicitNull, ] = None task_ids: Annotated[ list[str] | None, Field(description='Filter by specific task IDs', max_length=100, min_length=1), + NoExplicitNull, ] = None context_contains: Annotated[ str | None, Field( description='Filter tasks where context contains this text (searches media_buy_id, signal_id, etc.)' ), + NoExplicitNull, ] = None has_webhook: Annotated[ StrictBool | None, Field(description='Filter tasks that have webhook configuration when true'), + NoExplicitNull, ] = None @@ -86,9 +102,11 @@ class Sort(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - field: Annotated[Field1 | None, Field(description='Field to sort by')] = Field1.created_at + field: Annotated[Field1 | None, Field(description='Field to sort by'), NoExplicitNull] = ( + Field1.created_at + ) direction: Annotated[ - sort_direction.SortDirection | None, Field(description='Sort direction') + sort_direction.SortDirection | None, Field(description='Sort direction'), NoExplicitNull ] = sort_direction.SortDirection.desc @@ -101,17 +119,19 @@ class TasksListRequest(AdcpVersionEnvelope): Field( description="Account scope for task reconciliation. Sellers MUST only return tasks created for the caller's authenticated account + principal pair. When omitted, the seller MAY use the credential-bound singleton account, but multi-account credentials SHOULD require an explicit account." ), + NoExplicitNull, ] = None - filters: Annotated[Filters | None, Field(description='Filter criteria for querying tasks')] = ( - None - ) - sort: Annotated[Sort | None, Field(description='Sorting parameters')] = None - pagination: pagination_request.PaginationRequest | None = None + filters: Annotated[ + Filters | None, Field(description='Filter criteria for querying tasks'), NoExplicitNull + ] = None + sort: Annotated[Sort | None, Field(description='Sorting parameters'), NoExplicitNull] = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None include_history: Annotated[ StrictBool | None, Field( description='Include full conversation history for each task (may significantly increase response size)' ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/tasks_list_response.py b/src/adcp/types/domains/core/tasks_list_response.py index 0f5b7e550..9bc088955 100644 --- a/src/adcp/types/domains/core/tasks_list_response.py +++ b/src/adcp/types/domains/core/tasks_list_response.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/tasks_list_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..enums import task_status @@ -26,12 +26,17 @@ class DomainBreakdown(AdCPBaseModel): media_buy: Annotated[ SchemaInt | None, Field(alias='media-buy', description='Number of media-buy tasks in results', ge=0), + NoExplicitNull, ] = None signals: Annotated[ - SchemaInt | None, Field(description='Number of signals tasks in results', ge=0) + SchemaInt | None, + Field(description='Number of signals tasks in results', ge=0), + NoExplicitNull, ] = None creative: Annotated[ - SchemaInt | None, Field(description='Number of creative tasks in results', ge=0) + SchemaInt | None, + Field(description='Number of creative tasks in results', ge=0), + NoExplicitNull, ] = None @@ -55,21 +60,26 @@ class QuerySummary(AdCPBaseModel): total_matching: Annotated[ SchemaInt | None, Field(description='Total number of tasks matching filters (across all pages)', ge=0), + NoExplicitNull, ] = None returned: Annotated[ - SchemaInt | None, Field(description='Number of tasks returned in this response', ge=0) + SchemaInt | None, + Field(description='Number of tasks returned in this response', ge=0), + NoExplicitNull, ] = None domain_breakdown: Annotated[ - DomainBreakdown | None, Field(description='Count of tasks by domain') + DomainBreakdown | None, Field(description='Count of tasks by domain'), NoExplicitNull ] = None status_breakdown: Annotated[ - dict[str, SchemaInt] | None, Field(description='Count of tasks by status') + dict[str, SchemaInt] | None, Field(description='Count of tasks by status'), NoExplicitNull ] = None filters_applied: Annotated[ - list[str] | None, Field(description='List of filters that were applied to the query') + list[str] | None, + Field(description='List of filters that were applied to the query'), + NoExplicitNull, ] = None sort_applied: Annotated[ - SortApplied | None, Field(description='Sort order that was applied') + SortApplied | None, Field(description='Sort order that was applied'), NoExplicitNull ] = None @@ -98,9 +108,12 @@ class Task(AdCPBaseModel): Field( description='When the task completed (ISO 8601, only for completed/failed/canceled tasks)' ), + NoExplicitNull, ] = None has_webhook: Annotated[ - StrictBool | None, Field(description='Whether this task has webhook configuration') + StrictBool | None, + Field(description='Whether this task has webhook configuration'), + NoExplicitNull, ] = None @@ -113,5 +126,5 @@ class TasksListResponse(AdcpVersionEnvelope, ProtocolEnvelope): ] tasks: Annotated[list[Task], Field(description='Array of tasks matching the query criteria')] pagination: pagination_response.PaginationResponse - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/tracker_execution_selector.py b/src/adcp/types/domains/core/tracker_execution_selector.py index c3b58ceed..b0b04b94b 100644 --- a/src/adcp/types/domains/core/tracker_execution_selector.py +++ b/src/adcp/types/domains/core/tracker_execution_selector.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/tracker_execution_selector.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import pixel_tracking_event, tracker_execution_actor, tracker_firing_path @@ -35,7 +35,9 @@ class TrackerExecutionSelector1(AdCPBaseModel): ), ] = 'img' custom_event_name: Annotated[ - str | None, Field(description='Required only when event is custom.', min_length=1) + str | None, + Field(description='Required only when event is custom.', min_length=1), + NoExplicitNull, ] = None execution_actor: tracker_execution_actor.TrackerExecutionActor firing_paths: Annotated[ @@ -62,7 +64,7 @@ class TrackerExecutionSelector3(DaastTrackerConstraints): daast_versions: daast_tracker_constraints.DaastVersions daast_event: daast_tracker_constraints.DaastEvent target: daast_tracker_constraints.DaastTarget - offset: daast_tracker_constraints.DaastOffset | None = None + offset: Annotated[daast_tracker_constraints.DaastOffset | None, NoExplicitNull] = None execution_actor: tracker_execution_actor.TrackerExecutionActor firing_paths: Annotated[ list[tracker_firing_path.TrackerFiringPath], @@ -88,7 +90,7 @@ class TrackerExecutionSelector2(VastTrackerConstraints): vast_versions: vast_tracker_constraints.VastVersions vast_event: vast_tracker_constraints.VastEvent target: vast_tracker_constraints.VastTarget - offset: vast_tracker_constraints.VastOffset | None = None + offset: Annotated[vast_tracker_constraints.VastOffset | None, NoExplicitNull] = None execution_actor: tracker_execution_actor.TrackerExecutionActor firing_paths: Annotated[ list[tracker_firing_path.TrackerFiringPath], diff --git a/src/adcp/types/domains/core/transformer.py b/src/adcp/types/domains/core/transformer.py index 59666ebe1..c694305ec 100644 --- a/src/adcp/types/domains/core/transformer.py +++ b/src/adcp/types/domains/core/transformer.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/transformer.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, ConfigDict, Field, StrictBool, model_validator @@ -59,6 +59,7 @@ class VoiceSynthesisRefItem(AdCPBaseModel): Field( description='Optional rights offering or buyer-specific grant identifier associated with this provisioned voice. Brand-side voice_synthesis uses rights_offering_id for the configuration-time offering anchor. This transformer field may carry that offering ID or a grant ID after provisioning; it remains provenance metadata only, not a build_creative rights token.' ), + NoExplicitNull, ] = None @@ -90,23 +91,29 @@ class Multiplicity(AdCPBaseModel): extra='allow', ) supports_catalog_fanout: Annotated[ - StrictBool | None, Field(description='Whether this transformer accepts max_creatives.') + StrictBool | None, + Field(description='Whether this transformer accepts max_creatives.'), + NoExplicitNull, ] = False max_creatives_limit: Annotated[ SchemaInt | None, Field(description='Per-transformer ceiling on max_creatives (≤ the agent ceiling).', ge=1), + NoExplicitNull, ] = None supports_variants: Annotated[ StrictBool | None, Field(description='Whether this transformer accepts max_variants > 1 / variant_axis.'), + NoExplicitNull, ] = False max_variants_limit: Annotated[ SchemaInt | None, Field(description='Per-transformer ceiling on max_variants (≤ the agent ceiling).', ge=1), + NoExplicitNull, ] = None variant_dimensions: Annotated[ list[VariantDimension] | None, Field(description="Variant axis dimensions this transformer supports (⊆ the agent's)."), + NoExplicitNull, ] = None @@ -116,6 +123,7 @@ class InputFormat1(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -123,12 +131,14 @@ class InputFormat1(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -136,60 +146,70 @@ class InputFormat1(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -197,12 +217,14 @@ class InputFormat1(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['image'] = 'image' params: image.CanonicalFormatImage @@ -214,6 +236,7 @@ class InputFormat2(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -221,12 +244,14 @@ class InputFormat2(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -234,60 +259,70 @@ class InputFormat2(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -295,12 +330,14 @@ class InputFormat2(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['html5'] = 'html5' params: html5.CanonicalFormatHtml5Banner @@ -312,6 +349,7 @@ class InputFormat3(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -319,12 +357,14 @@ class InputFormat3(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -332,60 +372,70 @@ class InputFormat3(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -393,12 +443,14 @@ class InputFormat3(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['display_tag'] = 'display_tag' params: display_tag.CanonicalFormatDisplayTag @@ -410,6 +462,7 @@ class InputFormat4(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -417,12 +470,14 @@ class InputFormat4(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -430,60 +485,70 @@ class InputFormat4(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -491,12 +556,14 @@ class InputFormat4(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['image_carousel'] = 'image_carousel' params: image_carousel.CanonicalFormatImageCarousel @@ -508,6 +575,7 @@ class InputFormat5(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -515,12 +583,14 @@ class InputFormat5(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -528,60 +598,70 @@ class InputFormat5(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -589,12 +669,14 @@ class InputFormat5(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['video_hosted'] = 'video_hosted' params: video_hosted.CanonicalFormatHostedVideo @@ -606,6 +688,7 @@ class InputFormat6(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -613,12 +696,14 @@ class InputFormat6(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -626,60 +711,70 @@ class InputFormat6(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -687,12 +782,14 @@ class InputFormat6(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['video_vast'] = 'video_vast' params: video_vast.CanonicalFormatVastVideo @@ -704,6 +801,7 @@ class InputFormat7(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -711,12 +809,14 @@ class InputFormat7(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -724,60 +824,70 @@ class InputFormat7(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -785,12 +895,14 @@ class InputFormat7(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_hosted'] = 'audio_hosted' params: audio_hosted.CanonicalFormatHostedAudio @@ -802,6 +914,7 @@ class InputFormat8(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -809,12 +922,14 @@ class InputFormat8(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -822,60 +937,70 @@ class InputFormat8(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -883,12 +1008,14 @@ class InputFormat8(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_vast'] = 'audio_vast' params: audio_vast.CanonicalFormatVastAudio @@ -900,6 +1027,7 @@ class InputFormat9(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -907,12 +1035,14 @@ class InputFormat9(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -920,60 +1050,70 @@ class InputFormat9(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -981,12 +1121,14 @@ class InputFormat9(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_daast'] = 'audio_daast' params: audio_daast.CanonicalFormatDaastAudio @@ -998,6 +1140,7 @@ class InputFormat10(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1005,12 +1148,14 @@ class InputFormat10(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1018,60 +1163,70 @@ class InputFormat10(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1079,12 +1234,14 @@ class InputFormat10(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['sponsored_placement'] = 'sponsored_placement' params: sponsored_placement.CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven @@ -1096,6 +1253,7 @@ class InputFormat11(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1103,12 +1261,14 @@ class InputFormat11(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1116,60 +1276,70 @@ class InputFormat11(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1177,12 +1347,14 @@ class InputFormat11(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['native_in_feed'] = 'native_in_feed' params: native_in_feed.CanonicalFormatNativeInFeed @@ -1194,6 +1366,7 @@ class InputFormat12(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1201,12 +1374,14 @@ class InputFormat12(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1214,60 +1389,70 @@ class InputFormat12(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1275,12 +1460,14 @@ class InputFormat12(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['responsive_creative'] = 'responsive_creative' params: responsive_creative.CanonicalFormatResponsiveCreative @@ -1292,6 +1479,7 @@ class InputFormat13(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1299,12 +1487,14 @@ class InputFormat13(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1312,60 +1502,70 @@ class InputFormat13(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1373,12 +1573,14 @@ class InputFormat13(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['agent_placement'] = 'agent_placement' params: agent_placement.CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement @@ -1390,6 +1592,7 @@ class InputFormat14(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1397,12 +1600,14 @@ class InputFormat14(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1410,60 +1615,70 @@ class InputFormat14(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1471,12 +1686,14 @@ class InputFormat14(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['seller_rendered_stateful_display'] = 'seller_rendered_stateful_display' params: seller_rendered_stateful_display.CanonicalFormatSellerRenderedStatefulDisplay @@ -1488,6 +1705,7 @@ class InputFormat15(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1495,12 +1713,14 @@ class InputFormat15(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1508,60 +1728,70 @@ class InputFormat15(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1569,12 +1799,14 @@ class InputFormat15(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['coordinated_placements'] = 'coordinated_placements' params: coordinated_placements.CanonicalFormatCoordinatedPlacements @@ -1586,6 +1818,7 @@ class InputFormat16(AdCPBaseModel): Field( description="Stable identifier for this declaration within its namespace. REQUIRED when a product contains multiple declarations with the same format_kind and SHOULD be set on every entry. Publisher-backed options pair it with publisher_domain; product-local options omit publisher_domain. When a single declaration has a unique format_kind and no ID, buyers author canonically with format_kind plus params; they MUST NOT fall back to deprecated format_ids merely because this optional ID is absent. Examples: 'display_image_300x250', 'responsive_search', 'daily_pulse_homepage_image'." ), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -1593,12 +1826,14 @@ class InputFormat16(AdCPBaseModel): description="Namespace for `format_option_id` when this declaration references or narrows a publisher-declared format option from that publisher's adagents.json top-level `formats[]`. Product-local options omit this field and are selected by `format_option_id` within the target product.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None tracker_execution_contract: Annotated[ tracker_execution_contract_1.TrackerExecutionContract | None, Field( description='Seller- or publisher-authored commitment describing which first-class manifest trackers the selected format option accepts and initiates in production. The seller-returned Product declaration is binding; publisher and placement declarations are upstream inputs that the seller resolves into that effective contract. Presence requires a stable format_option_id. Creative-agent capability projections, transformer inputs, and deprecated canonical_parameters MUST reject this seller-authority field rather than copying it.' ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -1606,60 +1841,70 @@ class InputFormat16(AdCPBaseModel): description='Binding format-option processing capabilities for exact macro dialect identities, semantics, operations, actors, contexts, and encodings. Absence means undeclared, not supported on the opt-in declared-token path. Seller-wide capabilities are only a ceiling. This field does not claim that a buyer tracker asset is honored or fired.', min_length=1, ), + NoExplicitNull, ] = None technical_requirements_complete: Annotated[ StrictBool | None, Field( description='Completeness assertion for technical creative acceptance constraints in this declaration. When true, the declaring party asserts that every technical constraint within its authority is expressed by this declaration (including fetched custom-format and platform-extension schemas), and every omitted technical field means no constraint at that layer. A creative that satisfies the complete effective technical contract MUST NOT later be rejected for an undisclosed technical constraint. When false or absent, omitted technical constraints are undeclared: consumers MUST NOT interpret omission as support, no constraint, or a prose/default value. The effective product/placement contract is complete only when every applicable product, publisher, and placement declaration asserts true. This assertion is limited to technical acceptance; it does not waive policy, legal, security, malware, transport/fetch, corrupted-content, or materially misdeclared-asset checks. Creative size fields ending in `_kb` use exactly 1,000 bytes per KB and fields ending in `_mb` use exactly 1,000,000 bytes per MB.' ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description="Optional seller-controlled human-readable label for this format declaration. Used by buyer dashboards, catalog UIs, and reporting surfaces to show a seller's own naming ('Homepage Takeover', 'Branded Canvas', 'Reels Premium Video') rather than the raw `format_kind` or `format_option_id`. Has no machine semantics — buyer agents route on `format_kind` and `format_option_id`; `display_name` is purely for human presentation. Freeform; no enumeration. Sellers SHOULD keep it stable once published to avoid dashboard churn." ), + NoExplicitNull, ] = None sample_render_url: Annotated[ AnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), + NoExplicitNull, ] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field( description="Optional subset of the parent product's `channels` to which this declaration applies. When omitted, the declaration applies to ALL channels declared on the product. Lets a multi-channel product (e.g., `channels: ['display', 'video']`) carry distinct format_options per channel — `format_options: [{format_kind: 'image', applies_to_channels: ['display']}, {format_kind: 'video_hosted', applies_to_channels: ['video']}]`. Buyers ship channel-appropriate manifests per `applies_to_channels`." ), + NoExplicitNull, ] = None seller_preference: Annotated[ SellerPreference | None, Field( description="Optional soft routing hint *within* a product's accepted set of formats — NOT an enforcement axis. `preferred` — seller actively recommends this format (often because of measurement, viewability, or render-quality differences); `accepted` — supported on equal footing with other format_options (default when omitted); `discouraged` — supported but suboptimal (e.g., legacy 3p-tag where the seller would prefer html5 for OM-SDK coverage). Buyer agents picking between format_options SHOULD respect seller preferences when their own constraints don't override.\n\n**Not an enforcement axis (normative).** `seller_preference` does NOT carry the meaning of 'this format won't work / required-only'. That case is structural: `format_options[]` IS the closed set of accepted formats; anything outside the list is rejected at `create_media_buy` regardless of preference. A seller that accepts only one format lists exactly that one entry — the structural fact does the enforcement work, no enum value needed. There is intentionally no `required` value; preference is bounded to *ranking within the already-accepted set*, not gating into it." ), + NoExplicitNull, ] = None locale_policy: Annotated[ creative_locale_policy.CreativeLocalePolicy | None, Field( description='Optional seller-enforced creative-locale constraint for this format option. This is product/placement eligibility, not a new format kind or synthetic locale-specific format ID. Because legacy format_ids cannot preserve this constraint, declarations carrying locale_policy MUST set canonical_formats_only to true and MUST NOT carry v1_format_ref.' ), + NoExplicitNull, ] = None canonical_formats_only: Annotated[ StrictBool | None, Field( description='When true, this format declaration has no clean v1 projection and SDKs MUST NOT synthesize a v1 `format_id` for it. Buyers reading the product on the v1 wire path see this declaration absent from `format_ids`; only v2-aware buyers (reading `format_options`) discover it. Set explicitly for `format_kind: "custom"` declarations (no canonical exists in v1 to project onto) and for declarations whose canonical/parameter shape cannot round-trip through a v1 named format without semantic loss. The protocol does NOT mint synthetic v1 format_ids for unmappable declarations — the alternative (an `aao-synth/*` namespace populated automatically) was considered and rejected because adopters would index on synthetic IDs that have no stable identity. Producers SHOULD set `canonical_formats_only: true` rather than omit the declaration from `format_options` — explicit v2-only is more useful than silent absence.' ), + NoExplicitNull, ] = False experimental: Annotated[ StrictBool | None, Field( description="When true, this seller's specific canonical declaration may not work as declared even if the underlying canonical is stable. Buyers SHOULD preflight it with validate_input or in a sandbox before routing production budget and SHOULD filter it from default views unless the caller opts in. Experimental status never makes the deprecated named-format path preferable. This field is independent of the canonical's own experimental flag and replaces the earlier runtime_status enum." ), + NoExplicitNull, ] = False format_shape: Annotated[ str | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. Recognized global pattern this custom shape is an instance of, drawn from the [format-shape vocabulary registry](/schemas/core/format-shape-vocabulary.json) (`branded_content`, `cross_screen_sponsorship`, `sponsorship_lockup`, `newsletter_sponsorship`, `ar_lens`, `playable`, `live_event_sponsorship`, …). Non-canonical values are valid (validators MAY soft-warn) — adopters CAN ship a shape that isn\'t yet in the registry. Adding entries is a vocabulary PR. Once a `format_shape` entry sees 2+ adopters with substantively similar `format_schema` content for 90+ days, the working group may promote it to a first-class canonical. Retired vocabulary entries `multi_state_display` and `multi_placement_takeover` remain temporarily recognizable for migration; new declarations MUST use their promoted canonicals and validators SHOULD emit `FORMAT_SHAPE_PROMOTED`. `roadblock` remains an inventory/exclusivity classifier and is not a promoted creative format.' ), + NoExplicitNull, ] = None v1_format_ref: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1667,12 +1912,14 @@ class InputFormat16(AdCPBaseModel): description="Authoritative v2 → v1 link, expressed as an array of one or more v1 `format_id` ({agent_url, id}) values. Each entry asserts that this canonical-formats declaration IS the same underlying format as the referenced v1 named format. Always an array (single-ref is `[{...}]`) so the multi-size case below has a clean wire shape — adopters surveyed in the SDK implementor review pushed for this over the lossy single-ref form.\n\nThe v2 declaration's `params` MUST narrow (be compatible with) each referenced v1 format's `requirements` — see the 'Narrows — formal definition' section in canonical-formats.mdx. SDKs comparing dual-emitted shapes (`Product.format_ids[]` ⊇ entries from `v1_format_ref` AND `Product.format_options[]` carrying this declaration) treat the link as the authoritative pairing and run the narrowing check between this declaration and EACH referenced v1 format file's `requirements`.\n\n**Multi-size fan-out (normative).** When the declaration carries `params.sizes: [{w,h}, ...]` (multi-size flexible slot), sellers SHOULD carry one `v1_format_ref[]` entry per size, each pointing at the per-size v1 named format in the AAO catalog. Example: a multi-size image declaration with `sizes: [300x250, 728x90, 970x250]` SHOULD carry `v1_format_ref: [{aao, display_300x250_image}, {aao, display_728x90_image}, {aao, display_970x250_image}]`. v1-only buyers then see the product on all three sizes via the `format_ids[]` dual-emission. When `v1_format_ref[]` count < `sizes[]` count, SDKs MUST emit `FORMAT_DECLARATION_V1_LOSSY_MULTI_SIZE` on the response `errors[]` (advisory, alongside the partial-coverage v1 emit — NOT in place of it). SDKs MAY (non-normative) fan out automatically by catalog lookup when `v1_format_ref[]` has length 1 and `sizes[]` has length N — opt-in, requires catalog access; sellers asserting refs is the source of truth.\n\nMutually exclusive with `canonical_formats_only: true` — a declaration can EITHER assert no v1 projection (`canonical_formats_only: true`) OR link to v1 named formats (`v1_format_ref[]`), never both. When neither is present, SDKs fall back to the resolution order in `v1-canonical-mapping.json` (seller's explicit `canonical` field on the v1 file → registry glob → structural match → fail-closed).\n\nThis is the v2-side authoritative replacement for the v1-side `canonical_parameters` field on `format.json` (which is deprecated for 3.1, removed at 4.0). Sellers SHOULD prefer authoring v2 declarations with `v1_format_ref[]` over mirroring the v2 shape onto v1 files via `canonical_parameters`; the directional link (v2 declaration → v1 identifiers) is the same fact without the parallel-shape drift surface.\n\n**AAO-hosted convention (normative).** For IAB-standard formats (image dimensions, VAST/DAAST tags, standard third-party tags, HTML5 banner bundles), sellers SHOULD point each `v1_format_ref[].agent_url` at the AAO-hosted canonical agent URL `https://creative.adcontextprotocol.org` and use the registry-published id (e.g., `display_300x250_image`, `video_vast_30s`, `audio_standard_30s`, `display_300x250_html`, `display_js`). This converges the v1-wire namespace: every seller's IAB MREC points at the same `{agent_url, id}` pair, so v1-only buyers' allowlists work uniformly. Without this convention, every publisher's 300x250 ships with a different `v1_format_ref` (theirs vs nytimes.example vs cnn.example vs …) and the v1 wire fragments into per-publisher namespaces — exactly what canonical-formats was designed to eliminate.\n\nFor platform-specific formats (Meta Reels, TikTok Spark, Snap Spotlight, etc.), each `v1_format_ref[].agent_url` SHOULD point at the platform's own agent_url when the platform has adopted AdCP and publishes its own `adagents.json` with `formats[]`. When the platform has NOT adopted AdCP, sellers SHOULD point at the AAO community-registry mirror — `https://creative.adcontextprotocol.org/translated/` + `id: ` (e.g., `https://creative.adcontextprotocol.org/translated/meta` + `id: meta_reels`). This keeps the v1 namespace converged across all sellers selling that platform's inventory until the platform owns its own adagents.json.\n\n**Platform-adoption cutover (normative).** When a platform adopts AdCP and publishes its own adagents.json, sellers MUST update `v1_format_ref[].agent_url` to the platform's adopted agent_url in the same minor release as the AAO mirror entry's `superseded_by` field goes live (see `static/schemas/source/adagents.json#superseded_by`). The AAO mirror entry SHOULD continue serving for ≥1 minor release after `superseded_by` is set, returning an advisory 'superseded' marker so v1 buyer allowlists keyed on the mirror URL get an explicit signal rather than a silent break. **Identity-confusion note**: the mirror URL is *format-shape namespace*, NOT seller identity. Inventory authorization always flows from `authorized_agents[]` + publisher signing keys; a buyer matching `v1_format_ref[].agent_url` against an allowlist is matching format-shape provenance, not seller identity.\n\n**Mirror domain migration (3.1).** Earlier drafts used `https://mirror.adcontextprotocol.org/translated/`. As of this release, the convention is `https://creative.adcontextprotocol.org/translated/` — sibling content under the AAO catalog domain we already host. Adopters who hardcoded the earlier mirror URL MUST migrate to the new path; the canonical-formats.mdx migration section documents the move. No transitional redirect is currently published (the earlier subdomain was never provisioned).\n\nFor seller-bespoke formats (a publisher's `acme_homepage_takeover` that doesn't fit IAB conventions), each `v1_format_ref[].agent_url` is the seller's own agent_url and the id is seller-namespaced. These won't appear in `v1-canonical-mapping.json`'s registry; they're seller-asserted only.", min_length=1, ), + NoExplicitNull, ] = None format_schema: Annotated[ platform_extension_ref.PlatformExtensionReference | None, Field( description='REQUIRED when `format_kind: "custom"`; otherwise MUST be absent. URI+digest reference to a fetchable schema describing this custom shape\'s actual `params` and `slots`. Same hosting model as `platform_extensions`: open-ecosystem publishers host the artifact at the canonical URI on their subdomain; closed-platform / walled-garden shapes resolve through the AAO mirror at `https://creative.adcontextprotocol.org/translated/...`. Buyer agents fetch by `uri@digest` (immutable per digest, aggressive caching, `Cache-Control: public, max-age=31536000, immutable`), validate `params` and `slots` against the fetched schema, and reason about manifests structurally — same mechanic as platform_extensions but at the format-structure level. Without `format_schema`, custom shapes would be opaque to buyer agents and the protocol would regress to per-seller integration code; that\'s why the schema is required, not optional.\n\n**Fetch contract (normative)** — `format_schema` is load-bearing for validation (unlike `platform_extensions`, which is informational on the *consumption* side). The *transport* rules below apply identically to BOTH fields — any SDK fetching a `platform-extension-ref.json` URI MUST apply this contract regardless of whether the field name is `format_schema` or `platform_extensions`. A shared SDK fetch path that drops to the weakest bar undermines `format_schema`\'s hardening. The consumption distinction (load-bearing vs informational) is about *what the body means*; the transport distinction is `https`-and-allowlisted regardless.\n\n- **Transport**: `https` only. Buyers MUST reject `http://`, `file://`, `data:`, and any non-`https` scheme. The URI MUST resolve to a JSON document that is itself a valid JSON Schema (Draft 07 or 2020-12; producers MUST declare `$schema`).\n- **SSRF protection**: buyers MUST resolve the URI hostname and reject if any resolved address is in RFC 1918 private space (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local (`169.254.0.0/16`, `fe80::/10`), CGNAT (`100.64.0.0/10`), or any RFC 6761 special-use name (`.local`, `.localhost`, `.internal`, `.test`, `.example`, `.invalid`). Cloud metadata endpoints (`169.254.169.254`, `metadata.google.internal`, `kubernetes.default.svc`) are explicitly forbidden — these are credential-leak primitives. Buyers MUST pin the connection to the resolved IP (or re-resolve and re-validate the allowlist per request) to defeat DNS rebinding.\n- **HTTP redirects**: MUST be disabled. If a follow is implemented at all, the redirect target MUST pass the same scheme + SSRF + allowlist checks; otherwise the fetch hard-fails. Open redirects on same-origin paths are otherwise a free SSRF primitive.\n- **Response size cap**: response body MUST be capped at 1 MiB. Enforce during streaming, not after full buffering. Over-cap hard-fails identically to digest mismatch.\n- **Timeout**: SDKs SHOULD apply a fetch timeout ≤5 seconds. Timeout SHOULD be treated identically to an HTTP 5xx response (transient — retry policy at the SDK\'s discretion; on persistent failure surface as unresolved and skip the declaration for this session).\n- **Digest verification**: SHA-256 of the response body MUST equal `digest`. **Digest mismatch is a hard fail** — the buyer MUST treat the format declaration as unresolvable and MUST NOT validate manifests against the mismatched body. A divergent digest is either a malicious substitution or producer error; either way, falling back to the un-verified body breaks the trust model. Digest format: `sha256:` prefix + 64 lowercase hex characters. Cache key is `uri@digest`; digest mismatch MUST NOT be cached as a negative result keyed on `uri` alone (defeats CDN-flap recovery), and MUST be distinguishable in telemetry from network 5xx / 404 (sustained mismatch is a substitution-attack signal, not a flap).\n- **Sandboxing of `$ref`**: fetched schemas MAY use `$ref`. Buyers MUST resolve `$ref` only to URIs that are (a) same-origin as the parent `format_schema.uri` after RFC 3986 §6 normalization (lowercase scheme + host, strip default port, normalize path dot-segments, no userinfo component), OR (b) hosted under the AAO catalog domain (`https://creative.adcontextprotocol.org/...`), OR (c) intra-document JSON Pointer refs (`#/...`) bounded to the parent document\'s parsed tree. Cross-origin `$ref` to arbitrary URIs MUST be rejected. `$ref: file://...` MUST be rejected unconditionally. Transitive `$ref` chains MUST be bounded at depth ≤8 AND `$ref` count ≤256 across the resolved tree (depth 8 with breadth 100 per level is 10^16 nodes — depth alone is not enough). Publishers SHOULD inline rather than $ref where possible.\n- **Schema-compile bounds (DoS protection)**: validators MUST bound CPU/memory on fetched schemas. Recommended: compiled-schema keyword count ≤10 000, `pattern` regexes evaluated with a non-backtracking engine (re2) OR under a per-pattern timeout, per-manifest validation budget ≤250 ms (exceeded budget → treat manifest as invalid, surface telemetry signal). Without these, a \'valid\' schema with catastrophic regex backtracking or exponential `allOf`/`anyOf` expansion pins a CPU forever.\n- **Cache**: buyers cache fetched schemas by `uri@digest` and treat them as immutable (the same hosting contract as `platform_extensions`). On `404`, network partition, or persistent fetch failure, buyers SHOULD degrade gracefully (treat the declaration as unresolved, skip it for the current `get_products` response, surface via `errors[]` with the relevant code) rather than failing the entire session.\n- **Schema-not-valid handling**: if the fetched body parses as JSON but is not a valid JSON Schema, the buyer MUST treat the declaration as unresolvable (same as digest mismatch) and surface via `errors[]`. Validators MUST NOT attempt partial validation against an invalid schema.\n- **AAO catalog trust**: `https://creative.adcontextprotocol.org/*` is a single trust anchor in the same-origin allowlist; compromise of the catalog domain or its CA compromises every buyer agent. Catalog-served bodies MUST be digest-pinned identically to origin fetches (the digest is on the *parent* `format_schema.uri@digest`, not on the catalog response). Future hardening (signed bodies, transparency log) is tracked separately.' ), + NoExplicitNull, ] = None format_kind: Literal['custom'] = 'custom' params: Annotated[ @@ -1786,12 +2033,14 @@ class Transformer(AdCPBaseModel): description: Annotated[ str | None, Field(description='Plain-text explanation of what this transformer produces and how.'), + NoExplicitNull, ] = None metadata: Annotated[ dict[str, Any] | None, Field( description='Transformer-specific attributes a buyer can filter or display (e.g. provider, modality, language).' ), + NoExplicitNull, ] = None voice_synthesis_ref: Annotated[ list[VoiceSynthesisRefItem] | None, @@ -1799,6 +2048,7 @@ class Transformer(AdCPBaseModel): description='Optional discovery/audit anchors for voice transformers provisioned from brand-agent voice_synthesis entries. Informational only: these references help buyers match a discovered transformer to brand/rights-agent provenance, but they do not assert build-time authorization or require the creative agent to perform rights-token validation.', min_length=1, ), + NoExplicitNull, ] = None input_format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1806,6 +2056,7 @@ class Transformer(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named formats this transformer accepts as input. Use input_formats with canonical declarations.', ), + NoExplicitNull, ] = None output_format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -1814,6 +2065,7 @@ class Transformer(AdCPBaseModel): description='**DEPRECATED in 3.2.** Legacy named formats this transformer can produce. Use output_capability_ids.', min_length=1, ), + NoExplicitNull, ] = None input_formats: Annotated[ list[InputFormat] | None, @@ -1821,6 +2073,7 @@ class Transformer(AdCPBaseModel): description='Canonical format declarations this transformer accepts as input. Omitted means it builds from a brief or raw assets rather than transforming an existing creative. Compatibility uses canonical constraint satisfaction, not identifier equality. Transformer self-description has no seller production authority, so tracker_execution_contract and tracker_execution_contract_digest are forbidden.', min_length=1, ), + NoExplicitNull, ] = None output_capability_ids: Annotated[ list[OutputCapabilityId] | None, @@ -1828,12 +2081,14 @@ class Transformer(AdCPBaseModel): description="Canonical output capabilities this transformer can produce. Every value MUST match this agent's get_adcp_capabilities `creative.supported_formats[].capability_id`. A build_creative request's target_capability_id(s) MUST be a subset of this array.", min_length=1, ), + NoExplicitNull, ] = None params: Annotated[ list[transformer_param.TransformerParam] | None, Field( description="Configuration knobs this transformer exposes. The buyer supplies values in build_creative `config`, keyed by each param's `field`. Enumerable param values (e.g. account-specific voices) are returned only when requested via list_transformers `expand_params`." ), + NoExplicitNull, ] = None pricing_options: Annotated[ list[vendor_pricing_option.VendorPricingOption] | None, @@ -1841,12 +2096,14 @@ class Transformer(AdCPBaseModel): description='Per-account rate-card options for using this transformer. Present when the list_transformers request set include_pricing=true with an account. The applied option is echoed back per-leaf on the build_creative response and reconciled via report_usage.', min_length=1, ), + NoExplicitNull, ] = None multiplicity: Annotated[ Multiplicity | None, Field( description="Optional per-transformer fan-out limits that NARROW the agent-level get_adcp_capabilities `creative.multiplicity`. Same shape as the agent-level object. When present, this transformer's authoritative; its ceilings (max_creatives_limit / max_variants_limit) MUST NOT exceed the agent ceilings, and its variant_dimensions MUST be a subset of the agent's. Omit to inherit the agent-level capability unchanged." ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/transformer_param.py b/src/adcp/types/domains/core/transformer_param.py index 2651bd37c..51341f697 100644 --- a/src/adcp/types/domains/core/transformer_param.py +++ b/src/adcp/types/domains/core/transformer_param.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/transformer_param.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool, StrictFloat @@ -32,12 +32,15 @@ class Option(AdCPBaseModel): value: Annotated[ Any, Field(description='The value the buyer passes in `config` for this field.') ] - label: Annotated[str | None, Field(description='Human-readable label for this option.')] = None + label: Annotated[ + str | None, Field(description='Human-readable label for this option.'), NoExplicitNull + ] = None metadata: Annotated[ dict[str, Any] | None, Field( description='Option-specific attributes the buyer can filter or display (e.g. for a voice: language, gender, provider, custom).' ), + NoExplicitNull, ] = None @@ -66,6 +69,7 @@ class TransformerParam(AdCPBaseModel): description='Optional maximum character length for a `free_text` param. Omit for no declared limit.', ge=1, ), + NoExplicitNull, ] = None allowed_values: Annotated[ list[Any] | None, @@ -73,26 +77,31 @@ class TransformerParam(AdCPBaseModel): description='The closed set of legal values. Present when `value_source` is `inline`.', min_length=1, ), + NoExplicitNull, ] = None minimum: Annotated[ StrictFloat | None, Field(description='Inclusive lower bound. Present when `value_source` is `range`.'), + NoExplicitNull, ] = None maximum: Annotated[ StrictFloat | None, Field(description='Inclusive upper bound. Present when `value_source` is `range`.'), + NoExplicitNull, ] = None options: Annotated[ list[Option] | None, Field( description="Account-scoped legal values for an `enumerable` param. Populated ONLY when this param's `field` was named in the list_transformers `expand_params` request — otherwise omitted (the buyer enumerates on demand). Brief-filtered and paginated; use `options_cursor` for the next page." ), + NoExplicitNull, ] = None options_cursor: Annotated[ str | None, Field( description="Opaque pagination cursor for this param's `options[]`. Present when more option values are available than were returned. Pass back to list_transformers (scoped to this transformer + field) to fetch the next page." ), + NoExplicitNull, ] = None default: Annotated[ Any | None, @@ -105,7 +114,10 @@ class TransformerParam(AdCPBaseModel): Field( description='Whether the buyer MUST supply this field in `config`. When false and no `default` is declared, the agent chooses.' ), + NoExplicitNull, ] = False description: Annotated[ - str | None, Field(description='Human-readable explanation of what this knob does.') + str | None, + Field(description='Human-readable explanation of what this knob does.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/truncation_sentinel.py b/src/adcp/types/domains/core/truncation_sentinel.py index e83010fec..1e2471f03 100644 --- a/src/adcp/types/domains/core/truncation_sentinel.py +++ b/src/adcp/types/domains/core/truncation_sentinel.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/truncation_sentinel.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field @@ -26,12 +26,14 @@ class FieldTruncation(AdCPBaseModel): Field( description='Optional bounded human-readable excerpt of the original content. Sellers SHOULD keep previews under 8 KiB and SHOULD truncate on a UTF-8 codepoint boundary. Absent when the seller cannot safely surface even a preview (e.g., the original content is binary and not text-decodable, or seller policy forbids any payload surfacing). Receivers MUST NOT parse `preview` as a complete representation of the original value.' ), + NoExplicitNull, ] = None preview_format: Annotated[ str | None, Field( description='Hint for how to render `preview`. Common values: `text` (plain UTF-8), `json` (JSON fragment), `base64` (base64-encoded binary), `xml`, `html`. The list is open — adopters MAY use other MIME-derived shorthands. Receivers SHOULD treat unknown values as `text` and SHOULD NOT reject the sentinel when the value is unfamiliar.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/user_match.py b/src/adcp/types/domains/core/user_match.py index 857fd05e9..47a687ee4 100644 --- a/src/adcp/types/domains/core/user_match.py +++ b/src/adcp/types/domains/core/user_match.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/user_match.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, model_validator from ..enums import uid_type @@ -29,7 +29,9 @@ class UserMatch(AdCPBaseModel): extra='allow', ) uids: Annotated[ - list[Uid] | None, Field(description='Universal ID values for user matching', min_length=1) + list[Uid] | None, + Field(description='Universal ID values for user matching', min_length=1), + NoExplicitNull, ] = None hashed_email: Annotated[ str | None, @@ -37,6 +39,7 @@ class UserMatch(AdCPBaseModel): description='SHA-256 hash of lowercase, trimmed email address. Buyer must normalize before hashing: lowercase, trim whitespace. Pseudonymous PII, not anonymous — the email namespace is small enough that an unsalted SHA-256 is recoverable via precomputed dictionaries. Treat as PII for retention, consent, and access-control purposes. See docs/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous.', pattern='^[a-f0-9]{64}$', ), + NoExplicitNull, ] = None hashed_phone: Annotated[ str | None, @@ -44,22 +47,29 @@ class UserMatch(AdCPBaseModel): description='SHA-256 hash of E.164-formatted phone number (e.g. +12065551234). Buyer must normalize to E.164 before hashing. Pseudonymous PII, not anonymous — the E.164 namespace is small enough that an unsalted SHA-256 is recoverable via precomputed dictionaries. Treat as PII for retention, consent, and access-control purposes. See docs/reference/privacy-considerations#unsalted-hashed-identifiers-are-pseudonymous-not-anonymous.', pattern='^[a-f0-9]{64}$', ), + NoExplicitNull, ] = None click_id: Annotated[ str | None, Field(description='Platform click identifier (fbclid, gclid, ttclid, ScCid, etc.)'), + NoExplicitNull, ] = None click_id_type: Annotated[ str | None, Field(description='Type of click identifier (e.g. fbclid, gclid, ttclid, msclkid, ScCid)'), + NoExplicitNull, ] = None client_ip: Annotated[ - str | None, Field(description='Client IP address for probabilistic matching') + str | None, + Field(description='Client IP address for probabilistic matching'), + NoExplicitNull, ] = None client_user_agent: Annotated[ - str | None, Field(description='Client user agent string for probabilistic matching') + str | None, + Field(description='Client user agent string for probabilistic matching'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> UserMatch: diff --git a/src/adcp/types/domains/core/vast_media_file_requirements.py b/src/adcp/types/domains/core/vast_media_file_requirements.py index 25dd654bb..0df5a4f95 100644 --- a/src/adcp/types/domains/core/vast_media_file_requirements.py +++ b/src/adcp/types/domains/core/vast_media_file_requirements.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/vast_media_file_requirements.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -40,6 +40,7 @@ class VastMediafileRequirements(AdCPBaseModel): description='Accepted values of the required `MediaFile@delivery` attribute. `progressive` identifies a directly downloadable media file; `streaming` identifies a streaming media resource.', min_length=1, ), + NoExplicitNull, ] = None mime_types: Annotated[ list[MimeType] | None, @@ -47,6 +48,7 @@ class VastMediafileRequirements(AdCPBaseModel): description='Accepted MIME types from `MediaFile@type`, compared case-insensitively after trimming optional whitespace. Parameters are not accepted in this field. Examples: `video/mp4`, `video/webm`, `audio/mpeg`, `audio/aac`.', min_length=1, ), + NoExplicitNull, ] = None containers: Annotated[ list[Container] | None, @@ -54,6 +56,7 @@ class VastMediafileRequirements(AdCPBaseModel): description='Accepted normalized container identifiers, such as `mp4`, `webm`, or `mpeg_ts`. A receiver MUST determine the container from authoritative metadata or safe byte inspection and MUST NOT guess it only from the media URI suffix.', min_length=1, ), + NoExplicitNull, ] = None codecs: Annotated[ list[Codec] | None, @@ -61,18 +64,27 @@ class VastMediafileRequirements(AdCPBaseModel): description='Accepted codec identifiers for `MediaFile@codec`, using the codec syntax referenced by the applicable VAST version. A missing codec attribute does not satisfy a declared codec constraint unless safe inspection establishes the codec.', min_length=1, ), + NoExplicitNull, ] = None min_width: Annotated[ - SchemaInt | None, Field(description='Minimum accepted `MediaFile@width` in pixels.', ge=1) + SchemaInt | None, + Field(description='Minimum accepted `MediaFile@width` in pixels.', ge=1), + NoExplicitNull, ] = None max_width: Annotated[ - SchemaInt | None, Field(description='Maximum accepted `MediaFile@width` in pixels.', ge=1) + SchemaInt | None, + Field(description='Maximum accepted `MediaFile@width` in pixels.', ge=1), + NoExplicitNull, ] = None min_height: Annotated[ - SchemaInt | None, Field(description='Minimum accepted `MediaFile@height` in pixels.', ge=1) + SchemaInt | None, + Field(description='Minimum accepted `MediaFile@height` in pixels.', ge=1), + NoExplicitNull, ] = None max_height: Annotated[ - SchemaInt | None, Field(description='Maximum accepted `MediaFile@height` in pixels.', ge=1) + SchemaInt | None, + Field(description='Maximum accepted `MediaFile@height` in pixels.', ge=1), + NoExplicitNull, ] = None min_bitrate_kbps: Annotated[ SchemaInt | None, @@ -80,6 +92,7 @@ class VastMediafileRequirements(AdCPBaseModel): description='Minimum accepted MediaFile bitrate in kilobits per second. A fixed `MediaFile@bitrate` must be at least this value. For adaptive/streaming media, the declared `MediaFile@minBitrate` must be at least this value. Safe byte inspection may establish the value when metadata is absent.', ge=1, ), + NoExplicitNull, ] = None max_bitrate_kbps: Annotated[ SchemaInt | None, @@ -87,6 +100,7 @@ class VastMediafileRequirements(AdCPBaseModel): description='Maximum accepted MediaFile bitrate in kilobits per second. A fixed `MediaFile@bitrate` must not exceed this value. For adaptive/streaming media, the declared `MediaFile@maxBitrate` must not exceed this value. Safe byte inspection may establish the value when metadata is absent.', ge=1, ), + NoExplicitNull, ] = None max_file_size_bytes: Annotated[ SchemaInt | None, @@ -94,4 +108,5 @@ class VastMediafileRequirements(AdCPBaseModel): description='Maximum accepted MediaFile size in exact bytes. `MediaFile@fileSize`, when present, is expressed in bytes; a receiver MAY verify it against safely fetched media bytes and MUST use the verified byte count if the values disagree.', ge=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/vast_tracker_constraints.py b/src/adcp/types/domains/core/vast_tracker_constraints.py index 94f3664d0..21205d470 100644 --- a/src/adcp/types/domains/core/vast_tracker_constraints.py +++ b/src/adcp/types/domains/core/vast_tracker_constraints.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/vast_tracker_constraints.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import Field, RootModel @@ -60,7 +60,7 @@ class VastVersions(RootModel[list[vast_version.VastVersion]]): class VastTrackerConstraints(AdCPBaseModel): - vast_event: VastEvent | None = None - target: VastTarget | None = VastTarget.linear - offset: VastOffset | None = None - vast_versions: VastVersions | None = None + vast_event: Annotated[VastEvent | None, NoExplicitNull] = None + target: Annotated[VastTarget | None, NoExplicitNull] = VastTarget.linear + offset: Annotated[VastOffset | None, NoExplicitNull] = None + vast_versions: Annotated[VastVersions | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/vehicle_item.py b/src/adcp/types/domains/core/vehicle_item.py index b3c6b9bcf..546ddbf55 100644 --- a/src/adcp/types/domains/core/vehicle_item.py +++ b/src/adcp/types/domains/core/vehicle_item.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/vehicle_item.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -81,29 +81,56 @@ class VehicleItem(AdCPBaseModel): make: Annotated[str, Field(description="Vehicle manufacturer (e.g., 'Honda', 'Ford', 'BMW').")] model: Annotated[str, Field(description="Vehicle model (e.g., 'Civic', 'F-150', 'X5').")] year: Annotated[SchemaInt, Field(description='Model year.', ge=1900)] - price: Annotated[price_1.Price | None, Field(description='Vehicle price.')] = None - condition: Annotated[Condition | None, Field(description='Vehicle condition.')] = None + price: Annotated[price_1.Price | None, Field(description='Vehicle price.'), NoExplicitNull] = ( + None + ) + condition: Annotated[ + Condition | None, Field(description='Vehicle condition.'), NoExplicitNull + ] = None vin: Annotated[ - str | None, Field(description='Vehicle Identification Number (17-character VIN).') + str | None, + Field(description='Vehicle Identification Number (17-character VIN).'), + NoExplicitNull, ] = None trim: Annotated[ - str | None, Field(description="Trim level (e.g., 'EX', 'Limited', 'Sport').") + str | None, + Field(description="Trim level (e.g., 'EX', 'Limited', 'Sport')."), + NoExplicitNull, + ] = None + mileage: Annotated[Mileage | None, Field(description='Odometer reading.'), NoExplicitNull] = ( + None + ) + body_style: Annotated[ + BodyStyle | None, Field(description='Vehicle body style.'), NoExplicitNull + ] = None + transmission: Annotated[ + Transmission | None, Field(description='Transmission type.'), NoExplicitNull + ] = None + fuel_type: Annotated[ + FuelType | None, Field(description='Fuel or powertrain type.'), NoExplicitNull + ] = None + exterior_color: Annotated[str | None, Field(description='Exterior color.'), NoExplicitNull] = ( + None + ) + interior_color: Annotated[str | None, Field(description='Interior color.'), NoExplicitNull] = ( + None + ) + location: Annotated[ + Location | None, Field(description='Dealer or vehicle location.'), NoExplicitNull + ] = None + image_url: Annotated[ + AnyUrl | None, Field(description='Primary vehicle image URL.'), NoExplicitNull + ] = None + url: Annotated[ + AnyUrl | None, Field(description='Vehicle listing page URL.'), NoExplicitNull ] = None - mileage: Annotated[Mileage | None, Field(description='Odometer reading.')] = None - body_style: Annotated[BodyStyle | None, Field(description='Vehicle body style.')] = None - transmission: Annotated[Transmission | None, Field(description='Transmission type.')] = None - fuel_type: Annotated[FuelType | None, Field(description='Fuel or powertrain type.')] = None - exterior_color: Annotated[str | None, Field(description='Exterior color.')] = None - interior_color: Annotated[str | None, Field(description='Interior color.')] = None - location: Annotated[Location | None, Field(description='Dealer or vehicle location.')] = None - image_url: Annotated[AnyUrl | None, Field(description='Primary vehicle image URL.')] = None - url: Annotated[AnyUrl | None, Field(description='Vehicle listing page URL.')] = None tags: Annotated[ list[str] | None, Field( description="Tags for filtering (e.g., 'low-mileage', 'one-owner', 'dealer-certified').", min_length=1, ), + NoExplicitNull, ] = None assets: Annotated[ list[offering_asset_group.OfferingAssetGroup] | None, @@ -111,5 +138,6 @@ class VehicleItem(AdCPBaseModel): description="Typed creative asset pools for this vehicle. Uses the same OfferingAssetGroup structure as offering-type catalogs. Standard group IDs: 'images_landscape' (exterior hero), 'images_vertical' (9:16 for Stories), 'images_square' (1:1). Enables formats to declare typed image requirements that map unambiguously to the right asset regardless of platform.", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/vendor_metric_optimization_supported_metric.py b/src/adcp/types/domains/core/vendor_metric_optimization_supported_metric.py index 613c76d8c..f2b1c2b18 100644 --- a/src/adcp/types/domains/core/vendor_metric_optimization_supported_metric.py +++ b/src/adcp/types/domains/core/vendor_metric_optimization_supported_metric.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/vendor_metric_optimization_supported_metric.json -# timestamp: 2026-05-30T17:30:49+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from . import brand_ref, vendor_metric_id @@ -39,4 +39,5 @@ class VendorMetricOptimizationSupportedMetric(AdCPBaseModel): Field( description='Target kinds available for `vendor_metric` goals against this `(vendor, metric_id)` pair. Values match `target.kind` on the optimization goal. `cost_per` — target cost per metric unit (e.g., $0.05 per attention-second). `threshold_rate` — minimum per-impression value (e.g., attention_score ≥ 70). Only these target kinds are accepted — goals with unlisted target kinds will be rejected. A goal without a target implicitly maximizes the metric within budget — no declaration needed for that mode. When omitted, buyers can still set target-less vendor_metric goals.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/vendor_metric_value.py b/src/adcp/types/domains/core/vendor_metric_value.py index 8729dcfd3..377745be9 100644 --- a/src/adcp/types/domains/core/vendor_metric_value.py +++ b/src/adcp/types/domains/core/vendor_metric_value.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/vendor_metric_value.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import attribution_methodology as attribution_methodology_1 @@ -21,11 +21,15 @@ class Qualifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - viewability_standard: viewability_standard_1.ViewabilityStandard | None = None - completion_source: completion_source_1.CompletionSource | None = None - attribution_methodology: attribution_methodology_1.AttributionMethodology | None = None - attribution_window: duration.Duration | None = None - lift_dimension: lift_dimension_1.LiftDimension | None = None + viewability_standard: Annotated[ + viewability_standard_1.ViewabilityStandard | None, NoExplicitNull + ] = None + completion_source: Annotated[completion_source_1.CompletionSource | None, NoExplicitNull] = None + attribution_methodology: Annotated[ + attribution_methodology_1.AttributionMethodology | None, NoExplicitNull + ] = None + attribution_window: Annotated[duration.Duration | None, NoExplicitNull] = None + lift_dimension: Annotated[lift_dimension_1.LiftDimension | None, NoExplicitNull] = None class VendorMetricValue(AdCPBaseModel): @@ -56,6 +60,7 @@ class VendorMetricValue(AdCPBaseModel): description="Unit of the value. Free-form to accommodate the heterogeneity of vendor metrics (e.g., `score`, `seconds`, `persons`, `gCO2e`, `USD`, `lift_percent`). When the value is monetary, use the ISO 4217 code (e.g., `USD`, `EUR`); for non-monetary, use whatever the vendor publishes. Optional on every row — the canonical unit lives at the vendor's measurement-agent metric definition (`brand.json` `agents[type='measurement']`). When sellers populate it inline they SHOULD match the vendor's published unit; buyers MAY resolve from the vendor's measurement agent when this field is absent.", examples=['score', 'seconds', 'persons', 'gCO2e', 'USD', 'lift_percent', 'index'], ), + NoExplicitNull, ] = None measurable_impressions: Annotated[ StrictFloat | None, @@ -63,6 +68,7 @@ class VendorMetricValue(AdCPBaseModel): description='Number of impressions in this reporting period that the vendor was able to measure. Coverage denominator — buyers compute coverage rate as `measurable_impressions / impressions`. When absent, coverage is unspecified — buyers MUST NOT compute a coverage rate or assume full coverage. When the vendor measured zero impressions but is integrated, set to 0 explicitly. When the entry is omitted from `vendor_metric_values` entirely, the buyer infers no measurement happened (no integration). This pattern parallels `viewability.measurable_impressions` (`delivery-metrics.json#/properties/viewability`), which has handled vendor coverage in the IAS/DV/MRC ecosystem for over a decade — same convention: absence is unknown, not full. For channels where the atomic observation unit is a play or screen-second rather than an impression (DOOH, cinema, place-based), report `measurable_plays` or `measurable_play_seconds` instead — `impressions` there is itself a modelled figure (`plays × audience multiplier`), so `measurable_impressions / impressions` divides a measured count by a model output and has no interpretation.', ge=0.0, ), + NoExplicitNull, ] = None measurable_plays: Annotated[ StrictFloat | None, @@ -70,6 +76,7 @@ class VendorMetricValue(AdCPBaseModel): description='Number of plays (loop plays / spots aired) in this reporting period that the vendor was able to measure. Coverage denominator for channels where a play, not an impression, is the atomic observation unit — DOOH, cinema, place-based audio. Buyers compute coverage as `measurable_plays / plays` (top-level `plays` on `delivery-metrics.json`). Same absence semantics as `measurable_impressions`: absent means coverage is unspecified and buyers MUST NOT compute a rate; 0 means the vendor is integrated but measured nothing. A row SHOULD carry exactly one coverage denominator — the one matching the unit the vendor actually observes.', ge=0.0, ), + NoExplicitNull, ] = None measurable_play_seconds: Annotated[ StrictFloat | None, @@ -77,22 +84,26 @@ class VendorMetricValue(AdCPBaseModel): description="Play-seconds — seconds of creative playout summed across endpoints (screens, speakers, players) — in this reporting period that the vendor was able to measure. Medium-neutral on purpose: place-based audio has plays and duration but no screen. Coverage denominator when the vendor meters exposure duration rather than discrete plays. On screen networks buyers compute coverage as `measurable_play_seconds / dooh_metrics.screen_time_seconds`; on other place-based media, against the seller's reported playout seconds for the period. Same absence semantics as `measurable_impressions`.", ge=0.0, ), + NoExplicitNull, ] = None vendor_relationship: Annotated[ vendor_relationship_1.VendorRelationship | None, Field( description="Optional echo of the product's `reporting_capabilities.vendor_metrics[].vendor_relationship` for this `(vendor, metric_id)`, so the delivery row is self-describing without joining back to the product (same reasoning as `viewability.vendor`). When present it MUST equal the declared value; buyers MAY resolve from the product declaration when absent. Absence on the row is *undeclared*, never `third_party`. A relationship disposition, not a trust ranking. Deliberately not a `qualifier` key: the relationship is constant across every row for a (seller, vendor) pair and does not partition rows, so it stays out of the `(vendor, metric_id, qualifier)` reconciliation join." ), + NoExplicitNull, ] = None qualifier: Annotated[ Qualifier | None, Field( description='Optional qualifier disambiguating this row from sibling rows for the same (vendor, metric_id) — e.g., the same vendor outcome metric reported under 7-day and 30-day attribution windows. Same closed key set as `committed-metric`. When the matching `committed_metrics` entry carries a qualifier, this row MUST carry the identical qualifier so reconciliation joins on `(vendor, metric_id, qualifier)`.' ), + NoExplicitNull, ] = None breakdown: Annotated[ dict[str, Any] | None, Field( description="Optional structured payload for vendor metrics that don't fit a single scalar — panel demographic breakouts, co-view audience composition, incremental reach + frequency + lift decompositions. Free-form; the keys and value semantics are defined by the vendor (see the vendor's `brand.json` measurement-agent docs). Buyers MUST treat this object as opaque without consulting the vendor's documentation. Vendors place any fields beyond the standard envelope (e.g., confidence intervals, panel sizes) inside this object rather than at the top level." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/vendor_pricing_option.py b/src/adcp/types/domains/core/vendor_pricing_option.py index e4f5e35ac..a969ac217 100644 --- a/src/adcp/types/domains/core/vendor_pricing_option.py +++ b/src/adcp/types/domains/core/vendor_pricing_option.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/vendor_pricing_option.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictFloat @@ -27,7 +27,7 @@ class VendorPricingOption1(AdCPBaseModel): model: Literal['cpm'] = 'cpm' cpm: Annotated[StrictFloat, Field(description='Cost per thousand impressions', ge=0.0)] currency: Annotated[str, Field(description='ISO 4217 currency code', pattern='^[A-Z]{3}$')] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class VendorPricingOption2(AdCPBaseModel): @@ -44,12 +44,13 @@ class VendorPricingOption2(AdCPBaseModel): description='Optional CPM cap. When set, the effective charge is min(percent × media_spend_per_mille, max_cpm).', ge=0.0, ), + NoExplicitNull, ] = None currency: Annotated[ str, Field(description='ISO 4217 currency code for the resulting charge', pattern='^[A-Z]{3}$'), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Period(StrEnum): @@ -67,7 +68,7 @@ class VendorPricingOption3(AdCPBaseModel): amount: Annotated[StrictFloat, Field(description='Fixed charge for the billing period', ge=0.0)] period: Annotated[Period, Field(description='Billing period for the flat fee.')] currency: Annotated[str, Field(description='ISO 4217 currency code', pattern='^[A-Z]{3}$')] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class VendorPricingOption4(AdCPBaseModel): @@ -83,7 +84,7 @@ class VendorPricingOption4(AdCPBaseModel): ] unit_price: Annotated[StrictFloat, Field(description='Cost per one unit', ge=0.0)] currency: Annotated[str, Field(description='ISO 4217 currency code', pattern='^[A-Z]{3}$')] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Metadata(AdCPBaseModel): @@ -96,6 +97,7 @@ class Metadata(AdCPBaseModel): description="One or two sentences describing the pricing construct in plain language, displayed to the buyer's operator when requesting approval. Should not repeat the top-level `description` verbatim — summarize the charge mechanic instead (e.g., 'Base $12 CPM plus $0.50 per qualifying post-view conversion, capped at $45 CPM').", min_length=1, ), + NoExplicitNull, ] = None @@ -123,8 +125,9 @@ class VendorPricingOption5(AdCPBaseModel): description='ISO 4217 currency code. Present when the pricing resolves to a monetary charge in a specific currency.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class VendorPricingOption6(AdCPBaseModel): @@ -141,6 +144,7 @@ class VendorPricingOption6(AdCPBaseModel): description='**DEPRECATED in 3.2.** Legacy named-format pricing scope. Use applies_to_output_capability_ids.', min_length=1, ), + NoExplicitNull, ] = None applies_to_output_capability_ids: Annotated[ list[AppliesToOutputCapabilityId] | None, @@ -148,6 +152,7 @@ class VendorPricingOption6(AdCPBaseModel): description='Creative transformers only: scopes this pricing option to canonical output capabilities advertised in creative.supported_formats[].capability_id. When absent, the option is the default for any output. A build targeting an output that matches no scoped option and has no unscoped default is rejected with UNPRICEABLE_OUTPUT.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/verification_token_claims.py b/src/adcp/types/domains/core/verification_token_claims.py index c1f781f67..71a1ceb59 100644 --- a/src/adcp/types/domains/core/verification_token_claims.py +++ b/src/adcp/types/domains/core/verification_token_claims.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/verification_token_claims.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field @@ -49,12 +49,16 @@ class AgenticadvertisingOrgVerificationTokenClaims(AdCPBaseModel): Field( description='Grading policy that produced the badge. Absence on a historical token means Legacy; verification_modes remains an independent evidence axis.' ), + NoExplicitNull, ] = None first_failing_spec_at: Annotated[ AwareDatetime | None, Field( description='Start of the current Strict Spec failure episode. Omitted when no Strict Spec failure clock is active. The registry remains authoritative for real-time status.' ), + NoExplicitNull, ] = None - adcp_version: Annotated[str | None, Field(pattern='^[1-9][0-9]*\\.[0-9]+$')] = None - protocol_version: str | None = None + adcp_version: Annotated[str | None, Field(pattern='^[1-9][0-9]*\\.[0-9]+$'), NoExplicitNull] = ( + None + ) + protocol_version: Annotated[str | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/version_envelope.py b/src/adcp/types/domains/core/version_envelope.py index d75760373..d12ef821d 100644 --- a/src/adcp/types/domains/core/version_envelope.py +++ b/src/adcp/types/domains/core/version_envelope.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/version_envelope.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import Field @@ -18,6 +18,7 @@ class AdcpVersionEnvelope(AdCPBaseModel): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -27,4 +28,5 @@ class AdcpVersionEnvelope(AdCPBaseModel): ge=1, le=99, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/warning.py b/src/adcp/types/domains/core/warning.py index a233f4469..1fc49daaf 100644 --- a/src/adcp/types/domains/core/warning.py +++ b/src/adcp/types/domains/core/warning.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/warning.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import warning_code @@ -38,5 +38,6 @@ class Warning(AdCPBaseModel): Field( description='Optional seller-specific structured diagnostics. AdCP 3.2 defines interoperability through code and affected_resource only; buyers MUST NOT require portable keys inside details. Seller extensions that are not direct diagnostics belong in ext.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/warning_resource.py b/src/adcp/types/domains/core/warning_resource.py index 11a11e41a..68c654df7 100644 --- a/src/adcp/types/domains/core/warning_resource.py +++ b/src/adcp/types/domains/core/warning_resource.py @@ -1,12 +1,13 @@ # generated by datamodel-codegen: # filename: core/warning_resource.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum +from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict @@ -22,6 +23,6 @@ class WarningAffectedResource(AdCPBaseModel): extra='forbid', ) resource_type: ResourceType - media_buy_id: str | None = None - package_id: str | None = None - creative_id: str | None = None + media_buy_id: Annotated[str | None, NoExplicitNull] = None + package_id: Annotated[str | None, NoExplicitNull] = None + creative_id: Annotated[str | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/webhook_activity_record.py b/src/adcp/types/domains/core/webhook_activity_record.py index 9ac54ddb8..499a48330 100644 --- a/src/adcp/types/domains/core/webhook_activity_record.py +++ b/src/adcp/types/domains/core/webhook_activity_record.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/webhook_activity_record.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from ..enums import notification_type as notification_type_1 @@ -40,12 +40,14 @@ class WebhookActivityRecord(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None subscriber_id: Annotated[ str | None, Field( description='Identifies which registered webhook subscriber received this fire. **Required on records from account-anchored notification channels** (`notification_configs[]` registered via `sync_accounts`) — every subscriber has a `subscriber_id` at registration time, the seller MUST echo it on every fire and every activity record. **Optional on records from per-resource push channels** (`push_notification_config` on a media buy or task) — the calling principal is unambiguous in single-subscriber configurations and the field MAY be omitted; sellers MUST populate it once `reporting_webhook` adopts multi-subscriber (per #3009 in AdCP 4.0). Buyers MUST NOT use absence as a signal that no other subscribers exist; that information is not exposed by this surface.' ), + NoExplicitNull, ] = None fired_at: Annotated[ AwareDatetime, @@ -71,6 +73,7 @@ class WebhookActivityRecord(AdCPBaseModel): description='Sequence number from the webhook payload. Surfaced here so the buyer can spot stale-sequence drops and gaps without correlating against their own endpoint log. Absent for notification types that do not carry a sequence number.', ge=0, ), + NoExplicitNull, ] = None attempt: Annotated[ SchemaInt, @@ -112,6 +115,7 @@ class WebhookActivityRecord(AdCPBaseModel): description="Size of the request body the seller sent, in bytes. Useful for diagnosing oversized-payload rejections from the buyer's gateway.", ge=0, ), + NoExplicitNull, ] = None error_message: Annotated[ str | None, @@ -125,4 +129,5 @@ class WebhookActivityRecord(AdCPBaseModel): Field( description='Resource-specific extension slot. Adopters MAY surface a resource-specific cross-reference (e.g., `creative_id` on a creative-lifecycle record, `media_buy_id` on a record nested inside an account-level read) under `ext` rather than adding top-level fields — the canonical record shape stays uniform across resources and the `ext` envelope absorbs per-resource needs. Top-level extensions are not permitted (`additionalProperties: false`).' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/webhook_challenge.py b/src/adcp/types/domains/core/webhook_challenge.py index 5823f7f0e..41f1a47e7 100644 --- a/src/adcp/types/domains/core/webhook_challenge.py +++ b/src/adcp/types/domains/core/webhook_challenge.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/webhook_challenge.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..enums import notification_type @@ -35,6 +35,7 @@ class DeliveryAuth(AdCPBaseModel): description='SHA-256 hex fingerprint of the exact legacy credential string supplied in notification_configs[].authentication.credentials. Required when mode is Bearer or HMAC-SHA256; absent for rfc9421.', pattern='^[a-f0-9]{64}$', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/webhook_challenge_response.py b/src/adcp/types/domains/core/webhook_challenge_response.py index ab3e3427f..aed4a1bc0 100644 --- a/src/adcp/types/domains/core/webhook_challenge_response.py +++ b/src/adcp/types/domains/core/webhook_challenge_response.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: core/webhook_challenge_response.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, model_validator @@ -25,6 +25,7 @@ class WebhookChallengeResponse(AdCPBaseModel): min_length=32, pattern='^[A-Za-z0-9_.:-]{32,255}$', ), + NoExplicitNull, ] = None token: Annotated[ str | None, @@ -34,6 +35,7 @@ class WebhookChallengeResponse(AdCPBaseModel): min_length=32, pattern='^[A-Za-z0-9_.:-]{32,255}$', ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/core/wholesale_feed_event.py b/src/adcp/types/domains/core/wholesale_feed_event.py index 2d23ce13f..16e2a5c1c 100644 --- a/src/adcp/types/domains/core/wholesale_feed_event.py +++ b/src/adcp/types/domains/core/wholesale_feed_event.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/wholesale_feed_event.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from typing import Annotated, Literal from uuid import UUID -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..enums import signal_catalog_type, signal_value_type @@ -65,6 +65,7 @@ class AppliesTo2(AdCPBaseModel): description="Optional. When present, names the accounts whose overlays are affected. When omitted, subscribers infer that their own principal's overlay is affected (they received the event because the per-subscriber scope filter routed it to them).", min_length=1, ), + NoExplicitNull, ] = None @@ -105,6 +106,7 @@ class Payload(AdCPBaseModel): description='Full post-change pricing_options array. NOT a delta — consumers replace the prior options with this list.', min_length=1, ), + NoExplicitNull, ] = None canonical_pricing_options: Annotated[ list[canonical_pricing_option.CanonicalPricingOption] | None, @@ -112,22 +114,26 @@ class Payload(AdCPBaseModel): description='Full post-change canonical pricing array for list_products mirrors.', min_length=1, ), + NoExplicitNull, ] = None previous_pricing_option_ids: Annotated[ list[str] | None, Field(description='Optional. Lets consumers detect which pricing_option_ids were retired.'), + NoExplicitNull, ] = None effective_at: Annotated[ AwareDatetime | None, Field( description='When the price change takes effect. A value in the future is a pre-announcement: consumers MAY warm caches but MUST NOT bind decisions until the effective time has passed. See specs/wholesale-feed-webhooks.md §`effective_at` and pre-announce.' ), + NoExplicitNull, ] = None retracts_event_id: Annotated[ UUID | None, Field( description="Optional. When this event retracts a prior pre-announced *.priced (per the §`effective_at` and pre-announce rules), set this to the event_id of the announcement being retracted. Lets consumers detect retraction deterministically by event_id lookup rather than maintaining per-entity 'latest-event' state. When present, the retraction MUST carry effective_at <= the retracted event's effective_at." ), + NoExplicitNull, ] = None applies_to: AppliesTo @@ -174,7 +180,7 @@ class Payload20(AdCPBaseModel): extra='allow', ) product_id: str - removal_reason: RemovalReason | None = None + removal_reason: Annotated[RemovalReason | None, NoExplicitNull] = None applies_to: AppliesTo @@ -220,23 +226,25 @@ class Payload23(AdCPBaseModel): extra='allow', ) signal_agent_segment_id: str - signal_ref: signal_ref_1.SignalRef | None = None + signal_ref: Annotated[signal_ref_1.SignalRef | None, NoExplicitNull] = None pricing_options: Annotated[ list[vendor_pricing_option.VendorPricingOption], Field(description='Full post-change pricing_options array. NOT a delta.', min_length=1), ] - previous_pricing_option_ids: list[str] | None = None + previous_pricing_option_ids: Annotated[list[str] | None, NoExplicitNull] = None effective_at: Annotated[ AwareDatetime | None, Field( description='When the price change takes effect. A value in the future is a pre-announcement: consumers MAY warm caches but MUST NOT bind decisions until the effective time has passed. See specs/wholesale-feed-webhooks.md §`effective_at` and pre-announce.' ), + NoExplicitNull, ] = None retracts_event_id: Annotated[ UUID | None, Field( description='Optional. When this event retracts a prior pre-announced *.priced, set this to the event_id of the announcement being retracted. See product.priced for the full retraction contract.' ), + NoExplicitNull, ] = None applies_to: AppliesTo @@ -283,8 +291,8 @@ class Payload24(AdCPBaseModel): extra='allow', ) signal_agent_segment_id: str - signal_ref: signal_ref_1.SignalRef | None = None - removal_reason: RemovalReason | None = None + signal_ref: Annotated[signal_ref_1.SignalRef | None, NoExplicitNull] = None + removal_reason: Annotated[RemovalReason | None, NoExplicitNull] = None applies_to: AppliesTo @@ -343,6 +351,7 @@ class Payload25(AdCPBaseModel): Field( description='Advisory recommendation. Consumers SHOULD repair by re-reading the affected feed named by affected_entity_type; the recommendation is not a synchronized trigger. Modeled as a single-value enum (not a boolean) so future minor versions can extend the recommendation vocabulary without a breaking shape change. Consumers MUST be prepared to ignore unknown recommendation values.' ), + NoExplicitNull, ] = None applies_to: AppliesTo affected_entity_type: Annotated[ @@ -399,6 +408,7 @@ class Signal(SignalListing): Field( description='Canonical signal reference for this wholesale signal. New events SHOULD use signal_ref.' ), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -406,6 +416,7 @@ class Signal(SignalListing): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None signal_agent_segment_id: Annotated[ str, @@ -413,11 +424,11 @@ class Signal(SignalListing): ] name: Annotated[str, Field(description='Human-readable signal name', min_length=1)] description: Annotated[str, Field(description='Detailed signal description', min_length=1)] - value_type: signal_value_type.SignalValueType | None = None - categories: Annotated[list[str] | None, Field(min_length=1)] = None - range: Range | None = None + value_type: Annotated[signal_value_type.SignalValueType | None, NoExplicitNull] = None + categories: Annotated[list[str] | None, Field(min_length=1), NoExplicitNull] = None + range: Annotated[Range | None, NoExplicitNull] = None signal_type: signal_catalog_type.SignalAvailabilityType - data_provider: Annotated[str | None, Field(min_length=1)] = None + data_provider: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None coverage_percentage: Annotated[ StrictFloat | None, Field( @@ -426,16 +437,18 @@ class Signal(SignalListing): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None coverage_forecast: Annotated[ signal_coverage_forecast.SignalCoverageForecast | None, Field( description='Optional forecast-shaped signal availability guidance using the same wire shape as get_signals.signals[].coverage_forecast. When present, this is authoritative for signal-level discovery coverage.' ), + NoExplicitNull, ] = None deployments: Annotated[Sequence[deployment.Deployment], Field(min_length=1)] pricing_options: Annotated[ - list[vendor_pricing_option.VendorPricingOption] | None, Field(min_length=1) + list[vendor_pricing_option.VendorPricingOption] | None, Field(min_length=1), NoExplicitNull ] = None @@ -444,7 +457,7 @@ class Payload21(AdCPBaseModel): extra='allow', ) signal_agent_segment_id: str - signal_ref: signal_ref_1.SignalRef | None = None + signal_ref: Annotated[signal_ref_1.SignalRef | None, NoExplicitNull] = None applies_to: Annotated[ AppliesTo, Field( @@ -496,12 +509,13 @@ class Payload22(AdCPBaseModel): extra='allow', ) signal_agent_segment_id: str - signal_ref: signal_ref_1.SignalRef | None = None + signal_ref: Annotated[signal_ref_1.SignalRef | None, NoExplicitNull] = None changed_fields: Annotated[ list[str] | None, Field( description='Advisory list of changed top-level field names. Consumers MAY use for fine-grained re-render, but the signal object is the source of truth for this webhook payload.' ), + NoExplicitNull, ] = None applies_to: AppliesTo signal: Annotated[ @@ -559,12 +573,14 @@ class Payload17(AdCPBaseModel): Field( description='Legacy 3.x post-change Product. Subscribers using get_products consume this form.' ), + NoExplicitNull, ] = None canonical_product: Annotated[ canonical_product_1.CanonicalProduct | None, Field( description='Canonical post-change Product for subscribers that negotiated lifecycle_tools.list_products. Consumers replace the matching canonical mirror entry directly.' ), + NoExplicitNull, ] = None applies_to: Annotated[ AppliesTo, @@ -619,16 +635,19 @@ class Payload18(AdCPBaseModel): product: Annotated[ product_1.Product | None, Field(description='Legacy 3.x post-change Product for get_products mirrors.'), + NoExplicitNull, ] = None canonical_product: Annotated[ canonical_product_1.CanonicalProduct | None, Field(description='Canonical post-change Product for list_products mirrors.'), + NoExplicitNull, ] = None changed_fields: Annotated[ list[str] | None, Field( description="Advisory list of changed top-level field names (e.g., ['format_ids', 'performance_standards']). Consumers MAY use for fine-grained re-render, but the product object is the source of truth for this webhook payload." ), + NoExplicitNull, ] = None applies_to: AppliesTo diff --git a/src/adcp/types/domains/core/wholesale_feed_webhook.py b/src/adcp/types/domains/core/wholesale_feed_webhook.py index 6a78e73dd..eb768c8c7 100644 --- a/src/adcp/types/domains/core/wholesale_feed_webhook.py +++ b/src/adcp/types/domains/core/wholesale_feed_webhook.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/wholesale_feed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated from uuid import UUID -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from . import ext as ext_1 @@ -94,12 +94,14 @@ class WholesaleFeedWebhook(AdCPBaseModel): Field( description='Product representation selected by the receiving notification config. Present on product.* fires; canonical uses canonical_product/canonical_pricing_options and legacy uses product/pricing_options.' ), + NoExplicitNull, ] = None previous_wholesale_feed_version: Annotated[ str | None, Field( description='Opaque version token for the affected wholesale feed before this change, when the seller can cheaply provide it. Receivers MAY use this to detect obvious gaps, but MUST NOT require it.' ), + NoExplicitNull, ] = None cache_scope: Annotated[ CacheScope, @@ -113,4 +115,4 @@ class WholesaleFeedWebhook(AdCPBaseModel): description='The actual product, signal, or bulk-change event. Consumers MAY apply this payload to their local mirror. Before any binding action, or when ordering/gap checks fail, consumers MUST reconcile through list_products / get_signals.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/audit_observation.py b/src/adcp/types/domains/creative/audit_observation.py index ebe0e57b5..b67ba72bb 100644 --- a/src/adcp/types/domains/creative/audit_observation.py +++ b/src/adcp/types/domains/creative/audit_observation.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/audit_observation.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat from ..core import ext as ext_1 @@ -40,7 +40,9 @@ class Details(AdCPBaseModel): AnyUrl, Field(description='Governance agent URL that produced the observation.') ] feature_id: Annotated[ - str | None, Field(description='Feature or policy check that produced the observation.') + str | None, + Field(description='Feature or policy check that produced the observation.'), + NoExplicitNull, ] = None claimed_value: Annotated[ ClaimedValue, @@ -55,12 +57,14 @@ class Details(AdCPBaseModel): confidence: Annotated[ StrictFloat | None, Field(description='Confidence score for the observation, when applicable.', ge=0.0, le=1.0), + NoExplicitNull, ] = None substituted_for: Annotated[ AnyUrl | None, Field( description='Buyer-nominated verifier URL when the seller or orchestrator used a different on-list governance agent.' ), + NoExplicitNull, ] = None @@ -104,4 +108,4 @@ class CreativeAuditObservation(AdCPBaseModel): description='Audit-safe structured details. Mirrors the safe allowlist keys used for `PROVENANCE_CLAIM_CONTRADICTED`; value shapes remain observation-specific. Top-level `ext` remains the standard protocol extension point, but details do not allow arbitrary verifier response fields.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/creative_assignment_changed_webhook.py b/src/adcp/types/domains/creative/creative_assignment_changed_webhook.py index 5f4cc9518..947fce518 100644 --- a/src/adcp/types/domains/creative/creative_assignment_changed_webhook.py +++ b/src/adcp/types/domains/creative/creative_assignment_changed_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/creative_assignment_changed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import ext as ext_1 @@ -46,4 +46,4 @@ class CreativeAssignmentChangedWebhook(AdCPBaseModel): creative_id: str change_kind: ChangeKind observed_at: AwareDatetime - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/creative_feature_result.py b/src/adcp/types/domains/creative/creative_feature_result.py index 51210c409..f4f63795e 100644 --- a/src/adcp/types/domains/creative/creative_feature_result.py +++ b/src/adcp/types/domains/creative/creative_feature_result.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: creative/creative_feature_result.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import ext as ext_1 @@ -33,29 +33,36 @@ class CreativeFeatureResult(AdCPBaseModel): Field( description="Unit of measurement for quantitative values (e.g., 'percentage', 'score')" ), + NoExplicitNull, ] = None confidence: Annotated[ StrictFloat | None, Field(description='Confidence score for this value (0-1)', ge=0.0, le=1.0), + NoExplicitNull, ] = None measured_at: Annotated[ - AwareDatetime | None, Field(description='When this feature was evaluated') + AwareDatetime | None, Field(description='When this feature was evaluated'), NoExplicitNull ] = None expires_at: Annotated[ AwareDatetime | None, Field(description='When this evaluation expires and should be refreshed'), + NoExplicitNull, ] = None methodology_version: Annotated[ - str | None, Field(description='Version of the methodology used to evaluate this feature') + str | None, + Field(description='Version of the methodology used to evaluate this feature'), + NoExplicitNull, ] = None details: Annotated[ dict[str, Any] | None, Field(description='Additional vendor-specific details about this evaluation'), + NoExplicitNull, ] = None policy_id: Annotated[ str | None, Field( description='Optional attribution — when this feature was evaluated for the purpose of a specific policy, policy_id references the authorizing PolicyEntry. Creative agents and sellers populate when the measurement was motivated by a specific policy; do NOT populate when the feature is a generic measurement (carbon score, brand consistency) unrelated to any policy. See /docs/governance/policy-attribution.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/creative_purged_webhook.py b/src/adcp/types/domains/creative/creative_purged_webhook.py index 340bc8e66..979e10e3b 100644 --- a/src/adcp/types/domains/creative/creative_purged_webhook.py +++ b/src/adcp/types/domains/creative/creative_purged_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/creative_purged_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import ext as ext_1 @@ -98,6 +98,7 @@ class CreativePurgedWebhook(AdCPBaseModel): description='Human-readable supplement. Sellers MUST NOT include third-party PII or the asset content in this field — the field is meant for operational context, not asset preservation.', max_length=500, ), + NoExplicitNull, ] = None initiator: Annotated[ Initiator, @@ -105,4 +106,4 @@ class CreativePurgedWebhook(AdCPBaseModel): description='Who initiated the purge. `seller` — explicit decision (legal team, content policy, takedown handler). `system` — automated retention sweep, storage policy enforcement. `buyer` never appears on this event — buyer-initiated destruction (if and when supported) is acknowledged on the `sync_creatives` response path.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/creative_status_changed_webhook.py b/src/adcp/types/domains/creative/creative_status_changed_webhook.py index 21e563e53..1ba5c03a9 100644 --- a/src/adcp/types/domains/creative/creative_status_changed_webhook.py +++ b/src/adcp/types/domains/creative/creative_status_changed_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/creative_status_changed_webhook.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import creative_revision_id @@ -107,6 +107,7 @@ class CreativeStatusChangedWebhook(AdCPBaseModel): Field( description='Buyer-authored input revision to which this transition applies. Required when the reviewed state has revision identity and the seller advertises creative.supports_revisions; it need not be the current list revision when the fire is received. Omitted for unversioned current content. A stale review outcome MUST NOT emit a fire as though it applied to a newer current revision.' ), + NoExplicitNull, ] = None transition: Annotated[ Transition, @@ -126,6 +127,7 @@ class CreativeStatusChangedWebhook(AdCPBaseModel): description='Human-readable supplement to `reason_code`. Free text from the seller. Sellers MUST NOT include third-party PII in this field.', max_length=500, ), + NoExplicitNull, ] = None initiator: Annotated[ Initiator, @@ -133,4 +135,4 @@ class CreativeStatusChangedWebhook(AdCPBaseModel): description='Who initiated the transition. `seller` — explicit decision by the seller (human reviewer, policy operator, takedown handler). `system` — automated seller-side process (processing pipeline, retention sweep, inactivity scan). `buyer` never appears on this event — buyer-initiated transitions are acknowledged on the `sync_creatives` response path and MUST NOT fire `creative.status_changed`.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/get_creative_delivery_request.py b/src/adcp/types/domains/creative/get_creative_delivery_request.py index a0d8958db..58a12a31a 100644 --- a/src/adcp/types/domains/creative/get_creative_delivery_request.py +++ b/src/adcp/types/domains/creative/get_creative_delivery_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/get_creative_delivery_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, model_validator from ..core import account_ref @@ -29,6 +30,7 @@ class GetCreativeDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account for routing and scoping. Limits results to creatives within this account.' ), + NoExplicitNull, ] = None media_buy_ids: Annotated[ list[str] | None, @@ -36,6 +38,7 @@ class GetCreativeDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): description='Filter to specific media buys by publisher ID. If omitted, returns creative delivery across all matching media buys.', min_length=1, ), + NoExplicitNull, ] = None creative_ids: Annotated[ list[str] | None, @@ -43,6 +46,7 @@ class GetCreativeDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): description='Filter to specific creatives by ID. If omitted, returns delivery for all creatives matching the other filters.', min_length=1, ), + NoExplicitNull, ] = None start_date: Annotated[ str | None, @@ -50,6 +54,7 @@ class GetCreativeDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): description="Start date for delivery period (YYYY-MM-DD). Interpreted in the platform's reporting timezone.", pattern='^\\d{4}-\\d{2}-\\d{2}$', ), + NoExplicitNull, ] = None end_date: Annotated[ str | None, @@ -57,6 +62,7 @@ class GetCreativeDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): description="End date for delivery period (YYYY-MM-DD). Interpreted in the platform's reporting timezone.", pattern='^\\d{4}-\\d{2}-\\d{2}$', ), + NoExplicitNull, ] = None max_variants: Annotated[ SchemaInt | None, @@ -64,15 +70,17 @@ class GetCreativeDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): description='Maximum number of variants to return per creative. When omitted, the agent returns all variants. Use this to limit response size for generative creatives that may produce large numbers of variants.', ge=1, ), + NoExplicitNull, ] = None pagination: Annotated[ pagination_request.PaginationRequest | None, Field( description='Pagination parameters for the creatives array in the response. Uses cursor-based pagination consistent with other list operations.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> GetCreativeDeliveryRequest: diff --git a/src/adcp/types/domains/creative/get_creative_delivery_response.py b/src/adcp/types/domains/creative/get_creative_delivery_response.py index e710949a3..25127fa9f 100644 --- a/src/adcp/types/domains/creative/get_creative_delivery_response.py +++ b/src/adcp/types/domains/creative/get_creative_delivery_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/get_creative_delivery_response.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from collections.abc import Sequence from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -32,6 +32,7 @@ class ReportingPeriod(AdCPBaseModel): Field( description="IANA timezone identifier for the reporting period (e.g., 'America/New_York', 'UTC'). Platforms report in their native timezone." ), + NoExplicitNull, ] = None @@ -50,6 +51,7 @@ class Pagination(AdCPBaseModel): description='Total number of creatives matching the request filters. Canonical field name (matches `PaginationResponse.total_count`). Sellers SHOULD populate this and the deprecated `total` field identically until 4.0; buyers SHOULD prefer this field.', ge=0, ), + NoExplicitNull, ] = None total: Annotated[ SchemaInt | None, @@ -58,6 +60,7 @@ class Pagination(AdCPBaseModel): description='**Deprecated** — use `total_count` instead. Retained as a legacy alias for 3.x backward compatibility; removed in AdCP 4.0. Sellers populating this field MUST also populate `total_count` with the same value.', ge=0, ), + NoExplicitNull, ] = None @@ -71,6 +74,7 @@ class Creative(AdCPBaseModel): Field( description="Publisher's media buy identifier for this creative. Present when the request spanned multiple media buys, so the buyer can correlate each creative to its media buy." ), + NoExplicitNull, ] = None format_id: Annotated[ format_id_1.FormatReferenceStructuredObject | None, @@ -78,19 +82,24 @@ class Creative(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named format of this creative. New responses use `format_kind` and optional `format_option_ref`.', ), + NoExplicitNull, ] = None format_kind: Annotated[ - str | None, Field(description='Canonical format kind delivered for this creative.') + str | None, + Field(description='Canonical format kind delivered for this creative.'), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Portable reference to the product or publisher format option used for delivery, when disambiguation is required.' ), + NoExplicitNull, ] = None totals: Annotated[ delivery_metrics.DeliveryMetrics | None, Field(description='Aggregate delivery metrics across all variants of this creative'), + NoExplicitNull, ] = None variant_count: Annotated[ SchemaInt | None, @@ -98,6 +107,7 @@ class Creative(AdCPBaseModel): description='Total number of agent-unique variant_id rows for this creative. When max_variants was specified in the request, this may exceed the number of items in the variants array.', ge=0, ), + NoExplicitNull, ] = None variants: Annotated[ list[creative_variant.CreativeVariant], @@ -116,12 +126,14 @@ class GetCreativeDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv Field( description='Account identifier. Present when the response spans or is scoped to a specific account.' ), + NoExplicitNull, ] = None media_buy_id: Annotated[ str | None, Field( description="Publisher's media buy identifier. Present when the request was scoped to a single media buy." ), + NoExplicitNull, ] = None currency: Annotated[ str, @@ -139,9 +151,12 @@ class GetCreativeDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv Field( description='Pagination information. Present when the request included pagination parameters. **Note:** `get_creative_delivery` uses page-based pagination (`limit`/`offset`) for historical reasons, distinct from the cursor-based [`PaginationResponse`](/schemas/v3/core/pagination-response.json) used by `list_*` tools. Field naming aligned with `PaginationResponse.total_count` in 3.1; the legacy `total` field is retained as a deprecated alias until 4.0. Sellers MUST populate both fields identically; buyers SHOULD prefer `total_count` (the canonical name) and ignore `total` if both are present.' ), + NoExplicitNull, ] = None errors: Annotated[ - list[error.Error] | None, Field(description='Task-specific errors and warnings') + list[error.Error] | None, + Field(description='Task-specific errors and warnings'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/get_creative_features_async_response_submitted.py b/src/adcp/types/domains/creative/get_creative_features_async_response_submitted.py index e941d061b..c4e2bbb1a 100644 --- a/src/adcp/types/domains/creative/get_creative_features_async_response_submitted.py +++ b/src/adcp/types/domains/creative/get_creative_features_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: creative/get_creative_features_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -36,6 +36,7 @@ class GetCreativeFeaturesSubmitted(AdCPBaseModel): description='Provider-generated identity allocated when the evaluation is accepted. Optional in AdCP 3.x and required in AdCP 4.0. Providers SHOULD emit it in 3.x; when present, exact replays and the terminal result MUST carry this same value. Distinct from task_id and idempotency_key.', min_length=1, ), + NoExplicitNull, ] = None message: Annotated[ str | None, @@ -43,6 +44,7 @@ class GetCreativeFeaturesSubmitted(AdCPBaseModel): description='Optional human-readable explanation of why the evaluation is submitted. Plain text only; callers treat it as untrusted provider input.', max_length=2000, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/get_creative_features_request.py b/src/adcp/types/domains/creative/get_creative_features_request.py index c56997404..0147d5891 100644 --- a/src/adcp/types/domains/creative/get_creative_features_request.py +++ b/src/adcp/types/domains/creative/get_creative_features_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/get_creative_features_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref @@ -30,6 +31,7 @@ class GetCreativeFeaturesRequest(AdcpRequest, AdcpVersionEnvelope): min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$', ), + NoExplicitNull, ] = None creative_manifest: Annotated[ creative_manifest_1.CreativeManifest, @@ -43,18 +45,21 @@ class GetCreativeFeaturesRequest(AdcpRequest, AdcpVersionEnvelope): description='Optional filter to specific features. If omitted, returns all available features.', min_length=1, ), + NoExplicitNull, ] = None account: Annotated[ account_ref.AccountReference | None, Field( description='Account for billing this evaluation. Required when the governance agent charges per evaluation.' ), + NoExplicitNull, ] = None push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Optional webhook configuration for async terminal completion/failure notifications. A submitted acknowledgement remains pollable through get_task_status whether or not this field is present. If the provider accepts this field and returns status: submitted, it MUST deliver at least the terminal completion or failure notification to the configured URL. If it cannot honor the webhook channel, it MUST reject the request instead of silently downgrading delivery.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/get_creative_features_response.py b/src/adcp/types/domains/creative/get_creative_features_response.py index d4ba8cf32..8c3110dc1 100644 --- a/src/adcp/types/domains/creative/get_creative_features_response.py +++ b/src/adcp/types/domains/creative/get_creative_features_response.py @@ -31,7 +31,7 @@ class GetCreativeFeaturesResponse2(AdcpResponse, AdcpVersionEnvelope, ProtocolEn class GetCreativeFeaturesResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): - model_config = ConfigDict(extra='allow', validate_default=True) + model_config = ConfigDict(extra='allow') status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted task_id: Annotated[str, StringConstraints(min_length=1)] evaluation_id: Annotated[str, StringConstraints(min_length=1)] | None = None diff --git a/src/adcp/types/domains/creative/get_creative_features_terminal_success.py b/src/adcp/types/domains/creative/get_creative_features_terminal_success.py index a834279b9..f742bd712 100644 --- a/src/adcp/types/domains/creative/get_creative_features_terminal_success.py +++ b/src/adcp/types/domains/creative/get_creative_features_terminal_success.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: creative/get_creative_features_terminal_success.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from ..core import context as context_1 @@ -25,6 +25,7 @@ class GetCreativeFeaturesSuccess(AdCPBaseModel): description='Provider-generated identity for this evaluation. Optional in AdCP 3.x and required in AdCP 4.0. Providers SHOULD emit it in 3.x. When present, it MUST be stable across exact response replays and, for async work whose submitted acknowledgement included evaluation_id, MUST be identical to that value. Used for result provenance, consumption and provider-cost reconciliation, and support; it is not an idempotency key or task identity.', min_length=1, ), + NoExplicitNull, ] = None results: Annotated[ list[creative_feature_result.CreativeFeatureResult], @@ -35,32 +36,38 @@ class GetCreativeFeaturesSuccess(AdCPBaseModel): Field( description="URL to the vendor's full assessment report. The vendor controls what information is disclosed and access control." ), + NoExplicitNull, ] = None audit_observations: Annotated[ list[audit_observation.CreativeAuditObservation] | None, Field( description='Non-blocking audit observations from the governance agent. Observations surface audit-worthy claims that are not verifier refutations and are not rejection grounds by themselves.' ), + NoExplicitNull, ] = None pricing_option_id: Annotated[ str | None, Field( description='Which rate card pricing option was applied for this evaluation. Present when the governance agent charges for evaluations and account was provided in the request.' ), + NoExplicitNull, ] = None vendor_cost: Annotated[ StrictFloat | None, Field(description='Cost incurred for this evaluation, denominated in currency.', ge=0.0), + NoExplicitNull, ] = None currency: Annotated[ str | None, Field(description='ISO 4217 currency code for vendor_cost.', pattern='^[A-Z]{3}$'), + NoExplicitNull, ] = None consumption: Annotated[ creative_consumption.CreativeConsumption | None, Field( description='Structured consumption details for this evaluation. Informational — lets the buyer verify that vendor_cost is consistent with the rate card. vendor_cost is the billing source of truth.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/list_creative_formats_request.py b/src/adcp/types/domains/creative/list_creative_formats_request.py index cc7d1aaac..b754aceae 100644 --- a/src/adcp/types/domains/creative/list_creative_formats_request.py +++ b/src/adcp/types/domains/creative/list_creative_formats_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_creative_formats_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,6 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref @@ -40,12 +41,14 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Return only these specific named-format IDs. Use canonical-format discovery in 4.0.', min_length=1, ), + NoExplicitNull, ] = None type: Annotated[ Type | None, Field( description='Filter by format type (technical categories with distinct requirements)' ), + NoExplicitNull, ] = None asset_types: Annotated[ list[asset_content_type.AssetContentType] | None, @@ -53,45 +56,54 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description="Filter to formats that include these asset types. For third-party tags, search for 'html' or 'javascript'. For published-post reference formats, search for 'published_post'. E.g., ['image', 'text'] returns formats with images and text, ['javascript'] returns formats accepting JavaScript tags.", min_length=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, Field( description='Maximum width in pixels (inclusive). Returns formats with width <= this value. Omit for responsive/fluid formats.' ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, Field( description='Maximum height in pixels (inclusive). Returns formats with height <= this value. Omit for responsive/fluid formats.' ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, Field( description='Minimum width in pixels (inclusive). Returns formats with width >= this value.' ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, Field( description='Minimum height in pixels (inclusive). Returns formats with height >= this value.' ), + NoExplicitNull, ] = None is_responsive: Annotated[ StrictBool | None, Field( description='Filter for responsive formats that adapt to container size. When true, returns formats without fixed dimensions.' ), + NoExplicitNull, ] = None name_search: Annotated[ - str | None, Field(description='Search for formats by name (case-insensitive partial match)') + str | None, + Field(description='Search for formats by name (case-insensitive partial match)'), + NoExplicitNull, ] = None wcag_level: Annotated[ wcag_level_1.WcagLevel | None, Field( description='Filter to formats that meet at least this WCAG conformance level (A < AA < AAA)' ), + NoExplicitNull, ] = None disclosure_positions: Annotated[ list[disclosure_position.DisclosurePosition] | None, @@ -99,6 +111,7 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description="Filter to formats that support all of these disclosure positions. When a format has disclosure_capabilities, match against those positions. Otherwise fall back to supported_disclosure_positions. Use to find formats compatible with a brief's compliance requirements.", min_length=1, ), + NoExplicitNull, ] = None disclosure_persistence: Annotated[ list[disclosure_persistence_1.DisclosurePersistence] | None, @@ -106,6 +119,7 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description='Filter to formats where each requested persistence mode is supported by at least one position in disclosure_capabilities. Different positions may satisfy different modes. Use to find formats compatible with jurisdiction-specific persistence requirements (e.g., continuous for EU AI Act).', min_length=1, ), + NoExplicitNull, ] = None output_format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -114,6 +128,7 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description='**DEPRECATED in 3.1.** Discover build capability via `list_transformers` (filter its `output_format_ids`) instead — build capability is a property of transformers, not a relationship between formats. *Legacy:* filter to formats whose `output_format_ids` includes any of these format IDs.', min_length=1, ), + NoExplicitNull, ] = None input_format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -122,6 +137,7 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description='**DEPRECATED in 3.1.** Discover build capability via `list_transformers` (filter its `input_format_ids`) instead. *Legacy:* filter to formats whose `input_format_ids` includes any of these format IDs.', min_length=1, ), + NoExplicitNull, ] = None include_pricing: Annotated[ StrictBool | None, @@ -129,6 +145,7 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): deprecated=True, description='**DEPRECATED in 3.1. Removed at 4.0.** Use `list_transformers` with `include_pricing` and `account` for transformation and generation pricing. *Legacy 3.x behavior:* include `pricing_options` on each returned format. Requires `account`; when false or omitted, pricing is not computed. Agents supporting the 3.1 format-pricing surface MUST continue to honor this field through 3.x.', ), + NoExplicitNull, ] = False account: Annotated[ account_ref.AccountReference | None, @@ -136,7 +153,8 @@ class ListCreativeFormatsRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): deprecated=True, description='**DEPRECATED for `list_creative_formats` in 3.1. Removed at 4.0.** Use the account-scoped `list_transformers` request instead. *Legacy 3.x behavior:* identifies the rate card used when `include_pricing` is true.', ), + NoExplicitNull, ] = None - pagination: pagination_request.PaginationRequest | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/list_creative_formats_response.py b/src/adcp/types/domains/creative/list_creative_formats_response.py index 87cf3f3f6..1da0e9eb8 100644 --- a/src/adcp/types/domains/creative/list_creative_formats_response.py +++ b/src/adcp/types/domains/creative/list_creative_formats_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_creative_formats_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..core import context as context_1 @@ -28,11 +28,12 @@ class CreativeAgent(AdCPBaseModel): ), ] agent_name: Annotated[ - str | None, Field(description='Human-readable name for the creative agent') + str | None, Field(description='Human-readable name for the creative agent'), NoExplicitNull ] = None capabilities: Annotated[ list[creative_agent_capability.CreativeAgentCapability] | None, Field(description='Capabilities this creative agent provides'), + NoExplicitNull, ] = None @@ -53,10 +54,13 @@ class ListCreativeFormatsResponseCreativeAgent(AdcpResponse, AdcpVersionEnvelope deprecated=True, description='Deprecated recursive discovery projection retained only for historical 3.x responses. New buyers use registry reverse lookup and direct get_adcp_capabilities confirmation.', ), + NoExplicitNull, ] = None errors: Annotated[ - list[error.Error] | None, Field(description='Task-specific errors and warnings') + list[error.Error] | None, + Field(description='Task-specific errors and warnings'), + NoExplicitNull, ] = None - pagination: pagination_response.PaginationResponse | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/list_creatives_request.py b/src/adcp/types/domains/creative/list_creatives_request.py index 7d945c87d..d45494ac2 100644 --- a/src/adcp/types/domains/creative/list_creatives_request.py +++ b/src/adcp/types/domains/creative/list_creatives_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_creatives_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref @@ -54,10 +54,12 @@ class Sort(AdCPBaseModel): extra='allow', ) field: Annotated[ - creative_sort_field.CreativeSortField | None, Field(description='Field to sort by') + creative_sort_field.CreativeSortField | None, + Field(description='Field to sort by'), + NoExplicitNull, ] = creative_sort_field.CreativeSortField.created_date direction: Annotated[ - sort_direction.SortDirection | None, Field(description='Sort direction') + sort_direction.SortDirection | None, Field(description='Sort direction'), NoExplicitNull ] = sort_direction.SortDirection.desc @@ -65,17 +67,20 @@ class ListCreativesRequest(AdcpRequest, AdcpVersionEnvelope): model_config = ConfigDict( extra='allow', ) - filters: creative_filters.CreativeFilters | None = None - sort: Annotated[Sort | None, Field(description='Sorting parameters')] = None - pagination: pagination_request.PaginationRequest | None = None + filters: Annotated[creative_filters.CreativeFilters | None, NoExplicitNull] = None + sort: Annotated[Sort | None, Field(description='Sorting parameters'), NoExplicitNull] = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None include_assignments: Annotated[ - StrictBool | None, Field(description='Include package assignment information in response') + StrictBool | None, + Field(description='Include package assignment information in response'), + NoExplicitNull, ] = True assignment_projection: Annotated[ AssignmentProjection | None, Field( description='Controls nested assigned_packages projection when assignments are included. all returns up to assignment_limit active assignments per creative. matching returns only assignments matching filters.indicator_types and requires that filter. Use matching for compact indicator discovery; get_media_buys remains the complete authoritative repair path when assignments_truncated is true.' ), + NoExplicitNull, ] = AssignmentProjection.all assignment_limit: Annotated[ SchemaInt | None, @@ -84,40 +89,47 @@ class ListCreativesRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=200, ), + NoExplicitNull, ] = 50 include_snapshot: Annotated[ StrictBool | None, Field( description='Include a lightweight delivery snapshot per creative (lifetime impressions and last-served date). For detailed performance analytics, use get_creative_delivery.' ), + NoExplicitNull, ] = False include_items: Annotated[ StrictBool | None, Field(description='Include items for multi-asset formats like carousels and native ads'), + NoExplicitNull, ] = False include_variables: Annotated[ StrictBool | None, Field( description='Include dynamic content variable definitions (DCO slots) for each creative' ), + NoExplicitNull, ] = False include_pricing: Annotated[ StrictBool | None, Field( description='Include pricing_options on each creative. Requires account to be provided. When false or omitted, pricing is not computed.' ), + NoExplicitNull, ] = False include_purged: Annotated[ StrictBool | None, Field( description="Include soft-purged creative tombstones in the result set. When true, creatives destroyed via `creative.purged` with `purge_kind: soft` surface as tombstone records carrying `purged: true`, `purged_at`, and the purge reason — within the seller's webhook activity retention window (30 days from `purged_at`, MUST match `webhook-activity-record` retention). Hard-purged creatives MUST NOT appear regardless of this flag. When false or omitted, the result set excludes all purged creatives — same default as today." ), + NoExplicitNull, ] = False include_webhook_activity: Annotated[ StrictBool | None, Field( description='Include recent webhook activity per creative. When true, each returned creative carries a `webhook_activity[]` array of the most recent fires scoped to that creative — `creative.status_changed` and `creative.purged` deliveries. Adoption of the `webhook_activity[]` pattern per `snapshot-and-log.mdx § Webhook activity log pattern`. Retention is 30 days from `completed_at` (MUST). Three-state presence applies: omitted = seller does not surface; `[]` = persists but no recent fires; non-empty = actual records.' ), + NoExplicitNull, ] = False webhook_activity_limit: Annotated[ SchemaInt | None, @@ -126,12 +138,14 @@ class ListCreativesRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=200, ), + NoExplicitNull, ] = 50 account: Annotated[ account_ref.AccountReference | None, Field( description="Account reference for pricing and access. When provided with include_pricing, the agent returns pricing_options from this account's rate card on each creative." ), + NoExplicitNull, ] = None fields: Annotated[ list[Field1] | None, @@ -139,6 +153,7 @@ class ListCreativesRequest(AdcpRequest, AdcpVersionEnvelope): description="Specific fields to include in response (omit for all fields). The 'concept' value returns both concept_id and concept_name. `format_id` is a deprecated 3.x compatibility projection; new integrations request `format_kind` and `format_option_ref`. Selecting localization automatically includes creative_id, status, assets, the selected format identity, and localization_unavailable when applicable. Selecting rights_attestation_evaluations automatically includes rights so each seller-produced result can be reconciled with the exact retained constraint and reference.", min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/list_creatives_response.py b/src/adcp/types/domains/creative/list_creatives_response.py index ebc685840..c3e438ffd 100644 --- a/src/adcp/types/domains/creative/list_creatives_response.py +++ b/src/adcp/types/domains/creative/list_creatives_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_creatives_response.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from collections.abc import Sequence from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, RootModel, StrictBool, StringConstraints, model_validator from ..core import account as account_1 @@ -88,6 +88,7 @@ class Indicator(AdCPBaseModel): Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), + NoExplicitNull, ] = None scope: Annotated[ list[indicator_scope.IndicatorScope] | None, @@ -95,12 +96,14 @@ class Indicator(AdCPBaseModel): description='Optional narrower publisher or placement scope within the enclosing media buy, package, or package–creative assignment. Omit only when the seller evaluated and asserts the indicator across the whole enclosing resource/relationship. When partial indicators_evaluated_scope is declared, every returned indicator MUST include scope and every entry MUST fall within that coverage. This is scope, not source: the responding seller remains the source.', min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Seller- or provider-specific detail such as scores, thresholds, evaluation windows, methodology identifiers, or upstream attribution. Core consumers must not need ext to understand the broad meaning of type.' ), + NoExplicitNull, ] = None @@ -130,6 +133,7 @@ class Snapshot(AdCPBaseModel): Field( description='Last time this creative served an impression. Absent when the creative has never served.' ), + NoExplicitNull, ] = None @@ -160,25 +164,29 @@ class StatusSummary(AdCPBaseModel): extra='allow', ) processing: Annotated[ - SchemaInt | None, Field(description='Number of creatives being processed', ge=0) + SchemaInt | None, + Field(description='Number of creatives being processed', ge=0), + NoExplicitNull, ] = None approved: Annotated[ - SchemaInt | None, Field(description='Number of approved creatives', ge=0) + SchemaInt | None, Field(description='Number of approved creatives', ge=0), NoExplicitNull ] = None pending_review: Annotated[ - SchemaInt | None, Field(description='Number of creatives pending review', ge=0) + SchemaInt | None, + Field(description='Number of creatives pending review', ge=0), + NoExplicitNull, ] = None rejected: Annotated[ - SchemaInt | None, Field(description='Number of rejected creatives', ge=0) + SchemaInt | None, Field(description='Number of rejected creatives', ge=0), NoExplicitNull ] = None archived: Annotated[ - SchemaInt | None, Field(description='Number of archived creatives', ge=0) + SchemaInt | None, Field(description='Number of archived creatives', ge=0), NoExplicitNull ] = None class SortApplied(AdCPBaseModel): - field: str | None = None - direction: sort_direction.SortDirection | None = None + field: Annotated[str | None, NoExplicitNull] = None + direction: Annotated[sort_direction.SortDirection | None, NoExplicitNull] = None class QuerySummary(AdCPBaseModel): @@ -193,10 +201,12 @@ class QuerySummary(AdCPBaseModel): SchemaInt, Field(description='Number of creatives returned in this response', ge=0) ] filters_applied: Annotated[ - list[str] | None, Field(description='List of filters that were applied to the query') + list[str] | None, + Field(description='List of filters that were applied to the query'), + NoExplicitNull, ] = None sort_applied: Annotated[ - SortApplied | None, Field(description='Sort order that was applied') + SortApplied | None, Field(description='Sort order that was applied'), NoExplicitNull ] = None @@ -210,12 +220,14 @@ class AssignedPackage(IndicatorBearingResourceState): description='Indicator types covered by this snapshot. Required whenever indicators is present. Types omitted from this list remain unknown even when indicators is empty. Every returned indicator.type MUST appear in this list.', min_length=1, ), + NoExplicitNull, ] = None indicators: Annotated[ list[Indicator] | None, Field( description='Current seller assertions for the indicator types and publisher/placement coverage named by the sibling evaluation fields. Omitted means unknown or not evaluated. A present empty array means evaluated with no current assertion for indicator_types_evaluated in the evaluated scope.' ), + NoExplicitNull, ] = None package_id: Annotated[str, Field(description='Package identifier')] media_buy_id: Annotated[ @@ -223,6 +235,7 @@ class AssignedPackage(IndicatorBearingResourceState): Field( description='Media buy containing this package. A seller advertising list_creatives in media_buy.relationship_notifications.projection_tasks MUST include this field on every assignment row, including rows where indicators is omitted as unknown, so buyers can key and reread the relationship unambiguously when package IDs are reused across media buys.' ), + NoExplicitNull, ] = None assigned_date: Annotated[AwareDatetime, Field(description='When this assignment was created')] approval_status: Annotated[ @@ -230,12 +243,14 @@ class AssignedPackage(IndicatorBearingResourceState): Field( description='Aggregate approval state for this creative in this package assignment. This mirrors the same relationship in get_media_buys. Sellers advertising list_creatives as an indicator projection task MUST include it; partially_approved requires approval_scopes.' ), + NoExplicitNull, ] = None rejection_reason: Annotated[ str | None, Field( description='Human-readable explanation when approval_status is rejected. Mirrors get_media_buys for the same relationship.' ), + NoExplicitNull, ] = None approval_scopes: Annotated[ list[creative_approval_scope.ScopedCreativeApproval] | None, @@ -243,12 +258,14 @@ class AssignedPackage(IndicatorBearingResourceState): description='Complete, disjoint publisher/placement approval partition when approval_status is partially_approved. A normalized scope appears once. For one publisher, use either one publisher-wide row or placement-specific rows, never both. Omit when one approval_status applies uniformly to the whole assignment. The same scoped outcomes are mirrored on get_media_buys.', min_length=2, ), + NoExplicitNull, ] = None indicators_as_of: Annotated[ AwareDatetime | None, Field( description='When the seller last completed the evaluation represented by indicators for this relationship. Required whenever indicators is present, including an empty array.' ), + NoExplicitNull, ] = None indicators_evaluated_scope: Annotated[ list[indicator_scope.IndicatorScope] | None, @@ -256,6 +273,7 @@ class AssignedPackage(IndicatorBearingResourceState): description='Optional publisher or placement scopes covered by this evaluation. Omit when indicators covers the whole package–creative assignment. When present, scopes not listed remain unknown; every returned indicator.scope entry MUST be contained by this set.', min_length=1, ), + NoExplicitNull, ] = None @@ -273,6 +291,7 @@ class Assignments(AdCPBaseModel): ge=0, le=200, ), + NoExplicitNull, ] = None matching_assignment_count: Annotated[ SchemaInt | None, @@ -280,18 +299,21 @@ class Assignments(AdCPBaseModel): description='Total active assignments matching filters.indicator_types. MUST be present exactly when assignment_projection was matching. May exceed returned_assignment_count.', ge=0, ), + NoExplicitNull, ] = None assignments_truncated: Annotated[ StrictBool | None, Field( description='True exactly when more qualifying assignments exist than were returned under assignment_limit. Qualifying means assignment_count for the all projection and matching_assignment_count for the matching projection. Buyers needing complete state repair through get_media_buys.' ), + NoExplicitNull, ] = None assigned_packages: Annotated[ list[AssignedPackage] | None, Field( description='Bounded package assignment projection. Under assignment_projection: matching, contains only assignments carrying a requested indicator type; otherwise contains active assignments up to assignment_limit. The response ceiling is enforced via verifier_constraints rather than maxItems, so payloads from 3.1 sellers remain schema-valid.' ), + NoExplicitNull, ] = None @@ -309,15 +331,19 @@ class Creative(AdCPBaseModel): Field( description='Current buyer-authored input revision for this creative. Present when the current effective content has revision identity and the seller advertises creative.supports_revisions; omitted after an accepted content-bearing legacy update without revision_id.' ), + NoExplicitNull, ] = None representation_selection: Annotated[ representation_selection_1.RepresentationSelection | None, Field( description='Exact source-set and selected-output lineage retained when the current stored creative was resolved from a CreativeRepresentationSet.' ), + NoExplicitNull, ] = None account: Annotated[ - account_1.Account | None, Field(description='Account that owns this creative') + account_1.Account | None, + Field(description='Account that owns this creative'), + NoExplicitNull, ] = None name: Annotated[str, Field(description='Human-readable creative name')] format_id: Annotated[ @@ -326,18 +352,21 @@ class Creative(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format path. New listed creatives use `format_kind` and optional `format_option_ref`.', ), + NoExplicitNull, ] = None format_kind: Annotated[ str | None, Field( description='Canonical 3.2 path. The canonical format kind this creative targets. Mutually exclusive with deprecated `format_id`.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Optional reference to the concrete canonical format option this creative targets. Required when `format_kind` alone is ambiguous in the enclosing product context.' ), + NoExplicitNull, ] = None status: Annotated[ creative_status.CreativeStatus, Field(description='Current approval status of the creative') @@ -349,27 +378,33 @@ class Creative(AdCPBaseModel): Field( description='Assets for this creative, keyed by asset_id. Each slot value is either a single asset object or an array of asset objects (for slots with `min`/`max > 1`). Each asset value carries an `asset_type` discriminator that selects the matching asset schema.' ), + NoExplicitNull, ] = None component_assets: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[a-z][a-z0-9_]*$')], creative_assets.CreativeAssets] | None, Field( description='Preserved component-addressed asset maps for `coordinated_placements`, keyed by coordinated component ID.' ), + NoExplicitNull, ] = None localization: Annotated[ creative_localization_readback.CreativeLocalizationReadback | None, Field( description='Authoritative exact materialized locale-variant state. Present for localized creatives when complete. The enclosing creative status is the single review lifecycle for all variants.' ), + NoExplicitNull, ] = None localization_unavailable: Annotated[ LocalizationUnavailable | None, Field( description='Per-creative fail-closed state returned instead of localization when the seller knows the creative is localized but cannot construct complete exact readback. The creative remains in this page and counts toward query_summary.returned and pagination; buyers may continue using the base creative fields but MUST NOT infer locale eligibility.' ), + NoExplicitNull, ] = None tags: Annotated[ - list[str] | None, Field(description='User-defined tags for organization and searchability') + list[str] | None, + Field(description='User-defined tags for organization and searchability'), + NoExplicitNull, ] = None rights: Annotated[ list[rights_constraint.RightsConstraint] | None, @@ -377,6 +412,7 @@ class Creative(AdCPBaseModel): description="Exact rights constraints retained from the buyer's creative submission. Presence is presentation readback, not proof that the seller accepted or verified the rights.", min_length=1, ), + NoExplicitNull, ] = None rights_attestation_evaluations: Annotated[ list[rights_attestation_evaluation.RightsAttestationEvaluation] | None, @@ -384,41 +420,50 @@ class Creative(AdCPBaseModel): description="Complete seller-produced verifier-of-record results for retained rights references. Buyers MUST ignore any evaluation they originally supplied and rely only on this seller readback for this seller's eligibility decision. This array has no independent item ceiling because rights is not capped; under a required policy every applicable retained constraint needs a corresponding current verified result.", min_length=1, ), + NoExplicitNull, ] = None concept_id: Annotated[ str | None, Field( description='Creative concept this creative belongs to. Concepts group related creatives across sizes and formats.' ), + NoExplicitNull, + ] = None + concept_name: Annotated[ + str | None, Field(description='Human-readable concept name'), NoExplicitNull ] = None - concept_name: Annotated[str | None, Field(description='Human-readable concept name')] = None variables: Annotated[ list[creative_variable.CreativeVariable] | None, Field( description='Dynamic content variables (DCO slots) for this creative. Included when include_variables=true.' ), + NoExplicitNull, ] = None assignments: Annotated[ Assignments | None, Field(description='Current package assignments (included when include_assignments=true)'), + NoExplicitNull, ] = None snapshot: Annotated[ Snapshot | None, Field( description='Lightweight delivery snapshot (included when include_snapshot=true). For detailed performance analytics, use get_creative_delivery.' ), + NoExplicitNull, ] = None snapshot_unavailable_reason: Annotated[ snapshot_unavailable_reason_1.SnapshotUnavailableReason | None, Field( description='Machine-readable reason the snapshot is omitted. Present only when include_snapshot was true and snapshot data is unavailable for this creative.' ), + NoExplicitNull, ] = None items: Annotated[ list[creative_item.CreativeItem] | None, Field( description='Items for multi-asset formats like carousels and native ads (included when include_items=true)' ), + NoExplicitNull, ] = None pricing_options: Annotated[ list[vendor_pricing_option.VendorPricingOption] | None, @@ -426,12 +471,14 @@ class Creative(AdCPBaseModel): description='Pricing options for using this creative (serving, delivery). Used by ad servers and library agents. Transformation agents expose build pricing on canonical transformer.pricing_options entries from list_transformers instead. Present when include_pricing=true and account provided. The buyer passes the applied pricing_option_id in report_usage.', min_length=1, ), + NoExplicitNull, ] = None purge: Annotated[ Purge | None, Field( description="Tombstone block — present only when this record is a soft-purged creative surfaced via `include_purged: true`. The record's `status` field reflects the last status before purge (frozen — buyers MUST treat the creative as gone; assignments, snapshot, and serving operations no longer apply). Tombstones surface for the seller's webhook activity retention window (30 days from `purge.at`). Hard purges (`purge_kind: hard` on the webhook) do not surface on this read — the [`creative.purged`](https://adcontextprotocol.org/schemas/v3/creative/creative-purged-webhook.json) webhook is the only signal." ), + NoExplicitNull, ] = None webhook_activity: Annotated[ list[webhook_activity_record.WebhookActivityRecord] | None, @@ -439,6 +486,7 @@ class Creative(AdCPBaseModel): description='Recent webhook fires scoped to this creative — creative.status_changed, creative.purged, creative.assignment_changed, and assignment-level indicators.changed deliveries. Present only when include_webhook_activity is true. Account-anchored records include subscriber_id; the parent creative_id disambiguates the record. Retention: 30 days from completed_at. See snapshot-and-log.mdx § Webhook activity log pattern.', max_length=200, ), + NoExplicitNull, ] = None @@ -472,17 +520,20 @@ class ListCreativesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) Field( description='Breakdown of creatives by canonical format kind. Keys SHOULD be `format_kind` values; an implementation may append a stable option suffix when separate product or publisher options must be distinguished.' ), + NoExplicitNull, ] = None status_summary: Annotated[ - StatusSummary | None, Field(description='Breakdown of creatives by status') + StatusSummary | None, Field(description='Breakdown of creatives by status'), NoExplicitNull ] = None errors: Annotated[ list[error.Error] | None, Field(description='Task-specific errors (e.g., invalid filters, account not found)'), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field(description='When true, this response contains simulated data from sandbox mode.'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/list_transformers_request.py b/src/adcp/types/domains/creative/list_transformers_request.py index ad24fcc9e..528f98b82 100644 --- a/src/adcp/types/domains/creative/list_transformers_request.py +++ b/src/adcp/types/domains/creative/list_transformers_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_transformers_request.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool @@ -44,6 +44,7 @@ class ListTransformersRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): transformer_ids: Annotated[ list[str] | None, Field(description='Return only these specific transformer IDs.', min_length=1), + NoExplicitNull, ] = None input_format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -52,6 +53,7 @@ class ListTransformersRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description='**DEPRECATED in 3.2.** Filter by legacy named input formats. Use input_format_kinds.', min_length=1, ), + NoExplicitNull, ] = None output_format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -60,6 +62,7 @@ class ListTransformersRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description='**DEPRECATED in 3.2.** Filter by legacy named output formats. Use output_capability_ids.', min_length=1, ), + NoExplicitNull, ] = None input_format_kinds: Annotated[ list[str] | None, @@ -67,6 +70,7 @@ class ListTransformersRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description='Filter to transformers whose canonical input_formats include any of these canonical format kinds.', min_length=1, ), + NoExplicitNull, ] = None output_capability_ids: Annotated[ list[OutputCapabilityId] | None, @@ -74,16 +78,19 @@ class ListTransformersRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description='Filter to transformers that can produce any of these canonical creative.supported_formats capability IDs.', min_length=1, ), + NoExplicitNull, ] = None name_search: Annotated[ str | None, Field(description='Search transformers by name (case-insensitive partial match).'), + NoExplicitNull, ] = None brief: Annotated[ str | None, Field( description="Natural-language brief used to rank and filter transformers (and their enumerable option values when expanded) — e.g. 'warm female Spanish-language voiceover'. Curates to intent rather than returning the full set, the way get_products curates inventory." ), + NoExplicitNull, ] = None expand_params: Annotated[ list[str] | None, @@ -91,6 +98,7 @@ class ListTransformersRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description="Param `field` names for which to return the FIRST page of account-scoped option VALUES inline on each transformer's `params[].options[]`. Omit to return param descriptors without enumerated values (the lean default). When a param's options are truncated, its `params[].options_cursor` is set — fetch the next page via `expand_pagination` (below).", min_length=1, ), + NoExplicitNull, ] = None expand_pagination: Annotated[ list[ExpandPaginationItem] | None, @@ -98,17 +106,20 @@ class ListTransformersRequestCreativeAgent(AdcpRequest, AdcpVersionEnvelope): description="Fetch the NEXT page of a specific param's account-scoped options, using the `options_cursor` a prior response returned for that `(transformer, param)`. Scoped per `(transformer_id, field)` so multiple params can be paged independently. Use this instead of `expand_params` once you hold a cursor.", min_length=1, ), + NoExplicitNull, ] = None include_pricing: Annotated[ StrictBool | None, Field(description='Include `pricing_options` on each transformer. Requires `account`.'), + NoExplicitNull, ] = False account: Annotated[ account_ref.AccountReference | None, Field( description='Account reference. Transformers are account-scoped — the returned set, the enumerable option values, and (with include_pricing) the rate card are all resolved for this credential.' ), + NoExplicitNull, ] = None - pagination: pagination_request.PaginationRequest | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/list_transformers_response.py b/src/adcp/types/domains/creative/list_transformers_response.py index b96006e15..0e9145b39 100644 --- a/src/adcp/types/domains/creative/list_transformers_response.py +++ b/src/adcp/types/domains/creative/list_transformers_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_transformers_response.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -27,8 +28,10 @@ class ListTransformersResponseCreativeAgent(AdcpResponse, AdcpVersionEnvelope, P Field(description='Transformer descriptors matching the query.'), ] errors: Annotated[ - list[error.Error] | None, Field(description='Task-specific errors and warnings.') + list[error.Error] | None, + Field(description='Task-specific errors and warnings.'), + NoExplicitNull, ] = None - pagination: pagination_response.PaginationResponse | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/preview_creative_request.py b/src/adcp/types/domains/creative/preview_creative_request.py index 8c1daca29..204b77e1d 100644 --- a/src/adcp/types/domains/creative/preview_creative_request.py +++ b/src/adcp/types/domains/creative/preview_creative_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/preview_creative_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -42,12 +42,14 @@ class Input(AdCPBaseModel): Field( description="Macro values to use for this preview. Supports all universal macros from the format's supported_macros list." ), + NoExplicitNull, ] = None context_description: Annotated[ str | None, Field( description="Natural language description of the context for AI-generated content (e.g., 'User just searched for running shoes', 'Podcast discussing weather patterns')" ), + NoExplicitNull, ] = None @@ -57,11 +59,14 @@ class Input10(AdCPBaseModel): ) name: Annotated[str, Field(description='Human-readable name for this input set')] macros: Annotated[ - dict[str, str] | None, Field(description='Macro values to use for this preview') + dict[str, str] | None, + Field(description='Macro values to use for this preview'), + NoExplicitNull, ] = None context_description: Annotated[ str | None, Field(description='Natural language description of the context for AI-generated content'), + NoExplicitNull, ] = None @@ -75,6 +80,7 @@ class Request(AdCPBaseModel): description='Canonical preview-operation selector for this batch item. Overrides the batch-level target_capability_id and MUST identify an advertised capability whose operations contains preview. If neither item nor batch supplies one, renderer inference is permitted only for a unique compatible preview capability.', pattern='^[a-zA-Z0-9_-]+$', ), + NoExplicitNull, ] = None format_id: Annotated[ format_id_1.FormatReferenceStructuredObject | None, @@ -82,37 +88,46 @@ class Request(AdCPBaseModel): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format preview route. Use target_capability_id plus the canonical identity in creative_manifest.', ), + NoExplicitNull, ] = None creative_manifest: Annotated[ creative_manifest_1.CreativeManifest | None, Field(description='Complete creative manifest with all required assets.'), + NoExplicitNull, ] = None creative_id: Annotated[ str | None, Field( description='Creative-library identifier. Use instead of creative_manifest to preview a stored canonical creative.' ), + NoExplicitNull, ] = None inputs: Annotated[ list[Input10] | None, Field( description='Array of input sets for generating multiple preview variants', min_length=1 ), + NoExplicitNull, ] = None template_id: Annotated[ - str | None, Field(description='Specific template ID for custom format rendering') + str | None, + Field(description='Specific template ID for custom format rendering'), + NoExplicitNull, ] = None quality: Annotated[ creative_quality.CreativeQuality | None, Field(description='Render quality for this preview. Overrides batch-level default.'), + NoExplicitNull, ] = None output_format: Annotated[ preview_output_format.PreviewOutputFormat | None, Field(description='Output format for this preview. Overrides batch-level default.'), + NoExplicitNull, ] = preview_output_format.PreviewOutputFormat.url item_limit: Annotated[ SchemaInt | None, Field(description='Maximum number of catalog items to render in this preview.', ge=1), + NoExplicitNull, ] = None @@ -131,6 +146,7 @@ class PreviewCreativeRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Complete creative manifest with all required assets for the format. In single mode, provide exactly one of creative_manifest or creative_id. Also accepted per item in batch mode.' ), + NoExplicitNull, ] = None target_capability_id: Annotated[ str | None, @@ -138,6 +154,7 @@ class PreviewCreativeRequest(AdcpRequest, AdcpVersionEnvelope): description="Canonical preview-operation selector. Identifies one get_adcp_capabilities creative.supported_formats[].capability_id entry whose operations contains preview. In single mode it selects the renderer for this request; in batch mode it is the default for items that omit their own target_capability_id. When omitted, the agent MAY resolve the renderer only if exactly one advertised preview capability satisfies the manifest's canonical declaration; zero matches or multiple matches MUST be rejected with FORMAT_NOT_SUPPORTED rather than choosing nondeterministically. Mutually exclusive with deprecated format_id.", pattern='^[a-zA-Z0-9_-]+$', ), + NoExplicitNull, ] = None format_id: Annotated[ format_id_1.FormatReferenceStructuredObject | None, @@ -145,6 +162,7 @@ class PreviewCreativeRequest(AdcpRequest, AdcpVersionEnvelope): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format preview route. New requests select an advertised preview renderer with target_capability_id and carry portable format identity in creative_manifest_1.format_kind plus optional creative_manifest_1.format_option_ref.', ), + NoExplicitNull, ] = None inputs: Annotated[ list[Input] | None, @@ -152,22 +170,26 @@ class PreviewCreativeRequest(AdcpRequest, AdcpVersionEnvelope): description='Array of input sets for generating multiple preview variants. Each input set defines macros and context values for one preview rendering. Used in single mode.', min_length=1, ), + NoExplicitNull, ] = None template_id: Annotated[ str | None, Field(description='Specific template ID for custom format rendering. Used in single mode.'), + NoExplicitNull, ] = None quality: Annotated[ creative_quality.CreativeQuality | None, Field( description="Render quality. 'draft' produces fast, lower-fidelity renderings. 'production' produces full-quality renderings. In batch mode, sets the default for all requests (individual items can override)." ), + NoExplicitNull, ] = None output_format: Annotated[ preview_output_format.PreviewOutputFormat | None, Field( description="Output format. 'url' returns preview_url (iframe-embeddable URL), 'html' returns preview_html (raw HTML). In batch mode, sets the default for all requests (individual items can override). Default: 'url'." ), + NoExplicitNull, ] = preview_output_format.PreviewOutputFormat.url item_limit: Annotated[ SchemaInt | None, @@ -175,6 +197,7 @@ class PreviewCreativeRequest(AdcpRequest, AdcpVersionEnvelope): description='Maximum number of catalog items to render per preview variant. Used in single mode. Creative agents SHOULD default to a reasonable sample when omitted and the catalog is large.', ge=1, ), + NoExplicitNull, ] = None requests: Annotated[ list[Request] | None, @@ -183,30 +206,35 @@ class PreviewCreativeRequest(AdcpRequest, AdcpVersionEnvelope): max_length=50, min_length=1, ), + NoExplicitNull, ] = None variant_id: Annotated[ str | None, Field( description="Agent-assigned AdCP served-execution identifier from get_creative_delivery. Required when request_type is 'variant'. It is agent-unique when the source agent advertises creative.supports_revisions; for legacy agents the published scope remains agent plus creative, and callers SHOULD also send creative_id to disambiguate reused values." ), + NoExplicitNull, ] = None creative_id: Annotated[ str | None, Field( description='Creative-library identifier. In single mode, previews the stored canonical creative without requiring the caller to reconstruct its manifest. Also available as context in variant mode.' ), + NoExplicitNull, ] = None allow_async: Annotated[ StrictBool | None, Field( description="Opt in to an asynchronous preview response. When true, the creative agent MAY return status 'submitted' with a task_id only when rendering has been handed to a queue or external renderer and will continue after the request connection is released. Active processing on an open connection uses working progress instead. The buyer polls get_task_status for completion. When false or absent, the agent MUST return a synchronous preview response or a terminal protocol error; it MUST NOT return the submitted shape. This field applies to preview_creative only; build_creative already defines its own async lifecycle." ), + NoExplicitNull, ] = False push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Optional webhook configuration for terminal completion/failure notifications when allow_async is true and preview_creative returns a submitted task envelope. Submitted tasks remain pollable through get_task_status whether or not this field is present. If the agent accepts this configuration and returns submitted, it MUST deliver at least the terminal notification; if it cannot honor the webhook, it MUST return a structured error. Presence of this field alone MUST NOT cause asynchronous execution.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/preview_creative_response.py b/src/adcp/types/domains/creative/preview_creative_response.py index 82d3d678d..d5734aea2 100644 --- a/src/adcp/types/domains/creative/preview_creative_response.py +++ b/src/adcp/types/domains/creative/preview_creative_response.py @@ -103,7 +103,7 @@ class PreviewCreativeResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelo class PreviewCreativeResponse4(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): - model_config = ConfigDict(extra='allow', validate_default=True) + model_config = ConfigDict(extra='allow') response_type: Literal['submitted'] = 'submitted' status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted task_id: str diff --git a/src/adcp/types/domains/creative/preview_render.py b/src/adcp/types/domains/creative/preview_render.py index 30d08fbbb..a3284dfe4 100644 --- a/src/adcp/types/domains/creative/preview_render.py +++ b/src/adcp/types/domains/creative/preview_render.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: creative/preview_render.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat from ..core import preview_renderer_metadata @@ -27,12 +27,17 @@ class Embedding(AdCPBaseModel): requires_https: Annotated[ StrictBool | None, Field(description='Whether this output requires HTTPS for secure embedding'), + NoExplicitNull, ] = None supports_fullscreen: Annotated[ - StrictBool | None, Field(description='Whether this output supports fullscreen mode') + StrictBool | None, + Field(description='Whether this output supports fullscreen mode'), + NoExplicitNull, ] = None csp_policy: Annotated[ - str | None, Field(description='Content Security Policy requirements for embedding') + str | None, + Field(description='Content Security Policy requirements for embedding'), + NoExplicitNull, ] = None @@ -59,17 +64,19 @@ class PreviewRender1(AdCPBaseModel): ), ] dimensions: Annotated[ - Dimensions | None, Field(description='Dimensions for this rendered piece') + Dimensions | None, Field(description='Dimensions for this rendered piece'), NoExplicitNull ] = None embedding: Annotated[ Embedding | None, Field(description='Optional security and embedding metadata for safe iframe integration'), + NoExplicitNull, ] = None renderer: Annotated[ preview_renderer_metadata.PreviewRendererMetadata | None, Field( description='Optional renderer implementation and safety metadata for audit and reproducibility. Authority is still resolved from capability discovery and placement delegation.' ), + NoExplicitNull, ] = None @@ -96,16 +103,19 @@ class PreviewRender2(AdCPBaseModel): ), ] dimensions: Annotated[ - Dimensions | None, Field(description='Dimensions for this rendered piece') + Dimensions | None, Field(description='Dimensions for this rendered piece'), NoExplicitNull ] = None embedding: Annotated[ - Embedding | None, Field(description='Optional security and embedding metadata') + Embedding | None, + Field(description='Optional security and embedding metadata'), + NoExplicitNull, ] = None renderer: Annotated[ preview_renderer_metadata.PreviewRendererMetadata | None, Field( description='Optional renderer implementation and safety metadata for audit and reproducibility. Authority is still resolved from capability discovery and placement delegation.' ), + NoExplicitNull, ] = None @@ -141,17 +151,19 @@ class PreviewRender3(AdCPBaseModel): ), ] dimensions: Annotated[ - Dimensions | None, Field(description='Dimensions for this rendered piece') + Dimensions | None, Field(description='Dimensions for this rendered piece'), NoExplicitNull ] = None embedding: Annotated[ Embedding | None, Field(description='Optional security and embedding metadata for safe iframe integration'), + NoExplicitNull, ] = None renderer: Annotated[ preview_renderer_metadata.PreviewRendererMetadata | None, Field( description='Optional renderer implementation and safety metadata for audit and reproducibility. Authority is still resolved from capability discovery and placement delegation.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/creative/sync_creatives_async_response_input_required.py b/src/adcp/types/domains/creative/sync_creatives_async_response_input_required.py index 64c70998f..aee77e31b 100644 --- a/src/adcp/types/domains/creative/sync_creatives_async_response_input_required.py +++ b/src/adcp/types/domains/creative/sync_creatives_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/sync_creatives_async_response_input_required.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -25,7 +25,9 @@ class SyncCreativesInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why buyer input is needed') + Reason | None, + Field(description='Reason code indicating why buyer input is needed'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/sync_creatives_async_response_submitted.py b/src/adcp/types/domains/creative/sync_creatives_async_response_submitted.py index 15a54bc6d..ad8148467 100644 --- a/src/adcp/types/domains/creative/sync_creatives_async_response_submitted.py +++ b/src/adcp/types/domains/creative/sync_creatives_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: creative/sync_creatives_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -36,12 +36,14 @@ class SyncCreativesSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Batch ingestion queued; typical turnaround 15-30 minutes.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/sync_creatives_async_response_working.py b/src/adcp/types/domains/creative/sync_creatives_async_response_working.py index 24172dac3..ff0738ad6 100644 --- a/src/adcp/types/domains/creative/sync_creatives_async_response_working.py +++ b/src/adcp/types/domains/creative/sync_creatives_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: creative/sync_creatives_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..core import context as context_1 @@ -18,20 +18,30 @@ class SyncCreativesWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ - str | None, Field(description='Current step or phase of the operation') + str | None, Field(description='Current step or phase of the operation'), NoExplicitNull ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, + ] = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None creatives_processed: Annotated[ - SchemaInt | None, Field(description='Number of creatives processed so far', ge=0) + SchemaInt | None, + Field(description='Number of creatives processed so far', ge=0), + NoExplicitNull, ] = None creatives_total: Annotated[ - SchemaInt | None, Field(description='Total number of creatives to process', ge=0) + SchemaInt | None, + Field(description='Total number of creatives to process', ge=0), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/sync_creatives_request.py b/src/adcp/types/domains/creative/sync_creatives_request.py index 11ff22d25..2d76b73e0 100644 --- a/src/adcp/types/domains/creative/sync_creatives_request.py +++ b/src/adcp/types/domains/creative/sync_creatives_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/sync_creatives_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator @@ -38,6 +38,7 @@ class Assignment(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None placement_ids: Annotated[ list[str] | None, @@ -45,6 +46,7 @@ class Assignment(AdCPBaseModel): description='Restrict this creative to specific placements within the package. When omitted, the creative is eligible for all placements.', min_length=1, ), + NoExplicitNull, ] = None @@ -60,8 +62,8 @@ class AssignmentOperations1(AdCPBaseModel): operation: Literal['assign'] = 'assign' creative_id: Annotated[str, Field(min_length=1)] package_id: Annotated[str, Field(min_length=1)] - weight: Annotated[StrictFloat | None, Field(ge=0.0, le=100.0)] = None - placement_ids: Annotated[list[PlacementId] | None, Field(min_length=1)] = None + weight: Annotated[StrictFloat | None, Field(ge=0.0, le=100.0), NoExplicitNull] = None + placement_ids: Annotated[list[PlacementId] | None, Field(min_length=1), NoExplicitNull] = None class AssignmentOperations2(AdCPBaseModel): @@ -83,8 +85,8 @@ class AssignmentOperations3(AdCPBaseModel): str, Field(description='Currently assigned creative ID to remove atomically.', min_length=1) ] package_id: Annotated[str, Field(min_length=1)] - weight: Annotated[StrictFloat | None, Field(ge=0.0, le=100.0)] = None - placement_ids: Annotated[list[PlacementId] | None, Field(min_length=1)] = None + weight: Annotated[StrictFloat | None, Field(ge=0.0, le=100.0), NoExplicitNull] = None + placement_ids: Annotated[list[PlacementId] | None, Field(min_length=1), NoExplicitNull] = None AssignmentOperations = Annotated[ @@ -99,6 +101,7 @@ class Creative(CreativeAsset): Field( description='Optional buyer-assigned identity for this exact input-content state. Buyers may send it to any 3.2 peer; creative.supports_revisions gates reliance on immutability, echo, readback, and delivery correlation, not whether the open request shape may carry the field. Within a supporting seller, a revision_id is immutable under one creative_id: reusing it with different canonical revision content MUST fail with CREATIVE_REVISION_CONTENT_MISMATCH. Metadata and assignment fields excluded from revision content may change without minting a new revision.' ), + NoExplicitNull, ] = None localization: Annotated[ creative_localization.CreativeLocalization | None, @@ -122,6 +125,7 @@ class SyncCreativesRequest(AdcpRequest, AdcpVersionEnvelope): max_length=100, min_length=1, ), + NoExplicitNull, ] = None creative_ids: Annotated[ list[str] | None, @@ -130,6 +134,7 @@ class SyncCreativesRequest(AdcpRequest, AdcpVersionEnvelope): max_length=100, min_length=1, ), + NoExplicitNull, ] = None assignments: Annotated[ list[Assignment] | None, @@ -138,6 +143,7 @@ class SyncCreativesRequest(AdcpRequest, AdcpVersionEnvelope): description='Deprecated additive assignment shorthand. Each entry upserts one creative-to-package assignment. Use assignment_operations for explicit assign, unassign, and replace semantics. Standalone creative agents that do not manage media buys ignore this field.', min_length=1, ), + NoExplicitNull, ] = None assignment_operations: Annotated[ list[AssignmentOperations] | None, @@ -146,6 +152,7 @@ class SyncCreativesRequest(AdcpRequest, AdcpVersionEnvelope): max_length=500, min_length=1, ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -161,27 +168,31 @@ class SyncCreativesRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='When true, creatives not included in this sync will be archived. Use with caution for full library replacement. Invalid when creative_ids is provided — delete_missing applies to the entire library scope, not a filtered subset.' ), + NoExplicitNull, ] = False dry_run: Annotated[ StrictBool | None, Field( description="When true, rehearse this sync_creatives operation without applying it. Validates the actual trafficking request in the seller's current context, including library upsert semantics, creative IDs, assignments, account-scoped gates, and seller policies, then returns what would be created/updated/deleted. This is distinct from validate_input, which only validates manifest structure against canonical/product format targets." ), + NoExplicitNull, ] = False validation_mode: Annotated[ validation_mode_1.ValidationMode | None, Field( description="Validation strictness. 'strict' fails entire sync on any validation error. 'lenient' processes valid creatives and reports errors." ), + NoExplicitNull, ] = validation_mode_1.ValidationMode.strict push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Optional webhook configuration for async sync notifications. The agent will send a webhook when sync completes if the operation takes longer than immediate response time (typically for large bulk operations or manual approval/HITL).' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> SyncCreativesRequest: diff --git a/src/adcp/types/domains/creative/sync_creatives_response.py b/src/adcp/types/domains/creative/sync_creatives_response.py index 5a11c8cce..20cdbd188 100644 --- a/src/adcp/types/domains/creative/sync_creatives_response.py +++ b/src/adcp/types/domains/creative/sync_creatives_response.py @@ -60,7 +60,7 @@ class SyncCreativesResponse2(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope class SyncCreativesResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): - model_config = ConfigDict(extra='allow', validate_default=True) + model_config = ConfigDict(extra='allow') status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted task_id: str message: Annotated[str, StringConstraints(max_length=2000)] | None = None diff --git a/src/adcp/types/domains/creative/validate_input_request.py b/src/adcp/types/domains/creative/validate_input_request.py index a8bfc5f2a..5373a681c 100644 --- a/src/adcp/types/domains/creative/validate_input_request.py +++ b/src/adcp/types/domains/creative/validate_input_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/validate_input_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..core import account_ref, brand_ref, creative_manifest @@ -70,12 +70,14 @@ class ValidateInputRequest(AdcpRequest, AdCPBaseModel): Field( description='Optional account scope for seller-specific product validation. Required by sellers that route product declarations by buyer account.' ), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Optional brand scope when account is omitted or the seller keys sandbox validation by brand identity.' ), + NoExplicitNull, ] = None manifest: Annotated[ creative_manifest.CreativeManifest, Field(description='Creative manifest to validate.') @@ -87,4 +89,5 @@ class ValidateInputRequest(AdcpRequest, AdCPBaseModel): max_length=50, min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/creative/validate_input_result.py b/src/adcp/types/domains/creative/validate_input_result.py index c7b4fbbf7..d91ebb98b 100644 --- a/src/adcp/types/domains/creative/validate_input_result.py +++ b/src/adcp/types/domains/creative/validate_input_result.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/validate_input_result.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import macro_resolution_result @@ -67,6 +67,7 @@ class Violation(AdCPBaseModel): Field( description='Optional advisory adjustment hint. Platforms MAY suggest a corrected input shape; buyers MUST treat this as advisory, not authoritative.' ), + NoExplicitNull, ] = None @@ -95,16 +96,19 @@ class ValidateInputResult(AdCPBaseModel): Field( description="When `result_kind` is `validated_fail`, the specific constraints the manifest fails to meet. MUST be absent (or empty) for `validated_pass` and `unvalidatable_nondeterministic` — neither has constraint violations to enumerate (`unvalidatable_nondeterministic` doesn't validate at all; `validated_pass` has nothing to fail)." ), + NoExplicitNull, ] = None warnings: Annotated[ list[Warning] | None, Field( description='Non-blocking observations (e.g. LEAN policy advisories such as hover-triggered expansion or non-user-initiated entry into overlay anchoring) that do not affect `result_kind`. MAY be present alongside `validated_pass`, `validated_fail`, or `unvalidatable_nondeterministic`. Same item shape as `violations`.' ), + NoExplicitNull, ] = None macro_resolution_results: Annotated[ list[macro_resolution_result.MacroResolutionResult] | None, Field( description='Per-token compatibility against the selected target. Required or unsafe `unsupported`/`ambiguous` results make the target `validated_fail`; deliberate preservation for the declared downstream resolver may still pass.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/creative/video_brief.py b/src/adcp/types/domains/creative/video_brief.py index bee1c4e05..c01564708 100644 --- a/src/adcp/types/domains/creative/video_brief.py +++ b/src/adcp/types/domains/creative/video_brief.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: creative/video_brief.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field @@ -27,11 +27,13 @@ class Segment(AdCPBaseModel): description='Text prompt fed to the synthesis pipeline for this segment (subject, action, setting, mood). Renamed from earlier `description` to make explicit that this is a generation prompt — not a description-of-finished-content.' ), ] - vo: Annotated[str | None, Field(description='Voiceover line for this segment (optional).')] = ( - None - ) + vo: Annotated[ + str | None, Field(description='Voiceover line for this segment (optional).'), NoExplicitNull + ] = None caption: Annotated[ - str | None, Field(description='On-screen caption text for this segment (optional).') + str | None, + Field(description='On-screen caption text for this segment (optional).'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/accessibility_violation.py b/src/adcp/types/domains/error_details/accessibility_violation.py index dd15f47d1..a3eaa2001 100644 --- a/src/adcp/types/domains/error_details/accessibility_violation.py +++ b/src/adcp/types/domains/error_details/accessibility_violation.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/accessibility_violation.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool @@ -83,6 +83,7 @@ class Violation(AdCPBaseModel): max_length=256, pattern='^[^\\u0000-\\u001F\\u007F]*$', ), + NoExplicitNull, ] = None @@ -103,4 +104,5 @@ class AccessibilityViolationDetails(AdCPBaseModel): Field( description='True when the producer omitted additional violations or remediation text to keep the complete serialized error within the 4096-byte transport safety limit. Omission means false.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/account_moved.py b/src/adcp/types/domains/error_details/account_moved.py index 49548a938..f6508c0dc 100644 --- a/src/adcp/types/domains/error_details/account_moved.py +++ b/src/adcp/types/domains/error_details/account_moved.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/account_moved.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from ..core import canonical_account_ref @@ -27,4 +27,5 @@ class AccountMovedDetails(AdCPBaseModel): Field( description='Current account revision when the seller exposes account revisions.', ge=1 ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/account_setup_required.py b/src/adcp/types/domains/error_details/account_setup_required.py index 4b7b5875b..a32396035 100644 --- a/src/adcp/types/domains/error_details/account_setup_required.py +++ b/src/adcp/types/domains/error_details/account_setup_required.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/account_setup_required.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -15,8 +15,10 @@ class AccountSetupRequiredDetails(AdCPBaseModel): extra='allow', ) setup_url: Annotated[ - AnyUrl | None, Field(description='URL where account setup can be completed') + AnyUrl | None, Field(description='URL where account setup can be completed'), NoExplicitNull ] = None setup_steps: Annotated[ - list[str] | None, Field(description='Steps remaining before the account is ready') + list[str] | None, + Field(description='Steps remaining before the account is ready'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/action_not_allowed.py b/src/adcp/types/domains/error_details/action_not_allowed.py index fad18a7d1..899f9837b 100644 --- a/src/adcp/types/domains/error_details/action_not_allowed.py +++ b/src/adcp/types/domains/error_details/action_not_allowed.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/action_not_allowed.json -# timestamp: 2026-09-24T08:59:48+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import media_buy_available_action, media_buy_available_action_id @@ -34,10 +34,12 @@ class ActionNotAllowedDetails(AdCPBaseModel): Field( description="Echo of the buy's resolved `available_actions[]` at rejection time. Buyer SDKs render this to the caller as the recovery option set." ), + NoExplicitNull, ] = None decline_reason: Annotated[ seller_policy_decline_reason.SellerPolicyDeclineReason | None, Field( description='Coarse seller-policy dimension behind a buy-specific denial. Sellers SHOULD populate this for `reason: not_supported_on_buy` when they have a structured reason and disclosure does not expose private thresholds or enforcement controls. It SHOULD be omitted for structural reasons such as `wrong_status`, `not_supported_on_product`, `mode_mismatch`, and `condition_unresolved`. When present, it MUST be consistent with the enclosing error.recovery classification.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/audience_too_small.py b/src/adcp/types/domains/error_details/audience_too_small.py index 0ea9a883e..dec88dc79 100644 --- a/src/adcp/types/domains/error_details/audience_too_small.py +++ b/src/adcp/types/domains/error_details/audience_too_small.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/audience_too_small.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -15,6 +15,8 @@ class AudienceTooSmallDetails(AdCPBaseModel): extra='allow', ) minimum_size: Annotated[ - StrictFloat | None, Field(description='Minimum audience size required') + StrictFloat | None, Field(description='Minimum audience size required'), NoExplicitNull + ] = None + current_size: Annotated[ + StrictFloat | None, Field(description='Current audience size'), NoExplicitNull ] = None - current_size: Annotated[StrictFloat | None, Field(description='Current audience size')] = None diff --git a/src/adcp/types/domains/error_details/authorization_required.py b/src/adcp/types/domains/error_details/authorization_required.py index b49400dab..da78aeb09 100644 --- a/src/adcp/types/domains/error_details/authorization_required.py +++ b/src/adcp/types/domains/error_details/authorization_required.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/authorization_required.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..core import downstream_connection_requirement @@ -21,24 +21,28 @@ class AuthorizationRequiredDetails(AdCPBaseModel): Field( description='Complete set of downstream connections known to be required for the relevant product, format, or request.' ), + NoExplicitNull, ] = None missing_connections: Annotated[ list[downstream_connection_requirement.DownstreamConnectionRequirement] | None, Field( description='Subset of downstream connections that blocked the current request. Sellers SHOULD populate this array when the caller needs to route a human through a connections flow. Entries with `status` of `missing`, `pending`, `expired`, or `revoked` MUST include either `provider` or `authorization_url` so the buyer can route the remediation unambiguously.' ), + NoExplicitNull, ] = None authorization_url: Annotated[ AnyUrl | None, Field( description='General recovery URL when there is a single obvious authorization step or when the seller has its own connection-management page.' ), + NoExplicitNull, ] = None authorization_instructions: Annotated[ str | None, Field( description='Human-readable recovery instructions. Use `missing_connections[].authorization_instructions` when instructions differ per downstream connection.' ), + NoExplicitNull, ] = None reference_authorization: Annotated[ dict[str, Any] | None, @@ -46,4 +50,5 @@ class AuthorizationRequiredDetails(AdCPBaseModel): deprecated=True, description='Legacy or provider-specific authorization hint for the referenced object. Prefer `missing_connections[]` for new implementations.', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/billing_not_permitted_for_agent.py b/src/adcp/types/domains/error_details/billing_not_permitted_for_agent.py index 113dae0a8..8eae1ad5e 100644 --- a/src/adcp/types/domains/error_details/billing_not_permitted_for_agent.py +++ b/src/adcp/types/domains/error_details/billing_not_permitted_for_agent.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/billing_not_permitted_for_agent.json -# timestamp: 2026-05-22T13:15:12+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import billing_party @@ -27,4 +27,5 @@ class BillingNotPermittedForAgentDetails(AdCPBaseModel): Field( description="A single billing value the calling buyer agent MAY retry with autonomously. Typically `operator` for passthrough-only agents. Sellers MAY omit this field when no retryable value exists for this agent's commercial relationship — in that case the rejection is terminal-pending-onboarding and the buyer MUST complete offline payments-relationship onboarding before any value other than the original request will succeed. Sellers MUST NOT populate this field with the full subset of values the agent may use; the field surfaces at most one canonical retry." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/billing_not_supported.py b/src/adcp/types/domains/error_details/billing_not_supported.py index ca1042ad1..f7e3ac825 100644 --- a/src/adcp/types/domains/error_details/billing_not_supported.py +++ b/src/adcp/types/domains/error_details/billing_not_supported.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/billing_not_supported.json -# timestamp: 2026-05-22T13:15:12+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import billing_party @@ -27,6 +27,7 @@ class BillingNotSupportedDetails(AdCPBaseModel): Field( description='Which gate fired. `"capability"` — the seller\'s `supported_billing` capability does not include the requested value (advice: choose from `supported_billing` and retry). `"account"` — the seller\'s capability accepts the value generally but not for the specific operator on this account (advice: try the next-most-permissive value the capability allows). Registered subset of the shared discriminator vocabulary in `enums/error-scope.json` — the per-agent scope is reserved for `BILLING_NOT_PERMITTED_FOR_AGENT` (which carries no explicit `scope` field) and for `PERMISSION_DENIED` with `error-details/agent-permission-denied.json`. Sellers MUST omit this field when the response is being returned to a caller whose agent identity has not been established (signed-request derivation or credential-to-agent mapping); see the uniform-response requirement in error-handling.mdx Billing and Account Setup.' ), + NoExplicitNull, ] = None supported_billing: Annotated[ list[billing_party.BillingParty] | None, @@ -34,4 +35,5 @@ class BillingNotSupportedDetails(AdCPBaseModel): description='Echoed copy of the seller\'s `supported_billing` capability for the caller\'s convenience — saves a `get_adcp_capabilities` round-trip when `scope` is `"capability"`. Sellers MAY omit this field; callers MUST treat absence as "call `get_adcp_capabilities`" rather than "any value is permitted."', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/budget_too_low.py b/src/adcp/types/domains/error_details/budget_too_low.py index bc2f8d718..b05c3c1b6 100644 --- a/src/adcp/types/domains/error_details/budget_too_low.py +++ b/src/adcp/types/domains/error_details/budget_too_low.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/budget_too_low.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -15,6 +15,10 @@ class BudgetTooLowDetails(AdCPBaseModel): extra='allow', ) minimum_budget: Annotated[ - StrictFloat | None, Field(description="Seller's minimum budget for this product") + StrictFloat | None, + Field(description="Seller's minimum budget for this product"), + NoExplicitNull, ] = None - currency: Annotated[str | None, Field(description='ISO 4217 currency code')] = None + currency: Annotated[str | None, Field(description='ISO 4217 currency code'), NoExplicitNull] = ( + None + ) diff --git a/src/adcp/types/domains/error_details/conflict.py b/src/adcp/types/domains/error_details/conflict.py index 4095d020c..c2365a5ac 100644 --- a/src/adcp/types/domains/error_details/conflict.py +++ b/src/adcp/types/domains/error_details/conflict.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/conflict.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -15,12 +15,15 @@ class ConflictDetails(AdCPBaseModel): extra='allow', ) resource_id: Annotated[ - str | None, Field(description='Identifier of the conflicting resource') + str | None, Field(description='Identifier of the conflicting resource'), NoExplicitNull ] = None expected_version: Annotated[ StrictFloat | str | None, Field(description='Version or ETag the client was operating against'), + NoExplicitNull, ] = None current_version: Annotated[ - StrictFloat | str | None, Field(description='Current version or ETag on the server') + StrictFloat | str | None, + Field(description='Current version or ETag on the server'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/creative_rejected.py b/src/adcp/types/domains/error_details/creative_rejected.py index 458eb442c..fe18694f7 100644 --- a/src/adcp/types/domains/error_details/creative_rejected.py +++ b/src/adcp/types/domains/error_details/creative_rejected.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/creative_rejected.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -14,10 +14,16 @@ class CreativeRejectedDetails(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - policy_id: Annotated[str | None, Field(description='Identifier for the violated policy')] = None + policy_id: Annotated[ + str | None, Field(description='Identifier for the violated policy'), NoExplicitNull + ] = None policy_url: Annotated[ - AnyUrl | None, Field(description='URL where the full policy can be reviewed') + AnyUrl | None, + Field(description='URL where the full policy can be reviewed'), + NoExplicitNull, ] = None reasons: Annotated[ - list[str] | None, Field(description='Specific reasons the creative was rejected') + list[str] | None, + Field(description='Specific reasons the creative was rejected'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/governance_agent_not_accepted.py b/src/adcp/types/domains/error_details/governance_agent_not_accepted.py index 5b6d9c9fd..f9e072682 100644 --- a/src/adcp/types/domains/error_details/governance_agent_not_accepted.py +++ b/src/adcp/types/domains/error_details/governance_agent_not_accepted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/governance_agent_not_accepted.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..governance import accepted_governance_agents as accepted_governance_agents_1 @@ -38,6 +38,7 @@ class GovernanceAgentNotAcceptedDetails2(AdCPBaseModel): max_length=128, pattern='^[A-Za-z0-9_.:-]+$', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/policy_violation.py b/src/adcp/types/domains/error_details/policy_violation.py index 4e1dd159f..a512732bc 100644 --- a/src/adcp/types/domains/error_details/policy_violation.py +++ b/src/adcp/types/domains/error_details/policy_violation.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/policy_violation.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -26,12 +26,14 @@ class PolicyViolationDetails(AdCPBaseModel): Field( description='Whose policy produced the rejection. Optional so undisclosed seller policies can return only the typed POLICY_VIOLATION code if even origin would reveal sensitive information.' ), + NoExplicitNull, ] = None policy_id: Annotated[ str | None, Field( description='Shared policy-registry identifier for the violated policy. Use seller_policy_ref instead when the seller is not disclosing a registry policy.' ), + NoExplicitNull, ] = None seller_policy_ref: Annotated[ str | None, @@ -39,12 +41,15 @@ class PolicyViolationDetails(AdCPBaseModel): description='Opaque seller-scoped reference that lets support and audit records correlate an undisclosed policy without publishing its rules. This is not a shared policy-registry ID.', min_length=1, ), + NoExplicitNull, ] = None policy_url: Annotated[ - AnyUrl | None, Field(description='URL where the full policy can be reviewed') + AnyUrl | None, + Field(description='URL where the full policy can be reviewed'), + NoExplicitNull, ] = None violated_rules: Annotated[ - list[str] | None, Field(description='Specific rules that were violated') + list[str] | None, Field(description='Specific rules that were violated'), NoExplicitNull ] = None category: Annotated[ str | None, @@ -52,4 +57,5 @@ class PolicyViolationDetails(AdCPBaseModel): description="Optional coarse policy category disclosed at the seller's discretion.", pattern='^[a-z][a-z0-9_]*$', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/rate_limited.py b/src/adcp/types/domains/error_details/rate_limited.py index 16ef25ce8..2cf234ca1 100644 --- a/src/adcp/types/domains/error_details/rate_limited.py +++ b/src/adcp/types/domains/error_details/rate_limited.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/rate_limited.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -22,12 +22,20 @@ class RateLimitedDetails(AdCPBaseModel): extra='allow', ) limit: Annotated[ - StrictFloat | None, Field(description='Maximum requests allowed in the window') + StrictFloat | None, + Field(description='Maximum requests allowed in the window'), + NoExplicitNull, ] = None remaining: Annotated[ - StrictFloat | None, Field(description='Requests remaining in the current window') + StrictFloat | None, + Field(description='Requests remaining in the current window'), + NoExplicitNull, ] = None window_seconds: Annotated[ - StrictFloat | None, Field(description='Duration of the rate-limit window in seconds') + StrictFloat | None, + Field(description='Duration of the rate-limit window in seconds'), + NoExplicitNull, + ] = None + scope: Annotated[ + Scope | None, Field(description='What the limit applies to'), NoExplicitNull ] = None - scope: Annotated[Scope | None, Field(description='What the limit applies to')] = None diff --git a/src/adcp/types/domains/error_details/requote_required.py b/src/adcp/types/domains/error_details/requote_required.py index 3eb8c2608..ee75c1f39 100644 --- a/src/adcp/types/domains/error_details/requote_required.py +++ b/src/adcp/types/domains/error_details/requote_required.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/requote_required.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, RootModel @@ -46,16 +46,19 @@ class RequoteRequiredDetails(AdCPBaseModel): Field( description='Buyer-visible request field path or paths that breached the accepted commercial envelope. Values MUST NOT name seller-internal objects or identifiers. New emitters SHOULD use the array form even for one path; the scalar form remains valid for compatibility with existing 3.x sellers.' ), + NoExplicitNull, ] = None change_term_id: Annotated[ media_buy_change_term_id.MediaBuyChangeTermId | None, Field( description='Accepted proposal change term whose typed constraint the request exceeded.' ), + NoExplicitNull, ] = None decline_reason: Annotated[ seller_policy_decline_reason.SellerPolicyDeclineReason | None, Field( description='Coarse seller-policy dimension that declined the requested shape. Sellers SHOULD populate this when they have a structured reason and disclosure does not expose private thresholds or enforcement controls. When present, it MUST be consistent with the enclosing error.recovery classification.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/stale_response.py b/src/adcp/types/domains/error_details/stale_response.py index 77b47bdd8..b9fcb53f9 100644 --- a/src/adcp/types/domains/error_details/stale_response.py +++ b/src/adcp/types/domains/error_details/stale_response.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: error_details/stale_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field @@ -19,12 +19,14 @@ class Upstream(AdCPBaseModel): Field( description='URL of the unreachable upstream, when the upstream is HTTP-addressable. Sellers MUST omit when the upstream is an internal (non-URL) dependency.' ), + NoExplicitNull, ] = None name: Annotated[ str | None, Field( description="Human-readable identifier for the upstream, e.g., 'creative-agent-foo' or 'inventory-service'. Used when the upstream is internal or when the URL is not informative on its own." ), + NoExplicitNull, ] = None @@ -37,12 +39,14 @@ class OriginalError(AdCPBaseModel): Field( description="Error code from the underlying failure, e.g., SERVICE_UNAVAILABLE, or a transport-level identifier like 'TIMEOUT' / 'CONNECTION_REFUSED' when the underlying call did not produce an AdCP error code." ), + NoExplicitNull, ] = None message: Annotated[ str | None, Field( description="Short human-readable description of the underlying failure (e.g., 'Connection timeout after 20s'). MUST NOT include credentials, internal hostnames, or stack traces." ), + NoExplicitNull, ] = None @@ -69,16 +73,19 @@ class StaleResponseDetails(AdCPBaseModel): description="The seller's freshness target for this surface, in seconds. `cache_age_seconds - freshness_target_seconds` is how far beyond target the cached entry is. Optional — sellers MAY omit when no public freshness contract is declared.", ge=0, ), + NoExplicitNull, ] = None upstream: Annotated[ Upstream | None, Field( description='Identifies the upstream or sub-agent whose fetch failed and triggered cache fallback. When N upstreams are stale, the seller emits N separate STALE_RESPONSE entries (one per upstream) rather than aggregating into a single entry — mirrors the per-asset advisory precedent set by PIXEL_TRACKER_LOSSY_DOWNGRADE.' ), + NoExplicitNull, ] = None original_error: Annotated[ OriginalError | None, Field( description='Minimal subset of the underlying failure that triggered cache fallback. Sellers MUST NOT include internal stack traces, credentials, or connection strings.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/vast_version_mismatch.py b/src/adcp/types/domains/error_details/vast_version_mismatch.py index 1d19e8d17..cc07dbcc4 100644 --- a/src/adcp/types/domains/error_details/vast_version_mismatch.py +++ b/src/adcp/types/domains/error_details/vast_version_mismatch.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/vast_version_mismatch.json -# timestamp: 2026-10-04T00:56:30+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import format_option_ref as format_option_ref_1 @@ -46,6 +46,7 @@ class VastVersionMismatchDetails1(AdCPBaseModel): Field( description='Role of the inspected document. Only the submitted document is required to equal the asset declaration; wrapper and terminal documents are checked against the applicable acceptance sets.' ), + NoExplicitNull, ] = None product_vast_versions: Annotated[list[vast_version.VastVersion], Field(min_length=1)] seller_vast_versions: Annotated[list[vast_version.VastVersion], Field(min_length=1)] @@ -54,6 +55,7 @@ class VastVersionMismatchDetails1(AdCPBaseModel): Field( description='Selected option reference when the product option is addressable. Omitted for a unique id-less product option.' ), + NoExplicitNull, ] = None supported_versions: Annotated[ list[vast_version.VastVersion] | None, @@ -62,6 +64,7 @@ class VastVersionMismatchDetails1(AdCPBaseModel): description='Deprecated 3.x compatibility field. For an acceptance mismatch, this is the effective product/seller intersection when known. Modern emitters use the typed sets and mismatch_reason.', min_length=1, ), + NoExplicitNull, ] = None @@ -87,17 +90,18 @@ class VastVersionMismatchDetails2(AdCPBaseModel): description='Role of the inspected document. Only the submitted document is required to equal the asset declaration; wrapper and terminal documents are checked against the applicable acceptance sets.' ), ] - product_vast_versions: Annotated[list[vast_version.VastVersion] | None, Field(min_length=1)] = ( - None - ) - seller_vast_versions: Annotated[list[vast_version.VastVersion] | None, Field(min_length=1)] = ( - None - ) + product_vast_versions: Annotated[ + list[vast_version.VastVersion] | None, Field(min_length=1), NoExplicitNull + ] = None + seller_vast_versions: Annotated[ + list[vast_version.VastVersion] | None, Field(min_length=1), NoExplicitNull + ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Selected option reference when the product option is addressable. Omitted for a unique id-less product option.' ), + NoExplicitNull, ] = None supported_versions: Annotated[ list[vast_version.VastVersion] | None, @@ -106,6 +110,7 @@ class VastVersionMismatchDetails2(AdCPBaseModel): description='Deprecated 3.x compatibility field. For an acceptance mismatch, this is the effective product/seller intersection when known. Modern emitters use the typed sets and mismatch_reason.', min_length=1, ), + NoExplicitNull, ] = None @@ -114,9 +119,11 @@ class VastVersionMismatchDetails3(AdCPBaseModel): extra='allow', ) mismatch_reason: Annotated[ - MismatchReason | None, Field(description='Machine-readable modern mismatch branch.') + MismatchReason | None, + Field(description='Machine-readable modern mismatch branch.'), + NoExplicitNull, ] = None - asset_vast_version: vast_version.VastVersion | None = None + asset_vast_version: Annotated[vast_version.VastVersion | None, NoExplicitNull] = None observed_document_vast_version: Annotated[ str | None, Field( @@ -129,18 +136,20 @@ class VastVersionMismatchDetails3(AdCPBaseModel): Field( description='Role of the inspected document. Only the submitted document is required to equal the asset declaration; wrapper and terminal documents are checked against the applicable acceptance sets.' ), + NoExplicitNull, + ] = None + product_vast_versions: Annotated[ + list[vast_version.VastVersion] | None, Field(min_length=1), NoExplicitNull + ] = None + seller_vast_versions: Annotated[ + list[vast_version.VastVersion] | None, Field(min_length=1), NoExplicitNull ] = None - product_vast_versions: Annotated[list[vast_version.VastVersion] | None, Field(min_length=1)] = ( - None - ) - seller_vast_versions: Annotated[list[vast_version.VastVersion] | None, Field(min_length=1)] = ( - None - ) format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Selected option reference when the product option is addressable. Omitted for a unique id-less product option.' ), + NoExplicitNull, ] = None supported_versions: Annotated[ list[vast_version.VastVersion], diff --git a/src/adcp/types/domains/error_details/vendor_error_codes.py b/src/adcp/types/domains/error_details/vendor_error_codes.py index 284bcee04..f6440abee 100644 --- a/src/adcp/types/domains/error_details/vendor_error_codes.py +++ b/src/adcp/types/domains/error_details/vendor_error_codes.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/vendor_error_codes.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, Field @@ -24,7 +24,9 @@ class Codes(AdCPBaseModel): class Vendors(AdCPBaseModel): name: Annotated[str, Field(description='Full vendor name')] - url: Annotated[AnyUrl | None, Field(description='Vendor website or documentation URL')] = None + url: Annotated[ + AnyUrl | None, Field(description='Vendor website or documentation URL'), NoExplicitNull + ] = None codes: Annotated[ dict[str, Codes], Field(description='Map of code suffix to description and recovery classification'), @@ -35,4 +37,5 @@ class VendorErrorCodeRegistry(AdCPBaseModel): vendors: Annotated[ dict[str, Vendors] | None, Field(description='Map of vendor prefix to vendor metadata and codes'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/version_unsupported.py b/src/adcp/types/domains/error_details/version_unsupported.py index dbfa92cfa..1c0fe528f 100644 --- a/src/adcp/types/domains/error_details/version_unsupported.py +++ b/src/adcp/types/domains/error_details/version_unsupported.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/version_unsupported.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt, ScalarStr - from typing import Annotated +from adcp.types.base import NoExplicitNull +from adcp.types._scalar import ScalarInt, ScalarStr from pydantic import ConfigDict, Field from ..core.version_envelope import AdcpVersionEnvelope @@ -43,6 +43,7 @@ class VersionUnsupportedDetails(AdcpVersionEnvelope): deprecated=True, description='DEPRECATED in favor of `supported_versions`. Major versions the seller supports. Servers SHOULD emit both through 3.x; removed in 4.0.', ), + NoExplicitNull, ] = None build_version: Annotated[ str | None, @@ -56,4 +57,5 @@ class VersionUnsupportedDetails(AdcpVersionEnvelope): ], pattern='^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$', ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/extensions/extension_meta.py b/src/adcp/types/domains/extensions/extension_meta.py index 6aa395838..766691f9e 100644 --- a/src/adcp/types/domains/extensions/extension_meta.py +++ b/src/adcp/types/domains/extensions/extension_meta.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: extensions/extension_meta.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, Field @@ -29,9 +29,12 @@ class AdcpExtensionFileSchema(AdCPBaseModel): description="Last AdCP version this extension is compatible with (e.g., '3.0'). Omit if extension is still valid for current and future versions.", pattern='^\\d+\\.\\d+$', ), + NoExplicitNull, ] = None docs_url: Annotated[ - AnyUrl | None, Field(description='URL to documentation for implementors of this extension') + AnyUrl | None, + Field(description='URL to documentation for implementors of this extension'), + NoExplicitNull, ] = None type: Annotated[ Literal['object'], @@ -42,7 +45,9 @@ class AdcpExtensionFileSchema(AdCPBaseModel): Field(description='Schema properties defining the structure of ext.{namespace} data'), ] required: Annotated[ - list[str] | None, Field(description='Required properties within the extension data') + list[str] | None, + Field(description='Required properties within the extension data'), + NoExplicitNull, ] = None additionalProperties: Annotated[ Any | None, diff --git a/src/adcp/types/domains/formats/canonical/_base.py b/src/adcp/types/domains/formats/canonical/_base.py index 11adf76c7..3fe546af2 100644 --- a/src/adcp/types/domains/formats/canonical/_base.py +++ b/src/adcp/types/domains/formats/canonical/_base.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/_base.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat from pydantic import ConfigDict, Field, StrictBool @@ -77,13 +77,19 @@ class Slot(AdCPBaseModel): ), ] required: Annotated[ - StrictBool | None, Field(description='Whether this slot is required for a valid manifest.') + StrictBool | None, + Field(description='Whether this slot is required for a valid manifest.'), + NoExplicitNull, ] = False min: Annotated[ - SchemaInt | None, Field(description='Minimum count for repeatable / pool slots.', ge=0) + SchemaInt | None, + Field(description='Minimum count for repeatable / pool slots.', ge=0), + NoExplicitNull, ] = None max: Annotated[ - SchemaInt | None, Field(description='Maximum count for repeatable / pool slots.', ge=1) + SchemaInt | None, + Field(description='Maximum count for repeatable / pool slots.', ge=1), + NoExplicitNull, ] = None max_chars: Annotated[ SchemaInt | None, @@ -91,6 +97,7 @@ class Slot(AdCPBaseModel): description="Per-slot character limit. Valid only when `asset_type` is `text`, `markdown`, or `brief`. Mutually exclusive with `max_size_kb` (which applies to binary asset types). Schema enforces via if/then so a producer can't set both on the same slot.", ge=1, ), + NoExplicitNull, ] = None max_size_kb: Annotated[ SchemaInt | None, @@ -98,6 +105,7 @@ class Slot(AdCPBaseModel): description="Per-slot file size limit in exact kilobytes, where 1 KB = 1,000 bytes. Valid only when `asset_type` is `image`, `video`, `audio`, or `zip`. Mutually exclusive with `max_chars` (which applies to text asset types). Schema enforces via if/then so a producer can't set both on the same slot.", ge=1, ), + NoExplicitNull, ] = None pixel_ratios: Annotated[ list[PixelRatio] | None, @@ -105,6 +113,7 @@ class Slot(AdCPBaseModel): description="Accepted intrinsic-pixel densities for this image-bearing slot. Valid when `asset_type` is `image`, and on a `card` slot where it constrains each card's image media (video media is unaffected). This makes density available to every canonical carrying image assets (native, carousel, responsive, companion images, and image itself), not only `format_kind: image`. When the image canonical also declares top-level `params.pixel_ratios`, the effective set is the intersection; an empty intersection is invalid. One matching asset satisfies the slot unless `required_pixel_ratios` requires rendition coverage.", min_length=1, ), + NoExplicitNull, ] = None required_pixel_ratios: Annotated[ list[RequiredPixelRatio] | None, @@ -112,28 +121,33 @@ class Slot(AdCPBaseModel): description='Required density coverage for an image rendition set. Valid only when `asset_type` is `image` and `pixel_ratios` is also declared. Every value MUST appear in the effective accepted set after intersecting any top-level image `params.pixel_ratios`, and the manifest slot value MUST be an array containing exactly one matching image rendition for each required ratio. Other accepted ratios remain optional. For example, `pixel_ratios: [1, 1.5, 2]` with `required_pixel_ratios: [1, 2]` requires the 1x and 2x renditions while making 1.5x optional. SDKs enforce intersection, subset, coverage, and duplicate-ratio rules because JSON Schema draft-07 cannot express them generically.', min_length=1, ), + NoExplicitNull, ] = None logo_slots: Annotated[ list[logo_slot.LogoSlot] | None, Field( description='When `asset_group_id` is `logo`, renderer-facing brand.json logo slots acceptable for this format slot. Producers selecting from brand.json SHOULD prefer `logos[]` entries whose `slots[]` intersects this list, then apply `visual_guidelines.logo_usage_rules[]`.' ), + NoExplicitNull, ] = None required_logo_slots: Annotated[ list[logo_slot.LogoSlot] | None, Field( description='Subset of `logo_slots` for which this format expects explicit logo coverage. A manifest or brand-derived logo pool SHOULD include at least one usable logo for each required slot; if coverage is missing, builders SHOULD surface a validation warning or approval mapping instead of guessing from prose.' ), + NoExplicitNull, ] = None description: Annotated[ str | None, Field(description='Human-readable description of what the slot expects from the buyer.'), + NoExplicitNull, ] = None consumed_for_production: Annotated[ StrictBool | None, Field( description="Dispatch hint for `build_creative` and v1↔v2 wire translators: when `true`, the slot's value is consumed as INPUT to a production step (host-read script, brief copy fed to generative synthesis, catalog feed driving per-SKU rendering) and is not rendered verbatim. When `false` (default), the slot's value is rendered verbatim on the placement (image bytes, video file, display tag).\n\nMotivates the v1↔v2 dispatch table: pre-v2 buyers shipped production-consumed inputs separately in a `inputs` map on the build_creative request; v2 collapses inputs and rendered assets into a single `assets` map keyed by `asset_group_id`. SDK translators between v1 and v2 use this flag per canonical to know which assets in the v2 manifest map back to v1 `inputs` vs v1 `assets`. Without the per-slot flag the dispatch table lives in adopter code and every SDK gets it slightly different.\n\nProducers SHOULD set this explicitly on slots whose consumption pattern isn't obvious (host-read scripts on `audio_hosted`, briefs on generative `video_hosted`, catalog feeds on `sponsored_placement`). For canonicals where every slot is render-verbatim (`image`, `display_tag`, `video_vast`, `audio_vast`), the default `false` is sufficient and the flag MAY be omitted." ), + NoExplicitNull, ] = False @@ -146,18 +160,21 @@ class CanonicalFormatBase(AdCPBaseModel): Field( description='When true, this canonical or seller narrowing may not work as declared. Adopters SHOULD preflight it with validate_input or in a sandbox and SHOULD NOT route production budget without testing; experimental status never makes the deprecated v1 path preferable. Drivers include unsettled spec shape, an adopter runtime gap, and custom shapes awaiting promotion. This replaces the earlier status plus runtime_status axes. Sellers SHOULD set experimental whenever a canonical or declaration is not production-ready.' ), + NoExplicitNull, ] = False deprecated: Annotated[ StrictBool | None, Field( description="When true, this canonical (or a seller's specific narrowing of it) is going away. Existing adopters are supported through the deprecation cycle; new adoption is discouraged. Pair with `migration_target_version` to indicate when the canonical is expected to be removed. Distinct from `experimental`: an experimental canonical may stabilize and stop being experimental; a deprecated canonical is on a sunset path." ), + NoExplicitNull, ] = False v1_translatable: Annotated[ StrictBool | None, Field( description="Whether this canonical has any v1 named-format equivalent. `true` (default) — the canonical is structurally expressible as one or more v1 named formats (IAB display sizes, VAST tags, DAAST tags, etc.); v1→v2 projection via `v1-canonical-mapping.json` is meaningful. `false` — the canonical is inherently new in v2 and has no v1 form; v1's `list_creative_formats` couldn't express it because the underlying concept (algorithmic surface composition, AI-surface mentions, retail-media catalog placements, multi-card carousels) didn't exist as a v1 named-format archetype.\n\nLets SDKs distinguish two failure modes that today look identical: (a) the registry hasn't covered this canonical yet (correctable — seller adds explicit `canonical` field or files a registry entry) vs (b) no v1 path is possible (informational — buyer needs v2-aware consumption, or seller declares `canonical_formats_only: true` on the product declaration). SDKs encountering `v1_translatable: false` on a canonical SHOULD NOT emit `FORMAT_PROJECTION_FAILED` (which signals registry-coverage gap) — instead surface the inherent v1-unreachability as a different diagnostic or skip silently. The six inherently-v2 canonicals in 3.2 are `image_carousel`, `sponsored_placement`, `responsive_creative`, `agent_placement`, `seller_rendered_stateful_display`, and `coordinated_placements`." ), + NoExplicitNull, ] = True since_version: Annotated[ str | None, @@ -165,6 +182,7 @@ class CanonicalFormatBase(AdCPBaseModel): description="AdCP MAJOR.MINOR version that introduced this canonical (e.g., '3.1', '3.2'). Lets adopters reason about minimum protocol version requirements when consuming a format declaration. Patch precision is intentionally rejected — canonicals are introduced at minor-version boundaries.", pattern='^[1-9]\\d*\\.(0|[1-9]\\d*)$', ), + NoExplicitNull, ] = None migration_target_version: Annotated[ str | None, @@ -172,48 +190,56 @@ class CanonicalFormatBase(AdCPBaseModel): description="AdCP MAJOR.MINOR version by which the working group expects this canonical to stabilize, surface a breaking revision, or (when `deprecated: true`) be removed. Patch precision is intentionally rejected — canonicals shift at minor-version boundaries. Absence signals 'no specific target' (omit the field rather than use a placeholder like 'unknown').", pattern='^[1-9]\\d*\\.(0|[1-9]\\d*)$', ), + NoExplicitNull, ] = None composition_model: Annotated[ CompositionModel | None, Field( description='Whether the surface composes deterministically (buyer can predict per-slot rendering — sponsored_placement, image, video) or algorithmically (surface chooses combinations or phrasing — responsive_creative, agent_placement).' ), + NoExplicitNull, ] = None provenance_required: Annotated[ StrictBool | None, Field( description='When true, the product rejects unsigned synthesized assets. Builders calling build_creative MUST attach a C2PA-compatible provenance manifest attributing synthesis to the creative agent.' ), + NoExplicitNull, ] = None platform_extensions: Annotated[ list[platform_extension_ref.PlatformExtensionReference] | None, Field( description='Platform-specific extensions narrowing the canonical (pixel ID shapes, conversion event taxonomies, platform-specific CTAs/destinations). Each extension is a URI+digest reference resolved against the bundled `extensions` map in get_products responses or fetched directly.\n\n**Collision precedence (normative).** When two or more `platform_extensions[]` entries on the same declaration extend the same target (e.g., both extend `tracking`) with overlapping field names, **array order is authoritative — later entries override earlier ones on a per-field basis** (last-in-array-wins). SDKs MUST surface the overlap via the `errors[]` array on the `get_products` response with a structured code (`FORMAT_DECLARATION_DIVERGENT` is appropriate when the overlap appears across dual-emitted shapes; a producer-self-emitted overlap on a single declaration SHOULD use the same code with `error.details: { collision_kind: "platform_extension_field", target, overlapping_fields, winning_extension_uri }`). Producers SHOULD avoid the collision by emitting one extension per target or by partitioning fields across extensions; the deterministic precedence is for last-resort consistency across SDK implementations, not a sanctioned merging strategy.' ), + NoExplicitNull, ] = None synthesis_nondeterministic: Annotated[ StrictBool | None, Field( description="When true, the format's production pipeline is genuinely nondeterministic — the platform cannot guarantee that synthesis from a given input set produces in-spec output. Veo / Sora / Runway-class generative video, and other AI-synthesis flows where output dimensions, duration, or quality vary per run. Implies a different validation contract: predictive `validate_input` is impossible; the platform's own post-synthesis QA loop applies; if the QA loop exhausts without producing a valid artifact, `build_creative` returns task_failed with a synthesis_failed reason. Distinct from `composition_model` (which describes how the surface composes per-slot rendering, not whether synthesis is deterministic). When false or absent, the format's production is predictable enough that `validate_input` can predict output properties from input properties.\n\n**Compatibility with `asset_source` / `item_production_model`**: `synthesis_nondeterministic: true` MAY pair with any of `seller_pre_rendered_from_brief`, `seller_human_designed`, or `agent_synthesized` (the QA loop is concept-level, not source-specific — 'seller renders from brief but each retry differs' is just as nondeterministic as Veo). It MUST NOT pair with `buyer_uploaded` (the buyer ships pre-rendered bytes; there's no synthesis step to be nondeterministic about). It MUST NOT pair with `publisher_host_recorded` (the publisher's host produces a deterministic-from-script output even if the human voice varies). When `synthesis_nondeterministic: true` is set with an incompatible source, validators SHOULD reject with a structured error." ), + NoExplicitNull, ] = False slots: Annotated[ list[Slot] | None, Field( description="Programmatic declaration of which canonical asset_group_id slots a manifest targeting this format must (or may) populate. Lets SDK codegen and validators enumerate expected slots without parsing the format's prose description. Each entry references an asset_group_id from the canonical vocabulary registry, paired with an `asset_type` so the validator knows which asset schema to apply. Format-level narrowing parameters that apply across all slots (e.g., flat `headline_max_chars` on responsive_creative) may also live on the format declaration; per-slot constraints (a specific slot's `max_chars` or `max_size_kb`) live on the slot entry." ), + NoExplicitNull, ] = None required_connections: Annotated[ list[downstream_connection_requirement.DownstreamConnectionRequirement] | None, Field( description='Downstream platform connections or grants required to use this format declaration. These are in addition to the single AdCP caller credential. Use this when a platform product requires multiple downstream grants, such as an advertiser account connection plus a publisher identity or post authorization for published-post references.' ), + NoExplicitNull, ] = None reference_mutability: Annotated[ ReferenceMutability | None, Field( description='Policy for formats whose `slots` accept a `published_post` reference. `immutable_snapshot`: seller snapshots the referenced post at approval and later source changes do not change the served creative. `mutable_requires_reapproval`: the source post may change and material changes require review before continued serving. `mutable_auto_recheck`: the source post may change and the seller continuously or periodically rechecks authorization/policy without requiring buyer resubmission. Omit when the format has no `published_post` slot.' ), + NoExplicitNull, ] = None production_window_business_days: Annotated[ SchemaInt | None, @@ -221,4 +247,5 @@ class CanonicalFormatBase(AdCPBaseModel): description='Typical production turnaround in business days when the format requires seller-side production (e.g., host-recording from a buyer-supplied script). 0 for synchronous (e.g., generative AI); >0 for human-produced (e.g., podcast host-read). Absent when no production is required (buyer uploads complete creative).', ge=0, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/agent_placement.py b/src/adcp/types/domains/formats/canonical/agent_placement.py index 3caf1718c..5526c68f7 100644 --- a/src/adcp/types/domains/formats/canonical/agent_placement.py +++ b/src/adcp/types/domains/formats/canonical/agent_placement.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/agent_placement.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ._base import CanonicalFormatBase @@ -49,6 +49,7 @@ class CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement(CanonicalFormatBa Field( description='How the surface presents the mention. `text` = inline text (chat, search snippet). `audio` = TTS-synthesized voice. `card` = structured card with optional image + text.' ), + NoExplicitNull, ] = None max_mention_length_chars: Annotated[ SchemaInt | None, @@ -56,6 +57,7 @@ class CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement(CanonicalFormatBa description='For text output: maximum length of the surface-composed mention text.', ge=1, ), + NoExplicitNull, ] = None max_mention_duration_ms: Annotated[ SchemaInt | None, @@ -63,28 +65,33 @@ class CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement(CanonicalFormatBa description='For audio output: maximum duration of the spoken mention in milliseconds.', ge=1, ), + NoExplicitNull, ] = None supports_offering_reference: Annotated[ StrictBool | None, Field( description='Whether the product accepts an offering reference (specific product/service to promote within the mention) in addition to brand context.' ), + NoExplicitNull, ] = None supports_landing_page_url: Annotated[ StrictBool | None, Field( description='Whether the surface attaches a landing page URL to the mention (citation, learn-more link).' ), + NoExplicitNull, ] = None tone_constraints: Annotated[ list[str] | None, Field( description="**Advisory only.** Buyer-declared brand-voice preferences the surface SHOULD honor (e.g., ['formal', 'no_superlatives']). LLM/agentic surfaces have no protocol-level mechanism to verify enforcement — adopters that need hard guarantees should rely on brand.json voice declarations and post-mention review rather than this field. Future revisions may tie this to a structured tone vocabulary; for now treat as free-text guidance." ), + NoExplicitNull, ] = None disclosure_required: Annotated[ StrictBool | None, Field( description='Whether the surface must include an explicit sponsorship disclosure label.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/audio_daast.py b/src/adcp/types/domains/formats/canonical/audio_daast.py index 992d7d5e3..ee6adb1b5 100644 --- a/src/adcp/types/domains/formats/canonical/audio_daast.py +++ b/src/adcp/types/domains/formats/canonical/audio_daast.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/audio_daast.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt - from typing import Annotated, Any -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt +from adcp.types._scalar import ScalarInt from pydantic import ConfigDict, Field, StrictBool from ...core import daast_tracker_constraints @@ -40,12 +39,14 @@ class CanonicalFormatDaastAudio(CanonicalFormatBase): deprecated=True, description='Deprecated one-element alias for `daast_versions`. Producers use either the singular legacy alias or the plural 3.2 field, never both.', ), + NoExplicitNull, ] = None daast_versions: Annotated[ daast_tracker_constraints.DaastVersions | None, Field( description="Accepted DAAST versions for this format option. A tracker execution selector's daast_versions must be a nonempty subset of this set." ), + NoExplicitNull, ] = None duration_ms_range: Annotated[ list[DurationMsRangeItem] | None, @@ -54,6 +55,7 @@ class CanonicalFormatDaastAudio(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None duration_ms_exact: Annotated[ SchemaInt | None, @@ -61,8 +63,9 @@ class CanonicalFormatDaastAudio(CanonicalFormatBase): description='When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship.', ge=1, ), + NoExplicitNull, ] = None - linear_required: StrictBool | None = None - max_wrapper_depth: Annotated[SchemaInt | None, Field(ge=0)] = None - ssl_required: StrictBool | None = None - companion_image_required: StrictBool | None = None + linear_required: Annotated[StrictBool | None, NoExplicitNull] = None + max_wrapper_depth: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + ssl_required: Annotated[StrictBool | None, NoExplicitNull] = None + companion_image_required: Annotated[StrictBool | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/formats/canonical/audio_hosted.py b/src/adcp/types/domains/formats/canonical/audio_hosted.py index bc9bc1480..29739932a 100644 --- a/src/adcp/types/domains/formats/canonical/audio_hosted.py +++ b/src/adcp/types/domains/formats/canonical/audio_hosted.py @@ -1,15 +1,14 @@ # generated by datamodel-codegen: # filename: formats/canonical/audio_hosted.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt - from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt +from adcp.types._scalar import ScalarInt from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ._base import CanonicalFormatBase @@ -74,6 +73,7 @@ class CanonicalFormatHostedAudio(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None duration_ms_exact: Annotated[ SchemaInt | None, @@ -81,45 +81,55 @@ class CanonicalFormatHostedAudio(CanonicalFormatBase): description='When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship.', ge=1, ), + NoExplicitNull, ] = None - audio_codecs: list[AudioCodec] | None = None - audio_sample_rates: list[AudioSampleRate] | None = None - audio_channels: list[AudioChannel] | None = None - min_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1)] = None - max_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1)] = None + audio_codecs: Annotated[list[AudioCodec] | None, NoExplicitNull] = None + audio_sample_rates: Annotated[list[AudioSampleRate] | None, NoExplicitNull] = None + audio_channels: Annotated[list[AudioChannel] | None, NoExplicitNull] = None + min_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None max_file_size_mb: Annotated[ StrictFloat | None, Field( description='Maximum hosted audio file size in decimal megabytes. Agents that proxy or cache media SHOULD advertise their effective transport ceiling here.', gt=0.0, ), + NoExplicitNull, ] = None loudness_lufs: Annotated[ StrictFloat | None, Field( description='Required integrated loudness in LUFS (typical: -16 for streaming/podcast, -23 for broadcast). Negative values.' ), + NoExplicitNull, ] = None loudness_tolerance_db: Annotated[ StrictFloat | None, Field(description='Permitted deviation from loudness_lufs in dB.', ge=0.0), + NoExplicitNull, ] = None true_peak_dbfs: Annotated[ - StrictFloat | None, Field(description='Maximum true-peak level in dBFS (typical: -2).') + StrictFloat | None, + Field(description='Maximum true-peak level in dBFS (typical: -2).'), + NoExplicitNull, ] = None asset_source: Annotated[ AssetSource | None, Field( description="Where the rendered audio bytes come from. Single shared enum across canonicals (see `image.json#asset_source` for the full semantics). `publisher_host_recorded`: the publisher's host records the audio (podcast host-read pattern); buyer must use the publisher's build_creative capability. `publisher_owned_reference` is valid only when the product accepts a reference asset whose publisher-owned source resolves to playable audio. `publisher_host_recorded` remains the normal audio-specific host-read value." ), + NoExplicitNull, ] = AssetSource.buyer_uploaded buyer_asset_acceptance: Annotated[ BuyerAssetAcceptance | None, Field( description="Whether the product accepts buyer-uploaded audio. When `rejected`, the buyer cannot ship an audio asset directly — they must use build_creative (or sync_creatives with brief inputs) so the seller produces the audio. Combined with `asset_source`, lets a product declare 'I produce audio from briefs and refuse buyer uploads' (asset_source=`seller_pre_rendered_from_brief`, buyer_asset_acceptance=`rejected`)." ), + NoExplicitNull, ] = BuyerAssetAcceptance.accepted - companion_image_required: StrictBool | None = None - companion_image_aspect_ratio: str | None = None - companion_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None - brand_name_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None + companion_image_required: Annotated[StrictBool | None, NoExplicitNull] = None + companion_image_aspect_ratio: Annotated[str | None, NoExplicitNull] = None + companion_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = ( + None + ) + brand_name_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None diff --git a/src/adcp/types/domains/formats/canonical/audio_vast.py b/src/adcp/types/domains/formats/canonical/audio_vast.py index 841bbe664..ae34e0228 100644 --- a/src/adcp/types/domains/formats/canonical/audio_vast.py +++ b/src/adcp/types/domains/formats/canonical/audio_vast.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/audio_vast.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt - from typing import Annotated, Any -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt +from adcp.types._scalar import ScalarInt from pydantic import ConfigDict, Field, StrictBool from ...core import vast_media_file_requirements @@ -40,6 +39,7 @@ class CanonicalFormatVastAudio(CanonicalFormatBase): description='VAST audio versions accepted by this product format option. Standardized audio support begins at VAST 4.1; listing 2.0, 3.0, or 4.0 explicitly declares seller-supported legacy audio interoperability rather than standards-conformant VAST audio. The asset declares exactly one vast_version; compatibility requires membership in this set and the seller-wide execution set.', min_length=1, ), + NoExplicitNull, ] = None vast_version: Annotated[ vast_version_1.VastVersion | None, @@ -47,12 +47,14 @@ class CanonicalFormatVastAudio(CanonicalFormatBase): deprecated=True, description='Deprecated one-element alias for vast_versions. Producers use either the singular legacy alias or the plural 3.2 field, never both. VAST 4.1+ is the standards-conformant audio profile; older values declare legacy audio interoperability.', ), + NoExplicitNull, ] = None media_file_requirements: Annotated[ vast_media_file_requirements.VastMediafileRequirements | None, Field( description='Technical acceptance constraints for alternative audio MediaFile renditions in each resolved InLine linear creative. Declared MIME types must be audio/*; visual-dimension constraints are invalid because VAST represents audio MediaFiles with width and height 0.' ), + NoExplicitNull, ] = None duration_ms_range: Annotated[ list[DurationMsRangeItem] | None, @@ -61,6 +63,7 @@ class CanonicalFormatVastAudio(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None duration_ms_exact: Annotated[ SchemaInt | None, @@ -68,16 +71,21 @@ class CanonicalFormatVastAudio(CanonicalFormatBase): description='When set, the resolved audio creative duration must equal this value. Takes precedence over duration_ms_range.', ge=1, ), + NoExplicitNull, ] = None skippable_after_ms: Annotated[ SchemaInt | None, Field(description='When skippable, the buyer-side skip threshold in milliseconds.', ge=0), + NoExplicitNull, ] = None max_wrapper_depth: Annotated[ - SchemaInt | None, Field(description='Maximum VAST wrapper redirect depth permitted.', ge=0) + SchemaInt | None, + Field(description='Maximum VAST wrapper redirect depth permitted.', ge=0), + NoExplicitNull, ] = None - ssl_required: StrictBool | None = None + ssl_required: Annotated[StrictBool | None, NoExplicitNull] = None companion_image_required: Annotated[ StrictBool | None, Field(description='Whether the resolved VAST audio ad must include a companion creative.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/coordinated_placements.py b/src/adcp/types/domains/formats/canonical/coordinated_placements.py index 1cf202bf3..8accf090a 100644 --- a/src/adcp/types/domains/formats/canonical/coordinated_placements.py +++ b/src/adcp/types/domains/formats/canonical/coordinated_placements.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/coordinated_placements.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat, ScalarInt, ScalarStr from pydantic import ConfigDict, Field, StrictBool, StrictFloat @@ -295,7 +295,7 @@ class Breakpoint(AdCPBaseModel): pattern='^[a-z][a-z0-9_]*$', ), ] - width: Annotated[SchemaInt | None, Field(ge=1)] = None + width: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None width_range: Annotated[ list[WidthRangeItem] | None, Field( @@ -303,14 +303,16 @@ class Breakpoint(AdCPBaseModel): max_length=2, min_length=2, ), + NoExplicitNull, ] = None width_mode: Annotated[ WidthMode | None, Field( description='Fluid width classes. `full_bleed`: edge-to-edge viewport width (IAB adhesion, topscroll). `gutter_residual`: the margin remaining beside the page content column (desktop skins). Fluid breakpoints cannot be targeted by `rendered_canvases` supply — semantic validation rejects that pairing.' ), + NoExplicitNull, ] = None - height: Annotated[SchemaInt | None, Field(ge=1)] = None + height: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None height_range: Annotated[ list[HeightRangeItem] | None, Field( @@ -318,6 +320,7 @@ class Breakpoint(AdCPBaseModel): max_length=2, min_length=2, ), + NoExplicitNull, ] = None viewport_height_percent: Annotated[ StrictFloat | None, @@ -326,6 +329,7 @@ class Breakpoint(AdCPBaseModel): gt=0.0, le=100.0, ), + NoExplicitNull, ] = None canvas_aspect_ratio: Annotated[ str | None, @@ -333,6 +337,7 @@ class Breakpoint(AdCPBaseModel): description='Aspect ratio the rendered canvas maintains across the size range (IAB flexible units, ratio-bucket responsive). When both fixed `width` and `height` are declared, the ratio MUST be consistent with them.', pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None @@ -358,12 +363,14 @@ class State(AdCPBaseModel): Field( description='Asset group IDs rendered in this state (components mode). Makes the template executable: given components and bindings, assembly is deterministic. Every value MUST resolve to a declared slot. Omitted means all supplied component slots may render.' ), + NoExplicitNull, ] = None motion: Annotated[ Motion | None, Field( description='Whether the seller-rendered layout animates within this state (attract loops, load animations). Intra-state animation is seller-rendered; buyer canvases stay static images.' ), + NoExplicitNull, ] = Motion.static max_animation_s: Annotated[ StrictFloat | None, @@ -371,6 +378,7 @@ class State(AdCPBaseModel): description='Upper bound on intra-state animation duration. Required when `motion` is `animated`.', gt=0.0, ), + NoExplicitNull, ] = None breakpoints: Annotated[list[Breakpoint], Field(min_length=1)] close_affordance: Annotated[ @@ -455,18 +463,21 @@ class Transitions(AdCPBaseModel): Field( description='Input driving a user or scroll transition. `hover` expansion is disallowed by IAB NAP guidance and emits a LEAN policy warning visible to buyers.' ), + NoExplicitNull, ] = None media_event: Annotated[ MediaEvent | None, Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -484,6 +495,7 @@ class Transitions(AdCPBaseModel): duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -492,12 +504,14 @@ class Transitions(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference | None, Field( description='Reference frame for scroll percentages. Progress is `scroll_offset / max(scroll_extent - viewport_extent, 1) * 100`, measured on either the page document or the nearest seller-declared containing scroller.' ), + NoExplicitNull, ] = None scroll_start_percent: Annotated[ StrictFloat | None, @@ -506,6 +520,7 @@ class Transitions(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -514,12 +529,14 @@ class Transitions(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -547,18 +564,21 @@ class Transitions1(AdCPBaseModel): Field( description='Input driving a user or scroll transition. `hover` expansion is disallowed by IAB NAP guidance and emits a LEAN policy warning visible to buyers.' ), + NoExplicitNull, ] = None media_event: Annotated[ MediaEvent | None, Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -576,6 +596,7 @@ class Transitions1(AdCPBaseModel): duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -584,12 +605,14 @@ class Transitions1(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference | None, Field( description='Reference frame for scroll percentages. Progress is `scroll_offset / max(scroll_extent - viewport_extent, 1) * 100`, measured on either the page document or the nearest seller-declared containing scroller.' ), + NoExplicitNull, ] = None scroll_start_percent: Annotated[ StrictFloat | None, @@ -598,6 +621,7 @@ class Transitions1(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -606,12 +630,14 @@ class Transitions1(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -645,12 +671,14 @@ class Transitions2(AdCPBaseModel): Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -664,10 +692,12 @@ class Transitions2(AdCPBaseModel): description='For `timer`: delay after `from_state_id` activates (re-entry restarts it). For `in_view_timer`: accumulated viewable milliseconds in `from_state_id`. Timer-class transitions in a state cycle MUST declare at least 1000 (anti-strobe floor).', ge=0, ), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat, @@ -690,6 +720,7 @@ class Transitions2(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -698,12 +729,14 @@ class Transitions2(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -743,12 +776,14 @@ class Transitions3(AdCPBaseModel): Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ Literal['scroll_linked'], @@ -762,10 +797,12 @@ class Transitions3(AdCPBaseModel): description='For `timer`: delay after `from_state_id` activates (re-entry restarts it). For `in_view_timer`: accumulated viewable milliseconds in `from_state_id`. Timer-class transitions in a state cycle MUST declare at least 1000 (anti-strobe floor).', ge=0, ), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -774,6 +811,7 @@ class Transitions3(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference, @@ -802,6 +840,7 @@ class Transitions3(AdCPBaseModel): Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -835,12 +874,14 @@ class Transitions4(AdCPBaseModel): Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -854,10 +895,12 @@ class Transitions4(AdCPBaseModel): description='For `timer`: delay after `from_state_id` activates (re-entry restarts it). For `in_view_timer`: accumulated viewable milliseconds in `from_state_id`. Timer-class transitions in a state cycle MUST declare at least 1000 (anti-strobe floor).', ge=0, ), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -866,12 +909,14 @@ class Transitions4(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference | None, Field( description='Reference frame for scroll percentages. Progress is `scroll_offset / max(scroll_extent - viewport_extent, 1) * 100`, measured on either the page document or the nearest seller-declared containing scroller.' ), + NoExplicitNull, ] = None scroll_start_percent: Annotated[ StrictFloat | None, @@ -880,6 +925,7 @@ class Transitions4(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -888,12 +934,14 @@ class Transitions4(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -921,6 +969,7 @@ class Transitions5(AdCPBaseModel): Field( description='Input driving a user or scroll transition. `hover` expansion is disallowed by IAB NAP guidance and emits a LEAN policy warning visible to buyers.' ), + NoExplicitNull, ] = None media_event: Annotated[ MediaEvent, @@ -933,6 +982,7 @@ class Transitions5(AdCPBaseModel): Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -946,10 +996,12 @@ class Transitions5(AdCPBaseModel): description='For `timer`: delay after `from_state_id` activates (re-entry restarts it). For `in_view_timer`: accumulated viewable milliseconds in `from_state_id`. Timer-class transitions in a state cycle MUST declare at least 1000 (anti-strobe floor).', ge=0, ), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -958,12 +1010,14 @@ class Transitions5(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference | None, Field( description='Reference frame for scroll percentages. Progress is `scroll_offset / max(scroll_extent - viewport_extent, 1) * 100`, measured on either the page document or the nearest seller-declared containing scroller.' ), + NoExplicitNull, ] = None scroll_start_percent: Annotated[ StrictFloat | None, @@ -972,6 +1026,7 @@ class Transitions5(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -980,12 +1035,14 @@ class Transitions5(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -1039,9 +1096,9 @@ class SharedSlot(AdCPBaseModel): ) asset_group_id: Annotated[str, Field(pattern='^[a-z0-9_]+$')] asset_type: AssetType - required: StrictBool | None = False - min: Annotated[SchemaInt | None, Field(ge=0)] = None - max: Annotated[SchemaInt | None, Field(ge=1)] = None + required: Annotated[StrictBool | None, NoExplicitNull] = False + min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + max: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None consumed_by: Annotated[ list[str], Field( @@ -1075,18 +1132,21 @@ class Components(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference, @@ -1094,15 +1154,15 @@ class Components(AdCPBaseModel): description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), ] - format_kind: str | None = None - params: dict[str, Any] | None = None + format_kind: Annotated[str | None, NoExplicitNull] = None + params: Annotated[dict[str, Any] | None, NoExplicitNull] = None class Params(CanonicalFormatBase): model_config = ConfigDict( extra='allow', ) - motion_level: MotionLevel | None = None + motion_level: Annotated[MotionLevel | None, NoExplicitNull] = None slots: Annotated[ Any | None, Field( @@ -1122,6 +1182,7 @@ class Params(CanonicalFormatBase): description="Logical render width in pixels — use for fixed-size slots (e.g., a 300×250 IAB MREC). When `pixel_ratios` is absent, the required image asset width is the same value (1x). When `pixel_ratios` is present, an accepted asset's intrinsic width is `width × pixel_ratio`. For multi-size flexible slots, use `sizes[]`; for responsive slots, use the min/max fields. The three size modes are mutually exclusive.", ge=1, ), + NoExplicitNull, ] = None height: Annotated[ SchemaInt | None, @@ -1129,6 +1190,7 @@ class Params(CanonicalFormatBase): description='Logical render height in pixels. Intrinsic asset height is `height × pixel_ratio`, where the ratio defaults to 1 when `pixel_ratios` is absent. See `width` for size-mode mutual exclusion.', ge=1, ), + NoExplicitNull, ] = None sizes: Annotated[ list[Size] | None, @@ -1136,6 +1198,7 @@ class Params(CanonicalFormatBase): description='List of accepted logical (width, height) render pairs for a multi-size flexible slot. The buyer ships an asset matching one logical size multiplied by one accepted `pixel_ratios` entry (or by 1 when `pixel_ratios` is absent). SDKs MUST treat size and density as separate axes: a 600×500 intrinsic asset at 2x satisfies a logical 300×250 size; it does not create a logical 600×500 placement. Mirrors OpenRTB `banner.format[]` semantics. Mutually exclusive with `(width, height)` and with responsive ranges.', min_length=1, ), + NoExplicitNull, ] = None pixel_ratios: Annotated[ list[PixelRatio] | None, @@ -1143,6 +1206,7 @@ class Params(CanonicalFormatBase): description='Accepted intrinsic-pixel densities for image assets, expressed as intrinsic pixels per logical render pixel (for example `[1, 2]` accepts both standard and Retina renditions). Absence means `[1]` for backward compatibility. This is an acceptance set, not a requirement to submit every rendition: one `image_main` asset satisfying any listed ratio is sufficient unless the effective `image_main` slot declares `required_pixel_ratios`. SDKs determine the effective ratio from `asset.pixel_ratio` when supplied, otherwise infer it from intrinsic asset dimensions divided by the matched logical size. Width and height ratios MUST agree.', min_length=1, ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, @@ -1150,6 +1214,7 @@ class Params(CanonicalFormatBase): description="Minimum accepted width in pixels for responsive slots that adapt within a range (e.g., 'any width from 300 to 970'). Use with `max_width` (and optionally `min_height`/`max_height`). Mutually exclusive with `(width, height)` and `sizes[]`.", ge=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, @@ -1157,6 +1222,7 @@ class Params(CanonicalFormatBase): description='Maximum accepted width in pixels for responsive slots. Pair with `min_width`. See `min_width` for size-mode mutual exclusion.', ge=1, ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, @@ -1164,6 +1230,7 @@ class Params(CanonicalFormatBase): description='Minimum accepted height in pixels for responsive slots. Pair with `max_height`.', ge=1, ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, @@ -1171,6 +1238,7 @@ class Params(CanonicalFormatBase): description='Maximum accepted height in pixels for responsive slots. Pair with `min_height`.', ge=1, ), + NoExplicitNull, ] = None aspect_ratio: Annotated[ str | None, @@ -1178,48 +1246,55 @@ class Params(CanonicalFormatBase): description="Optional aspect ratio constraint (e.g., '1.91:1', '1:1'). When provided alongside `width`/`height`, must agree. When used with `sizes[]` or responsive ranges, narrows accepted entries to those matching the aspect ratio.", pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None max_file_size_kb: Annotated[ - SchemaInt | None, Field(description='Maximum file size in kilobytes.', ge=1) + SchemaInt | None, Field(description='Maximum file size in kilobytes.', ge=1), NoExplicitNull ] = None image_formats: Annotated[ - list[ImageFormat] | None, Field(description='Permitted image file formats.') + list[ImageFormat] | None, Field(description='Permitted image file formats.'), NoExplicitNull ] = None ssl_required: Annotated[ StrictBool | None, Field(description='Whether the image and its trackers must be served over HTTPS.'), + NoExplicitNull, ] = None - headline_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - body_text_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None + headline_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + body_text_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None cta_values: Annotated[ list[str] | None, Field( description="Permitted CTA values for this product (e.g., ['LEARN_MORE', 'SHOP_NOW'])." ), + NoExplicitNull, ] = None asset_source: Annotated[ AssetSource | None, Field( description="Where the rendered asset bytes come from. Single shared enum across all canonicals (`image`, `video_hosted`, `audio_hosted` — replaces the earlier per-canonical `image_source` / `video_source` / `audio_source` fields). `buyer_uploaded` (default): buyer ships a pre-rendered asset. `publisher_host_recorded`: publisher's host records the asset (audio-specific; podcast host-read pattern). `seller_pre_rendered_from_brief`: buyer ships a brief plus structured copy; seller renders ONE asset at sync_creatives or build_creative time (generative-DSP pattern). `seller_human_designed`: seller's design team renders manually from a brief. `agent_synthesized`: AI synthesis pipeline; pair with `synthesis_nondeterministic: true` when the platform cannot guarantee in-spec output (Veo/Sora/Imagen-class). `publisher_owned_reference`: buyer references an existing post or publisher-owned object via a `published_post` slot; the seller resolves and serves the referenced content after authorization/review rather than receiving uploaded bytes.\n\nNot every value is meaningful on every canonical — `publisher_host_recorded` is audio-specific; on `image` or `video_hosted` it has no defined behavior. `publisher_owned_reference` is meaningful only when the product's `slots` declaration accepts a reference asset such as `published_post`. Adopters MUST select a value appropriate to the canonical's asset type. The `slots` declaration is the binding contract for what the buyer ships; `asset_source` is informational and lets buyers understand the production model when picking products." ), + NoExplicitNull, ] = AssetSource.buyer_uploaded buyer_asset_acceptance: Annotated[ BuyerAssetAcceptance | None, Field( description="Whether the product accepts buyer-uploaded assets. When `rejected`, the buyer cannot ship pre-rendered bytes directly — they must use build_creative (or sync_creatives with brief inputs or reference assets) so the seller produces or resolves the asset. Combined with `asset_source`, lets a product declare 'I produce assets from briefs and refuse buyer uploads' (asset_source=`seller_pre_rendered_from_brief`, buyer_asset_acceptance=`rejected`) or 'I accept existing post references, not uploaded bytes' (asset_source=`publisher_owned_reference`, buyer_asset_acceptance=`rejected`)." ), + NoExplicitNull, ] = BuyerAssetAcceptance.accepted ctv_ad_experience: Annotated[ ctv_ad_experience_1.CtvAdExperience | None, Field( description='CTV experience this option serves. On `image` only `pause` and `screensaver` are valid — the image-plus-copy contract the major pause-ad sellers ingest (seller composites the frame; typical canvas 1920×1080 or a transparent-region overlay). Other experiences route per the matrix in docs/creative/ctv-experiences.mdx.' ), + NoExplicitNull, ] = None activation_methods: Annotated[ list[activation_method.CreativeActivationMethod] | None, Field( description='Viewer activation mechanisms this option offers (e.g. `qr_code` on a pause frame). Activations are engagement events, not impressions.' ), + NoExplicitNull, ] = None @@ -1247,24 +1322,28 @@ class Components1(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['image'] = 'image' params: Annotated[ @@ -1296,6 +1375,7 @@ class Params2(CanonicalFormatBase): description='Required banner width in pixels — use for fixed-size slots. For multi-size flexible slots use `sizes[]`; for responsive use `min_width`/`max_width`/`min_height`/`max_height`. Exactly one of `(width, height)`, `sizes[]`, or `min/max_width` + `min/max_height` ranges MUST be set.', ge=1, ), + NoExplicitNull, ] = None height: Annotated[ SchemaInt | None, @@ -1303,6 +1383,7 @@ class Params2(CanonicalFormatBase): description='Required banner height in pixels. See `width` for size-mode mutual exclusion.', ge=1, ), + NoExplicitNull, ] = None sizes: Annotated[ list[Size] | None, @@ -1310,6 +1391,7 @@ class Params2(CanonicalFormatBase): description='List of accepted (width, height) pairs for a multi-size flexible slot (publisher banner that accepts 300×250 OR 728×90 OR 970×250). Mirrors OpenRTB `banner.format[]`. Mutually exclusive with `(width, height)` and with responsive ranges.', min_length=1, ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, @@ -1317,6 +1399,7 @@ class Params2(CanonicalFormatBase): description='Minimum accepted width for responsive HTML5 banners that adapt within a range. Pair with `max_width`. Mutually exclusive with `(width, height)` and `sizes[]`.', ge=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, @@ -1324,6 +1407,7 @@ class Params2(CanonicalFormatBase): description='Maximum accepted width for responsive HTML5 banners. Pair with `min_width`.', ge=1, ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, @@ -1331,6 +1415,7 @@ class Params2(CanonicalFormatBase): description='Minimum accepted height for responsive HTML5 banners. Pair with `max_height`.', ge=1, ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, @@ -1338,6 +1423,7 @@ class Params2(CanonicalFormatBase): description='Maximum accepted height for responsive HTML5 banners. Pair with `min_height`.', ge=1, ), + NoExplicitNull, ] = None max_initial_load_kb: Annotated[ SchemaInt | None, @@ -1345,6 +1431,7 @@ class Params2(CanonicalFormatBase): description='Maximum initial-load file size (zip + above-the-fold assets) in kilobytes. IAB display standards: 200 KB for fixed sizes, 100 KB for mobile.', ge=1, ), + NoExplicitNull, ] = None max_polite_load_kb: Annotated[ SchemaInt | None, @@ -1352,12 +1439,14 @@ class Params2(CanonicalFormatBase): description='Maximum polite-load file size after host-initiated subload, in kilobytes. IAB display standards: 500 KB for fixed sizes.', ge=1, ), + NoExplicitNull, ] = None host_initiated_subload: Annotated[ StrictBool | None, Field( description='Whether the host page must initiate the polite-load phase. IAB-compliant banners require true.' ), + NoExplicitNull, ] = None max_animation_duration_ms: Annotated[ SchemaInt | None, @@ -1365,36 +1454,46 @@ class Params2(CanonicalFormatBase): description='Maximum total animation duration in milliseconds. IAB standard: 30000 (30 seconds).', ge=0, ), + NoExplicitNull, ] = None max_cpu_load_percent: Annotated[ SchemaInt | None, Field(description='Maximum CPU load percentage during render.', ge=1, le=100), + NoExplicitNull, ] = None mraid_required: Annotated[ StrictBool | None, Field(description='Whether MRAID compatibility is required (mobile in-app).'), + NoExplicitNull, ] = None mraid_version: Annotated[ MraidVersion | None, Field(description='Required MRAID version when mraid_required is true.'), + NoExplicitNull, ] = None om_sdk_required: Annotated[ StrictBool | None, Field(description='Whether IAB Open Measurement SDK integration is required.'), + NoExplicitNull, ] = None clicktag_macro: Annotated[ - ClicktagMacro | None, Field(description='Name of the click-tag macro the bundle must use.') + ClicktagMacro | None, + Field(description='Name of the click-tag macro the bundle must use.'), + NoExplicitNull, ] = None backup_image_required: Annotated[ StrictBool | None, Field( description='Whether a backup image must accompany the zip for non-HTML5 environments.' ), + NoExplicitNull, ] = None backup_image_max_size_kb: Annotated[ - SchemaInt | None, Field(description='Maximum backup image file size in kilobytes.', ge=1) + SchemaInt | None, + Field(description='Maximum backup image file size in kilobytes.', ge=1), + NoExplicitNull, ] = None - ssl_required: StrictBool | None = None + ssl_required: Annotated[StrictBool | None, NoExplicitNull] = None class Components2(AdCPBaseModel): @@ -1421,24 +1520,28 @@ class Components2(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['html5'] = 'html5' params: Annotated[ @@ -1469,6 +1572,7 @@ class Params3(CanonicalFormatBase): description='Required tag rendering width in pixels — use for fixed-size slots. For multi-size flexible slots use `sizes[]`; for responsive use `min_width`/`max_width`/`min_height`/`max_height`. Exactly one of `(width, height)`, `sizes[]`, or `min/max_width` + `min/max_height` ranges MUST be set.', ge=1, ), + NoExplicitNull, ] = None height: Annotated[ SchemaInt | None, @@ -1476,6 +1580,7 @@ class Params3(CanonicalFormatBase): description='Required tag rendering height in pixels. See `width` for size-mode mutual exclusion.', ge=1, ), + NoExplicitNull, ] = None sizes: Annotated[ list[Size] | None, @@ -1483,6 +1588,7 @@ class Params3(CanonicalFormatBase): description="List of accepted (width, height) pairs for a multi-size flexible slot. The buyer's third-party tag must render at one of the listed sizes; the seller picks which size to request at impression time. Mutually exclusive with `(width, height)` and with responsive ranges.", min_length=1, ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, @@ -1490,6 +1596,7 @@ class Params3(CanonicalFormatBase): description='Minimum accepted width for responsive third-party tags. Pair with `max_width`. Mutually exclusive with `(width, height)` and `sizes[]`.', ge=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, @@ -1497,6 +1604,7 @@ class Params3(CanonicalFormatBase): description='Maximum accepted width for responsive third-party tags. Pair with `min_width`.', ge=1, ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, @@ -1504,6 +1612,7 @@ class Params3(CanonicalFormatBase): description='Minimum accepted height for responsive third-party tags. Pair with `max_height`.', ge=1, ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, @@ -1511,6 +1620,7 @@ class Params3(CanonicalFormatBase): description='Maximum accepted height for responsive third-party tags. Pair with `min_height`.', ge=1, ), + NoExplicitNull, ] = None supported_tag_types: Annotated[ list[SupportedTagType] | None, @@ -1518,6 +1628,7 @@ class Params3(CanonicalFormatBase): deprecated=True, description='Deprecated ambiguous mechanism list. Use `supported_delivery_types`; markup subtype lives on the `display_tag` asset.', ), + NoExplicitNull, ] = None supported_delivery_types: Annotated[ list[SupportedDeliveryType] | None, @@ -1525,29 +1636,35 @@ class Params3(CanonicalFormatBase): description='Closed set of delivery types this format option can traffic. `paired_redirect` means one atomic ad-request/click-through pair (Internal Redirect semantics), never independently matchable URL slots.', min_length=1, ), + NoExplicitNull, ] = None ssl_required: Annotated[ - StrictBool | None, Field(description='Whether the tag URL must be HTTPS.') + StrictBool | None, Field(description='Whether the tag URL must be HTTPS.'), NoExplicitNull ] = None max_redirect_depth: Annotated[ - SchemaInt | None, Field(description='Maximum redirect chain depth permitted.', ge=0) + SchemaInt | None, + Field(description='Maximum redirect chain depth permitted.', ge=0), + NoExplicitNull, ] = None max_response_time_ms: Annotated[ SchemaInt | None, Field(description='Maximum tag-server response time in milliseconds.', ge=1), + NoExplicitNull, ] = None backup_image_required: Annotated[ StrictBool | None, Field( description='Whether a backup image must accompany the tag for environments that cannot render the third-party tag.' ), + NoExplicitNull, ] = None - backup_image_max_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None + backup_image_max_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None om_sdk_required: Annotated[ StrictBool | None, Field( description="Whether the buyer's tag must integrate IAB Open Measurement SDK for viewability." ), + NoExplicitNull, ] = None @@ -1575,24 +1692,28 @@ class Components3(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['display_tag'] = 'display_tag' params: Annotated[ @@ -1630,19 +1751,24 @@ class Params4(CanonicalFormatBase): description="Aspect ratio shared across all cards (e.g., '1:1', '1.91:1', '4:5').", pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None min_cards: Annotated[ - SchemaInt | None, Field(description='Minimum card count (typical: 2 or 3).', ge=2) + SchemaInt | None, + Field(description='Minimum card count (typical: 2 or 3).', ge=2), + NoExplicitNull, ] = None max_cards: Annotated[ SchemaInt | None, Field(description='Maximum card count (typical: 6, 10, or 35 depending on platform).'), + NoExplicitNull, ] = None allowed_card_media_asset_types: Annotated[ list[AllowedCardMediaAssetType] | None, Field( description='Asset types each card\'s `media` field may carry. Default: [\'image\']. Polymorphic carousels (Meta) allow [\'image\', \'video\']. Renamed from `allowed_card_asset_types` to disambiguate that this constrains the card\'s media payload, not the card-asset itself (which is always asset_type: "card").' ), + NoExplicitNull, ] = None allowed_card_asset_types: Annotated[ list[AllowedCardMediaAssetType] | None, @@ -1650,13 +1776,15 @@ class Params4(CanonicalFormatBase): deprecated=True, description='DEPRECATED — alias for `allowed_card_media_asset_types`. Kept for back-compat; prefer the new field name. Removed in 5.0.', ), + NoExplicitNull, ] = None - card_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None - card_video_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None - card_video_max_duration_ms: Annotated[SchemaInt | None, Field(ge=1)] = None + card_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + card_video_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + card_video_max_duration_ms: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None primary_text_max_chars: Annotated[ SchemaInt | None, Field(description='Maximum length of the carousel-level primary text.', ge=1), + NoExplicitNull, ] = None card_headline_max_chars: Annotated[ SchemaInt | None, @@ -1664,6 +1792,7 @@ class Params4(CanonicalFormatBase): description='Per-card headline character limit. Governs the `headline` field on each card-asset in the `cards` slot.', ge=1, ), + NoExplicitNull, ] = None card_description_max_chars: Annotated[ SchemaInt | None, @@ -1671,8 +1800,9 @@ class Params4(CanonicalFormatBase): description='Per-card description character limit. Governs the `description` field on each card-asset in the `cards` slot. Distinct from `card_headline_max_chars`: description is longer body copy (typically 100-500 chars); headline is the short label (typically 25-40 chars).', ge=1, ), + NoExplicitNull, ] = None - ssl_required: StrictBool | None = None + ssl_required: Annotated[StrictBool | None, NoExplicitNull] = None class Components4(AdCPBaseModel): @@ -1699,24 +1829,28 @@ class Components4(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['image_carousel'] = 'image_carousel' params: Annotated[ @@ -1751,6 +1885,7 @@ class Params5(CanonicalFormatBase): Field( description='Video orientation. Vertical = 9:16 (Reels, Stories, Shorts). Horizontal = 16:9 (instream, CTV). Square = 1:1 (in-feed).' ), + NoExplicitNull, ] = None aspect_ratio: Annotated[ str | None, @@ -1758,11 +1893,12 @@ class Params5(CanonicalFormatBase): description='Aspect ratio. Inferred from orientation if omitted.', pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None - min_width: Annotated[SchemaInt | None, Field(ge=1)] = None - min_height: Annotated[SchemaInt | None, Field(ge=1)] = None - max_width: Annotated[SchemaInt | None, Field(ge=1)] = None - max_height: Annotated[SchemaInt | None, Field(ge=1)] = None + min_width: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + min_height: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max_width: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max_height: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None duration_ms_range: Annotated[ list[DurationMsRange | None] | None, Field( @@ -1770,6 +1906,7 @@ class Params5(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None duration_ms_exact: Annotated[ SchemaInt | None, @@ -1777,45 +1914,51 @@ class Params5(CanonicalFormatBase): description='When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship (see `duration_ms_range` description).', ge=1, ), + NoExplicitNull, ] = None - video_codecs: list[VideoCodec] | None = None - audio_codecs: list[AudioCodec] | None = None - containers: list[Container] | None = None - min_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1)] = None - max_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1)] = None + video_codecs: Annotated[list[VideoCodec] | None, NoExplicitNull] = None + audio_codecs: Annotated[list[AudioCodec] | None, NoExplicitNull] = None + containers: Annotated[list[Container] | None, NoExplicitNull] = None + min_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None max_file_size_mb: Annotated[ SchemaInt | None, Field(description='Maximum file size, where 1 MB is exactly 1,000,000 bytes.', ge=1), - ] = None - frame_rates: list[StrictFloat] | None = None - captions: Captions | None = None - om_sdk_required: StrictBool | None = None - headline_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - primary_text_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - brand_name_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - cta_values: list[str] | None = None + NoExplicitNull, + ] = None + frame_rates: Annotated[list[StrictFloat] | None, NoExplicitNull] = None + captions: Annotated[Captions | None, NoExplicitNull] = None + om_sdk_required: Annotated[StrictBool | None, NoExplicitNull] = None + headline_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + primary_text_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + brand_name_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + cta_values: Annotated[list[str] | None, NoExplicitNull] = None companion_banner_widths: Annotated[ list[CompanionBannerWidth] | None, Field(description='Permitted companion banner widths (instream video).'), + NoExplicitNull, ] = None - companion_banner_heights: list[CompanionBannerHeight] | None = None + companion_banner_heights: Annotated[list[CompanionBannerHeight] | None, NoExplicitNull] = None asset_source: Annotated[ AssetSource | None, Field( description='Where the rendered asset bytes come from. Single shared enum across canonicals. See `image.json#asset_source` for the full semantics. `publisher_host_recorded` is audio-specific and has no defined behavior on video. `publisher_owned_reference` is valid when the product accepts an existing post reference via a `published_post` slot instead of uploaded video bytes. Adopters MUST select a value appropriate to the canonical.' ), + NoExplicitNull, ] = AssetSource.buyer_uploaded buyer_asset_acceptance: Annotated[ BuyerAssetAcceptance | None, Field( description='Whether the product accepts buyer-uploaded video. When `rejected`, the buyer cannot ship a video asset directly — they must use build_creative, sync_creatives with brief inputs, or sync_creatives with an accepted reference asset so the seller produces or resolves the video.' ), + NoExplicitNull, ] = BuyerAssetAcceptance.accepted ctv_ad_experience: Annotated[ ctv_ad_experience_1.CtvAdExperience | None, Field( description='CTV experience this option serves. On `video_hosted` only `screensaver` is valid (ambient looping video the platform plays on idle). Other experiences route per the matrix in docs/creative/ctv-experiences.mdx; linear CTV video declares no experience.' ), + NoExplicitNull, ] = None @@ -1843,24 +1986,28 @@ class Components5(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['video_hosted'] = 'video_hosted' params: Annotated[ @@ -1885,9 +2032,9 @@ class Params6(CanonicalFormatBase): {'asset_group_id': 'vast_tag', 'asset_type': 'vast', 'required': True}, {'asset_group_id': 'landing_page_url', 'asset_type': 'url', 'required': False}, ] - orientation: Orientation | None = None + orientation: Annotated[Orientation | None, NoExplicitNull] = None aspect_ratio: Annotated[ - str | None, Field(pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$') + str | None, Field(pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$'), NoExplicitNull ] = None vast_versions: Annotated[ list[vast_version_1.VastVersion] | None, @@ -1895,6 +2042,7 @@ class Params6(CanonicalFormatBase): description='VAST versions accepted by this product format option. The asset still declares exactly one `vast_version`; compatibility requires membership in this set and the seller-wide execution set.', min_length=1, ), + NoExplicitNull, ] = None vast_version: Annotated[ vast_version_1.VastVersion | None, @@ -1902,25 +2050,29 @@ class Params6(CanonicalFormatBase): deprecated=True, description='Deprecated one-element alias for `vast_versions`. Producers use either the singular legacy alias or the plural 3.2 field, never both.', ), + NoExplicitNull, ] = None media_file_requirements: Annotated[ vast_media_file_requirements.VastMediafileRequirements | None, Field( description='Technical acceptance constraints for alternative VAST MediaFile renditions. Each applicable resolved InLine linear creative needs at least one MediaFile satisfying all declared constraints.' ), + NoExplicitNull, ] = None vpaid_enabled: Annotated[ StrictBool | None, Field( description='Whether VPAID interactivity is supported. When true, the VAST tag may carry VPAID JS/Flash payloads.' ), + NoExplicitNull, ] = None - vpaid_version: VpaidVersion | None = None + vpaid_version: Annotated[VpaidVersion | None, NoExplicitNull] = None simid_supported: Annotated[ StrictBool | None, Field( description='Whether the seller accepts IAB SIMID through `` on a Linear VAST creative. SIMID is not a generic VAST extension and cannot be serialized under NonLinearAds; every `ctv_ad_experience` profile therefore forbids `true`.' ), + NoExplicitNull, ] = None duration_ms_range: Annotated[ list[DurationMsRangeItem] | None, @@ -1929,6 +2081,7 @@ class Params6(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None duration_ms_exact: Annotated[ SchemaInt | None, @@ -1936,6 +2089,7 @@ class Params6(CanonicalFormatBase): description='When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship.', ge=1, ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, @@ -1943,6 +2097,7 @@ class Params6(CanonicalFormatBase): description='Minimum placement/player width in pixels. MediaFile rendition dimensions are declared in `media_file_requirements`.', ge=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, @@ -1950,6 +2105,7 @@ class Params6(CanonicalFormatBase): description='Maximum placement/player width in pixels. MediaFile rendition dimensions are declared in `media_file_requirements`.', ge=1, ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, @@ -1957,6 +2113,7 @@ class Params6(CanonicalFormatBase): description='Minimum placement/player height in pixels. MediaFile rendition dimensions are declared in `media_file_requirements`.', ge=1, ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, @@ -1964,34 +2121,40 @@ class Params6(CanonicalFormatBase): description='Maximum placement/player height in pixels. MediaFile rendition dimensions are declared in `media_file_requirements`.', ge=1, ), + NoExplicitNull, ] = None creative_type: Annotated[ CreativeType | None, Field( description='Required VAST creative class: `linear` (in-stream Linear), `nonlinear` (NonLinearAds overlay-class), or `either`. Supersedes `linear_required`; when both are present `creative_type` wins, and validators treat `linear_required: true` with no `creative_type` as `linear`.' ), + NoExplicitNull, ] = None ctv_ad_experience: Annotated[ ctv_ad_experience_1.CtvAdExperience | None, Field( description='CTV experience this option is eligible to serve. On video_vast only `pause`, `screensaver`, `overlay`, `squeezeback`, and `in_scene` are valid (`menu` routes to native_in_feed or sponsored_placement), and `creative_type` MUST be `nonlinear`. Because VAST places `` only under Linear ``, `simid_supported` MUST NOT be true on any of these NonLinear profiles. Per-experience floors: `overlay` and `squeezeback` require a 10s minimum duration; `in_scene` requires a 3s minimum brand-exposure duration and forbids interactivity (`vpaid_enabled` MUST NOT be true); `pause` has no duration floor and ends on viewer or device action.' ), + NoExplicitNull, ] = None motion_level: Annotated[ motion_level_1.CreativeMotionLevel | None, Field(description='Accepted motion class for the rendered creative (AdCOM attrs 21-23).'), + NoExplicitNull, ] = None activation_methods: Annotated[ list[activation_method.CreativeActivationMethod] | None, Field( description='Viewer activation mechanisms this option offers. Activations are engagement events, not impressions.' ), + NoExplicitNull, ] = None linear_required: Annotated[ StrictBool | None, Field( description='Whether the VAST creative must be linear (non-skippable in-stream). Superseded by `creative_type`; retained for pre-3.2 declarations.' ), + NoExplicitNull, ] = None skippable_after_ms: Annotated[ SchemaInt | None, @@ -1999,11 +2162,14 @@ class Params6(CanonicalFormatBase): description='When skippable, the buyer-side skip threshold in milliseconds (e.g., 5000 for 5-second skippable pre-roll).', ge=0, ), + NoExplicitNull, ] = None max_wrapper_depth: Annotated[ - SchemaInt | None, Field(description='Maximum VAST wrapper redirect depth permitted.', ge=0) + SchemaInt | None, + Field(description='Maximum VAST wrapper redirect depth permitted.', ge=0), + NoExplicitNull, ] = None - ssl_required: StrictBool | None = None + ssl_required: Annotated[StrictBool | None, NoExplicitNull] = None class Components6(AdCPBaseModel): @@ -2030,24 +2196,28 @@ class Components6(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['video_vast'] = 'video_vast' params: Annotated[ @@ -2081,6 +2251,7 @@ class Params7(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None duration_ms_exact: Annotated[ SchemaInt | None, @@ -2088,48 +2259,58 @@ class Params7(CanonicalFormatBase): description='When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship.', ge=1, ), + NoExplicitNull, ] = None - audio_codecs: list[AudioCodec2] | None = None - audio_sample_rates: list[AudioSampleRate] | None = None - audio_channels: list[AudioChannel] | None = None - min_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1)] = None - max_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1)] = None + audio_codecs: Annotated[list[AudioCodec2] | None, NoExplicitNull] = None + audio_sample_rates: Annotated[list[AudioSampleRate] | None, NoExplicitNull] = None + audio_channels: Annotated[list[AudioChannel] | None, NoExplicitNull] = None + min_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None max_file_size_mb: Annotated[ StrictFloat | None, Field( description='Maximum hosted audio file size in decimal megabytes. Agents that proxy or cache media SHOULD advertise their effective transport ceiling here.', gt=0.0, ), + NoExplicitNull, ] = None loudness_lufs: Annotated[ StrictFloat | None, Field( description='Required integrated loudness in LUFS (typical: -16 for streaming/podcast, -23 for broadcast). Negative values.' ), + NoExplicitNull, ] = None loudness_tolerance_db: Annotated[ StrictFloat | None, Field(description='Permitted deviation from loudness_lufs in dB.', ge=0.0), + NoExplicitNull, ] = None true_peak_dbfs: Annotated[ - StrictFloat | None, Field(description='Maximum true-peak level in dBFS (typical: -2).') + StrictFloat | None, + Field(description='Maximum true-peak level in dBFS (typical: -2).'), + NoExplicitNull, ] = None asset_source: Annotated[ AssetSource | None, Field( description="Where the rendered audio bytes come from. Single shared enum across canonicals (see `image.json#asset_source` for the full semantics). `publisher_host_recorded`: the publisher's host records the audio (podcast host-read pattern); buyer must use the publisher's build_creative capability. `publisher_owned_reference` is valid only when the product accepts a reference asset whose publisher-owned source resolves to playable audio. `publisher_host_recorded` remains the normal audio-specific host-read value." ), + NoExplicitNull, ] = AssetSource.buyer_uploaded buyer_asset_acceptance: Annotated[ BuyerAssetAcceptance | None, Field( description="Whether the product accepts buyer-uploaded audio. When `rejected`, the buyer cannot ship an audio asset directly — they must use build_creative (or sync_creatives with brief inputs) so the seller produces the audio. Combined with `asset_source`, lets a product declare 'I produce audio from briefs and refuse buyer uploads' (asset_source=`seller_pre_rendered_from_brief`, buyer_asset_acceptance=`rejected`)." ), + NoExplicitNull, ] = BuyerAssetAcceptance.accepted - companion_image_required: StrictBool | None = None - companion_image_aspect_ratio: str | None = None - companion_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None - brand_name_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None + companion_image_required: Annotated[StrictBool | None, NoExplicitNull] = None + companion_image_aspect_ratio: Annotated[str | None, NoExplicitNull] = None + companion_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = ( + None + ) + brand_name_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None class Components7(AdCPBaseModel): @@ -2156,24 +2337,28 @@ class Components7(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_hosted'] = 'audio_hosted' params: Annotated[ @@ -2209,46 +2394,57 @@ class Params9(CanonicalFormatBase): supported_catalog_types: Annotated[ list[catalog_type.CatalogType] | None, Field(description='Catalog types this product accepts.'), + NoExplicitNull, ] = None min_items: Annotated[ - SchemaInt | None, Field(description='Minimum catalog item count buyer must supply.', ge=1) + SchemaInt | None, + Field(description='Minimum catalog item count buyer must supply.', ge=1), + NoExplicitNull, ] = None max_items: Annotated[ - SchemaInt | None, Field(description='Maximum items considered for placement.') + SchemaInt | None, + Field(description='Maximum items considered for placement.'), + NoExplicitNull, ] = None fanout_mode: Annotated[ FanoutMode | None, Field( description='How items map to delivery: per_item = one ad per catalog item; multi_item_in_creative = composed multi-item ad (Pinterest Collection, Snap Collection); single_item = one ad showing one item.' ), + NoExplicitNull, ] = None required_catalog_fields: Annotated[ list[str] | None, Field( description="Catalog item fields the seller requires (e.g., ['title', 'image_url', 'price'])." ), + NoExplicitNull, ] = None supported_id_types: Annotated[ list[SupportedIdType] | None, Field(description='Catalog identifier types the placement renders against.'), + NoExplicitNull, ] = None hero_asset_supported: Annotated[ StrictBool | None, Field( description='Whether the buyer can supply a hero/banner asset alongside the catalog (Pinterest Collection pattern).' ), + NoExplicitNull, ] = None item_production_model: Annotated[ ItemProductionModel | None, Field( description='How each per-item creative is produced. Covers the same production-source axis as `asset_source` on `image` / `video_hosted` / `audio_hosted` but with a 4-value subset — drops `publisher_host_recorded` because it\'s audio-specific and doesn\'t apply to retail-media catalog placements. SDK codegen MAY share a base enum and narrow per-canonical, or emit two distinct enums; either way the wire values overlap exactly for the 4 retained values. `buyer_uploaded` (default, current Amazon/Criteo/CitrusAd pattern): the buyer\'s catalog already contains rendered assets per item; the seller composes the placement using those assets. ("Uploaded" reads slightly off for catalog-keyed items where the buyer didn\'t actively upload bytes — the catalog ingestion already supplied them — but the semantic is the same: rendered bytes are buyer-supplied, not seller-produced.) `seller_pre_rendered_from_brief`: the buyer ships a brief plus the catalog reference; the seller renders one creative per catalog item from the brief at sync_creatives time. `seller_human_designed`: seller\'s design team produces per-item renders manually. `agent_synthesized`: AI synthesis pipeline produces per-item renders; pair with `synthesis_nondeterministic: true` for Veo/Sora-class generative video applied per item. Captures the multi-output generative pattern (1 brief × N catalog items → N rendered creatives) under the existing canonical without requiring a separate canonical. Distinct from `fanout_mode`, which describes how items map to delivery slots after rendering.' ), + NoExplicitNull, ] = ItemProductionModel.buyer_uploaded ctv_ad_experience: Annotated[ ctv_ad_experience_1.CtvAdExperience | None, Field( description='CTV experience this option serves. On `sponsored_placement`: `menu` (sponsored app/content tiles and rows whose assets derive from a catalog listing — Fire-TV-tile pattern), `squeezeback`, and `in_scene` (seller-composited brand integrations produced from the catalog/brief rather than a buyer wire creative). Asset-bundle menu heroes route to `native_in_feed`.' ), + NoExplicitNull, ] = None @@ -2276,24 +2472,28 @@ class Components9(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['sponsored_placement'] = 'sponsored_placement' params: Annotated[ @@ -2348,28 +2548,33 @@ class Params10(CanonicalFormatBase): Field( description='CTV experience this option serves. On native_in_feed `menu` and `overlay` are valid. `menu`: smart-TV home/menu surfaces where the platform assembles buyer assets (background/main image, logo/icon, copy, optional focus-triggered video); `menu_placement` selects the tile vs headline-banner variant, and catalog-derived sponsored tiles route to `sponsored_placement` instead. `overlay`: seller-composited in-stream overlays supplied as an asset bundle (video, logo, imagery, copy, activation copy) — the contract overlay sellers that do not ingest VAST tags use; VAST-ingesting sellers publish a `video_vast` sibling option instead. The wire name stays `native_in_feed` for 3.x even though neither surface is literally in-feed.' ), + NoExplicitNull, ] = None menu_placement: Annotated[ MenuPlacement | None, Field( description='Menu surface variant, mapping to OpenRTB Native `plcmttype` 1 (tile/feed) and 3 (headline banner). Valid only with `ctv_ad_experience: "menu"`.' ), + NoExplicitNull, ] = None focus_behavior: Annotated[ FocusBehavior | None, Field( description='What happens when the viewer\'s remote focus lands on the unit. `autoplay_*` requires a `video` asset; playback method maps to AdCOM playbackmethod on OpenRTB bridges. Valid only with `ctv_ad_experience: "menu"`.' ), + NoExplicitNull, ] = None motion_level: Annotated[ motion_level_1.CreativeMotionLevel | None, Field(description='Accepted motion class for the rendered unit (AdCOM attrs 21-23).'), + NoExplicitNull, ] = None activation_methods: Annotated[ list[activation_method.CreativeActivationMethod] | None, Field( description='Viewer activation mechanisms this option offers (QR, deep link, send-to-device). Activations are engagement events, not impressions.' ), + NoExplicitNull, ] = None title_max_chars: Annotated[ SchemaInt | None, @@ -2377,6 +2582,7 @@ class Params10(CanonicalFormatBase): description='Maximum character length for the title slot. IAB native typical: 25 (short) to 90 (long). Buyer agents SHOULD validate ship-time title length against this.', ge=1, ), + NoExplicitNull, ] = None body_text_max_chars: Annotated[ SchemaInt | None, @@ -2384,16 +2590,19 @@ class Params10(CanonicalFormatBase): description='Maximum character length for the body_text slot. IAB native typical: 90 (mainline) to 140 (extended).', ge=1, ), + NoExplicitNull, ] = None cta_max_chars: Annotated[ SchemaInt | None, Field(description='Maximum character length for the cta slot. Typical: 15–25.', ge=1), + NoExplicitNull, ] = None cta_values: Annotated[ list[str] | None, Field( description="Permitted CTA values for this product (e.g., ['LEARN_MORE', 'SHOP_NOW', 'SIGN_UP', 'DOWNLOAD']). When set, narrows the cta slot to a closed enum." ), + NoExplicitNull, ] = None main_image_sizes: Annotated[ list[MainImageSize] | None, @@ -2401,37 +2610,45 @@ class Params10(CanonicalFormatBase): description='Accepted logical (width, height) pairs for the main_image slot. Common IAB native sizes: 1200×627 (1.91:1), 1080×1080 (1:1), 1080×1350 (4:5). When the effective main_image slot declares `pixel_ratios`, intrinsic asset dimensions are the matched logical pair multiplied by the selected ratio; absence remains 1x.', min_length=1, ), + NoExplicitNull, ] = None icon_size: Annotated[ IconSize | None, Field( description='Required logical (width, height) for the icon slot when present (typical: 80×80 or 100×100). When the effective icon slot declares `pixel_ratios`, intrinsic dimensions are multiplied by the selected ratio; absence remains 1x.' ), + NoExplicitNull, ] = None max_image_file_size_kb: Annotated[ SchemaInt | None, Field(description='Maximum file size in kilobytes for main_image and icon.', ge=1), + NoExplicitNull, ] = None image_formats: Annotated[ - list[ImageFormat1] | None, Field(description='Permitted image file formats.') + list[ImageFormat1] | None, + Field(description='Permitted image file formats.'), + NoExplicitNull, ] = None ssl_required: Annotated[ StrictBool | None, Field( description='Whether trackers, landing pages, and image URLs must be served over HTTPS.' ), + NoExplicitNull, ] = None asset_source: Annotated[ AssetSource5 | None, Field( description="Where the rendered native assets come from. `publisher_host_recorded` is omitted (audio-specific and not meaningful for native). Other values mirror the shared production-source axis used on `image` / `video_hosted`. `buyer_uploaded` (default): buyer ships pre-rendered title/image/body. `seller_pre_rendered_from_brief`: buyer ships a brief, seller renders the native bundle. `agent_synthesized`: AI synthesis pipeline produces title + image + body from a brief; pair with `synthesis_nondeterministic: true` for generative pipelines that can't guarantee in-spec output. `publisher_owned_reference`: buyer ships an existing published post reference; the seller resolves the post into the native presentation after authorization/review." ), + NoExplicitNull, ] = AssetSource5.buyer_uploaded buyer_asset_acceptance: Annotated[ BuyerAssetAcceptance | None, Field( description='Whether the product accepts buyer-uploaded native assets. When `rejected`, the buyer cannot ship pre-rendered title/image/body — they must use `build_creative`, `sync_creatives` with brief inputs, or an accepted `published_post` reference so the seller produces or resolves the native bundle.' ), + NoExplicitNull, ] = BuyerAssetAcceptance.accepted @@ -2459,24 +2676,28 @@ class Components10(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['native_in_feed'] = 'native_in_feed' params: Annotated[ @@ -2570,36 +2791,37 @@ class Params11(CanonicalFormatBase): 'max': 1, }, ] - headlines_min: Annotated[SchemaInt | None, Field(ge=0)] = None - headlines_max: Annotated[SchemaInt | None, Field(ge=0)] = None - headline_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - long_headlines_min: Annotated[SchemaInt | None, Field(ge=0)] = None - long_headlines_max: Annotated[SchemaInt | None, Field(ge=0)] = None - long_headline_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - descriptions_min: Annotated[SchemaInt | None, Field(ge=0)] = None - descriptions_max: Annotated[SchemaInt | None, Field(ge=0)] = None - description_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - images_landscape_min: Annotated[SchemaInt | None, Field(ge=0)] = None - images_landscape_max: Annotated[SchemaInt | None, Field(ge=0)] = None - images_landscape_aspect_ratio: str | None = None - images_square_min: Annotated[SchemaInt | None, Field(ge=0)] = None - images_square_max: Annotated[SchemaInt | None, Field(ge=0)] = None - images_vertical_min: Annotated[SchemaInt | None, Field(ge=0)] = None - images_vertical_max: Annotated[SchemaInt | None, Field(ge=0)] = None - videos_min: Annotated[SchemaInt | None, Field(ge=0)] = None - videos_max: Annotated[SchemaInt | None, Field(ge=0)] = None - video_min_duration_ms: Annotated[SchemaInt | None, Field(ge=1)] = None - video_max_duration_ms: Annotated[SchemaInt | None, Field(ge=1)] = None - logo_min: Annotated[SchemaInt | None, Field(ge=0)] = None - logo_max: Annotated[SchemaInt | None, Field(ge=0)] = None - logo_aspect_ratios: list[str] | None = None - business_name_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - asset_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None + headlines_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + headlines_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + headline_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + long_headlines_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + long_headlines_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + long_headline_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + descriptions_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + descriptions_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + description_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + images_landscape_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_landscape_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_landscape_aspect_ratio: Annotated[str | None, NoExplicitNull] = None + images_square_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_square_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_vertical_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_vertical_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + videos_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + videos_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + video_min_duration_ms: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + video_max_duration_ms: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + logo_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + logo_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + logo_aspect_ratios: Annotated[list[str] | None, NoExplicitNull] = None + business_name_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + asset_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None supports_catalog_input: Annotated[ StrictBool | None, Field( description='Whether the product can additionally consume a catalog reference (e.g., PMax with product feed).' ), + NoExplicitNull, ] = None @@ -2627,24 +2849,28 @@ class Components11(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['responsive_creative'] = 'responsive_creative' params: Annotated[ @@ -2686,6 +2912,7 @@ class Params12(CanonicalFormatBase): Field( description='How the surface presents the mention. `text` = inline text (chat, search snippet). `audio` = TTS-synthesized voice. `card` = structured card with optional image + text.' ), + NoExplicitNull, ] = None max_mention_length_chars: Annotated[ SchemaInt | None, @@ -2693,6 +2920,7 @@ class Params12(CanonicalFormatBase): description='For text output: maximum length of the surface-composed mention text.', ge=1, ), + NoExplicitNull, ] = None max_mention_duration_ms: Annotated[ SchemaInt | None, @@ -2700,30 +2928,35 @@ class Params12(CanonicalFormatBase): description='For audio output: maximum duration of the spoken mention in milliseconds.', ge=1, ), + NoExplicitNull, ] = None supports_offering_reference: Annotated[ StrictBool | None, Field( description='Whether the product accepts an offering reference (specific product/service to promote within the mention) in addition to brand context.' ), + NoExplicitNull, ] = None supports_landing_page_url: Annotated[ StrictBool | None, Field( description='Whether the surface attaches a landing page URL to the mention (citation, learn-more link).' ), + NoExplicitNull, ] = None tone_constraints: Annotated[ list[str] | None, Field( description="**Advisory only.** Buyer-declared brand-voice preferences the surface SHOULD honor (e.g., ['formal', 'no_superlatives']). LLM/agentic surfaces have no protocol-level mechanism to verify enforcement — adopters that need hard guarantees should rely on brand.json voice declarations and post-mention review rather than this field. Future revisions may tie this to a structured tone vocabulary; for now treat as free-text guidance." ), + NoExplicitNull, ] = None disclosure_required: Annotated[ StrictBool | None, Field( description='Whether the surface must include an explicit sponsorship disclosure label.' ), + NoExplicitNull, ] = None @@ -2751,24 +2984,28 @@ class Components12(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['agent_placement'] = 'agent_placement' params: Annotated[ @@ -2803,6 +3040,7 @@ class Params13(CanonicalFormatBase): Field( description='Which end of the template contract the buyer feeds. `components`: buyer supplies component slots; seller renders states (no `state_canvases`/`layered_source` assets allowed). `rendered_canvases`: buyer supplies exactly one `state_canvases` image per declared state × breakpoint pair. `layered_source`: buyer ships design source (+ optional `font_files`); seller production derives states (`production_window_business_days` applies) — transitional for sellers without executable templates.' ), + NoExplicitNull, ] = SupplyMode.components slots: Annotated[ Any | None, @@ -2842,6 +3080,7 @@ class Params13(CanonicalFormatBase): Field( description='How the unit enters view, distinct from state changes. `clip_window`: canvas fixed and progressively exposed through a scrolling window (interscroller, topscroll). `scroll_parallax`: canvas moves at a different rate than content. Reveal is presentation of one canvas, not a transition; do not fabricate a second state to express it.' ), + NoExplicitNull, ] = Reveal.none transitions: Annotated[ list[Transitions | Transitions1 | Transitions2 | Transitions3 | Transitions4 | Transitions5] @@ -2850,12 +3089,14 @@ class Params13(CanonicalFormatBase): description='Bounded seller-rendered transitions between declared visual states. Required when `states` has more than one entry; MUST be omitted for single-state units. Every non-initial state MUST be reachable from `initial_state_id`. Dismissal is terminal unit behavior declared by `user_controls.dismissible`, not a hidden visual state.', min_length=1, ), + NoExplicitNull, ] = None clickthrough: Annotated[ Clickthrough | None, Field( description='Destination policy. `required` (default): manifest MUST supply `landing_page_url`. `optional`: click-optional units (in-feed brand units) may omit it. `none`: unit is non-clickable; manifests MUST NOT supply `landing_page_url` or `state_click_urls`. Per-state overrides via `state_click_urls` entries carrying `state_id`; `landing_page_url` is the fallback for unlisted states.' ), + NoExplicitNull, ] = Clickthrough.required user_controls: Annotated[ UserControls, @@ -2868,6 +3109,7 @@ class Params13(CanonicalFormatBase): Field( description='Rectangular areas constraining buyer artwork. Omitted state/breakpoint selectors apply the constraint to every canvas. For fluid or range-sized breakpoints, use percent-unit regions.' ), + NoExplicitNull, ] = None duration_ms_range: Annotated[ list[DurationMsRange | None] | None, @@ -2876,30 +3118,34 @@ class Params13(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None - duration_ms_exact: Annotated[SchemaInt | None, Field(ge=1)] = None + duration_ms_exact: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None aspect_ratio: Annotated[ str | None, Field( description='Embedded-video aspect ratio.', pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None - containers: list[Container] | None = None - video_playback: VideoPlayback | None = None - max_initial_load_kb: Annotated[SchemaInt | None, Field(ge=1)] = None + containers: Annotated[list[Container] | None, NoExplicitNull] = None + video_playback: Annotated[VideoPlayback | None, NoExplicitNull] = None + max_initial_load_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None max_subload_kb: Annotated[ SchemaInt | None, Field( description='Ceiling on assets loaded after the window load event (IAB LEAN subload). Pairs with `max_initial_load_kb` to mirror the New Ad Portfolio initial/subload weight pair.', ge=1, ), + NoExplicitNull, ] = None polite_load: Annotated[ StrictBool | None, Field( description="When true, non-initial assets load only after the host page's window load event (IAB LEAN subload boundary)." ), + NoExplicitNull, ] = None @@ -2927,24 +3173,28 @@ class Components13(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['seller_rendered_stateful_display'] = 'seller_rendered_stateful_display' params: Annotated[ @@ -2975,12 +3225,14 @@ class Params8(CanonicalFormatBase): deprecated=True, description='Deprecated one-element alias for `daast_versions`. Producers use either the singular legacy alias or the plural 3.2 field, never both.', ), + NoExplicitNull, ] = None daast_versions: Annotated[ daast_tracker_constraints.DaastVersions | None, Field( description="Accepted DAAST versions for this format option. A tracker execution selector's daast_versions must be a nonempty subset of this set." ), + NoExplicitNull, ] = None duration_ms_range: Annotated[ list[DurationMsRangeItem] | None, @@ -2989,6 +3241,7 @@ class Params8(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None duration_ms_exact: Annotated[ SchemaInt | None, @@ -2996,11 +3249,12 @@ class Params8(CanonicalFormatBase): description='When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship.', ge=1, ), + NoExplicitNull, ] = None - linear_required: StrictBool | None = None - max_wrapper_depth: Annotated[SchemaInt | None, Field(ge=0)] = None - ssl_required: StrictBool | None = None - companion_image_required: StrictBool | None = None + linear_required: Annotated[StrictBool | None, NoExplicitNull] = None + max_wrapper_depth: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + ssl_required: Annotated[StrictBool | None, NoExplicitNull] = None + companion_image_required: Annotated[StrictBool | None, NoExplicitNull] = None class Components8(AdCPBaseModel): @@ -3027,24 +3281,28 @@ class Components8(AdCPBaseModel): description='Declared presentation order for sequential-messaging coordinated buys. Components sharing a sequence value present simultaneously; absent means unordered/simultaneous (default). Sequence declares seller-rendered ordering, not buyer-controlled timing.', ge=1, ), + NoExplicitNull, ] = None serving_policy: Annotated[ ServingPolicy | None, Field( description='Per-component serving/tracking policy. Some sellers restrict specific components (e.g., page skins) to first-party serving while allowing third-party tags on sibling components. Defaults to the product-level policy when absent.' ), + NoExplicitNull, ] = None canvas_constraints: Annotated[ list[canvas_constraint.CanvasConstraint] | None, Field( description='Artwork constraints applied to this component, including safe areas and seller-reserved regions.' ), + NoExplicitNull, ] = None format_option_ref: Annotated[ format_option_ref_1.FormatOptionReference | None, Field( description='Reference to a sibling `format_options[]` entry on the same product. It MUST NOT resolve to `custom` or `coordinated_placements`.' ), + NoExplicitNull, ] = None format_kind: Literal['audio_daast'] = 'audio_daast' params: Annotated[ @@ -3098,4 +3356,5 @@ class CanonicalFormatCoordinatedPlacements(CanonicalFormatBase): Field( description='Manifest slots supplied once and consumed by one or more coordinated components.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/display_tag.py b/src/adcp/types/domains/formats/canonical/display_tag.py index cd4079799..ea9ef9a45 100644 --- a/src/adcp/types/domains/formats/canonical/display_tag.py +++ b/src/adcp/types/domains/formats/canonical/display_tag.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/display_tag.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ._base import CanonicalFormatBase @@ -52,6 +52,7 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): description='Required tag rendering width in pixels — use for fixed-size slots. For multi-size flexible slots use `sizes[]`; for responsive use `min_width`/`max_width`/`min_height`/`max_height`. Exactly one of `(width, height)`, `sizes[]`, or `min/max_width` + `min/max_height` ranges MUST be set.', ge=1, ), + NoExplicitNull, ] = None height: Annotated[ SchemaInt | None, @@ -59,6 +60,7 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): description='Required tag rendering height in pixels. See `width` for size-mode mutual exclusion.', ge=1, ), + NoExplicitNull, ] = None sizes: Annotated[ list[Size] | None, @@ -66,6 +68,7 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): description="List of accepted (width, height) pairs for a multi-size flexible slot. The buyer's third-party tag must render at one of the listed sizes; the seller picks which size to request at impression time. Mutually exclusive with `(width, height)` and with responsive ranges.", min_length=1, ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, @@ -73,6 +76,7 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): description='Minimum accepted width for responsive third-party tags. Pair with `max_width`. Mutually exclusive with `(width, height)` and `sizes[]`.', ge=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, @@ -80,6 +84,7 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): description='Maximum accepted width for responsive third-party tags. Pair with `min_width`.', ge=1, ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, @@ -87,6 +92,7 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): description='Minimum accepted height for responsive third-party tags. Pair with `max_height`.', ge=1, ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, @@ -94,6 +100,7 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): description='Maximum accepted height for responsive third-party tags. Pair with `min_height`.', ge=1, ), + NoExplicitNull, ] = None supported_tag_types: Annotated[ list[SupportedTagType] | None, @@ -101,6 +108,7 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): deprecated=True, description='Deprecated ambiguous mechanism list. Use `supported_delivery_types`; markup subtype lives on the `display_tag` asset.', ), + NoExplicitNull, ] = None supported_delivery_types: Annotated[ list[SupportedDeliveryType] | None, @@ -108,27 +116,33 @@ class CanonicalFormatDisplayTag(CanonicalFormatBase): description='Closed set of delivery types this format option can traffic. `paired_redirect` means one atomic ad-request/click-through pair (Internal Redirect semantics), never independently matchable URL slots.', min_length=1, ), + NoExplicitNull, ] = None ssl_required: Annotated[ - StrictBool | None, Field(description='Whether the tag URL must be HTTPS.') + StrictBool | None, Field(description='Whether the tag URL must be HTTPS.'), NoExplicitNull ] = None max_redirect_depth: Annotated[ - SchemaInt | None, Field(description='Maximum redirect chain depth permitted.', ge=0) + SchemaInt | None, + Field(description='Maximum redirect chain depth permitted.', ge=0), + NoExplicitNull, ] = None max_response_time_ms: Annotated[ SchemaInt | None, Field(description='Maximum tag-server response time in milliseconds.', ge=1), + NoExplicitNull, ] = None backup_image_required: Annotated[ StrictBool | None, Field( description='Whether a backup image must accompany the tag for environments that cannot render the third-party tag.' ), + NoExplicitNull, ] = None - backup_image_max_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None + backup_image_max_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None om_sdk_required: Annotated[ StrictBool | None, Field( description="Whether the buyer's tag must integrate IAB Open Measurement SDK for viewability." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/html5.py b/src/adcp/types/domains/formats/canonical/html5.py index 055fb170c..dbfb2aa14 100644 --- a/src/adcp/types/domains/formats/canonical/html5.py +++ b/src/adcp/types/domains/formats/canonical/html5.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/html5.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ._base import CanonicalFormatBase @@ -51,6 +51,7 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='Required banner width in pixels — use for fixed-size slots. For multi-size flexible slots use `sizes[]`; for responsive use `min_width`/`max_width`/`min_height`/`max_height`. Exactly one of `(width, height)`, `sizes[]`, or `min/max_width` + `min/max_height` ranges MUST be set.', ge=1, ), + NoExplicitNull, ] = None height: Annotated[ SchemaInt | None, @@ -58,6 +59,7 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='Required banner height in pixels. See `width` for size-mode mutual exclusion.', ge=1, ), + NoExplicitNull, ] = None sizes: Annotated[ list[Size] | None, @@ -65,6 +67,7 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='List of accepted (width, height) pairs for a multi-size flexible slot (publisher banner that accepts 300×250 OR 728×90 OR 970×250). Mirrors OpenRTB `banner.format[]`. Mutually exclusive with `(width, height)` and with responsive ranges.', min_length=1, ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, @@ -72,6 +75,7 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='Minimum accepted width for responsive HTML5 banners that adapt within a range. Pair with `max_width`. Mutually exclusive with `(width, height)` and `sizes[]`.', ge=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, @@ -79,6 +83,7 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='Maximum accepted width for responsive HTML5 banners. Pair with `min_width`.', ge=1, ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, @@ -86,6 +91,7 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='Minimum accepted height for responsive HTML5 banners. Pair with `max_height`.', ge=1, ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, @@ -93,6 +99,7 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='Maximum accepted height for responsive HTML5 banners. Pair with `min_height`.', ge=1, ), + NoExplicitNull, ] = None max_initial_load_kb: Annotated[ SchemaInt | None, @@ -100,6 +107,7 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='Maximum initial-load file size (zip + above-the-fold assets) in kilobytes. IAB display standards: 200 KB for fixed sizes, 100 KB for mobile.', ge=1, ), + NoExplicitNull, ] = None max_polite_load_kb: Annotated[ SchemaInt | None, @@ -107,12 +115,14 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='Maximum polite-load file size after host-initiated subload, in kilobytes. IAB display standards: 500 KB for fixed sizes.', ge=1, ), + NoExplicitNull, ] = None host_initiated_subload: Annotated[ StrictBool | None, Field( description='Whether the host page must initiate the polite-load phase. IAB-compliant banners require true.' ), + NoExplicitNull, ] = None max_animation_duration_ms: Annotated[ SchemaInt | None, @@ -120,33 +130,43 @@ class CanonicalFormatHtml5Banner(CanonicalFormatBase): description='Maximum total animation duration in milliseconds. IAB standard: 30000 (30 seconds).', ge=0, ), + NoExplicitNull, ] = None max_cpu_load_percent: Annotated[ SchemaInt | None, Field(description='Maximum CPU load percentage during render.', ge=1, le=100), + NoExplicitNull, ] = None mraid_required: Annotated[ StrictBool | None, Field(description='Whether MRAID compatibility is required (mobile in-app).'), + NoExplicitNull, ] = None mraid_version: Annotated[ MraidVersion | None, Field(description='Required MRAID version when mraid_required is true.'), + NoExplicitNull, ] = None om_sdk_required: Annotated[ StrictBool | None, Field(description='Whether IAB Open Measurement SDK integration is required.'), + NoExplicitNull, ] = None clicktag_macro: Annotated[ - ClicktagMacro | None, Field(description='Name of the click-tag macro the bundle must use.') + ClicktagMacro | None, + Field(description='Name of the click-tag macro the bundle must use.'), + NoExplicitNull, ] = None backup_image_required: Annotated[ StrictBool | None, Field( description='Whether a backup image must accompany the zip for non-HTML5 environments.' ), + NoExplicitNull, ] = None backup_image_max_size_kb: Annotated[ - SchemaInt | None, Field(description='Maximum backup image file size in kilobytes.', ge=1) + SchemaInt | None, + Field(description='Maximum backup image file size in kilobytes.', ge=1), + NoExplicitNull, ] = None - ssl_required: StrictBool | None = None + ssl_required: Annotated[StrictBool | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/formats/canonical/image.py b/src/adcp/types/domains/formats/canonical/image.py index dc9cf22f0..ce9900579 100644 --- a/src/adcp/types/domains/formats/canonical/image.py +++ b/src/adcp/types/domains/formats/canonical/image.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/image.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat from pydantic import ConfigDict, Field, StrictBool @@ -61,7 +61,7 @@ class CanonicalFormatImage(CanonicalFormatBase): model_config = ConfigDict( extra='allow', ) - motion_level: MotionLevel | None = None + motion_level: Annotated[MotionLevel | None, NoExplicitNull] = None slots: Annotated[ Any | None, Field( @@ -81,6 +81,7 @@ class CanonicalFormatImage(CanonicalFormatBase): description="Logical render width in pixels — use for fixed-size slots (e.g., a 300×250 IAB MREC). When `pixel_ratios` is absent, the required image asset width is the same value (1x). When `pixel_ratios` is present, an accepted asset's intrinsic width is `width × pixel_ratio`. For multi-size flexible slots, use `sizes[]`; for responsive slots, use the min/max fields. The three size modes are mutually exclusive.", ge=1, ), + NoExplicitNull, ] = None height: Annotated[ SchemaInt | None, @@ -88,6 +89,7 @@ class CanonicalFormatImage(CanonicalFormatBase): description='Logical render height in pixels. Intrinsic asset height is `height × pixel_ratio`, where the ratio defaults to 1 when `pixel_ratios` is absent. See `width` for size-mode mutual exclusion.', ge=1, ), + NoExplicitNull, ] = None sizes: Annotated[ list[Size] | None, @@ -95,6 +97,7 @@ class CanonicalFormatImage(CanonicalFormatBase): description='List of accepted logical (width, height) render pairs for a multi-size flexible slot. The buyer ships an asset matching one logical size multiplied by one accepted `pixel_ratios` entry (or by 1 when `pixel_ratios` is absent). SDKs MUST treat size and density as separate axes: a 600×500 intrinsic asset at 2x satisfies a logical 300×250 size; it does not create a logical 600×500 placement. Mirrors OpenRTB `banner.format[]` semantics. Mutually exclusive with `(width, height)` and with responsive ranges.', min_length=1, ), + NoExplicitNull, ] = None pixel_ratios: Annotated[ list[PixelRatio] | None, @@ -102,6 +105,7 @@ class CanonicalFormatImage(CanonicalFormatBase): description='Accepted intrinsic-pixel densities for image assets, expressed as intrinsic pixels per logical render pixel (for example `[1, 2]` accepts both standard and Retina renditions). Absence means `[1]` for backward compatibility. This is an acceptance set, not a requirement to submit every rendition: one `image_main` asset satisfying any listed ratio is sufficient unless the effective `image_main` slot declares `required_pixel_ratios`. SDKs determine the effective ratio from `asset.pixel_ratio` when supplied, otherwise infer it from intrinsic asset dimensions divided by the matched logical size. Width and height ratios MUST agree.', min_length=1, ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, @@ -109,6 +113,7 @@ class CanonicalFormatImage(CanonicalFormatBase): description="Minimum accepted width in pixels for responsive slots that adapt within a range (e.g., 'any width from 300 to 970'). Use with `max_width` (and optionally `min_height`/`max_height`). Mutually exclusive with `(width, height)` and `sizes[]`.", ge=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, @@ -116,6 +121,7 @@ class CanonicalFormatImage(CanonicalFormatBase): description='Maximum accepted width in pixels for responsive slots. Pair with `min_width`. See `min_width` for size-mode mutual exclusion.', ge=1, ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, @@ -123,6 +129,7 @@ class CanonicalFormatImage(CanonicalFormatBase): description='Minimum accepted height in pixels for responsive slots. Pair with `max_height`.', ge=1, ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, @@ -130,6 +137,7 @@ class CanonicalFormatImage(CanonicalFormatBase): description='Maximum accepted height in pixels for responsive slots. Pair with `min_height`.', ge=1, ), + NoExplicitNull, ] = None aspect_ratio: Annotated[ str | None, @@ -137,46 +145,53 @@ class CanonicalFormatImage(CanonicalFormatBase): description="Optional aspect ratio constraint (e.g., '1.91:1', '1:1'). When provided alongside `width`/`height`, must agree. When used with `sizes[]` or responsive ranges, narrows accepted entries to those matching the aspect ratio.", pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None max_file_size_kb: Annotated[ - SchemaInt | None, Field(description='Maximum file size in kilobytes.', ge=1) + SchemaInt | None, Field(description='Maximum file size in kilobytes.', ge=1), NoExplicitNull ] = None image_formats: Annotated[ - list[ImageFormat] | None, Field(description='Permitted image file formats.') + list[ImageFormat] | None, Field(description='Permitted image file formats.'), NoExplicitNull ] = None ssl_required: Annotated[ StrictBool | None, Field(description='Whether the image and its trackers must be served over HTTPS.'), + NoExplicitNull, ] = None - headline_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - body_text_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None + headline_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + body_text_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None cta_values: Annotated[ list[str] | None, Field( description="Permitted CTA values for this product (e.g., ['LEARN_MORE', 'SHOP_NOW'])." ), + NoExplicitNull, ] = None asset_source: Annotated[ AssetSource | None, Field( description="Where the rendered asset bytes come from. Single shared enum across all canonicals (`image`, `video_hosted`, `audio_hosted` — replaces the earlier per-canonical `image_source` / `video_source` / `audio_source` fields). `buyer_uploaded` (default): buyer ships a pre-rendered asset. `publisher_host_recorded`: publisher's host records the asset (audio-specific; podcast host-read pattern). `seller_pre_rendered_from_brief`: buyer ships a brief plus structured copy; seller renders ONE asset at sync_creatives or build_creative time (generative-DSP pattern). `seller_human_designed`: seller's design team renders manually from a brief. `agent_synthesized`: AI synthesis pipeline; pair with `synthesis_nondeterministic: true` when the platform cannot guarantee in-spec output (Veo/Sora/Imagen-class). `publisher_owned_reference`: buyer references an existing post or publisher-owned object via a `published_post` slot; the seller resolves and serves the referenced content after authorization/review rather than receiving uploaded bytes.\n\nNot every value is meaningful on every canonical — `publisher_host_recorded` is audio-specific; on `image` or `video_hosted` it has no defined behavior. `publisher_owned_reference` is meaningful only when the product's `slots` declaration accepts a reference asset such as `published_post`. Adopters MUST select a value appropriate to the canonical's asset type. The `slots` declaration is the binding contract for what the buyer ships; `asset_source` is informational and lets buyers understand the production model when picking products." ), + NoExplicitNull, ] = AssetSource.buyer_uploaded buyer_asset_acceptance: Annotated[ BuyerAssetAcceptance | None, Field( description="Whether the product accepts buyer-uploaded assets. When `rejected`, the buyer cannot ship pre-rendered bytes directly — they must use build_creative (or sync_creatives with brief inputs or reference assets) so the seller produces or resolves the asset. Combined with `asset_source`, lets a product declare 'I produce assets from briefs and refuse buyer uploads' (asset_source=`seller_pre_rendered_from_brief`, buyer_asset_acceptance=`rejected`) or 'I accept existing post references, not uploaded bytes' (asset_source=`publisher_owned_reference`, buyer_asset_acceptance=`rejected`)." ), + NoExplicitNull, ] = BuyerAssetAcceptance.accepted ctv_ad_experience: Annotated[ ctv_ad_experience_1.CtvAdExperience | None, Field( description='CTV experience this option serves. On `image` only `pause` and `screensaver` are valid — the image-plus-copy contract the major pause-ad sellers ingest (seller composites the frame; typical canvas 1920×1080 or a transparent-region overlay). Other experiences route per the matrix in docs/creative/ctv-experiences.mdx.' ), + NoExplicitNull, ] = None activation_methods: Annotated[ list[activation_method.CreativeActivationMethod] | None, Field( description='Viewer activation mechanisms this option offers (e.g. `qr_code` on a pause frame). Activations are engagement events, not impressions.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/image_carousel.py b/src/adcp/types/domains/formats/canonical/image_carousel.py index ed5b1bade..3c9c90d93 100644 --- a/src/adcp/types/domains/formats/canonical/image_carousel.py +++ b/src/adcp/types/domains/formats/canonical/image_carousel.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/image_carousel.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ._base import CanonicalFormatBase @@ -44,19 +44,24 @@ class CanonicalFormatImageCarousel(CanonicalFormatBase): description="Aspect ratio shared across all cards (e.g., '1:1', '1.91:1', '4:5').", pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None min_cards: Annotated[ - SchemaInt | None, Field(description='Minimum card count (typical: 2 or 3).', ge=2) + SchemaInt | None, + Field(description='Minimum card count (typical: 2 or 3).', ge=2), + NoExplicitNull, ] = None max_cards: Annotated[ SchemaInt | None, Field(description='Maximum card count (typical: 6, 10, or 35 depending on platform).'), + NoExplicitNull, ] = None allowed_card_media_asset_types: Annotated[ list[AllowedCardMediaAssetType] | None, Field( description='Asset types each card\'s `media` field may carry. Default: [\'image\']. Polymorphic carousels (Meta) allow [\'image\', \'video\']. Renamed from `allowed_card_asset_types` to disambiguate that this constrains the card\'s media payload, not the card-asset itself (which is always asset_type: "card").' ), + NoExplicitNull, ] = None allowed_card_asset_types: Annotated[ list[AllowedCardMediaAssetType] | None, @@ -64,13 +69,15 @@ class CanonicalFormatImageCarousel(CanonicalFormatBase): deprecated=True, description='DEPRECATED — alias for `allowed_card_media_asset_types`. Kept for back-compat; prefer the new field name. Removed in 5.0.', ), + NoExplicitNull, ] = None - card_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None - card_video_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None - card_video_max_duration_ms: Annotated[SchemaInt | None, Field(ge=1)] = None + card_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + card_video_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + card_video_max_duration_ms: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None primary_text_max_chars: Annotated[ SchemaInt | None, Field(description='Maximum length of the carousel-level primary text.', ge=1), + NoExplicitNull, ] = None card_headline_max_chars: Annotated[ SchemaInt | None, @@ -78,6 +85,7 @@ class CanonicalFormatImageCarousel(CanonicalFormatBase): description='Per-card headline character limit. Governs the `headline` field on each card-asset in the `cards` slot.', ge=1, ), + NoExplicitNull, ] = None card_description_max_chars: Annotated[ SchemaInt | None, @@ -85,5 +93,6 @@ class CanonicalFormatImageCarousel(CanonicalFormatBase): description='Per-card description character limit. Governs the `description` field on each card-asset in the `cards` slot. Distinct from `card_headline_max_chars`: description is longer body copy (typically 100-500 chars); headline is the short label (typically 25-40 chars).', ge=1, ), + NoExplicitNull, ] = None - ssl_required: StrictBool | None = None + ssl_required: Annotated[StrictBool | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/formats/canonical/native_in_feed.py b/src/adcp/types/domains/formats/canonical/native_in_feed.py index c23c5ad89..dd3f79c61 100644 --- a/src/adcp/types/domains/formats/canonical/native_in_feed.py +++ b/src/adcp/types/domains/formats/canonical/native_in_feed.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/native_in_feed.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ...enums import activation_method @@ -103,28 +103,33 @@ class CanonicalFormatNativeInFeed(CanonicalFormatBase): Field( description='CTV experience this option serves. On native_in_feed `menu` and `overlay` are valid. `menu`: smart-TV home/menu surfaces where the platform assembles buyer assets (background/main image, logo/icon, copy, optional focus-triggered video); `menu_placement` selects the tile vs headline-banner variant, and catalog-derived sponsored tiles route to `sponsored_placement` instead. `overlay`: seller-composited in-stream overlays supplied as an asset bundle (video, logo, imagery, copy, activation copy) — the contract overlay sellers that do not ingest VAST tags use; VAST-ingesting sellers publish a `video_vast` sibling option instead. The wire name stays `native_in_feed` for 3.x even though neither surface is literally in-feed.' ), + NoExplicitNull, ] = None menu_placement: Annotated[ MenuPlacement | None, Field( description='Menu surface variant, mapping to OpenRTB Native `plcmttype` 1 (tile/feed) and 3 (headline banner). Valid only with `ctv_ad_experience: "menu"`.' ), + NoExplicitNull, ] = None focus_behavior: Annotated[ FocusBehavior | None, Field( description='What happens when the viewer\'s remote focus lands on the unit. `autoplay_*` requires a `video` asset; playback method maps to AdCOM playbackmethod on OpenRTB bridges. Valid only with `ctv_ad_experience: "menu"`.' ), + NoExplicitNull, ] = None motion_level: Annotated[ motion_level_1.CreativeMotionLevel | None, Field(description='Accepted motion class for the rendered unit (AdCOM attrs 21-23).'), + NoExplicitNull, ] = None activation_methods: Annotated[ list[activation_method.CreativeActivationMethod] | None, Field( description='Viewer activation mechanisms this option offers (QR, deep link, send-to-device). Activations are engagement events, not impressions.' ), + NoExplicitNull, ] = None title_max_chars: Annotated[ SchemaInt | None, @@ -132,6 +137,7 @@ class CanonicalFormatNativeInFeed(CanonicalFormatBase): description='Maximum character length for the title slot. IAB native typical: 25 (short) to 90 (long). Buyer agents SHOULD validate ship-time title length against this.', ge=1, ), + NoExplicitNull, ] = None body_text_max_chars: Annotated[ SchemaInt | None, @@ -139,16 +145,19 @@ class CanonicalFormatNativeInFeed(CanonicalFormatBase): description='Maximum character length for the body_text slot. IAB native typical: 90 (mainline) to 140 (extended).', ge=1, ), + NoExplicitNull, ] = None cta_max_chars: Annotated[ SchemaInt | None, Field(description='Maximum character length for the cta slot. Typical: 15–25.', ge=1), + NoExplicitNull, ] = None cta_values: Annotated[ list[str] | None, Field( description="Permitted CTA values for this product (e.g., ['LEARN_MORE', 'SHOP_NOW', 'SIGN_UP', 'DOWNLOAD']). When set, narrows the cta slot to a closed enum." ), + NoExplicitNull, ] = None main_image_sizes: Annotated[ list[MainImageSize] | None, @@ -156,35 +165,41 @@ class CanonicalFormatNativeInFeed(CanonicalFormatBase): description='Accepted logical (width, height) pairs for the main_image slot. Common IAB native sizes: 1200×627 (1.91:1), 1080×1080 (1:1), 1080×1350 (4:5). When the effective main_image slot declares `pixel_ratios`, intrinsic asset dimensions are the matched logical pair multiplied by the selected ratio; absence remains 1x.', min_length=1, ), + NoExplicitNull, ] = None icon_size: Annotated[ IconSize | None, Field( description='Required logical (width, height) for the icon slot when present (typical: 80×80 or 100×100). When the effective icon slot declares `pixel_ratios`, intrinsic dimensions are multiplied by the selected ratio; absence remains 1x.' ), + NoExplicitNull, ] = None max_image_file_size_kb: Annotated[ SchemaInt | None, Field(description='Maximum file size in kilobytes for main_image and icon.', ge=1), + NoExplicitNull, ] = None image_formats: Annotated[ - list[ImageFormat] | None, Field(description='Permitted image file formats.') + list[ImageFormat] | None, Field(description='Permitted image file formats.'), NoExplicitNull ] = None ssl_required: Annotated[ StrictBool | None, Field( description='Whether trackers, landing pages, and image URLs must be served over HTTPS.' ), + NoExplicitNull, ] = None asset_source: Annotated[ AssetSource | None, Field( description="Where the rendered native assets come from. `publisher_host_recorded` is omitted (audio-specific and not meaningful for native). Other values mirror the shared production-source axis used on `image` / `video_hosted`. `buyer_uploaded` (default): buyer ships pre-rendered title/image/body. `seller_pre_rendered_from_brief`: buyer ships a brief, seller renders the native bundle. `agent_synthesized`: AI synthesis pipeline produces title + image + body from a brief; pair with `synthesis_nondeterministic: true` for generative pipelines that can't guarantee in-spec output. `publisher_owned_reference`: buyer ships an existing published post reference; the seller resolves the post into the native presentation after authorization/review." ), + NoExplicitNull, ] = AssetSource.buyer_uploaded buyer_asset_acceptance: Annotated[ BuyerAssetAcceptance | None, Field( description='Whether the product accepts buyer-uploaded native assets. When `rejected`, the buyer cannot ship pre-rendered title/image/body — they must use `build_creative`, `sync_creatives` with brief inputs, or an accepted `published_post` reference so the seller produces or resolves the native bundle.' ), + NoExplicitNull, ] = BuyerAssetAcceptance.accepted diff --git a/src/adcp/types/domains/formats/canonical/responsive_creative.py b/src/adcp/types/domains/formats/canonical/responsive_creative.py index bc698e42e..208bc3186 100644 --- a/src/adcp/types/domains/formats/canonical/responsive_creative.py +++ b/src/adcp/types/domains/formats/canonical/responsive_creative.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: formats/canonical/responsive_creative.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ._base import CanonicalFormatBase @@ -94,34 +94,35 @@ class CanonicalFormatResponsiveCreative(CanonicalFormatBase): 'max': 1, }, ] - headlines_min: Annotated[SchemaInt | None, Field(ge=0)] = None - headlines_max: Annotated[SchemaInt | None, Field(ge=0)] = None - headline_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - long_headlines_min: Annotated[SchemaInt | None, Field(ge=0)] = None - long_headlines_max: Annotated[SchemaInt | None, Field(ge=0)] = None - long_headline_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - descriptions_min: Annotated[SchemaInt | None, Field(ge=0)] = None - descriptions_max: Annotated[SchemaInt | None, Field(ge=0)] = None - description_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - images_landscape_min: Annotated[SchemaInt | None, Field(ge=0)] = None - images_landscape_max: Annotated[SchemaInt | None, Field(ge=0)] = None - images_landscape_aspect_ratio: str | None = None - images_square_min: Annotated[SchemaInt | None, Field(ge=0)] = None - images_square_max: Annotated[SchemaInt | None, Field(ge=0)] = None - images_vertical_min: Annotated[SchemaInt | None, Field(ge=0)] = None - images_vertical_max: Annotated[SchemaInt | None, Field(ge=0)] = None - videos_min: Annotated[SchemaInt | None, Field(ge=0)] = None - videos_max: Annotated[SchemaInt | None, Field(ge=0)] = None - video_min_duration_ms: Annotated[SchemaInt | None, Field(ge=1)] = None - video_max_duration_ms: Annotated[SchemaInt | None, Field(ge=1)] = None - logo_min: Annotated[SchemaInt | None, Field(ge=0)] = None - logo_max: Annotated[SchemaInt | None, Field(ge=0)] = None - logo_aspect_ratios: list[str] | None = None - business_name_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - asset_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1)] = None + headlines_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + headlines_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + headline_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + long_headlines_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + long_headlines_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + long_headline_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + descriptions_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + descriptions_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + description_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + images_landscape_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_landscape_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_landscape_aspect_ratio: Annotated[str | None, NoExplicitNull] = None + images_square_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_square_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_vertical_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + images_vertical_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + videos_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + videos_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + video_min_duration_ms: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + video_max_duration_ms: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + logo_min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + logo_max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + logo_aspect_ratios: Annotated[list[str] | None, NoExplicitNull] = None + business_name_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + asset_image_max_file_size_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None supports_catalog_input: Annotated[ StrictBool | None, Field( description='Whether the product can additionally consume a catalog reference (e.g., PMax with product feed).' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/seller_rendered_stateful_display.py b/src/adcp/types/domains/formats/canonical/seller_rendered_stateful_display.py index cb7c76dfa..128c50722 100644 --- a/src/adcp/types/domains/formats/canonical/seller_rendered_stateful_display.py +++ b/src/adcp/types/domains/formats/canonical/seller_rendered_stateful_display.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/seller_rendered_stateful_display.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarInt, ScalarStr from pydantic import ConfigDict, Field, StrictBool, StrictFloat @@ -65,7 +65,7 @@ class Breakpoint(AdCPBaseModel): pattern='^[a-z][a-z0-9_]*$', ), ] - width: Annotated[SchemaInt | None, Field(ge=1)] = None + width: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None width_range: Annotated[ list[WidthRangeItem] | None, Field( @@ -73,14 +73,16 @@ class Breakpoint(AdCPBaseModel): max_length=2, min_length=2, ), + NoExplicitNull, ] = None width_mode: Annotated[ WidthMode | None, Field( description='Fluid width classes. `full_bleed`: edge-to-edge viewport width (IAB adhesion, topscroll). `gutter_residual`: the margin remaining beside the page content column (desktop skins). Fluid breakpoints cannot be targeted by `rendered_canvases` supply — semantic validation rejects that pairing.' ), + NoExplicitNull, ] = None - height: Annotated[SchemaInt | None, Field(ge=1)] = None + height: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None height_range: Annotated[ list[HeightRangeItem] | None, Field( @@ -88,6 +90,7 @@ class Breakpoint(AdCPBaseModel): max_length=2, min_length=2, ), + NoExplicitNull, ] = None viewport_height_percent: Annotated[ StrictFloat | None, @@ -96,6 +99,7 @@ class Breakpoint(AdCPBaseModel): gt=0.0, le=100.0, ), + NoExplicitNull, ] = None canvas_aspect_ratio: Annotated[ str | None, @@ -103,6 +107,7 @@ class Breakpoint(AdCPBaseModel): description='Aspect ratio the rendered canvas maintains across the size range (IAB flexible units, ratio-bucket responsive). When both fixed `width` and `height` are declared, the ratio MUST be consistent with them.', pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None @@ -128,12 +133,14 @@ class State(AdCPBaseModel): Field( description='Asset group IDs rendered in this state (components mode). Makes the template executable: given components and bindings, assembly is deterministic. Every value MUST resolve to a declared slot. Omitted means all supplied component slots may render.' ), + NoExplicitNull, ] = None motion: Annotated[ Motion | None, Field( description='Whether the seller-rendered layout animates within this state (attract loops, load animations). Intra-state animation is seller-rendered; buyer canvases stay static images.' ), + NoExplicitNull, ] = Motion.static max_animation_s: Annotated[ StrictFloat | None, @@ -141,6 +148,7 @@ class State(AdCPBaseModel): description='Upper bound on intra-state animation duration. Required when `motion` is `animated`.', gt=0.0, ), + NoExplicitNull, ] = None breakpoints: Annotated[list[Breakpoint], Field(min_length=1)] close_affordance: Annotated[ @@ -225,18 +233,21 @@ class Transitions(AdCPBaseModel): Field( description='Input driving a user or scroll transition. `hover` expansion is disallowed by IAB NAP guidance and emits a LEAN policy warning visible to buyers.' ), + NoExplicitNull, ] = None media_event: Annotated[ MediaEvent | None, Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -254,6 +265,7 @@ class Transitions(AdCPBaseModel): duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -262,12 +274,14 @@ class Transitions(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference | None, Field( description='Reference frame for scroll percentages. Progress is `scroll_offset / max(scroll_extent - viewport_extent, 1) * 100`, measured on either the page document or the nearest seller-declared containing scroller.' ), + NoExplicitNull, ] = None scroll_start_percent: Annotated[ StrictFloat | None, @@ -276,6 +290,7 @@ class Transitions(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -284,12 +299,14 @@ class Transitions(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -317,18 +334,21 @@ class Transitions7(AdCPBaseModel): Field( description='Input driving a user or scroll transition. `hover` expansion is disallowed by IAB NAP guidance and emits a LEAN policy warning visible to buyers.' ), + NoExplicitNull, ] = None media_event: Annotated[ MediaEvent | None, Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -346,6 +366,7 @@ class Transitions7(AdCPBaseModel): duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -354,12 +375,14 @@ class Transitions7(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference | None, Field( description='Reference frame for scroll percentages. Progress is `scroll_offset / max(scroll_extent - viewport_extent, 1) * 100`, measured on either the page document or the nearest seller-declared containing scroller.' ), + NoExplicitNull, ] = None scroll_start_percent: Annotated[ StrictFloat | None, @@ -368,6 +391,7 @@ class Transitions7(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -376,12 +400,14 @@ class Transitions7(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -415,12 +441,14 @@ class Transitions8(AdCPBaseModel): Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -434,10 +462,12 @@ class Transitions8(AdCPBaseModel): description='For `timer`: delay after `from_state_id` activates (re-entry restarts it). For `in_view_timer`: accumulated viewable milliseconds in `from_state_id`. Timer-class transitions in a state cycle MUST declare at least 1000 (anti-strobe floor).', ge=0, ), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat, @@ -460,6 +490,7 @@ class Transitions8(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -468,12 +499,14 @@ class Transitions8(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -513,12 +546,14 @@ class Transitions9(AdCPBaseModel): Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ Literal['scroll_linked'], @@ -532,10 +567,12 @@ class Transitions9(AdCPBaseModel): description='For `timer`: delay after `from_state_id` activates (re-entry restarts it). For `in_view_timer`: accumulated viewable milliseconds in `from_state_id`. Timer-class transitions in a state cycle MUST declare at least 1000 (anti-strobe floor).', ge=0, ), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -544,6 +581,7 @@ class Transitions9(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference, @@ -572,6 +610,7 @@ class Transitions9(AdCPBaseModel): Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -605,12 +644,14 @@ class Transitions10(AdCPBaseModel): Field( description='Playback milestone of `video_main` driving a `media_event` transition (endframes, collapse-on-complete).' ), + NoExplicitNull, ] = None direction: Annotated[ Direction | None, Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -624,10 +665,12 @@ class Transitions10(AdCPBaseModel): description='For `timer`: delay after `from_state_id` activates (re-entry restarts it). For `in_view_timer`: accumulated viewable milliseconds in `from_state_id`. Timer-class transitions in a state cycle MUST declare at least 1000 (anti-strobe floor).', ge=0, ), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -636,12 +679,14 @@ class Transitions10(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference | None, Field( description='Reference frame for scroll percentages. Progress is `scroll_offset / max(scroll_extent - viewport_extent, 1) * 100`, measured on either the page document or the nearest seller-declared containing scroller.' ), + NoExplicitNull, ] = None scroll_start_percent: Annotated[ StrictFloat | None, @@ -650,6 +695,7 @@ class Transitions10(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -658,12 +704,14 @@ class Transitions10(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -691,6 +739,7 @@ class Transitions11(AdCPBaseModel): Field( description='Input driving a user or scroll transition. `hover` expansion is disallowed by IAB NAP guidance and emits a LEAN policy warning visible to buyers.' ), + NoExplicitNull, ] = None media_event: Annotated[ MediaEvent, @@ -703,6 +752,7 @@ class Transitions11(AdCPBaseModel): Field( description='Scroll direction that arms a `scroll_threshold` transition; enables direction-aware expand/collapse cycles. Re-crossing in the opposite direction does not re-fire this transition.' ), + NoExplicitNull, ] = Direction.down transition_mode: Annotated[ TransitionMode, @@ -716,10 +766,12 @@ class Transitions11(AdCPBaseModel): description='For `timer`: delay after `from_state_id` activates (re-entry restarts it). For `in_view_timer`: accumulated viewable milliseconds in `from_state_id`. Timer-class transitions in a state cycle MUST declare at least 1000 (anti-strobe floor).', ge=0, ), + NoExplicitNull, ] = None duration_ms: Annotated[ SchemaInt | None, Field(description='Duration of a seller-rendered animated transition.', ge=0), + NoExplicitNull, ] = None scroll_threshold_percent: Annotated[ StrictFloat | None, @@ -728,12 +780,14 @@ class Transitions11(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_reference: Annotated[ ScrollReference | None, Field( description='Reference frame for scroll percentages. Progress is `scroll_offset / max(scroll_extent - viewport_extent, 1) * 100`, measured on either the page document or the nearest seller-declared containing scroller.' ), + NoExplicitNull, ] = None scroll_start_percent: Annotated[ StrictFloat | None, @@ -742,6 +796,7 @@ class Transitions11(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None scroll_end_percent: Annotated[ StrictFloat | None, @@ -750,12 +805,14 @@ class Transitions11(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None preserve_playback: Annotated[ StrictBool | None, Field( description='Whether `video_main` continues without restart while the seller changes state.' ), + NoExplicitNull, ] = False @@ -813,6 +870,7 @@ class CanonicalFormatSellerRenderedStatefulDisplay(CanonicalFormatBase): Field( description='Which end of the template contract the buyer feeds. `components`: buyer supplies component slots; seller renders states (no `state_canvases`/`layered_source` assets allowed). `rendered_canvases`: buyer supplies exactly one `state_canvases` image per declared state × breakpoint pair. `layered_source`: buyer ships design source (+ optional `font_files`); seller production derives states (`production_window_business_days` applies) — transitional for sellers without executable templates.' ), + NoExplicitNull, ] = SupplyMode.components slots: Annotated[ Any | None, @@ -852,6 +910,7 @@ class CanonicalFormatSellerRenderedStatefulDisplay(CanonicalFormatBase): Field( description='How the unit enters view, distinct from state changes. `clip_window`: canvas fixed and progressively exposed through a scrolling window (interscroller, topscroll). `scroll_parallax`: canvas moves at a different rate than content. Reveal is presentation of one canvas, not a transition; do not fabricate a second state to express it.' ), + NoExplicitNull, ] = Reveal.none transitions: Annotated[ list[ @@ -862,12 +921,14 @@ class CanonicalFormatSellerRenderedStatefulDisplay(CanonicalFormatBase): description='Bounded seller-rendered transitions between declared visual states. Required when `states` has more than one entry; MUST be omitted for single-state units. Every non-initial state MUST be reachable from `initial_state_id`. Dismissal is terminal unit behavior declared by `user_controls.dismissible`, not a hidden visual state.', min_length=1, ), + NoExplicitNull, ] = None clickthrough: Annotated[ Clickthrough | None, Field( description='Destination policy. `required` (default): manifest MUST supply `landing_page_url`. `optional`: click-optional units (in-feed brand units) may omit it. `none`: unit is non-clickable; manifests MUST NOT supply `landing_page_url` or `state_click_urls`. Per-state overrides via `state_click_urls` entries carrying `state_id`; `landing_page_url` is the fallback for unlisted states.' ), + NoExplicitNull, ] = Clickthrough.required user_controls: Annotated[ UserControls, @@ -880,6 +941,7 @@ class CanonicalFormatSellerRenderedStatefulDisplay(CanonicalFormatBase): Field( description='Rectangular areas constraining buyer artwork. Omitted state/breakpoint selectors apply the constraint to every canvas. For fluid or range-sized breakpoints, use percent-unit regions.' ), + NoExplicitNull, ] = None duration_ms_range: Annotated[ list[DurationMsRange | None] | None, @@ -888,28 +950,32 @@ class CanonicalFormatSellerRenderedStatefulDisplay(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None - duration_ms_exact: Annotated[SchemaInt | None, Field(ge=1)] = None + duration_ms_exact: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None aspect_ratio: Annotated[ str | None, Field( description='Embedded-video aspect ratio.', pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None - containers: list[Container] | None = None - video_playback: VideoPlayback | None = None - max_initial_load_kb: Annotated[SchemaInt | None, Field(ge=1)] = None + containers: Annotated[list[Container] | None, NoExplicitNull] = None + video_playback: Annotated[VideoPlayback | None, NoExplicitNull] = None + max_initial_load_kb: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None max_subload_kb: Annotated[ SchemaInt | None, Field( description='Ceiling on assets loaded after the window load event (IAB LEAN subload). Pairs with `max_initial_load_kb` to mirror the New Ad Portfolio initial/subload weight pair.', ge=1, ), + NoExplicitNull, ] = None polite_load: Annotated[ StrictBool | None, Field( description="When true, non-initial assets load only after the host page's window load event (IAB LEAN subload boundary)." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/sponsored_placement.py b/src/adcp/types/domains/formats/canonical/sponsored_placement.py index b3ab0970d..4640e87dc 100644 --- a/src/adcp/types/domains/formats/canonical/sponsored_placement.py +++ b/src/adcp/types/domains/formats/canonical/sponsored_placement.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: formats/canonical/sponsored_placement.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool from ...enums import catalog_type @@ -68,44 +68,55 @@ class CanonicalFormatSponsoredPlacementRetailMediaCatalogDriven(CanonicalFormatB supported_catalog_types: Annotated[ list[catalog_type.CatalogType] | None, Field(description='Catalog types this product accepts.'), + NoExplicitNull, ] = None min_items: Annotated[ - SchemaInt | None, Field(description='Minimum catalog item count buyer must supply.', ge=1) + SchemaInt | None, + Field(description='Minimum catalog item count buyer must supply.', ge=1), + NoExplicitNull, ] = None max_items: Annotated[ - SchemaInt | None, Field(description='Maximum items considered for placement.') + SchemaInt | None, + Field(description='Maximum items considered for placement.'), + NoExplicitNull, ] = None fanout_mode: Annotated[ FanoutMode | None, Field( description='How items map to delivery: per_item = one ad per catalog item; multi_item_in_creative = composed multi-item ad (Pinterest Collection, Snap Collection); single_item = one ad showing one item.' ), + NoExplicitNull, ] = None required_catalog_fields: Annotated[ list[str] | None, Field( description="Catalog item fields the seller requires (e.g., ['title', 'image_url', 'price'])." ), + NoExplicitNull, ] = None supported_id_types: Annotated[ list[SupportedIdType] | None, Field(description='Catalog identifier types the placement renders against.'), + NoExplicitNull, ] = None hero_asset_supported: Annotated[ StrictBool | None, Field( description='Whether the buyer can supply a hero/banner asset alongside the catalog (Pinterest Collection pattern).' ), + NoExplicitNull, ] = None item_production_model: Annotated[ ItemProductionModel | None, Field( description='How each per-item creative is produced. Covers the same production-source axis as `asset_source` on `image` / `video_hosted` / `audio_hosted` but with a 4-value subset — drops `publisher_host_recorded` because it\'s audio-specific and doesn\'t apply to retail-media catalog placements. SDK codegen MAY share a base enum and narrow per-canonical, or emit two distinct enums; either way the wire values overlap exactly for the 4 retained values. `buyer_uploaded` (default, current Amazon/Criteo/CitrusAd pattern): the buyer\'s catalog already contains rendered assets per item; the seller composes the placement using those assets. ("Uploaded" reads slightly off for catalog-keyed items where the buyer didn\'t actively upload bytes — the catalog ingestion already supplied them — but the semantic is the same: rendered bytes are buyer-supplied, not seller-produced.) `seller_pre_rendered_from_brief`: the buyer ships a brief plus the catalog reference; the seller renders one creative per catalog item from the brief at sync_creatives time. `seller_human_designed`: seller\'s design team produces per-item renders manually. `agent_synthesized`: AI synthesis pipeline produces per-item renders; pair with `synthesis_nondeterministic: true` for Veo/Sora-class generative video applied per item. Captures the multi-output generative pattern (1 brief × N catalog items → N rendered creatives) under the existing canonical without requiring a separate canonical. Distinct from `fanout_mode`, which describes how items map to delivery slots after rendering.' ), + NoExplicitNull, ] = ItemProductionModel.buyer_uploaded ctv_ad_experience: Annotated[ ctv_ad_experience_1.CtvAdExperience | None, Field( description='CTV experience this option serves. On `sponsored_placement`: `menu` (sponsored app/content tiles and rows whose assets derive from a catalog listing — Fire-TV-tile pattern), `squeezeback`, and `in_scene` (seller-composited brand integrations produced from the catalog/brief rather than a buyer wire creative). Asset-bundle menu heroes route to `native_in_feed`.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/video_hosted.py b/src/adcp/types/domains/formats/canonical/video_hosted.py index b5bcd0e21..26e0d1d87 100644 --- a/src/adcp/types/domains/formats/canonical/video_hosted.py +++ b/src/adcp/types/domains/formats/canonical/video_hosted.py @@ -1,15 +1,14 @@ # generated by datamodel-codegen: # filename: formats/canonical/video_hosted.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt - from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt +from adcp.types._scalar import ScalarInt from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ...enums import ctv_ad_experience as ctv_ad_experience_1 @@ -101,6 +100,7 @@ class CanonicalFormatHostedVideo(CanonicalFormatBase): Field( description='Video orientation. Vertical = 9:16 (Reels, Stories, Shorts). Horizontal = 16:9 (instream, CTV). Square = 1:1 (in-feed).' ), + NoExplicitNull, ] = None aspect_ratio: Annotated[ str | None, @@ -108,11 +108,12 @@ class CanonicalFormatHostedVideo(CanonicalFormatBase): description='Aspect ratio. Inferred from orientation if omitted.', pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$', ), + NoExplicitNull, ] = None - min_width: Annotated[SchemaInt | None, Field(ge=1)] = None - min_height: Annotated[SchemaInt | None, Field(ge=1)] = None - max_width: Annotated[SchemaInt | None, Field(ge=1)] = None - max_height: Annotated[SchemaInt | None, Field(ge=1)] = None + min_width: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + min_height: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max_width: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max_height: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None duration_ms_range: Annotated[ list[DurationMsRange | None] | None, Field( @@ -120,6 +121,7 @@ class CanonicalFormatHostedVideo(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None duration_ms_exact: Annotated[ SchemaInt | None, @@ -127,43 +129,49 @@ class CanonicalFormatHostedVideo(CanonicalFormatBase): description='When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship (see `duration_ms_range` description).', ge=1, ), + NoExplicitNull, ] = None - video_codecs: list[VideoCodec] | None = None - audio_codecs: list[AudioCodec] | None = None - containers: list[Container] | None = None - min_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1)] = None - max_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1)] = None + video_codecs: Annotated[list[VideoCodec] | None, NoExplicitNull] = None + audio_codecs: Annotated[list[AudioCodec] | None, NoExplicitNull] = None + containers: Annotated[list[Container] | None, NoExplicitNull] = None + min_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + max_bitrate_kbps: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None max_file_size_mb: Annotated[ SchemaInt | None, Field(description='Maximum file size, where 1 MB is exactly 1,000,000 bytes.', ge=1), + NoExplicitNull, ] = None - frame_rates: list[StrictFloat] | None = None - captions: Captions | None = None - om_sdk_required: StrictBool | None = None - headline_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - primary_text_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - brand_name_max_chars: Annotated[SchemaInt | None, Field(ge=1)] = None - cta_values: list[str] | None = None + frame_rates: Annotated[list[StrictFloat] | None, NoExplicitNull] = None + captions: Annotated[Captions | None, NoExplicitNull] = None + om_sdk_required: Annotated[StrictBool | None, NoExplicitNull] = None + headline_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + primary_text_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + brand_name_max_chars: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + cta_values: Annotated[list[str] | None, NoExplicitNull] = None companion_banner_widths: Annotated[ list[CompanionBannerWidth] | None, Field(description='Permitted companion banner widths (instream video).'), + NoExplicitNull, ] = None - companion_banner_heights: list[CompanionBannerHeight] | None = None + companion_banner_heights: Annotated[list[CompanionBannerHeight] | None, NoExplicitNull] = None asset_source: Annotated[ AssetSource | None, Field( description='Where the rendered asset bytes come from. Single shared enum across canonicals. See `image.json#asset_source` for the full semantics. `publisher_host_recorded` is audio-specific and has no defined behavior on video. `publisher_owned_reference` is valid when the product accepts an existing post reference via a `published_post` slot instead of uploaded video bytes. Adopters MUST select a value appropriate to the canonical.' ), + NoExplicitNull, ] = AssetSource.buyer_uploaded buyer_asset_acceptance: Annotated[ BuyerAssetAcceptance | None, Field( description='Whether the product accepts buyer-uploaded video. When `rejected`, the buyer cannot ship a video asset directly — they must use build_creative, sync_creatives with brief inputs, or sync_creatives with an accepted reference asset so the seller produces or resolves the video.' ), + NoExplicitNull, ] = BuyerAssetAcceptance.accepted ctv_ad_experience: Annotated[ ctv_ad_experience_1.CtvAdExperience | None, Field( description='CTV experience this option serves. On `video_hosted` only `screensaver` is valid (ambient looping video the platform plays on idle). Other experiences route per the matrix in docs/creative/ctv-experiences.mdx; linear CTV video declares no experience.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/formats/canonical/video_vast.py b/src/adcp/types/domains/formats/canonical/video_vast.py index 4d746ee51..e0de48c63 100644 --- a/src/adcp/types/domains/formats/canonical/video_vast.py +++ b/src/adcp/types/domains/formats/canonical/video_vast.py @@ -1,15 +1,14 @@ # generated by datamodel-codegen: # filename: formats/canonical/video_vast.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarInt - from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt +from adcp.types._scalar import ScalarInt from pydantic import ConfigDict, Field, StrictBool from ...core import vast_media_file_requirements @@ -55,9 +54,9 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): {'asset_group_id': 'vast_tag', 'asset_type': 'vast', 'required': True}, {'asset_group_id': 'landing_page_url', 'asset_type': 'url', 'required': False}, ] - orientation: Orientation | None = None + orientation: Annotated[Orientation | None, NoExplicitNull] = None aspect_ratio: Annotated[ - str | None, Field(pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$') + str | None, Field(pattern='^[0-9]+(\\.[0-9]+)?:[0-9]+(\\.[0-9]+)?$'), NoExplicitNull ] = None vast_versions: Annotated[ list[vast_version_1.VastVersion] | None, @@ -65,6 +64,7 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): description='VAST versions accepted by this product format option. The asset still declares exactly one `vast_version`; compatibility requires membership in this set and the seller-wide execution set.', min_length=1, ), + NoExplicitNull, ] = None vast_version: Annotated[ vast_version_1.VastVersion | None, @@ -72,25 +72,29 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): deprecated=True, description='Deprecated one-element alias for `vast_versions`. Producers use either the singular legacy alias or the plural 3.2 field, never both.', ), + NoExplicitNull, ] = None media_file_requirements: Annotated[ vast_media_file_requirements.VastMediafileRequirements | None, Field( description='Technical acceptance constraints for alternative VAST MediaFile renditions. Each applicable resolved InLine linear creative needs at least one MediaFile satisfying all declared constraints.' ), + NoExplicitNull, ] = None vpaid_enabled: Annotated[ StrictBool | None, Field( description='Whether VPAID interactivity is supported. When true, the VAST tag may carry VPAID JS/Flash payloads.' ), + NoExplicitNull, ] = None - vpaid_version: VpaidVersion | None = None + vpaid_version: Annotated[VpaidVersion | None, NoExplicitNull] = None simid_supported: Annotated[ StrictBool | None, Field( description='Whether the seller accepts IAB SIMID through `` on a Linear VAST creative. SIMID is not a generic VAST extension and cannot be serialized under NonLinearAds; every `ctv_ad_experience` profile therefore forbids `true`.' ), + NoExplicitNull, ] = None duration_ms_range: Annotated[ list[DurationMsRangeItem] | None, @@ -99,6 +103,7 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): max_length=2, min_length=2, ), + NoExplicitNull, ] = None duration_ms_exact: Annotated[ SchemaInt | None, @@ -106,6 +111,7 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): description='When set, duration must equal exactly this value. Takes precedence over `duration_ms_range` when both ship.', ge=1, ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, @@ -113,6 +119,7 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): description='Minimum placement/player width in pixels. MediaFile rendition dimensions are declared in `media_file_requirements`.', ge=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, @@ -120,6 +127,7 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): description='Maximum placement/player width in pixels. MediaFile rendition dimensions are declared in `media_file_requirements`.', ge=1, ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, @@ -127,6 +135,7 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): description='Minimum placement/player height in pixels. MediaFile rendition dimensions are declared in `media_file_requirements`.', ge=1, ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, @@ -134,34 +143,40 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): description='Maximum placement/player height in pixels. MediaFile rendition dimensions are declared in `media_file_requirements`.', ge=1, ), + NoExplicitNull, ] = None creative_type: Annotated[ CreativeType | None, Field( description='Required VAST creative class: `linear` (in-stream Linear), `nonlinear` (NonLinearAds overlay-class), or `either`. Supersedes `linear_required`; when both are present `creative_type` wins, and validators treat `linear_required: true` with no `creative_type` as `linear`.' ), + NoExplicitNull, ] = None ctv_ad_experience: Annotated[ ctv_ad_experience_1.CtvAdExperience | None, Field( description='CTV experience this option is eligible to serve. On video_vast only `pause`, `screensaver`, `overlay`, `squeezeback`, and `in_scene` are valid (`menu` routes to native_in_feed or sponsored_placement), and `creative_type` MUST be `nonlinear`. Because VAST places `` only under Linear ``, `simid_supported` MUST NOT be true on any of these NonLinear profiles. Per-experience floors: `overlay` and `squeezeback` require a 10s minimum duration; `in_scene` requires a 3s minimum brand-exposure duration and forbids interactivity (`vpaid_enabled` MUST NOT be true); `pause` has no duration floor and ends on viewer or device action.' ), + NoExplicitNull, ] = None motion_level: Annotated[ motion_level_1.CreativeMotionLevel | None, Field(description='Accepted motion class for the rendered creative (AdCOM attrs 21-23).'), + NoExplicitNull, ] = None activation_methods: Annotated[ list[activation_method.CreativeActivationMethod] | None, Field( description='Viewer activation mechanisms this option offers. Activations are engagement events, not impressions.' ), + NoExplicitNull, ] = None linear_required: Annotated[ StrictBool | None, Field( description='Whether the VAST creative must be linear (non-skippable in-stream). Superseded by `creative_type`; retained for pre-3.2 declarations.' ), + NoExplicitNull, ] = None skippable_after_ms: Annotated[ SchemaInt | None, @@ -169,8 +184,11 @@ class CanonicalFormatVastVideo(CanonicalFormatBase): description='When skippable, the buyer-side skip threshold in milliseconds (e.g., 5000 for 5-second skippable pre-roll).', ge=0, ), + NoExplicitNull, ] = None max_wrapper_depth: Annotated[ - SchemaInt | None, Field(description='Maximum VAST wrapper redirect depth permitted.', ge=0) + SchemaInt | None, + Field(description='Maximum VAST wrapper redirect depth permitted.', ge=0), + NoExplicitNull, ] = None - ssl_required: StrictBool | None = None + ssl_required: Annotated[StrictBool | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/attribute_definition.py b/src/adcp/types/domains/governance/attribute_definition.py index 5946ab296..6443be766 100644 --- a/src/adcp/types/domains/governance/attribute_definition.py +++ b/src/adcp/types/domains/governance/attribute_definition.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: governance/attribute_definition.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -18,6 +18,7 @@ class RegulatoryBasi(AdCPBaseModel): jurisdictions: Annotated[ list[str] | None, Field(description='ISO 3166-1 alpha-2 codes where this regulation applies.'), + NoExplicitNull, ] = None summary: Annotated[ str, Field(description='How this regulation defines or restricts the attribute.') @@ -45,28 +46,33 @@ class AttributeDefinition(AdCPBaseModel): regulatory_basis: Annotated[ list[RegulatoryBasi] | None, Field(description='Regulations that define or restrict this attribute category.'), + NoExplicitNull, ] = None includes: Annotated[ list[str] | None, Field( description="Specific data types that fall within this category (e.g., for health_data: 'medical conditions', 'disability status', 'prescription history', 'inferred health from behavioral signals')." ), + NoExplicitNull, ] = None excludes: Annotated[ list[str] | None, Field( description='Data types that might seem related but are explicitly outside this category. Helps with boundary cases.' ), + NoExplicitNull, ] = None signal_patterns: Annotated[ list[str] | None, Field( description="Common signal naming or tagging patterns that indicate this attribute (e.g., 'health:', 'condition_', 'diagnosis_'). Data providers and governance agents use these as hints when signals lack explicit restricted_attributes declarations." ), + NoExplicitNull, ] = None guidance: Annotated[ str | None, Field( description='Implementation notes. Covers edge cases, inferred vs. declared data, and common pitfalls.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/governance/audience_constraints.py b/src/adcp/types/domains/governance/audience_constraints.py index 7364e4f4e..8a77c3fcc 100644 --- a/src/adcp/types/domains/governance/audience_constraints.py +++ b/src/adcp/types/domains/governance/audience_constraints.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: governance/audience_constraints.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import audience_selector @@ -22,6 +22,7 @@ class AudienceConstraints(AdCPBaseModel): description="Desired audience criteria. The seller's targeting should align with these. Each criterion is evaluated independently — the combined targeting should satisfy at least one inclusion criterion.", min_length=1, ), + NoExplicitNull, ] = None exclude: Annotated[ list[audience_selector.AudienceSelector] | None, @@ -29,4 +30,5 @@ class AudienceConstraints(AdCPBaseModel): description="Excluded audience criteria. The seller's targeting must not overlap with these. Exclusions take precedence over inclusions. Used for protected groups, vulnerable communities, regulatory restrictions, or brand safety.", min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/governance/check_governance_request.py b/src/adcp/types/domains/governance/check_governance_request.py index e5ad75148..a1a4e98cb 100644 --- a/src/adcp/types/domains/governance/check_governance_request.py +++ b/src/adcp/types/domains/governance/check_governance_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/check_governance_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, StringConstraints from ..core import brand_ref, business_entity, canonical_proposal @@ -73,6 +73,7 @@ class AudienceDistribution(AdCPBaseModel): Field( description="Description of the baseline when baseline is 'custom' (e.g., 'US adults 18+ with broadband access')." ), + NoExplicitNull, ] = None indices: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[a-z_]+:.+$')], StrictFloat], @@ -85,6 +86,7 @@ class AudienceDistribution(AdCPBaseModel): Field( description='Cumulative audience index values since the governed action started. Same key format as indices (dimension:value). Use for detecting sustained bias drift that may not appear in a single reporting period.' ), + NoExplicitNull, ] = None @@ -99,6 +101,7 @@ class DeliveryMetrics(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None statement_digest: Annotated[ str | None, @@ -106,6 +109,7 @@ class DeliveryMetrics(AdCPBaseModel): description='SHA-256 digest of RFC 8785 JCS over {seller_reference, delivery_metrics}, excluding statement_digest itself. The authenticated submission binds the seller to this digest.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None sequence: Annotated[ SchemaInt | None, @@ -113,19 +117,25 @@ class DeliveryMetrics(AdCPBaseModel): description='Monotonically increasing sequence for this governed action. A statement ID or sequence cannot be reused with different content.', ge=1, ), + NoExplicitNull, ] = None issued_at: Annotated[ - AwareDatetime | None, Field(description='When the seller issued the canonical statement.') + AwareDatetime | None, + Field(description='When the seller issued the canonical statement.'), + NoExplicitNull, ] = None reporting_period: Annotated[ ReportingPeriod, Field(description='Start and end timestamps for the reporting window.') ] spend: Annotated[ - StrictFloat | None, Field(description='Total spend during the reporting period.', ge=0.0) + StrictFloat | None, + Field(description='Total spend during the reporting period.', ge=0.0), + NoExplicitNull, ] = None cumulative_spend: Annotated[ StrictFloat | None, Field(description='Total spend since the governed action started.', ge=0.0), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -133,38 +143,45 @@ class DeliveryMetrics(AdCPBaseModel): description='Currency of spend fields; must match the plan and planned delivery.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None impressions: Annotated[ SchemaInt | None, Field(description='Impressions delivered during the reporting period.', ge=0), + NoExplicitNull, ] = None cumulative_impressions: Annotated[ SchemaInt | None, Field(description='Total impressions since the governed action started.', ge=0), + NoExplicitNull, ] = None geo_distribution: Annotated[ dict[str, StrictFloat] | None, Field( description='Actual geographic distribution. Keys are ISO 3166-1 alpha-2 codes, values are percentages.' ), + NoExplicitNull, ] = None channel_distribution: Annotated[ dict[str, StrictFloat] | None, Field( description='Actual channel distribution. Keys are channel enum values, values are percentages.' ), + NoExplicitNull, ] = None pacing: Annotated[ Pacing | None, Field( description='Whether delivery is ahead of, on track with, or behind the planned pace.' ), + NoExplicitNull, ] = None audience_distribution: Annotated[ AudienceDistribution | None, Field( description='Actual audience composition during the reporting period. Enables mid-flight drift detection when actual delivery skews from planned audience targeting.' ), + NoExplicitNull, ] = None @@ -183,6 +200,7 @@ class DeliveryMetrics2(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None statement_digest: Annotated[ str | None, @@ -190,6 +208,7 @@ class DeliveryMetrics2(AdCPBaseModel): description='SHA-256 digest of RFC 8785 JCS over {seller_reference, delivery_metrics}, excluding statement_digest itself. The authenticated submission binds the seller to this digest.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None sequence: Annotated[ SchemaInt | None, @@ -197,19 +216,25 @@ class DeliveryMetrics2(AdCPBaseModel): description='Monotonically increasing sequence for this governed action. A statement ID or sequence cannot be reused with different content.', ge=1, ), + NoExplicitNull, ] = None issued_at: Annotated[ - AwareDatetime | None, Field(description='When the seller issued the canonical statement.') + AwareDatetime | None, + Field(description='When the seller issued the canonical statement.'), + NoExplicitNull, ] = None reporting_period: Annotated[ ReportingPeriod, Field(description='Start and end timestamps for the reporting window.') ] spend: Annotated[ - StrictFloat | None, Field(description='Total spend during the reporting period.', ge=0.0) + StrictFloat | None, + Field(description='Total spend during the reporting period.', ge=0.0), + NoExplicitNull, ] = None cumulative_spend: Annotated[ StrictFloat | None, Field(description='Total spend since the governed action started.', ge=0.0), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -217,38 +242,45 @@ class DeliveryMetrics2(AdCPBaseModel): description='Currency of spend fields; must match the plan and planned delivery.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None impressions: Annotated[ SchemaInt | None, Field(description='Impressions delivered during the reporting period.', ge=0), + NoExplicitNull, ] = None cumulative_impressions: Annotated[ SchemaInt | None, Field(description='Total impressions since the governed action started.', ge=0), + NoExplicitNull, ] = None geo_distribution: Annotated[ dict[str, StrictFloat] | None, Field( description='Actual geographic distribution. Keys are ISO 3166-1 alpha-2 codes, values are percentages.' ), + NoExplicitNull, ] = None channel_distribution: Annotated[ dict[str, StrictFloat] | None, Field( description='Actual channel distribution. Keys are channel enum values, values are percentages.' ), + NoExplicitNull, ] = None pacing: Annotated[ Pacing | None, Field( description='Whether delivery is ahead of, on track with, or behind the planned pace.' ), + NoExplicitNull, ] = None audience_distribution: Annotated[ AudienceDistribution1 | None, Field( description='Actual audience composition during the reporting period. Enables mid-flight drift detection when actual delivery skews from planned audience targeting.' ), + NoExplicitNull, ] = None @@ -261,7 +293,7 @@ class Subject(AdCPBaseModel): agent_url: Annotated[ AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject6(AdCPBaseModel): @@ -295,8 +327,9 @@ class Subject6(AdCPBaseModel): description='Optional SHA-256 pin for the exact content or immutable snapshot identified by this resource subject. This is part of the complete typed subject identity and is distinct from AttestationReference.content_digest, which pins credential bytes.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Subject8(Subject): @@ -314,7 +347,7 @@ class Subject4(AdCPBaseModel): type: Literal['brand'] = 'brand' resource_type: Literal['https://adcontextprotocol.org/claims/subjects/signal'] = 'https://adcontextprotocol.org/claims/subjects/signal' brand: brand_ref.BrandReference - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class RuntimeAttestation(AttestationReference): @@ -337,6 +370,7 @@ class RuntimeAttestation(AttestationReference): ], title='Attestation Subject', ), + NoExplicitNull, ] = None @@ -349,6 +383,7 @@ class CheckGovernanceRequest2(AdcpVersionEnvelope): Field( description="Campaign governance plan identifier. Required on the initial intent or availability check, before a governance_context exists. Optional on subsequent checks: the governance agent derives the plan from its own signed governance_context. If both are present, the governance agent MUST reject the request when plan_id does not match the token's plan binding. Services MUST treat governance_context as authoritative and MUST NOT require a buyer to disclose plan_id. A plan is owned by the authenticated buyer principal that synchronized it; plan_id is an identifier, not an account credential." ), + NoExplicitNull, ] = None caller: Annotated[ AnyUrl, @@ -361,42 +396,49 @@ class CheckGovernanceRequest2(AdcpVersionEnvelope): Field( description="The type of financial commitment being checked. Determines which budget allocation (if any) to validate against. Defaults to 'media_buy' when omitted." ), + NoExplicitNull, ] = purchase_type_1.PurchaseType.media_buy target_agent: Annotated[ AnyUrl | None, Field( description='Exact agent URL of the downstream service that will receive the governed task. Required on intent checks and copied byte-for-byte into the signed governance_context aud claim. This routing and authorization field is not part of payload: payload remains exactly the downstream task arguments. A consultation re-check MUST use the same target_agent.' ), + NoExplicitNull, ] = None proposed_commitment: Annotated[ ProposedCommitment | None, Field( description='Task-neutral monetary amount the intent would authorize. For update_media_buy and control_media_buy this is the buyer-computed positive incremental commitment, not the post-update total. For accept_proposal it is derived from the supplied proposal commercial_terms; for buy_products it is derived from the purchase payload. Amount 0 explicitly represents a verified no-cost action. The governance agent persists this value as authoritative check state.' ), + NoExplicitNull, ] = None execution_commitment: Annotated[ ExecutionCommitment | None, Field( description='Seller-computed positive incremental commitment for a MediaBuy execution check. The seller MUST derive this atomically from its authoritative proposal or current revision and the requested operation, and the governance agent MUST reject it when it exceeds the prior intent ceiling or uses another currency.' ), + NoExplicitNull, ] = None tool: Annotated[ str | None, Field( description="The AdCP tool being checked (e.g., 'create_media_buy', 'acquire_rights', 'activate_signal'). Present on intent checks (orchestrator). The governance agent uses the presence of tool+payload to identify an intent check." ), + NoExplicitNull, ] = None payload: Annotated[ dict[str, Any] | None, Field( description='The full downstream tool arguments exactly as they will be sent to target_agent. Present on intent checks. Governance routing metadata is carried by target_agent, never injected into this object. The governance agent can inspect any field to validate against the plan.' ), + NoExplicitNull, ] = None proposal: Annotated[ canonical_proposal.CanonicalProposal | None, Field( description='Exact committed proposal being authorized for accept_proposal. The governance agent verifies proposal.terms_digest against commercial_terms and binds that digest into its decision state; the downstream payload carries the same digest without repeating the terms.' ), + NoExplicitNull, ] = None governance_context: Annotated[ str | None, @@ -406,6 +448,7 @@ class CheckGovernanceRequest2(AdcpVersionEnvelope): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None consultation_context: Annotated[ str | None, @@ -415,22 +458,26 @@ class CheckGovernanceRequest2(AdcpVersionEnvelope): min_length=1, pattern='^[A-Za-z0-9_.:-]+$', ), + NoExplicitNull, ] = None phase: Annotated[ governance_phase.GovernancePhase | None, Field( description="The phase of an execution-shaped governed action. Ignored for intent-shaped tool+payload requests. 'purchase': initial commitment; 'modification': update to an existing commitment; 'delivery': periodic delivery reporting. Defaults to purchase if omitted. planned_delivery_1.media_buy_id is optional for purchase and required for modification/delivery." ), + NoExplicitNull, ] = governance_phase.GovernancePhase.purchase planned_delivery: Annotated[ planned_delivery_1.PlannedDelivery | None, Field(description='What the seller will actually deliver. Present on execution checks.'), + NoExplicitNull, ] = None delivery_metrics: Annotated[ DeliveryMetrics | None, Field( description="Seller-attributed canonical delivery statement. MUST be present for 'delivery' phase. The authenticated seller binds one immutable statement_id and digest to a monotonically increasing sequence; the buyer can later submit the copy it received or an independent observation through report_plan_outcome." ), + NoExplicitNull, ] = None modification_summary: Annotated[ str | None, @@ -438,6 +485,7 @@ class CheckGovernanceRequest2(AdcpVersionEnvelope): description="Human-readable summary of what changed. SHOULD be present for 'modification' phase.", max_length=1000, ), + NoExplicitNull, ] = None runtime_attestations: Annotated[ list[RuntimeAttestation] | None, @@ -446,15 +494,17 @@ class CheckGovernanceRequest2(AdcpVersionEnvelope): max_length=10, min_length=1, ), + NoExplicitNull, ] = None invoice_recipient: Annotated[ business_entity.BusinessEntity | None, Field( description='Invoice recipient from the purchase request. MUST be present when the tool payload includes invoice_recipient, so the governance agent can validate billing changes.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class RuntimeAttestation1(AttestationReference): @@ -477,6 +527,7 @@ class RuntimeAttestation1(AttestationReference): ], title='Attestation Subject', ), + NoExplicitNull, ] = None @@ -489,6 +540,7 @@ class CheckGovernanceRequest3(CheckGovernanceRequest1, CheckGovernanceRequest2): Field( description="Campaign governance plan identifier. Required on the initial intent or availability check, before a governance_context exists. Optional on subsequent checks: the governance agent derives the plan from its own signed governance_context. If both are present, the governance agent MUST reject the request when plan_id does not match the token's plan binding. Services MUST treat governance_context as authoritative and MUST NOT require a buyer to disclose plan_id. A plan is owned by the authenticated buyer principal that synchronized it; plan_id is an identifier, not an account credential." ), + NoExplicitNull, ] = None caller: Annotated[ AnyUrl, @@ -501,42 +553,49 @@ class CheckGovernanceRequest3(CheckGovernanceRequest1, CheckGovernanceRequest2): Field( description="The type of financial commitment being checked. Determines which budget allocation (if any) to validate against. Defaults to 'media_buy' when omitted." ), + NoExplicitNull, ] = purchase_type_1.PurchaseType.media_buy target_agent: Annotated[ AnyUrl | None, Field( description='Exact agent URL of the downstream service that will receive the governed task. Required on intent checks and copied byte-for-byte into the signed governance_context aud claim. This routing and authorization field is not part of payload: payload remains exactly the downstream task arguments. A consultation re-check MUST use the same target_agent.' ), + NoExplicitNull, ] = None proposed_commitment: Annotated[ ProposedCommitment | None, Field( description='Task-neutral monetary amount the intent would authorize. For update_media_buy and control_media_buy this is the buyer-computed positive incremental commitment, not the post-update total. For accept_proposal it is derived from the supplied proposal commercial_terms; for buy_products it is derived from the purchase payload. Amount 0 explicitly represents a verified no-cost action. The governance agent persists this value as authoritative check state.' ), + NoExplicitNull, ] = None execution_commitment: Annotated[ ExecutionCommitment | None, Field( description='Seller-computed positive incremental commitment for a MediaBuy execution check. The seller MUST derive this atomically from its authoritative proposal or current revision and the requested operation, and the governance agent MUST reject it when it exceeds the prior intent ceiling or uses another currency.' ), + NoExplicitNull, ] = None tool: Annotated[ str | None, Field( description="The AdCP tool being checked (e.g., 'create_media_buy', 'acquire_rights', 'activate_signal'). Present on intent checks (orchestrator). The governance agent uses the presence of tool+payload to identify an intent check." ), + NoExplicitNull, ] = None payload: Annotated[ dict[str, Any] | None, Field( description='The full downstream tool arguments exactly as they will be sent to target_agent. Present on intent checks. Governance routing metadata is carried by target_agent, never injected into this object. The governance agent can inspect any field to validate against the plan.' ), + NoExplicitNull, ] = None proposal: Annotated[ canonical_proposal.CanonicalProposal | None, Field( description='Exact committed proposal being authorized for accept_proposal. The governance agent verifies proposal.terms_digest against commercial_terms and binds that digest into its decision state; the downstream payload carries the same digest without repeating the terms.' ), + NoExplicitNull, ] = None governance_context: Annotated[ str | None, @@ -546,6 +605,7 @@ class CheckGovernanceRequest3(CheckGovernanceRequest1, CheckGovernanceRequest2): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None consultation_context: Annotated[ str | None, @@ -555,22 +615,26 @@ class CheckGovernanceRequest3(CheckGovernanceRequest1, CheckGovernanceRequest2): min_length=1, pattern='^[A-Za-z0-9_.:-]+$', ), + NoExplicitNull, ] = None phase: Annotated[ governance_phase.GovernancePhase | None, Field( description="The phase of an execution-shaped governed action. Ignored for intent-shaped tool+payload requests. 'purchase': initial commitment; 'modification': update to an existing commitment; 'delivery': periodic delivery reporting. Defaults to purchase if omitted. planned_delivery_1.media_buy_id is optional for purchase and required for modification/delivery." ), + NoExplicitNull, ] = governance_phase.GovernancePhase.purchase planned_delivery: Annotated[ planned_delivery_1.PlannedDelivery | None, Field(description='What the seller will actually deliver. Present on execution checks.'), + NoExplicitNull, ] = None delivery_metrics: Annotated[ DeliveryMetrics2 | None, Field( description="Seller-attributed canonical delivery statement. MUST be present for 'delivery' phase. The authenticated seller binds one immutable statement_id and digest to a monotonically increasing sequence; the buyer can later submit the copy it received or an independent observation through report_plan_outcome." ), + NoExplicitNull, ] = None modification_summary: Annotated[ str | None, @@ -578,6 +642,7 @@ class CheckGovernanceRequest3(CheckGovernanceRequest1, CheckGovernanceRequest2): description="Human-readable summary of what changed. SHOULD be present for 'modification' phase.", max_length=1000, ), + NoExplicitNull, ] = None runtime_attestations: Annotated[ list[RuntimeAttestation1] | None, @@ -586,15 +651,17 @@ class CheckGovernanceRequest3(CheckGovernanceRequest1, CheckGovernanceRequest2): max_length=10, min_length=1, ), + NoExplicitNull, ] = None invoice_recipient: Annotated[ business_entity.BusinessEntity | None, Field( description='Invoice recipient from the purchase request. MUST be present when the tool payload includes invoice_recipient, so the governance agent can validate billing changes.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class CheckGovernanceRequest(AdcpRequest, CheckGovernanceRequest3): diff --git a/src/adcp/types/domains/governance/check_governance_response.py b/src/adcp/types/domains/governance/check_governance_response.py index 95abdfd38..81cc41cd2 100644 --- a/src/adcp/types/domains/governance/check_governance_response.py +++ b/src/adcp/types/domains/governance/check_governance_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/check_governance_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..core import context as context_1 @@ -103,6 +103,7 @@ class ActionBinding(AdCPBaseModel): description='SHA-256 digest of the domain-defined canonical action preimage.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None @@ -121,17 +122,21 @@ class Finding(AdCPBaseModel): Field( description="ID of the policy that triggered this finding. May reference a registry policy (with source: registry) or a bespoke inline policy (with source: inline). Bespoke policy_ids are unique within their authoring container; use source_plan_id when findings aggregate across multiple plans (e.g., portfolio evaluations). When the violation traces to a producer-tagged surface (feature-requirement, creative-feature-result, or validation-result feature) carrying policy_id, governance agents SHOULD echo that policy_id here for end-to-end traceability, and MUST NOT invent a policy_id that wasn't present on the originating surface. See /docs/governance/policy-attribution." ), + NoExplicitNull, ] = None source_plan_id: Annotated[ str | None, Field( description="For portfolio or aggregated evaluations where findings draw on bespoke policies from multiple member plans: identifies the plan whose policy triggered this finding. Omit when the finding's policy_id is unambiguous within the response context (e.g., single-plan check_governance)." ), + NoExplicitNull, ] = None severity: escalation_severity.EscalationSeverity explanation: Annotated[str, Field(description='Human-readable description of the issue.')] details: Annotated[ - dict[str, Any] | None, Field(description='Structured details for programmatic consumption.') + dict[str, Any] | None, + Field(description='Structured details for programmatic consumption.'), + NoExplicitNull, ] = None confidence: Annotated[ StrictFloat | None, @@ -140,12 +145,14 @@ class Finding(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None uncertainty_reason: Annotated[ str | None, Field( description="Explanation of why confidence is below 1.0 (e.g., 'Targeting includes regions that partially overlap jurisdiction boundaries'). Present when confidence is below a governance-agent-defined threshold." ), + NoExplicitNull, ] = None attestation_reference_digest: Annotated[ str | None, @@ -153,6 +160,7 @@ class Finding(AdCPBaseModel): description='When this finding relies on or reports a runtime attestation evaluation, the reference_digest of the exact AttestationReference in runtime_attestation_evaluations[]. Findings MUST NOT copy credential bodies or treat a presenter assertion as verified evidence.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None @@ -194,12 +202,14 @@ def _status_to_verdict(cls, data: Any) -> Any: Field( description='Check shape that produced the verdict. Required for the cross-role governance_enforcement contract. Its presence selects the modern verdict-specific response rules; its absence selects the deprecated legacy 3.x compatibility shape. Intent checks may return conditions; execution checks are binary approved or denied.' ), + NoExplicitNull, ] = None plan_id: Annotated[ str | None, Field( description='Plan identifier echoed on an initial plan-addressed check. Optional on continuation checks addressed by governance_context; services do not need this value and MUST treat the token binding as authoritative.' ), + NoExplicitNull, ] = None explanation: Annotated[ str, Field(description='Human-readable explanation of the governance decision.') @@ -209,12 +219,14 @@ def _status_to_verdict(cls, data: Any) -> Any: Field( description="Specific issues found during the governance check. Present when verdict is 'denied' or 'conditions'. MAY also be present on 'approved' for informational findings (e.g., budget approaching limit)." ), + NoExplicitNull, ] = None conditions: Annotated[ list[Condition] | None, Field( description="Intent-phase counterproposal. Present only when verdict is 'conditions'. It does not authorize execution and MUST NOT be returned for execution or lifecycle checks. Each field path is rooted at the complete check_governance request arguments, so both payload.* and proposed_commitment.* can be addressed. After applying conditions, the caller MUST re-call check_governance with the adjusted parameters and receive approved before proceeding." ), + NoExplicitNull, ] = None consultation_context: Annotated[ str | None, @@ -224,42 +236,49 @@ def _status_to_verdict(cls, data: Any) -> Any: min_length=1, pattern='^[A-Za-z0-9_.:-]+$', ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description="When this approval expires. In the cross-role shape, present only when verdict is 'approved'. Deprecated legacy conditions responses may also carry it for 3.x compatibility. The caller must act before this time or re-call check_governance. A lapsed approval is no approval." ), + NoExplicitNull, ] = None next_check: Annotated[ AwareDatetime | None, Field( description='When the seller should next call check_governance with delivery metrics. Present when the governance agent expects ongoing delivery reporting.' ), + NoExplicitNull, ] = None delivery_statement: Annotated[ DeliveryStatement | None, Field( description='Canonical seller-attributed delivery statement retained by governance. Present on delivery execution checks. The buyer binds any later observation to this exact statement through report_plan_outcome.' ), + NoExplicitNull, ] = None categories_evaluated: Annotated[ list[str] | None, Field( description="Governance categories evaluated during this check. Each value is an **agent-internal** label (e.g., `budget_authority`, `regulatory_compliance`, or any internal-reviewer key the agent's policy model defines) — not a protocol-level enum. Since one governance agent per account composes all specialist review behind its single endpoint, `categories_evaluated` is how that internal decomposition surfaces to auditors. Consumers MUST treat values as opaque labels for display and audit, not as a machine-level contract." ), + NoExplicitNull, ] = None policies_evaluated: Annotated[ list[str] | None, Field( description="Policy IDs evaluated during this check. Includes registry policy IDs (resolved via the policy registry) and any inline `policy_id`s declared in the plan's `custom_policies`." ), + NoExplicitNull, ] = None mode: Annotated[ governance_mode.GovernanceMode | None, Field( description='Governance enforcement mode active when this check was evaluated. Allows counterparties, regulators, and auditors to distinguish whether a finding blocked execution (enforce) or was logged silently (audit).' ), + NoExplicitNull, ] = None runtime_attestation_evaluations: Annotated[ list[RuntimeAttestationEvaluation] | None, @@ -268,6 +287,7 @@ def _status_to_verdict(cls, data: Any) -> Any: max_length=10, min_length=1, ), + NoExplicitNull, ] = None runtime_attestation_binding_digest: Annotated[ str | None, @@ -275,6 +295,7 @@ def _status_to_verdict(cls, data: Any) -> Any: description='SHA-256 of RFC 8785 JCS({ evaluations: runtime_attestation_evaluations, findings: attestation_bound_findings }), where attestation_bound_findings is the response findings[] subset carrying attestation_reference_digest, preserved in response order. Required whenever runtime_attestation_evaluations is present. The governance_context JWS carries this exact value as runtime_attestation_binding_digest; get_plan_audit_logs retains ordered {reference, evaluation} pairs so auditors can first recompute every reference_digest and then prove which evaluations and findings the signed decision relied on.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None governance_context: Annotated[ str | None, @@ -284,6 +305,7 @@ def _status_to_verdict(cls, data: Any) -> Any: min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/get_plan_audit_logs_request.py b/src/adcp/types/domains/governance/get_plan_audit_logs_request.py index c64043c8a..9602dd4c3 100644 --- a/src/adcp/types/domains/governance/get_plan_audit_logs_request.py +++ b/src/adcp/types/domains/governance/get_plan_audit_logs_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/get_plan_audit_logs_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import context as context_1 @@ -29,6 +30,7 @@ class GetPlanAuditLogsRequest(AdcpRequest, AdcpVersionEnvelope): description='Plan IDs to retrieve. For a single plan, pass a one-element array. Plans uniquely scope account and operator; do not include a separate `account` field — the governance agent resolves account from each plan. Including `account` is rejected by `additionalProperties: false`.', min_length=1, ), + NoExplicitNull, ] = None portfolio_plan_ids: Annotated[ list[str] | None, @@ -36,6 +38,7 @@ class GetPlanAuditLogsRequest(AdcpRequest, AdcpVersionEnvelope): description='Portfolio plan IDs. The governance agent expands each to its member_plan_ids and returns combined audit data.', min_length=1, ), + NoExplicitNull, ] = None governance_contexts: Annotated[ list[str] | None, @@ -43,6 +46,7 @@ class GetPlanAuditLogsRequest(AdcpRequest, AdcpVersionEnvelope): description='Filter audit entries by governance context. Returns only checks and outcomes that share these governance contexts, enabling lifecycle tracing across purchase types.', min_length=1, ), + NoExplicitNull, ] = None purchase_types: Annotated[ list[purchase_type.PurchaseType] | None, @@ -50,12 +54,15 @@ class GetPlanAuditLogsRequest(AdcpRequest, AdcpVersionEnvelope): description="Filter audit entries by purchase type. Returns only checks and outcomes matching these purchase types (e.g., ['rights_license'] to see all rights activity).", min_length=1, ), + NoExplicitNull, ] = None include_entries: Annotated[ - StrictBool | None, Field(description='Include the full audit trail. Default: false.') + StrictBool | None, + Field(description='Include the full audit trail. Default: false.'), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> GetPlanAuditLogsRequest: diff --git a/src/adcp/types/domains/governance/get_plan_audit_logs_response.py b/src/adcp/types/domains/governance/get_plan_audit_logs_response.py index 5e41b33ee..ed50527f8 100644 --- a/src/adcp/types/domains/governance/get_plan_audit_logs_response.py +++ b/src/adcp/types/domains/governance/get_plan_audit_logs_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/get_plan_audit_logs_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import attestation_evaluation, attestation_reference @@ -38,58 +38,70 @@ class Budget(AdCPBaseModel): extra='forbid', ) authorized: Annotated[ - StrictFloat | None, Field(description='Total authorized budget from the plan.') + StrictFloat | None, + Field(description='Total authorized budget from the plan.'), + NoExplicitNull, ] = None - accounting_mode: AccountingMode | None = None + accounting_mode: Annotated[AccountingMode | None, NoExplicitNull] = None committed: Annotated[ StrictFloat | None, Field( description='Legacy 3.x alias for net_committed after verified headroom-restoring adjustments.' ), + NoExplicitNull, ] = None gross_committed: Annotated[ StrictFloat | None, Field( description='Total authoritative commitment recorded from completed outcomes before adjustments.' ), + NoExplicitNull, ] = None adjustments_reported: Annotated[ StrictFloat | None, Field( description='Sum of all append-only adjustment amounts, including audit-only refunds, credits, and makegoods.' ), + NoExplicitNull, ] = None adjustments_verified: Annotated[ StrictFloat | None, Field( description='Sum of buyer-accepted economic reductions in plan currency; accepted makegoods contribute zero.' ), + NoExplicitNull, ] = None net_cost: Annotated[ StrictFloat | None, Field( description='Gross commitment minus verified economic reductions, independent of whether accounting_mode restores headroom.' ), + NoExplicitNull, ] = None headroom_restored: Annotated[ StrictFloat | None, Field( description='Sum of verified adjustments eligible to restore reusable headroom under accounting_mode.' ), + NoExplicitNull, ] = None net_committed: Annotated[ StrictFloat | None, Field(description='Current plan obligation after verified headroom-restoring adjustments.'), + NoExplicitNull, ] = None ledger_committed: Annotated[ StrictFloat | None, Field(description='Current amount charged against plan headroom under accounting_mode.'), + NoExplicitNull, + ] = None + remaining: Annotated[ + StrictFloat | None, Field(description='Authorized minus committed.'), NoExplicitNull ] = None - remaining: Annotated[StrictFloat | None, Field(description='Authorized minus committed.')] = ( - None - ) utilization_pct: Annotated[ - StrictFloat | None, Field(description='Committed as a percentage of authorized.') + StrictFloat | None, + Field(description='Committed as a percentage of authorized.'), + NoExplicitNull, ] = None @@ -98,10 +110,12 @@ class ChannelAllocation(AdCPBaseModel): extra='forbid', ) committed: Annotated[ - StrictFloat | None, Field(description='Budget committed to this channel.') + StrictFloat | None, Field(description='Budget committed to this channel.'), NoExplicitNull ] = None pct: Annotated[ - StrictFloat | None, Field(description="Channel's share of the authorized total budget.") + StrictFloat | None, + Field(description="Channel's share of the authorized total budget."), + NoExplicitNull, ] = None @@ -109,14 +123,15 @@ class Statuses(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - approved: SchemaInt | None = None - denied: SchemaInt | None = None - conditions: SchemaInt | None = None + approved: Annotated[SchemaInt | None, NoExplicitNull] = None + denied: Annotated[SchemaInt | None, NoExplicitNull] = None + conditions: Annotated[SchemaInt | None, NoExplicitNull] = None human_reviewed: Annotated[ SchemaInt | None, Field( description='Supplementary count of checks that went through internal human review. These checks are also counted in approved or denied.' ), + NoExplicitNull, ] = None @@ -129,9 +144,10 @@ class Escalation(AdCPBaseModel): resolution: Annotated[ str | None, Field(description="How it was resolved (e.g., 'approved_by_human', 'rejected_by_human')."), + NoExplicitNull, ] = None resolved_at: Annotated[ - AwareDatetime | None, Field(description='ISO 8601 resolution timestamp.') + AwareDatetime | None, Field(description='ISO 8601 resolution timestamp.'), NoExplicitNull ] = None @@ -152,6 +168,7 @@ class Thresholds(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None escalation_rate_min: Annotated[ StrictFloat | None, @@ -160,10 +177,12 @@ class Thresholds(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None auto_approval_rate_max: Annotated[ StrictFloat | None, Field(description='Maximum acceptable auto-approval rate.', ge=0.0, le=1.0), + NoExplicitNull, ] = None human_override_rate_max: Annotated[ StrictFloat | None, @@ -172,6 +191,7 @@ class Thresholds(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None @@ -182,16 +202,19 @@ class DriftMetrics(AdCPBaseModel): escalation_rate: Annotated[ StrictFloat | None, Field(description='Fraction of checks that resulted in escalation.', ge=0.0, le=1.0), + NoExplicitNull, ] = None escalation_rate_trend: Annotated[ EscalationRateTrend | None, Field(description="Direction of escalation rate over the plan's lifetime."), + NoExplicitNull, ] = None auto_approval_rate: Annotated[ StrictFloat | None, Field( description='Fraction of checks approved without human intervention.', ge=0.0, le=1.0 ), + NoExplicitNull, ] = None human_override_rate: Annotated[ StrictFloat | None, @@ -200,6 +223,7 @@ class DriftMetrics(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None mean_confidence: Annotated[ StrictFloat | None, @@ -208,12 +232,14 @@ class DriftMetrics(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None thresholds: Annotated[ Thresholds | None, Field( description='Organization-defined thresholds for drift metrics. When a metric crosses its threshold, the governance agent SHOULD include a finding on the next check. Set by the organization in governance agent configuration, echoed here for visibility.' ), + NoExplicitNull, ] = None @@ -222,34 +248,42 @@ class Summary(AdCPBaseModel): extra='forbid', ) checks_performed: Annotated[ - SchemaInt | None, Field(description='Total governance checks performed.') + SchemaInt | None, Field(description='Total governance checks performed.'), NoExplicitNull ] = None outcomes_reported: Annotated[ - SchemaInt | None, Field(description='Total outcomes reported.') + SchemaInt | None, Field(description='Total outcomes reported.'), NoExplicitNull ] = None adjustments_reported: Annotated[ - SchemaInt | None, Field(description='Total append-only adjustment records reported.') + SchemaInt | None, + Field(description='Total append-only adjustment records reported.'), + NoExplicitNull, ] = None adjustments_verified: Annotated[ SchemaInt | None, Field( description='Count of adjustment records accepted by the plan owner, including makegoods.' ), + NoExplicitNull, ] = None statuses: Annotated[ - Statuses | None, Field(description='Count of each governance check status.') + Statuses | None, Field(description='Count of each governance check status.'), NoExplicitNull ] = None findings_count: Annotated[ - SchemaInt | None, Field(description='Total findings across all checks and outcomes.') + SchemaInt | None, + Field(description='Total findings across all checks and outcomes.'), + NoExplicitNull, ] = None escalations: Annotated[ - list[Escalation] | None, Field(description='All escalations and their resolutions.') + list[Escalation] | None, + Field(description='All escalations and their resolutions.'), + NoExplicitNull, ] = None drift_metrics: Annotated[ DriftMetrics | None, Field( description='Aggregate governance metrics for detecting oversight drift. A declining escalation rate may indicate well-calibrated governance or eroding human oversight -- surfacing the trend lets the organization make that judgment.' ), + NoExplicitNull, ] = None @@ -269,12 +303,13 @@ class Finding(AdCPBaseModel): extra='forbid', ) category_id: str - policy_id: str | None = None + policy_id: Annotated[str | None, NoExplicitNull] = None source_plan_id: Annotated[ str | None, Field( description='Plan whose policy triggered this finding when the audit entry aggregates findings across plans.' ), + NoExplicitNull, ] = None severity: escalation_severity.EscalationSeverity explanation: str @@ -283,13 +318,15 @@ class Finding(AdCPBaseModel): Field( description='Structured finding details retained exactly as emitted by check_governance.' ), + NoExplicitNull, ] = None - confidence: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0)] = None + confidence: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0), NoExplicitNull] = None uncertainty_reason: Annotated[ str | None, Field( description='Reason the finding carries less than full confidence, retained exactly as emitted by check_governance.' ), + NoExplicitNull, ] = None attestation_reference_digest: Annotated[ str | None, @@ -297,6 +334,7 @@ class Finding(AdCPBaseModel): description='reference_digest of the runtime attestation evaluation this finding relied on.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None @@ -367,19 +405,22 @@ class Delivery(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None source: Annotated[ Source | None, Field( description='Whether the buyer is forwarding the seller statement it received or reporting an independently measured observation. A forwarded seller statement is not independent evidence.' ), + NoExplicitNull, ] = None - observed_at: AwareDatetime | None = None + observed_at: Annotated[AwareDatetime | None, NoExplicitNull] = None seller_statement_id: Annotated[ str | None, Field( description='Seller statement identifier as received by the buyer. Required for seller_statement_copy.' ), + NoExplicitNull, ] = None seller_statement_digest: Annotated[ str | None, @@ -387,23 +428,27 @@ class Delivery(AdCPBaseModel): description='Digest received with the seller statement. Governance compares it to the seller-authenticated canonical digest.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None reporting_period: Annotated[ ReportingPeriod | None, Field(description='Start and end timestamps for the reporting window.'), + NoExplicitNull, ] = None cumulative_spend: Annotated[ StrictFloat | None, Field(description='Buyer-attributed cumulative spend for the governed action.', ge=0.0), + NoExplicitNull, ] = None - currency: Annotated[str | None, Field(pattern='^[A-Z]{3}$')] = None + currency: Annotated[str | None, Field(pattern='^[A-Z]{3}$'), NoExplicitNull] = None period_closed: Annotated[ StrictBool | None, Field( description='Buyer-declared operational closure of this governance reporting period. When true, governance freezes the period after recording this observation. An unresolved mismatch becomes closed_unresolved and no longer blocks later governance. Closure is not final billing or commercial settlement.' ), + NoExplicitNull, ] = False - impressions: Annotated[SchemaInt | None, Field(ge=0)] = None + impressions: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None class DeliveryReconciliationStatus(StrEnum): @@ -463,29 +508,36 @@ class GovernedAction(AdCPBaseModel): adjustments_reported: Annotated[ StrictFloat | None, Field(description='Sum of all append-only adjustment amounts for this action.'), + NoExplicitNull, ] = None adjustments_verified: Annotated[ - StrictFloat | None, Field(description='Buyer-accepted economic reductions for this action.') + StrictFloat | None, + Field(description='Buyer-accepted economic reductions for this action.'), + NoExplicitNull, ] = None net_cost: Annotated[ StrictFloat | None, Field(description='Gross action commitment minus verified economic reductions.'), + NoExplicitNull, ] = None headroom_restored: Annotated[ StrictFloat | None, Field( description='Verified adjustments eligible to restore headroom under the plan accounting mode.' ), + NoExplicitNull, ] = None net_committed: Annotated[ StrictFloat | None, Field(description='Current ledger obligation for this action after restored headroom.'), + NoExplicitNull, ] = None - seller_reported_spend: Annotated[StrictFloat | None, Field(ge=0.0)] = None - buyer_observed_spend: Annotated[StrictFloat | None, Field(ge=0.0)] = None + seller_reported_spend: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + buyer_observed_spend: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None delivery_reporting_period: Annotated[ DeliveryReportingPeriod | None, Field(description='Current seller statement period summarized for this governed action.'), + NoExplicitNull, ] = None conservative_exposure: Annotated[ StrictFloat | None, @@ -493,13 +545,17 @@ class GovernedAction(AdCPBaseModel): description='Greater of seller-reported and buyer-observed spend while evidence is unresolved.', ge=0.0, ), + NoExplicitNull, + ] = None + delivery_reconciliation_status: Annotated[ + DeliveryReconciliationStatus | None, NoExplicitNull ] = None - delivery_reconciliation_status: DeliveryReconciliationStatus | None = None delivery_period_state: Annotated[ DeliveryPeriodState | None, Field( description='Whether the current governance reporting period remains actionable. Closure is not final billing.' ), + NoExplicitNull, ] = None check_count: Annotated[ SchemaInt, Field(description='Number of governance checks performed for this action.') @@ -509,6 +565,7 @@ class GovernedAction(AdCPBaseModel): Field( description="The seller's identifier for the resource (e.g., media_buy_id, rights_grant_id). Present when reported via report_plan_outcome." ), + NoExplicitNull, ] = None @@ -532,88 +589,105 @@ class Entry(AdCPBaseModel): Field( description='Plan this entry belongs to. Present when querying multiple plans or a portfolio.' ), + NoExplicitNull, ] = None caller: Annotated[ AnyUrl | None, Field( description='URL of the agent that made the request. Resolved from the credentials used on the governance callback.' ), + NoExplicitNull, + ] = None + tool: Annotated[ + str | None, Field(description='The AdCP tool (present for check entries).'), NoExplicitNull ] = None - tool: Annotated[str | None, Field(description='The AdCP tool (present for check entries).')] = ( - None - ) verdict: Annotated[ governance_decision.GovernanceDecision | None, Field( description='Governance verdict (present for check entries). Renamed from `status` in 3.1 alongside check-governance-response for vocabulary consistency.' ), + NoExplicitNull, ] = None check_type: Annotated[ CheckType | None, Field( description='Whether the check was an intent check (orchestrator) or execution check (seller). Inferred from the fields present on the original check request. Present for check entries.' ), + NoExplicitNull, ] = None mode: Annotated[ governance_mode.GovernanceMode | None, Field( description="Governance mode active at the moment this specific check was evaluated. Governance agents SHOULD populate this field on check entries, recording the mode from their runtime configuration at the moment check_governance was processed — not derived from a plan field. This is a per-check value: if the operator changes mode between checks for the same governed action, each entry records the mode active for that entry. A future `governed_actions[].mode` field would describe the action's current mode, which may differ from the most recent entry's `mode` if the plan has since been re-synced. Absent for outcome entries and for pre-3.1 governance agents that do not surface mode on audit responses." ), + NoExplicitNull, ] = None explanation: Annotated[ str | None, Field( description='Human-readable explanation of the governance decision (present for check entries).' ), + NoExplicitNull, ] = None policies_evaluated: Annotated[ list[str] | None, Field( description="Policy IDs evaluated during this check. Includes registry policy IDs (resolved via the policy registry) and any inline `policy_id`s declared in the plan's `custom_policies`. Present for check entries." ), + NoExplicitNull, ] = None categories_evaluated: Annotated[ list[str] | None, Field( description="Governance categories evaluated (e.g., 'budget_authority', 'regulatory_compliance'). Present for check entries." ), + NoExplicitNull, ] = None findings: Annotated[ list[Finding] | None, Field( description='Findings from this check or outcome. Same structure as check_governance response findings.' ), + NoExplicitNull, ] = None delivery_statement: Annotated[ DeliveryStatement | None, Field(description='Canonical seller statement retained on a delivery check entry.'), + NoExplicitNull, ] = None outcome: Annotated[ outcome_type.OutcomeType | None, Field(description='Outcome type (present for outcome entries).'), + NoExplicitNull, ] = None error: Annotated[ reported_outcome_error.ReportedOutcomeError | None, Field( description='Buyer-attributed error retained from a failed report_plan_outcome call. This is a copy or classification supplied by the reporter, not authenticated seller evidence.' ), + NoExplicitNull, ] = None outcome_id: Annotated[ - str | None, Field(description='Source completed outcome (present for adjustment entries).') + str | None, + Field(description='Source completed outcome (present for adjustment entries).'), + NoExplicitNull, ] = None seller_adjustment_id: Annotated[ str | None, Field( description='Seller-issued source adjustment identifier (present for adjustment entries).' ), + NoExplicitNull, ] = None adjustment_type: Annotated[ AdjustmentType | None, Field(description='Commercial adjustment type (present for adjustment entries).'), + NoExplicitNull, ] = None amount: Annotated[ Amount | None, Field(description='Adjustment amount and currency (present for adjustment entries).'), + NoExplicitNull, ] = None headroom_restored: Annotated[ StrictFloat | None, @@ -621,16 +695,22 @@ class Entry(AdCPBaseModel): description='Current-obligation headroom restored by this adjustment. Zero for audit-only types.', ge=0.0, ), + NoExplicitNull, + ] = None + reason: Annotated[ + str | None, Field(description='Seller-supplied adjustment reason.'), NoExplicitNull ] = None - reason: Annotated[str | None, Field(description='Seller-supplied adjustment reason.')] = None effective_at: Annotated[ - AwareDatetime | None, Field(description="When the seller's adjustment became effective.") + AwareDatetime | None, + Field(description="When the seller's adjustment became effective."), + NoExplicitNull, ] = None committed_budget: Annotated[ StrictFloat | None, Field( description='Governance-authorized budget reserved (present for completed outcome entries).' ), + NoExplicitNull, ] = None reported_committed_budget: Annotated[ StrictFloat | None, @@ -638,21 +718,26 @@ class Entry(AdCPBaseModel): description='Caller-reported seller amount retained for reconciliation. Never ledger authority.', ge=0.0, ), + NoExplicitNull, ] = None seller_reference: Annotated[ - str | None, Field(description='Seller resource identifier retained on an outcome entry.') + str | None, + Field(description='Seller resource identifier retained on an outcome entry.'), + NoExplicitNull, ] = None delivery: Annotated[ Delivery | None, Field( description='Buyer-attributed observation retained as audit evidence on a delivery outcome. It never overwrites the seller statement.' ), + NoExplicitNull, ] = None governance_context: Annotated[ str | None, Field( description='Governance context for this entry (present for check and outcome entries).' ), + NoExplicitNull, ] = None plan_hash: Annotated[ str | None, @@ -660,6 +745,7 @@ class Entry(AdCPBaseModel): description='Audit-layer binding to the plan revision this attestation was evaluated over — base64url_no_pad(SHA-256(JCS(plan_payload))) per Plan binding and audit in the campaign-governance specification. Present on check entries. Auditors and buyer-side compliance verify by recomputing over the retained plan revision and byte-comparing the decoded 32-byte digests.', pattern='^[A-Za-z0-9_-]{43}$', ), + NoExplicitNull, ] = None runtime_attestations: Annotated[ list[RuntimeAttestation] | None, @@ -668,6 +754,7 @@ class Entry(AdCPBaseModel): max_length=10, min_length=1, ), + NoExplicitNull, ] = None runtime_attestation_binding_digest: Annotated[ str | None, @@ -675,24 +762,32 @@ class Entry(AdCPBaseModel): description="Binding digest copied from the check response and signed governance_context. Auditors recompute it from this entry's ordered runtime_attestations[].evaluation values plus findings carrying attestation_reference_digest after verifying each paired reference digest.", pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None purchase_type: Annotated[ - purchase_type_1.PurchaseType | None, Field(description='Purchase type for this entry.') + purchase_type_1.PurchaseType | None, + Field(description='Purchase type for this entry.'), + NoExplicitNull, ] = None outcome_status: Annotated[ - str | None, Field(description='Outcome status (present for outcome entries).') + str | None, + Field(description='Outcome status (present for outcome entries).'), + NoExplicitNull, + ] = None + delivery_reconciliation_status: Annotated[ + DeliveryReconciliationStatus | None, NoExplicitNull ] = None - delivery_reconciliation_status: DeliveryReconciliationStatus | None = None delivery_period_state: Annotated[ DeliveryPeriodState | None, Field(description='Operational governance-window state for this delivery observation.'), + NoExplicitNull, ] = None - adjustment_state: AdjustmentState | None = None - verified_amount: Annotated[StrictFloat | None, Field(ge=0.0)] = None - evidence: Evidence | None = None - reviewed_by: AnyUrl | None = None - reviewed_at: AwareDatetime | None = None - review_reason: str | None = None + adjustment_state: Annotated[AdjustmentState | None, NoExplicitNull] = None + verified_amount: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + evidence: Annotated[Evidence | None, NoExplicitNull] = None + reviewed_by: Annotated[AnyUrl | None, NoExplicitNull] = None + reviewed_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + review_reason: Annotated[str | None, NoExplicitNull] = None class Plan(AdCPBaseModel): @@ -706,11 +801,13 @@ class Plan(AdCPBaseModel): channel_allocation: Annotated[ dict[str, ChannelAllocation] | None, Field(description='Current channel mix. Keyed by channel ID.'), + NoExplicitNull, ] = None summary: Annotated[Summary, Field(description='Aggregate validation and outcome statistics.')] entries: Annotated[ list[Entry] | None, Field(description='Ordered audit trail. Only present when include_entries is true.'), + NoExplicitNull, ] = None governed_actions: Annotated[ list[GovernedAction], @@ -725,5 +822,5 @@ class GetPlanAuditLogsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelo extra='allow', ) plans: Annotated[list[Plan], Field(description='Audit data for each requested plan.')] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/policy_category_definition.py b/src/adcp/types/domains/governance/policy_category_definition.py index 248d8c0ac..5123d5d3a 100644 --- a/src/adcp/types/domains/governance/policy_category_definition.py +++ b/src/adcp/types/domains/governance/policy_category_definition.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: governance/policy_category_definition.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..enums import restricted_attribute @@ -31,12 +31,15 @@ class RegulatoryFramework(AdCPBaseModel): jurisdictions: Annotated[ list[str] | None, Field(description='ISO 3166-1 alpha-2 codes where this framework applies.'), + NoExplicitNull, ] = None summary: Annotated[ str, Field(description='Brief summary of what the framework requires or prohibits.') ] policy_ids: Annotated[ - list[str] | None, Field(description='Registry policy IDs that implement this framework.') + list[str] | None, + Field(description='Registry policy IDs that implement this framework.'), + NoExplicitNull, ] = None @@ -66,40 +69,47 @@ class PolicyCategoryDefinition(AdCPBaseModel): description='Registry-defined distinctions within the category that materially affect policy applicability or seller acceptance. Facets refine a category without creating a new top-level policy category.', min_length=1, ), + NoExplicitNull, ] = None regulatory_frameworks: Annotated[ list[RegulatoryFramework] | None, Field( description='Key regulations and standards grouped under this category. Governance agents use this to resolve specific policies from the registry.' ), + NoExplicitNull, ] = None restricted_attributes: Annotated[ list[restricted_attribute.RestrictedAttribute] | None, Field( description="Restricted attribute categories that regulations in this category prohibit for targeting. Governance agents enforce these when the category is active on a plan — if a plan declares policy_categories: ['fair_housing'], the governance agent restricts targeting on these attributes." ), + NoExplicitNull, ] = None requires_human_review: Annotated[ StrictBool | None, Field( description='When true, any plan declaring this category MUST set plan.human_review_required = true. Use for regulatory regimes that mandate human oversight under GDPR Art 22 or EU AI Act Annex III — fair_housing, fair_lending, fair_employment, pharmaceutical_advertising, and similar high-risk categories. Category-level setting applies to all policies and plans referencing it; policies can override on policy-entry.requires_human_review. Effective immediately regardless of individual policy `effective_date` fields.' ), + NoExplicitNull, ] = False industries: Annotated[ list[str] | None, Field( description="Industries where this category commonly applies (e.g., 'pharmaceutical' for age_restricted). Governance agents MAY suggest relevant categories when a plan's brand industry matches but no policy_categories are declared." ), + NoExplicitNull, ] = None guidance: Annotated[ str | None, Field( description='Implementation notes for governance agents. Edge cases, disambiguation, and common pitfalls.' ), + NoExplicitNull, ] = None related_categories: Annotated[ list[str] | None, Field( description="Categories that frequently co-occur (e.g., 'children_directed' often appears with 'age_restricted')." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/governance/policy_entry.py b/src/adcp/types/domains/governance/policy_entry.py index ebf6dff1f..5ef9b3597 100644 --- a/src/adcp/types/domains/governance/policy_entry.py +++ b/src/adcp/types/domains/governance/policy_entry.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/policy_entry.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool from ..core import ext as ext_1 @@ -33,7 +33,7 @@ class Issuer(AdCPBaseModel): pattern='^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$', ), ] - name: Annotated[str | None, Field(min_length=1)] = None + name: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class Exemplar(AdCPBaseModel): @@ -54,9 +54,12 @@ class Exemplars(AdCPBaseModel): pass_: Annotated[ list[Exemplar] | None, Field(alias='pass', description='Scenarios that comply with this policy.'), + NoExplicitNull, ] = None fail: Annotated[ - list[Exemplar] | None, Field(description='Scenarios that violate this policy.') + list[Exemplar] | None, + Field(description='Scenarios that violate this policy.'), + NoExplicitNull, ] = None @@ -75,27 +78,33 @@ class PolicyEntry(AdCPBaseModel): Field( description="Origin of this policy. 'registry' = published to the shared AdCP policy registry with full regulatory metadata. 'inline' = authored bespoke for a specific standards configuration, plan, or portfolio. Defaults to 'inline'. Governance agents MUST set 'registry' when publishing to the registry. Within AdCP *task* payloads (every `$ref` to this schema in a request or response), the field is always 'inline' — registry entries are served by the policy registry API, not embedded in task traffic. The x-entity annotation on `policy_id` assumes the task-payload invariant; if a future task schema adopts registry-publishing, split the annotation accordingly (see issue #2685)." ), + NoExplicitNull, ] = Source.inline version: Annotated[ str | None, Field( description='Semver version string (e.g., "1.0.0"). Incremented when policy content changes. Optional for inline bespoke policies — defaults to "1.0.0". SHOULD be provided for registry-published policies.' ), + NoExplicitNull, ] = None name: Annotated[ str | None, Field( description='Human-readable name (e.g., "UK HFSS Restrictions"). Optional for inline bespoke policies — servers MAY default to policy_id.' ), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Brief summary of what this policy covers.', max_length=500) + str | None, + Field(description='Brief summary of what this policy covers.', max_length=500), + NoExplicitNull, ] = None category: Annotated[ policy_category.PolicyCategory | None, Field( description='The nature of the obligation: regulation (legal requirement) or standard (best practice). Optional for inline bespoke policies — defaults to "standard".' ), + NoExplicitNull, ] = None enforcement: Annotated[ policy_enforcement.PolicyEnforcementLevel, @@ -108,69 +117,82 @@ class PolicyEntry(AdCPBaseModel): Field( description='When true, plans subject to this policy MUST set plan.human_review_required = true. Use for policies that mandate human oversight of decisions affecting data subjects — e.g., GDPR Article 22 (solely automated decisions with legal or similarly significant effects) and EU AI Act Annex III high-risk categories (credit, insurance pricing, recruitment, housing allocation). Governance agents MUST escalate any plan action whose resolved policies include requires_human_review: true. Unlike `enforcement`, this flag applies as soon as the policy is resolved — it is NOT gated by `effective_date`. Art 22 GDPR and similar foundational obligations may predate an AI-Act-specific effective date; the human-review requirement fires regardless.' ), + NoExplicitNull, ] = False jurisdictions: Annotated[ list[str] | None, Field( description='ISO 3166-1 alpha-2 country codes where this policy applies. Empty array means the policy is not jurisdiction-specific.' ), + NoExplicitNull, ] = None region_aliases: Annotated[ dict[str, list[str]] | None, Field( description='Named groups of jurisdictions for convenience (e.g., {"EU": ["AT","BE","BG",...]}). Governance agents expand aliases when matching against a plan\'s target jurisdictions.' ), + NoExplicitNull, ] = None policy_categories: Annotated[ list[str] | None, Field( description='Regulatory categories this policy belongs to (e.g., ["children_directed", "age_restricted"]). Used for automatic matching against a campaign plan\'s declared policy_categories. A single policy can belong to multiple categories.' ), + NoExplicitNull, ] = None channels: Annotated[ list[channels_1.MediaChannel] | None, Field( description='Advertising channels this policy applies to. If omitted or null, the policy applies to all channels.' ), + NoExplicitNull, ] = None governance_domains: Annotated[ list[governance_domain.GovernanceDomain] | None, Field( description='Governance sub-domains this policy applies to. Determines which types of governance agents can declare registry:{policy_id} features. For example, a policy with domains ["creative", "property"] can be declared as a feature by both creative and property governance agents.' ), + NoExplicitNull, ] = None effective_date: Annotated[ date | None, Field( description='ISO 8601 date when the regulation or standard takes effect. Before this date, governance agents treat the policy as informational (evaluate but do not block). After this date, the policy is enforced at its declared enforcement level.' ), + NoExplicitNull, ] = None sunset_date: Annotated[ date | None, Field( description='ISO 8601 date when the regulation or standard is no longer enforced. After this date, governance agents stop evaluating this policy. Omit if the policy has no expiration.' ), + NoExplicitNull, ] = None source_url: Annotated[ - AnyUrl | None, Field(description='Link to the source regulation, standard, or legislation.') + AnyUrl | None, + Field(description='Link to the source regulation, standard, or legislation.'), + NoExplicitNull, ] = None source_name: Annotated[ str | None, Field( description='Name of the issuing body (e.g., "UK Food Standards Agency", "US Federal Trade Commission").' ), + NoExplicitNull, ] = None issuer: Annotated[ Issuer | None, Field( description='Machine-readable identity of the regulator, standards body, or platform operator that issued the policy. Registry publishers SHOULD provide this when independently versioned issuer policies must be distinguished.' ), + NoExplicitNull, ] = None acceptance_profile: Annotated[ acceptance_policy_profile.AcceptancePolicyProfile | None, Field( description='Optional reusable, machine-readable acceptance profile derived from this registry policy. Registry publishers MUST bind policy_refs to exact versions. Sellers adopt a profile explicitly; registry publication alone does not make it authoritative for a seller.' ), + NoExplicitNull, ] = None policy: Annotated[ str, @@ -184,11 +206,13 @@ class PolicyEntry(AdCPBaseModel): Field( description='Implementation notes for governance agent developers. Not used in evaluation prompts.' ), + NoExplicitNull, ] = None exemplars: Annotated[ Exemplars | None, Field( description='Calibration examples for governance agents, following the Content Standards pattern.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/policy_ref.py b/src/adcp/types/domains/governance/policy_ref.py index cf714b4c3..3021b0f21 100644 --- a/src/adcp/types/domains/governance/policy_ref.py +++ b/src/adcp/types/domains/governance/policy_ref.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: governance/policy_ref.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -25,10 +25,12 @@ class PolicyReference(AdCPBaseModel): Field( description='Pin a specific policy version (semver). If omitted, the current version is used.' ), + NoExplicitNull, ] = None config: Annotated[ dict[str, Any] | None, Field( description="Brand-specific parameter overrides for configurable policies. The accepted shape depends on the policy's config_schema." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/governance/report_plan_adjustment_request.py b/src/adcp/types/domains/governance/report_plan_adjustment_request.py index fcb5eeb10..d77016249 100644 --- a/src/adcp/types/domains/governance/report_plan_adjustment_request.py +++ b/src/adcp/types/domains/governance/report_plan_adjustment_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/report_plan_adjustment_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..core import context as context_1 @@ -95,15 +95,19 @@ class ReportPlanAdjustmentRequest(AdcpRequest, AdcpVersionEnvelope): description='Completed outcome whose authoritative commitment is being adjusted.', min_length=1, ), + NoExplicitNull, ] = None adjustment_id: Annotated[ - str | None, Field(description='Adjustment to accept or dispute. Required for review.') + str | None, + Field(description='Adjustment to accept or dispute. Required for review.'), + NoExplicitNull, ] = None decision: Annotated[ Decision | None, Field( description='Buyer review decision. Acceptance is blocked while delivery evidence for the governed action is disputed in an open governance period; a historical closed_unresolved period is audit evidence, not a billing determination.' ), + NoExplicitNull, ] = None seller_reference: Annotated[ str | None, @@ -112,6 +116,7 @@ class ReportPlanAdjustmentRequest(AdcpRequest, AdcpVersionEnvelope): max_length=255, min_length=1, ), + NoExplicitNull, ] = None seller_adjustment_id: Annotated[ str | None, @@ -120,15 +125,19 @@ class ReportPlanAdjustmentRequest(AdcpRequest, AdcpVersionEnvelope): max_length=255, min_length=1, ), + NoExplicitNull, ] = None adjustment_type: Annotated[ AdjustmentType | None, Field( description='Commercial meaning. Verified decommitments restore headroom in every mode; verified refunds and credits restore it only in verified_net_cost mode; makegoods never restore cash headroom.' ), + NoExplicitNull, ] = None amount: Annotated[ - Amount | None, Field(description='Positive adjustment amount in the plan currency.') + Amount | None, + Field(description='Positive adjustment amount in the plan currency.'), + NoExplicitNull, ] = None reason: Annotated[ str | None, @@ -137,15 +146,19 @@ class ReportPlanAdjustmentRequest(AdcpRequest, AdcpVersionEnvelope): max_length=1000, min_length=1, ), + NoExplicitNull, ] = None effective_at: Annotated[ - AwareDatetime | None, Field(description="When the seller's adjustment became effective.") + AwareDatetime | None, + Field(description="When the seller's adjustment became effective."), + NoExplicitNull, ] = None evidence: Annotated[ Evidence | None, Field( description='Integrity-bound commercial source record supplied by the seller. Its evidence_type must correspond to adjustment_type.' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -156,5 +169,5 @@ class ReportPlanAdjustmentRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/report_plan_adjustment_response.py b/src/adcp/types/domains/governance/report_plan_adjustment_response.py index 2ce2ee41d..1b5b443e7 100644 --- a/src/adcp/types/domains/governance/report_plan_adjustment_response.py +++ b/src/adcp/types/domains/governance/report_plan_adjustment_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/report_plan_adjustment_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import context as context_1 @@ -86,6 +86,7 @@ class ReportPlanAdjustmentResponse(AdcpResponse, AdcpVersionEnvelope): Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/report_plan_outcome_request.py b/src/adcp/types/domains/governance/report_plan_outcome_request.py index 9ce76b49d..053d3bf36 100644 --- a/src/adcp/types/domains/governance/report_plan_outcome_request.py +++ b/src/adcp/types/domains/governance/report_plan_outcome_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/report_plan_outcome_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import context as context_1 @@ -25,7 +25,7 @@ class Package(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - budget: Annotated[StrictFloat | None, Field(ge=0.0)] = None + budget: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None class Source(StrEnum): @@ -65,6 +65,7 @@ class Delivery(AdCPBaseModel): Field( description='Seller statement identifier as received by the buyer. Required for seller_statement_copy.' ), + NoExplicitNull, ] = None seller_statement_digest: Annotated[ str | None, @@ -72,6 +73,7 @@ class Delivery(AdCPBaseModel): description='Digest received with the seller statement. Governance compares it to the seller-authenticated canonical digest.', pattern='^sha256:[a-f0-9]{64}$', ), + NoExplicitNull, ] = None reporting_period: Annotated[ ReportingPeriod, Field(description='Start and end timestamps for the reporting window.') @@ -86,8 +88,9 @@ class Delivery(AdCPBaseModel): Field( description='Buyer-declared operational closure of this governance reporting period. When true, governance freezes the period after recording this observation. An unresolved mismatch becomes closed_unresolved and no longer blocks later governance. Closure is not final billing or commercial settlement. Only the authenticated plan owner may set this to true; the governance agent MUST reject it from any other authenticated reporter.' ), + NoExplicitNull, ] = False - impressions: Annotated[SchemaInt | None, Field(ge=0)] = None + impressions: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None class SellerResponse(AdCPBaseModel): @@ -100,6 +103,7 @@ class SellerResponse(AdCPBaseModel): description="The seller's identifier for the created resource (e.g., media_buy_id, rights_grant_id, deployment_id). Not interpreted by the governance agent — included in audit logs for human-readable traceability alongside the opaque governance_context.", max_length=255, ), + NoExplicitNull, ] = None committed_budget: Annotated[ StrictFloat | None, @@ -107,19 +111,24 @@ class SellerResponse(AdCPBaseModel): description='Buyer-reported seller amount retained for reconciliation and audit. It is never ledger authority: the governance agent derives the reserved commitment from its own approved intent check, or from the matching purchase execution check when one exists. A report above that authorized amount is rejected; a lower report does not restore headroom.', ge=0.0, ), + NoExplicitNull, ] = None packages: Annotated[ list[Package] | None, Field(description='Confirmed packages with actual budget and targeting.'), + NoExplicitNull, ] = None planned_delivery: Annotated[ planned_delivery_1.PlannedDelivery | None, Field( description="What the seller said it will deliver. When seller-side governance is not configured, this is the governance agent's only view of the seller's delivery parameters." ), + NoExplicitNull, ] = None creative_deadline: Annotated[ - AwareDatetime | None, Field(description='ISO 8601 deadline for creative submission.') + AwareDatetime | None, + Field(description='ISO 8601 deadline for creative submission.'), + NoExplicitNull, ] = None @@ -138,6 +147,7 @@ class ReportPlanOutcomeRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='The check_id from check_governance. Required for completed and failed outcomes and for buyer delivery observations. A delivery observation names the exact seller delivery check whose canonical statement is being compared.' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -153,23 +163,27 @@ class ReportPlanOutcomeRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="The type of financial commitment this outcome is for. Must equal the original approved intent's purchase_type. Determines which budget allocation (if any) to charge against. Defaults to 'media_buy' when omitted." ), + NoExplicitNull, ] = purchase_type_1.PurchaseType.media_buy outcome: Annotated[outcome_type.OutcomeType, Field(description='Outcome type.')] seller_response: Annotated[ SellerResponse | None, Field(description="The seller's full response. Required when outcome is 'completed'."), + NoExplicitNull, ] = None delivery: Annotated[ Delivery | None, Field( description='Buyer-attributed observation compared with the canonical seller delivery statement identified by check_id. This evidence never overwrites seller evidence or creates a second commitment. A conflict produces an explicit disputed reconciliation state while the operational period is open; the plan owner may close it without asserting final billing truth.' ), + NoExplicitNull, ] = None error: Annotated[ reported_outcome_error.ReportedOutcomeError | None, Field( description='Buyer-attributed error associated with a failed seller interaction. Required when outcome is failed; classification_source=seller_response_copy preserves what the buyer received without claiming seller-attested provenance.' ), + NoExplicitNull, ] = None governance_context: Annotated[ str | None, @@ -179,6 +193,7 @@ class ReportPlanOutcomeRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/report_plan_outcome_response.py b/src/adcp/types/domains/governance/report_plan_outcome_response.py index b58d97148..986fd42de 100644 --- a/src/adcp/types/domains/governance/report_plan_outcome_response.py +++ b/src/adcp/types/domains/governance/report_plan_outcome_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/report_plan_outcome_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, model_validator, StrictBool, StrictFloat from ..core import context as context_1 @@ -46,7 +46,9 @@ class Finding(AdCPBaseModel): ] explanation: Annotated[str, Field(description='Human-readable description of the issue.')] details: Annotated[ - dict[str, Any] | None, Field(description='Structured details for programmatic consumption.') + dict[str, Any] | None, + Field(description='Structured details for programmatic consumption.'), + NoExplicitNull, ] = None @@ -57,9 +59,12 @@ class PlanSummary(AdCPBaseModel): total_committed: Annotated[ StrictFloat | None, Field(description='Total budget committed across all campaigns in the plan.'), + NoExplicitNull, ] = None budget_remaining: Annotated[ - StrictFloat | None, Field(description='Authorized budget minus total committed.') + StrictFloat | None, + Field(description='Authorized budget minus total committed.'), + NoExplicitNull, ] = None @@ -87,34 +92,40 @@ def _status_to_outcome_state(cls, data: Any) -> Any: Field( description="Budget committed from this outcome. Present for 'completed' and 'failed' outcomes." ), + NoExplicitNull, ] = None delivery_reconciliation_status: Annotated[ DeliveryReconciliationStatus | None, Field( description='Comparison state between the buyer-attributed observation and canonical seller statement. Present for delivery outcomes. A disputed state blocks adjustment verification while the period is open. measurement_variance records a buyer-measured cumulative_spend that differs from the seller statement; it never blocks verification — the higher amount bounds conservative exposure and decommitments instead. closed_unresolved preserves the discrepancy after operational closure without claiming a final billing result.' ), + NoExplicitNull, ] = None delivery_period_state: Annotated[ DeliveryPeriodState | None, Field( description='Operational state of the governance reporting period. Closure freezes governance evidence for that period but is not billing settlement.' ), + NoExplicitNull, ] = None findings: Annotated[ list[Finding] | None, Field(description="Issues detected. Present only when outcome_state is 'findings'."), + NoExplicitNull, ] = None plan_summary: Annotated[ PlanSummary | None, Field( description="Updated plan budget state. Present for 'completed' and 'failed' outcomes." ), + NoExplicitNull, ] = None replayed: Annotated[ StrictBool | None, Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/reported_outcome_error.py b/src/adcp/types/domains/governance/reported_outcome_error.py index 38574a17f..4a76d4dba 100644 --- a/src/adcp/types/domains/governance/reported_outcome_error.py +++ b/src/adcp/types/domains/governance/reported_outcome_error.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: governance/reported_outcome_error.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Dict -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, RootModel, StrictBool, StrictFloat, StringConstraints @@ -74,24 +74,27 @@ class ReportedOutcomeError(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - code: Annotated[str | None, Field(max_length=64, min_length=1)] = None - message: Annotated[str | None, Field(max_length=4000)] = None - field: Annotated[str | None, Field(max_length=1000)] = None - suggestion: Annotated[str | None, Field(max_length=4000)] = None - recovery: Recovery | None = None + code: Annotated[str | None, Field(max_length=64, min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=4000), NoExplicitNull] = None + field: Annotated[str | None, Field(max_length=1000), NoExplicitNull] = None + suggestion: Annotated[str | None, Field(max_length=4000), NoExplicitNull] = None + recovery: Annotated[Recovery | None, NoExplicitNull] = None details: Annotated[ BoundedObject | None, Field( description='Bounded structured seller error details. Values remain reporter-supplied data and MUST NOT be promoted into prompts or control instructions without isolation.' ), + NoExplicitNull, ] = None classification_source: Annotated[ ClassificationSource | None, Field( description='seller_response_copy means the buyer forwards what it received; it is still not independently authenticated seller evidence.' ), + NoExplicitNull, ] = None ext: Annotated[ BoundedObject | None, Field(description='Bounded extension envelope for forward-compatible seller fields.'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/governance/sync_plans_request.py b/src/adcp/types/domains/governance/sync_plans_request.py index cafe37117..bbc9df3e5 100644 --- a/src/adcp/types/domains/governance/sync_plans_request.py +++ b/src/adcp/types/domains/governance/sync_plans_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/sync_plans_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import brand_ref @@ -31,7 +31,9 @@ class Allocations(AdCPBaseModel): extra='forbid', ) amount: Annotated[ - StrictFloat | None, Field(description='Maximum budget for this purchase type.', ge=0.0) + StrictFloat | None, + Field(description='Maximum budget for this purchase type.', ge=0.0), + NoExplicitNull, ] = None max_pct: Annotated[ StrictFloat | None, @@ -40,6 +42,7 @@ class Allocations(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None @@ -54,10 +57,12 @@ class Budget(AdCPBaseModel): Field( description='Controls which verified adjustments restore reusable plan headroom. gross_commitment allows verified decommitments only; verified_net_cost also allows buyer-accepted refunds and credits. Both modes retain the original commitment in anti-fragmentation history and expose economic net cost separately.' ), + NoExplicitNull, ] = AccountingMode.gross_commitment per_seller_max_pct: Annotated[ StrictFloat | None, Field(description='Maximum percentage of budget that can go to a single seller.'), + NoExplicitNull, ] = None reallocation_threshold: Annotated[ StrictFloat, @@ -71,12 +76,14 @@ class Budget(AdCPBaseModel): Field( description='Set to true to allow the orchestrator to reallocate without any limit up to `total`. Mutually exclusive with `reallocation_threshold`. Use this for deliberate full-autonomy declarations rather than setting `reallocation_threshold: total` (which silently tightens when `total` changes).' ), + NoExplicitNull, ] = None allocations: Annotated[ dict[purchase_type.PurchaseType, Allocations] | None, Field( description='Optional budget partition across purchase types. Keys are purchase-type enum values (media_buy, rights_license, signal_activation, creative_services). When present, the governance agent validates spend against both the total and the per-type allocation. When absent, all spend counts against the single total regardless of purchase type.' ), + NoExplicitNull, ] = None @@ -91,10 +98,12 @@ class Budget2(AdCPBaseModel): Field( description='Controls which verified adjustments restore reusable plan headroom. gross_commitment allows verified decommitments only; verified_net_cost also allows buyer-accepted refunds and credits. Both modes retain the original commitment in anti-fragmentation history and expose economic net cost separately.' ), + NoExplicitNull, ] = AccountingMode.gross_commitment per_seller_max_pct: Annotated[ StrictFloat | None, Field(description='Maximum percentage of budget that can go to a single seller.'), + NoExplicitNull, ] = None reallocation_threshold: Annotated[ StrictFloat | None, @@ -102,6 +111,7 @@ class Budget2(AdCPBaseModel): description='Amount above which budget reallocations require human escalation. The orchestrator can reallocate spend across sellers, channels, or purchase types up to this threshold per change without asking a human. Set equal to `total` for effectively unlimited reallocation; set to 0 to require approval for every reallocation. Separate from `plan.human_review_required`, which governs decisions affecting data subjects (targeting, creative, delivery) under GDPR Art 22 / EU AI Act Annex III. Denominated in `budget.currency`.', ge=0.0, ), + NoExplicitNull, ] = None reallocation_unlimited: Annotated[ Literal[True], @@ -114,6 +124,7 @@ class Budget2(AdCPBaseModel): Field( description='Optional budget partition across purchase types. Keys are purchase-type enum values (media_buy, rights_license, signal_activation, creative_services). When present, the governance agent validates spend against both the total and the per-type allocation. When absent, all spend counts against the single total regardless of purchase type.' ), + NoExplicitNull, ] = None @@ -121,8 +132,8 @@ class MixTargets(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - min_pct: StrictFloat | None = None - max_pct: StrictFloat | None = None + min_pct: Annotated[StrictFloat | None, NoExplicitNull] = None + max_pct: Annotated[StrictFloat | None, NoExplicitNull] = None class Channels(AdCPBaseModel): @@ -132,14 +143,17 @@ class Channels(AdCPBaseModel): required: Annotated[ list[channels_1.MediaChannel] | None, Field(description='Channels that must be included in the media mix.'), + NoExplicitNull, ] = None allowed: Annotated[ list[channels_1.MediaChannel] | None, Field(description='Channels the orchestrator may use.'), + NoExplicitNull, ] = None mix_targets: Annotated[ dict[str, MixTargets] | None, Field(description='Target allocation ranges per channel, keyed by channel ID.'), + NoExplicitNull, ] = None @@ -173,18 +187,21 @@ class Delegation(AdCPBaseModel): Field( description="Maximum budget this agent can commit. When omitted, the agent can commit up to the plan's total budget." ), + NoExplicitNull, ] = None markets: Annotated[ list[str] | None, Field( description='ISO 3166-1/3166-2 codes this agent is authorized for. When omitted, the agent can operate in all plan markets.' ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='When this delegation expires. After expiration, the governance agent denies actions from this agent.' ), + NoExplicitNull, ] = None @@ -205,18 +222,21 @@ class Portfolio(AdCPBaseModel): total_budget_cap: Annotated[ TotalBudgetCap | None, Field(description='Maximum aggregate budget across all member plans.'), + NoExplicitNull, ] = None shared_policy_ids: Annotated[ list[str] | None, Field( description='Registry policy IDs enforced across all member plans, regardless of individual brand configuration.' ), + NoExplicitNull, ] = None shared_exclusions: Annotated[ list[policy_entry.PolicyEntry] | None, Field( description="Bespoke exclusion policies applied across all member plans, using the same shape as registry entries. Authored typically as enforcement: must policies with exclusion language in the policy text (e.g., 'No advertising on properties owned by competitor holding companies')." ), + NoExplicitNull, ] = None @@ -242,6 +262,7 @@ class Plan(AdCPBaseModel): channels: Annotated[ Channels | None, Field(description='Channel constraints. If omitted, all channels are allowed.'), + NoExplicitNull, ] = None flight: Annotated[ Flight, @@ -255,6 +276,7 @@ class Plan(AdCPBaseModel): description="ISO 3166-1 alpha-2 country codes for authorized markets (e.g., ['US', 'GB']). The governance agent rejects governed actions targeting outside these countries and resolves applicable policies by matching against policy jurisdictions.", min_length=1, ), + NoExplicitNull, ] = None regions: Annotated[ list[str] | None, @@ -262,12 +284,14 @@ class Plan(AdCPBaseModel): description="ISO 3166-2 subdivision codes for authorized sub-national markets (e.g., ['US-MA', 'US-CA']). When present, the governance agent restricts governed actions to these specific regions rather than the full country. Use for plans limited to specific states or provinces (e.g., cannabis in legal states). Policy resolution matches against both the subdivision and its parent country.", min_length=1, ), + NoExplicitNull, ] = None policy_ids: Annotated[ list[str] | None, Field( description="Registry policy IDs to enforce for this plan. The governance agent resolves full policy definitions from the registry and evaluates actions against them. Intersected with the plan's countries/regions to activate only geographically relevant policies." ), + NoExplicitNull, ] = None policy_categories: Annotated[ list[str] | None, @@ -275,12 +299,14 @@ class Plan(AdCPBaseModel): description="Regulatory categories that apply to this campaign. Determines which policy regimes the governance agent enforces (e.g., 'children_directed' activates COPPA/AADC, 'political_advertising' activates disclosure requirements). The governance agent resolves categories to specific policies based on the plan's jurisdictions. When omitted, governance agents MAY infer categories from the brand's industries and the plan's objectives. Values are registry-defined category IDs (intentionally freeform strings, not an enum — new categories are added as regulations evolve).", min_length=1, ), + NoExplicitNull, ] = None audience: Annotated[ audience_constraints.AudienceConstraints | None, Field( description='Audience targeting constraints. Defines who the campaign should reach (include) and must not reach (exclude). The governance agent evaluates seller targeting against these constraints.' ), + NoExplicitNull, ] = None restricted_attributes: Annotated[ list[restricted_attribute.RestrictedAttribute] | None, @@ -288,6 +314,7 @@ class Plan(AdCPBaseModel): description='Personal data categories that must not be used for targeting in this campaign. Applies horizontally across all audience criteria. Used for EU DSA Article 26 compliance (prohibits targeting on GDPR Article 9 special categories) and similar regulations. The governance agent flags any audience targeting that references these attributes.', min_length=1, ), + NoExplicitNull, ] = None restricted_attributes_custom: Annotated[ list[str] | None, @@ -295,6 +322,7 @@ class Plan(AdCPBaseModel): description="Additional restricted attributes not covered by the restricted-attribute enum. Freeform strings for jurisdiction-specific or brand-specific restrictions beyond GDPR Article 9 categories (e.g., 'financial_status', 'immigration_status'). Governance agents use semantic matching for these.", min_length=1, ), + NoExplicitNull, ] = None min_audience_size: Annotated[ SchemaInt | None, @@ -302,18 +330,21 @@ class Plan(AdCPBaseModel): description='Minimum audience segment size. Prevents micro-targeting by ensuring segments meet a k-anonymity threshold. Applies to the estimated combined (intersection) audience when multiple criteria are used, not just individual criterion sizes. The governance agent validates this by querying published signal metadata or seller-reported segment sizes. When segment size data is unavailable, the governance agent SHOULD produce a finding with reduced confidence rather than silently passing.', ge=1, ), + NoExplicitNull, ] = None human_review_required: Annotated[ StrictBool | None, Field( description='When true, the governance agent MUST escalate any action on this plan for human review before execution — regardless of budget. Required by regulations that prohibit solely automated decisions affecting data subjects (GDPR Art 22, EU AI Act Annex III): credit, insurance pricing, recruitment, housing allocation. Governance agents MUST set this to true automatically when any resolved policy or policy_category has requires_human_review: true. Distinct from budget.reallocation_threshold, which only governs budget reallocation autonomy — human_review_required covers decisions affecting individuals (targeting, creative, delivery).' ), + NoExplicitNull, ] = False custom_policies: Annotated[ list[policy_entry.PolicyEntry] | None, Field( description='Bespoke policies specific to this campaign, using the same shape as registry entries. Each policy has a policy_id, enforcement (must|should), and natural-language policy text. Governance findings reference policy_id to identify which policy triggered. For quick authoring, omit version/name/category — servers default them.' ), + NoExplicitNull, ] = None approved_sellers: Annotated[ list[AnyUrl] | None, @@ -324,14 +355,16 @@ class Plan(AdCPBaseModel): Field( description="Agents authorized to execute against this plan. Each delegation scopes an agent's authority by budget, markets, and expiration. The governance agent validates that the requesting agent matches a delegation before approving actions." ), + NoExplicitNull, ] = None portfolio: Annotated[ Portfolio | None, Field( description='Portfolio-level governance constraints. When present, this plan acts as a portfolio plan that governs member plans. Portfolio plans define cross-brand constraints that no individual brand plan can override.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class SyncPlansRequest(AdcpRequest, AdcpVersionEnvelope): @@ -348,5 +381,5 @@ class SyncPlansRequest(AdcpRequest, AdcpVersionEnvelope): ), ] plans: Annotated[list[Plan], Field(description='One or more campaign plans to sync.')] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/sync_plans_response.py b/src/adcp/types/domains/governance/sync_plans_response.py index 09558cb01..032f79543 100644 --- a/src/adcp/types/domains/governance/sync_plans_response.py +++ b/src/adcp/types/domains/governance/sync_plans_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/sync_plans_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -62,6 +62,7 @@ class ResolvedPolicy(AdCPBaseModel): Field( description="Why this policy was included (e.g., 'Matched jurisdiction US and policy category pharmaceutical_advertising')." ), + NoExplicitNull, ] = None @@ -78,13 +79,16 @@ class Plan(AdCPBaseModel): ] version: Annotated[SchemaInt, Field(description='Plan version (increments on each sync).')] categories: Annotated[ - list[Category] | None, Field(description='Validation categories active for this plan.') + list[Category] | None, + Field(description='Validation categories active for this plan.'), + NoExplicitNull, ] = None resolved_policies: Annotated[ list[ResolvedPolicy] | None, Field( description='Policies the governance agent will enforce for this plan. Includes explicitly referenced policies from the brand compliance configuration and auto-applied policies matched by jurisdiction or policy category. Present when the governance agent supports policy resolution.' ), + NoExplicitNull, ] = None @@ -98,6 +102,7 @@ class SyncPlansResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/manifest_schema.py b/src/adcp/types/domains/manifest_schema.py index 0f4783a5e..e2148cfd2 100644 --- a/src/adcp/types/domains/manifest_schema.py +++ b/src/adcp/types/domains/manifest_schema.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: manifest.schema.json -# timestamp: 2026-10-04T18:54:18+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field, StrictBool @@ -85,6 +85,7 @@ class ErrorCodes(AdCPBaseModel): added_in: Annotated[ str | None, Field(description='Semver of the AdCP release that introduced this code. Optional.'), + NoExplicitNull, ] = None @@ -101,6 +102,7 @@ class Specialisms(AdCPBaseModel): title: Annotated[ str | None, Field(description="Human-readable title. Sourced from index.yaml's `title` field."), + NoExplicitNull, ] = None entry_point_tools: Annotated[ list[str], @@ -163,7 +165,9 @@ class LegacyFallback2(AdCPBaseModel): extra='forbid', ) tool: Annotated[ - ToolName | None, Field(description='Legacy peer tool targeted by the adapter.') + ToolName | None, + Field(description='Legacy peer tool targeted by the adapter.'), + NoExplicitNull, ] = None mode: Annotated[ Literal['none'], @@ -196,18 +200,21 @@ class Tools(AdCPBaseModel): max_length=240, min_length=1, ), + NoExplicitNull, ] = None operation_family: Annotated[ str | None, Field( description='Stable logical operation identity used for authorization, idempotency equivalence, task recovery, and webhook identity. Aliases in the same family share this value.' ), + NoExplicitNull, ] = None idempotency_requirement: Annotated[ IdempotencyRequirement | None, Field( description='Whether callers must, may, or cannot supply idempotency_key for this tool. Optional keys, when supplied, receive the same replay guarantees as required keys.' ), + NoExplicitNull, ] = None request_schema: SchemaPath response_schema: SchemaPath @@ -223,6 +230,7 @@ class Tools(AdCPBaseModel): description="How an SDK exposing this tool's current API may satisfy it against a peer that only exposes a legacy tool. This is caller-side adaptation metadata, not authorization or idempotency equivalence.", discriminator='mode', ), + NoExplicitNull, ] = None superseded_by: Annotated[ list[ToolName] | None, @@ -230,24 +238,28 @@ class Tools(AdCPBaseModel): description='Current tool names that replace this deprecated facade. This does not imply that each replacement is a one-call alias.', min_length=1, ), + NoExplicitNull, ] = None specialisms: Annotated[ list[str] | None, Field( description='Specialism IDs that include this tool in their required_tools (or via inherited scenarios). Lets SDKs answer "if I claim specialism X, which tools must I implement?" without re-deriving from the compliance cache.' ), + NoExplicitNull, ] = None added_in: Annotated[ str | None, Field( description='Semver of the AdCP release that introduced this tool. Optional; absent means "present since 1.0".' ), + NoExplicitNull, ] = None deprecated_in: Annotated[ str | None, Field( description='Semver of the AdCP release that deprecated this tool. Absent means active.' ), + NoExplicitNull, ] = None @@ -270,7 +282,9 @@ class AdcpManifest(AdCPBaseModel): extra='forbid', ) field_schema: Annotated[ - str | None, Field(alias='$schema', description='Reference to this manifest meta-schema.') + str | None, + Field(alias='$schema', description='Reference to this manifest meta-schema.'), + NoExplicitNull, ] = None adcp_version: Annotated[ str, diff --git a/src/adcp/types/domains/media_buy/accept_proposal_request.py b/src/adcp/types/domains/media_buy/accept_proposal_request.py index 5c5c68266..2096c59c6 100644 --- a/src/adcp/types/domains/media_buy/accept_proposal_request.py +++ b/src/adcp/types/domains/media_buy/accept_proposal_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/accept_proposal_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..core import canonical_account_ref @@ -36,7 +36,7 @@ class IoAcceptance(AdCPBaseModel): io_id: Annotated[str, Field(min_length=1)] accepted_at: AwareDatetime signatory: Annotated[str, Field(max_length=250, min_length=1)] - signature_id: Annotated[str | None, Field(min_length=1)] = None + signature_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class Status(StrEnum): @@ -64,6 +64,7 @@ class AcceptProposalRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -73,6 +74,7 @@ class AcceptProposalRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, Field(max_length=255, min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$') @@ -85,6 +87,7 @@ class AcceptProposalRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None account: canonical_account_ref.CanonicalAccountReference proposal_id: Annotated[str, Field(min_length=1)] @@ -100,6 +103,7 @@ class AcceptProposalRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Execution amount when the committed proposal defines scalable percentages or constraints rather than a fixed total.' ), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -107,6 +111,7 @@ class AcceptProposalRequest(AdcpRequest, AdcpVersionEnvelope): description="Optional hard aggregate daily spend ceiling applied when the committed proposal is accepted. It constrains execution without changing the proposal's negotiated pricing.", ge=0.0, ), + NoExplicitNull, ] = None budget_cap_timezone: Annotated[ str | None, @@ -114,22 +119,31 @@ class AcceptProposalRequest(AdcpRequest, AdcpVersionEnvelope): description='Optional shared IANA cap-day timezone override. Requires buyer_timezone_override support. When omitted, budget_capping.timezone_basis selects Account.timezone or fixed_timezone.', min_length=1, ), + NoExplicitNull, + ] = None + io_acceptance: Annotated[IoAcceptance | None, NoExplicitNull] = None + purchase_order_ref: Annotated[ + str | None, Field(max_length=255, min_length=1), NoExplicitNull + ] = None + governance_context: Annotated[ + str | None, Field(max_length=4096, min_length=1), NoExplicitNull + ] = None + push_notification_config: Annotated[ + push_notification_config_1.PushNotificationConfig | None, NoExplicitNull ] = None - io_acceptance: IoAcceptance | None = None - purchase_order_ref: Annotated[str | None, Field(max_length=255, min_length=1)] = None - governance_context: Annotated[str | None, Field(max_length=4096, min_length=1)] = None - push_notification_config: push_notification_config_1.PushNotificationConfig | None = None reporting_webhook: Annotated[ reporting_webhook_1.ReportingWebhook | None, Field( description='Optional reporting delivery configuration established atomically when the proposal is accepted. This is execution metadata and does not alter the accepted commercial terms digest.' ), + NoExplicitNull, ] = None opportunity: Annotated[ Opportunity | None, Field( description='Optional planning-cycle closure. Success infers closed with accepted_with_seller when status is omitted. If the proposal carries opportunity_id, a supplied ID MUST match; the accepted proposal preserves that association.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/accept_proposal_response.py b/src/adcp/types/domains/media_buy/accept_proposal_response.py index 6f82cf86b..bdee58017 100644 --- a/src/adcp/types/domains/media_buy/accept_proposal_response.py +++ b/src/adcp/types/domains/media_buy/accept_proposal_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/accept_proposal_response.json -# timestamp: 2026-10-04T04:34:38+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import canonical_media_buy_action @@ -54,9 +54,9 @@ class AcceptProposalResponse2(AdCPBaseModel): ) status: Literal['failed'] = 'failed' errors: Annotated[list[error.Error], Field(min_length=1)] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class AcceptProposalResponse3(AdCPBaseModel): @@ -65,11 +65,11 @@ class AcceptProposalResponse3(AdCPBaseModel): ) status: Literal['submitted'] = 'submitted' task_id: Annotated[str, Field(min_length=1)] - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class AcceptProposalResponse6(AdcpResponse, AcceptProposalResponse2, AcceptProposalResponse4): @@ -87,6 +87,7 @@ class Warning(Warning_1): description='Closed warning vocabulary for the negotiated AdCP release. A non-blocking condition observed while an operation still succeeded. New values are added through normal protocol releases; buyers validate against the negotiated release.', title='Warning Code', ), + NoExplicitNull, ] = None @@ -118,9 +119,10 @@ class AcceptProposalResponse1(AdCPBaseModel): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None revision: Annotated[SchemaInt, Field(ge=1)] - media_buy_status: media_buy_status_1.MediaBuyStatus | None = None + media_buy_status: Annotated[media_buy_status_1.MediaBuyStatus | None, NoExplicitNull] = None confirmed_at: AwareDatetime | None = None accepted_proposal: AcceptedProposal purchase_bindings: Annotated[ @@ -137,10 +139,11 @@ class AcceptProposalResponse1(AdCPBaseModel): description='Non-blocking observations about this completed commitment. The MediaBuy was still created or amended exactly as represented. Continuing conditions also appear as indicators on get_media_buys.', min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class AcceptProposalResponse5(AdcpResponse, AcceptProposalResponse1, AcceptProposalResponse4): diff --git a/src/adcp/types/domains/media_buy/acceptance_context.py b/src/adcp/types/domains/media_buy/acceptance_context.py index 82430b235..b6f255279 100644 --- a/src/adcp/types/domains/media_buy/acceptance_context.py +++ b/src/adcp/types/domains/media_buy/acceptance_context.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_context.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -24,7 +24,7 @@ class Subject(AdCPBaseModel): extra='forbid', ) subject_category: Annotated[str, Field(pattern='^[a-z][a-z0-9_]*$')] - subject_facets: Annotated[list[SubjectFacet] | None, Field(min_length=1)] = None + subject_facets: Annotated[list[SubjectFacet] | None, Field(min_length=1), NoExplicitNull] = None class AdvertiserRole(SubjectFacet): @@ -44,15 +44,20 @@ class AcceptanceContext(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - subjects: Annotated[list[Subject] | None, Field(min_length=1)] = None - advertiser_roles: Annotated[list[AdvertiserRole] | None, Field(min_length=1)] = None - advertiser_industry: advertiser_industry_1.AdvertiserIndustry | None = None + subjects: Annotated[list[Subject] | None, Field(min_length=1), NoExplicitNull] = None + advertiser_roles: Annotated[ + list[AdvertiserRole] | None, Field(min_length=1), NoExplicitNull + ] = None + advertiser_industry: Annotated[ + advertiser_industry_1.AdvertiserIndustry | None, NoExplicitNull + ] = None advertiser_jurisdictions: Annotated[ list[AdvertiserJurisdiction] | None, Field( description='Jurisdictions in which the advertiser is established or legally organized. This is distinct from where an ad will be delivered.', min_length=1, ), + NoExplicitNull, ] = None delivery_jurisdictions: Annotated[ list[DeliveryJurisdiction] | None, @@ -60,5 +65,6 @@ class AcceptanceContext(AdCPBaseModel): description="Jurisdictions in which the proposed advertising will be delivered. Seller acceptance rules' jurisdictions and jurisdiction_groups match this field.", min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py b/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py index 26446d982..44f2aa74a 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_catalog.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..core import ext as ext_1 @@ -21,9 +21,11 @@ class AcceptancePolicyCatalog(AdCPBaseModel): extra='forbid', ) catalog_version: Annotated[str, Field(min_length=1)] - generated_at: AwareDatetime | None = None + generated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None profiles: Annotated[ - list[acceptance_policy_profile.AcceptancePolicyProfile] | None, Field(min_length=1) + list[acceptance_policy_profile.AcceptancePolicyProfile] | None, + Field(min_length=1), + NoExplicitNull, ] = None registry_profiles: Annotated[ list[acceptance_policy_profile_ref.RegistryAcceptancePolicyProfileReference] | None, @@ -31,8 +33,9 @@ class AcceptancePolicyCatalog(AdCPBaseModel): description='Exact reusable profiles adopted from the shared policy registry. Resolution failure is unknown, never allowed. A seller adds a distinct local profile to narrow a registry profile.', min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> AcceptancePolicyCatalog: diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_profile.py b/src/adcp/types/domains/media_buy/acceptance_policy_profile.py index 63d64d68b..51c7f9cb6 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_profile.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_profile.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_profile.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T12:09:21+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StringConstraints @@ -66,16 +66,18 @@ class Scope(AdCPBaseModel): ) subject_categories: Annotated[list[SubjectCategory], Field(min_length=1)] applies_to: Annotated[list[AppliesToEnum], Field(min_length=1)] - jurisdictions: Annotated[list[Jurisdiction] | None, Field(min_length=1)] = None + jurisdictions: Annotated[list[Jurisdiction] | None, Field(min_length=1), NoExplicitNull] = None jurisdiction_groups: Annotated[ list[JurisdictionGroup] | None, Field(description="Named groups declared in this profile's region_aliases.", min_length=1), + NoExplicitNull, ] = None all_jurisdictions: Annotated[ Literal[True] | None, Field( description='Explicitly states that the scope is not limited by delivery jurisdiction.' ), + NoExplicitNull, ] = None @@ -114,13 +116,15 @@ class AcceptancePolicyProfile(AdCPBaseModel): Field( description='The boundary within which a complete profile claims exhaustiveness. It is informative for partial profiles and mandatory for complete profiles.' ), + NoExplicitNull, ] = None region_aliases: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[A-Z][A-Z0-9_-]*$')], list[RegionAliase]] | None, Field( description='Profile-local named country groups. Rules may reference only keys declared here; consumers expand them before matching.' ), + NoExplicitNull, ] = None - description: Annotated[str | None, Field(min_length=1)] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None rules: Annotated[list[acceptance_policy_rule.AcceptancePolicyRule], Field(min_length=1)] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_requirement.py b/src/adcp/types/domains/media_buy/acceptance_policy_requirement.py index 978f25770..7715e6fad 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_requirement.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_requirement.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_requirement.json -# timestamp: 2026-10-04T00:53:27+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -19,8 +19,8 @@ class AcceptancePolicyRequirement1(AdCPBaseModel): extra='forbid', ) kind: Literal['category_declaration'] = 'category_declaration' - declaration: Annotated[str | None, Field(min_length=1)] = None - description: Annotated[str | None, Field(min_length=1)] = None + declaration: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement2(AdCPBaseModel): @@ -28,8 +28,8 @@ class AcceptancePolicyRequirement2(AdCPBaseModel): extra='forbid', ) kind: Literal['advertiser_verification'] = 'advertiser_verification' - verification_scheme: Annotated[str | None, Field(min_length=1)] = None - description: Annotated[str | None, Field(min_length=1)] = None + verification_scheme: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class Criterion(ScalarStr): @@ -49,7 +49,7 @@ class AcceptancePolicyRequirement3(AdCPBaseModel): min_length=1, ), ] - description: Annotated[str | None, Field(min_length=1)] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement4(AdCPBaseModel): @@ -64,7 +64,7 @@ class AcceptancePolicyRequirement4(AdCPBaseModel): min_length=1, ), ] - description: Annotated[str | None, Field(min_length=1)] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement5(AdCPBaseModel): @@ -72,8 +72,8 @@ class AcceptancePolicyRequirement5(AdCPBaseModel): extra='forbid', ) kind: Literal['certification'] = 'certification' - credential: Annotated[str | None, Field(min_length=1)] = None - description: Annotated[str | None, Field(min_length=1)] = None + credential: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement6(AdCPBaseModel): @@ -81,8 +81,8 @@ class AcceptancePolicyRequirement6(AdCPBaseModel): extra='forbid', ) kind: Literal['license'] = 'license' - credential: Annotated[str | None, Field(min_length=1)] = None - description: Annotated[str | None, Field(min_length=1)] = None + credential: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement7(AdCPBaseModel): @@ -90,7 +90,7 @@ class AcceptancePolicyRequirement7(AdCPBaseModel): extra='forbid', ) kind: Literal['prior_authorization'] = 'prior_authorization' - description: Annotated[str | None, Field(min_length=1)] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement8(AdCPBaseModel): @@ -98,7 +98,7 @@ class AcceptancePolicyRequirement8(AdCPBaseModel): extra='forbid', ) kind: Literal['account_setup'] = 'account_setup' - description: Annotated[str | None, Field(min_length=1)] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement9(AdCPBaseModel): @@ -106,7 +106,7 @@ class AcceptancePolicyRequirement9(AdCPBaseModel): extra='forbid', ) kind: Literal['sales_assisted'] = 'sales_assisted' - description: Annotated[str | None, Field(min_length=1)] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement10(AdCPBaseModel): @@ -114,9 +114,9 @@ class AcceptancePolicyRequirement10(AdCPBaseModel): extra='forbid', ) kind: Literal['disclosure'] = 'disclosure' - format: Annotated[str | None, Field(min_length=1)] = None - placement: Annotated[str | None, Field(min_length=1)] = None - description: Annotated[str | None, Field(min_length=1)] = None + format: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + placement: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement12(AdCPBaseModel): @@ -152,8 +152,9 @@ class AcceptancePolicyRequirement14(AdCPBaseModel): description='Deprecated in AdCP 3.2 and removed in AdCP 4.0. This named-format restriction is retained for 3.x compatibility; new policies identify canonical format options in description or an extension until a registry-stable format-option identity is standardized.', min_length=1, ), + NoExplicitNull, ] = None - description: Annotated[str | None, Field(min_length=1)] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement15(AdCPBaseModel): @@ -161,9 +162,9 @@ class AcceptancePolicyRequirement15(AdCPBaseModel): extra='forbid', ) kind: Literal['time_restriction'] = 'time_restriction' - starts_at: AwareDatetime | None = None - ends_at: AwareDatetime | None = None - description: Annotated[str | None, Field(min_length=1)] = None + starts_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + ends_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement16(AdCPBaseModel): @@ -171,7 +172,7 @@ class AcceptancePolicyRequirement16(AdCPBaseModel): extra='forbid', ) kind: Literal['transparency_reporting'] = 'transparency_reporting' - description: Annotated[str | None, Field(min_length=1)] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement11(AdCPBaseModel): @@ -180,9 +181,9 @@ class AcceptancePolicyRequirement11(AdCPBaseModel): ) kind: Literal['targeting_restriction'] = 'targeting_restriction' restricted_attributes: Annotated[ - list[restricted_attribute.RestrictedAttribute] | None, Field(min_length=1) + list[restricted_attribute.RestrictedAttribute] | None, Field(min_length=1), NoExplicitNull ] = None - description: Annotated[str | None, Field(min_length=1)] = None + description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None class AcceptancePolicyRequirement17(AdCPBaseModel): @@ -192,7 +193,7 @@ class AcceptancePolicyRequirement17(AdCPBaseModel): kind: Literal['custom'] = 'custom' id: Annotated[str, Field(pattern='^[a-z][a-z0-9_.:-]*$')] description: Annotated[str, Field(min_length=1)] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None AcceptancePolicyRequirement = Annotated[ diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_rule.py b/src/adcp/types/domains/media_buy/acceptance_policy_rule.py index 3186497ba..7832dfdd8 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_rule.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_rule.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_rule.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -73,6 +73,7 @@ class AcceptancePolicyRule(AdCPBaseModel): description='Facet IDs defined by the selected policy category. Omission means the rule applies to every facet in the category.', min_length=1, ), + NoExplicitNull, ] = None advertiser_roles: Annotated[ list[AdvertiserRole] | None, @@ -80,6 +81,7 @@ class AcceptancePolicyRule(AdCPBaseModel): description='Registry-extensible roles such as political_actor, election_authority, government_entity, news_publisher, or commercial_advertiser.', min_length=1, ), + NoExplicitNull, ] = None jurisdictions: Annotated[ list[Jurisdiction] | None, @@ -87,6 +89,7 @@ class AcceptancePolicyRule(AdCPBaseModel): description='Delivery jurisdictions where this rule applies. Omission means every jurisdiction served by the seller.', min_length=1, ), + NoExplicitNull, ] = None jurisdiction_groups: Annotated[ list[JurisdictionGroup] | None, @@ -94,11 +97,14 @@ class AcceptancePolicyRule(AdCPBaseModel): description="Named country groups declared by the containing profile's region_aliases. Unknown group IDs invalidate the profile; they never match permissively.", min_length=1, ), + NoExplicitNull, ] = None applies_to: Annotated[list[AppliesToEnum], Field(min_length=1)] disposition: Disposition requirements: Annotated[ - list[acceptance_policy_requirement.AcceptancePolicyRequirement] | None, Field(min_length=1) + list[acceptance_policy_requirement.AcceptancePolicyRequirement] | None, + Field(min_length=1), + NoExplicitNull, ] = None policy_ids: Annotated[ list[PolicyId] | None, @@ -106,6 +112,7 @@ class AcceptancePolicyRule(AdCPBaseModel): description='Registry policies that define the exact obligations behind this coarse rule.', min_length=1, ), + NoExplicitNull, ] = None description: Annotated[ str | None, @@ -114,7 +121,8 @@ class AcceptancePolicyRule(AdCPBaseModel): max_length=1000, min_length=1, ), + NoExplicitNull, ] = None - effective_at: AwareDatetime | None = None - expires_at: AwareDatetime | None = None - ext: ext_1.ExtensionObject | None = None + effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + expires_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/build_creative_async_response_input_required.py b/src/adcp/types/domains/media_buy/build_creative_async_response_input_required.py index 2114f2a69..b40857dac 100644 --- a/src/adcp/types/domains/media_buy/build_creative_async_response_input_required.py +++ b/src/adcp/types/domains/media_buy/build_creative_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/build_creative_async_response_input_required.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -26,13 +26,16 @@ class BuildCreativeInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why input is needed') + Reason | None, + Field(description='Reason code indicating why input is needed'), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional validation errors or warnings explaining why input is required.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/build_creative_async_response_submitted.py b/src/adcp/types/domains/media_buy/build_creative_async_response_submitted.py index d4f0712e5..90af4a5f1 100644 --- a/src/adcp/types/domains/media_buy/build_creative_async_response_submitted.py +++ b/src/adcp/types/domains/media_buy/build_creative_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/build_creative_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -36,12 +36,14 @@ class BuildCreativeSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Generative build queued; typical turnaround 3–5 minutes.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/build_creative_async_response_working.py b/src/adcp/types/domains/media_buy/build_creative_async_response_working.py index 58a3cfee1..7fcf897ea 100644 --- a/src/adcp/types/domains/media_buy/build_creative_async_response_working.py +++ b/src/adcp/types/domains/media_buy/build_creative_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/build_creative_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..core import context as context_1 @@ -18,17 +18,24 @@ class BuildCreativeWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ str | None, Field( description="Current step or phase of the operation (e.g., 'generating_assets', 'resolving_macros', 'rendering_preview')" ), + NoExplicitNull, ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/build_creative_request.py b/src/adcp/types/domains/media_buy/build_creative_request.py index 6a3c5a0f4..aeb9136ac 100644 --- a/src/adcp/types/domains/media_buy/build_creative_request.py +++ b/src/adcp/types/domains/media_buy/build_creative_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/build_creative_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool, StrictFloat @@ -62,6 +62,7 @@ class SignalCondition4(AdCPBaseModel): Field( description="Optional opaque resolved-segment handle for this fan-out condition — the RESOLVED condition identity, distinct from signal_ref's DEFINITION identity. When get_signals or a product signal_targeting_options entry exposed a signal_agent_segment_id for the targeted signal, echo it here verbatim so the produced creative's signal_condition carries the resolved-segment identity that the trafficking-compatibility check (SIGNAL_TARGETING_INCOMPATIBLE) matches on exactly. Providers MAY namespace it (e.g. provider_a:weather:rain_realtime vs provider_b:precip:high) so cross-provider conditions stay distinct without a shared taxonomy registry; treat as opaque, do not parse the namespace for business logic. Prefer it over reconstructing condition identity from categorical values — categorical {signal_ref,value} identity is the weaker fallback, appropriate only for inherently-categorical signals with no resolved handle, and never a cross-provider equivalence claim." ), + NoExplicitNull, ] = None @@ -88,6 +89,7 @@ class VariantAxis(AdCPBaseModel): Field( description='The transformer `config` param `field` this axis sweeps. REQUIRED when `dimension` is `transformer_config`; `values[]` are then interpreted as values for `config[field]`. Omit for other dimensions.' ), + NoExplicitNull, ] = None values: Annotated[ list[Any] | None, @@ -95,10 +97,12 @@ class VariantAxis(AdCPBaseModel): description='Caller-fixed set of values for the axis (e.g. ["isaac", "sara"] for dimension `voice`). When present, len(values) is the variant count and is authoritative over max_variants.', min_length=1, ), + NoExplicitNull, ] = None label: Annotated[ str | None, Field(description='Human-readable description of the axis, especially for `custom`.'), + NoExplicitNull, ] = None @@ -119,11 +123,14 @@ class PreviewInput(AdCPBaseModel): ), ] macros: Annotated[ - dict[str, str] | None, Field(description='Macro values to use for this preview variant') + dict[str, str] | None, + Field(description='Macro values to use for this preview variant'), + NoExplicitNull, ] = None context_description: Annotated[ str | None, Field(description='Natural language description of the context for AI-generated content'), + NoExplicitNull, ] = None @@ -134,6 +141,7 @@ class SignalCondition1(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New targeting constraints SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -141,6 +149,7 @@ class SignalCondition1(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[Literal['binary'], Field(description='Discriminator for binary signals')] = 'binary' value: Annotated[ @@ -158,6 +167,7 @@ class SignalCondition2(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New targeting constraints SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -165,6 +175,7 @@ class SignalCondition2(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[ Literal['categorical'], Field(description='Discriminator for categorical signals') @@ -185,6 +196,7 @@ class SignalCondition3(AdCPBaseModel): signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field(description='The signal to target. New targeting constraints SHOULD use signal_ref.'), + NoExplicitNull, ] = None signal_id: Annotated[ signal_id_1.SignalId | None, @@ -192,6 +204,7 @@ class SignalCondition3(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older clients.', ), + NoExplicitNull, ] = None value_type: Annotated[ Literal['numeric'], Field(description='Discriminator for numeric signals') @@ -201,12 +214,14 @@ class SignalCondition3(AdCPBaseModel): Field( description="Minimum value (inclusive). Omit for no minimum. Must be <= max_value when both are provided. Should be >= signal's range.min if defined." ), + NoExplicitNull, ] = None max_value: Annotated[ StrictFloat | None, Field( description="Maximum value (inclusive). Omit for no maximum. Must be >= min_value when both are provided. Should be <= signal's range.max if defined." ), + NoExplicitNull, ] = None @@ -237,60 +252,70 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None message: Annotated[ str | None, Field( description='Natural language instructions for the transformation or generation. For pure generation, this is the creative brief. For transformation, this provides guidance on how to adapt the creative. For refinement, this describes the desired changes.' ), + NoExplicitNull, ] = None creative_manifest: Annotated[ creative_manifest_1.CreativeManifest | None, Field( description='Creative manifest to transform or generate from. On the canonical 3.2 path it carries `format_kind`, optional `format_option_ref`, and the required input assets. For transformation (for example resizing or reformatting), this is the complete creative to adapt. When creative_id is provided, the agent resolves the creative from its library and this field is ignored.' ), + NoExplicitNull, ] = None creative_representation_set: Annotated[ creative_representation_set_1.CreativeRepresentationSet | None, Field( description="Complete creative revision containing equivalent trafficking representations, of which exactly one is selected for this seller-bound output. This mode is accepted only by the destination sales agent and requires representation_destination plus representation_selection_strategy. The target capability selects the seller's build route; representation_destination supplies the binding inventory contract. The resolver verifies revision_content_digest against the complete set, retains every representation unchanged, selects exactly one compatible representation, and returns a manifest carrying representation_selection. If macro_values is present, selection happens first and binding affects only the derived output; the retained representation set and its revision binding never change. When none is compatible, the request fails with CREATIVE_REPRESENTATION_UNRESOLVED and one representation_rejections entry per candidate." ), + NoExplicitNull, ] = None representation_destination: Annotated[ representation_destination_1.RepresentationDestination | None, Field( description='Seller-owned product and effective format context for representation resolution. Required only with creative_representation_set and meaningful only when this endpoint is the destination sales agent.' ), + NoExplicitNull, ] = None representation_selection_strategy: Annotated[ representation_selection_strategy_1.RepresentationSelectionStrategy | None, Field( description='Deterministic strategy to apply after compatibility filtering. Required with creative_representation_set and MUST be advertised by creative.representation_resolution.strategies.' ), + NoExplicitNull, ] = None creative_id: Annotated[ str | None, Field( description="Reference to a creative in the agent's library. The creative agent resolves this to a manifest from its library. Use this instead of creative_manifest when retrieving an existing creative for tag generation or format adaptation." ), + NoExplicitNull, ] = None concept_id: Annotated[ str | None, Field( description='Creative concept containing the creative. Creative agents SHOULD assign globally unique creative_id values; when they cannot guarantee uniqueness, concept_id is REQUIRED to disambiguate.' ), + NoExplicitNull, ] = None media_buy_id: Annotated[ str | None, Field( description='Media buy identifier for tag generation context. When the creative agent is also the ad server, this provides the trafficking context needed to generate placement-specific tags (e.g., CM360 placement ID). Not needed when tags are generated at the creative level (most creative platforms).' ), + NoExplicitNull, ] = None package_id: Annotated[ str | None, Field( description='Package identifier within the media buy. Used with media_buy_id when the creative agent needs line-item-level context for tag generation. Omit to get a tag not scoped to a specific package.' ), + NoExplicitNull, ] = None target_format_id: Annotated[ format_id.FormatReferenceStructuredObject | None, @@ -298,6 +323,7 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format selector. Use `target_capability_id` with a value advertised in `get_adcp_capabilities.creative.supported_formats[].capability_id`.', ), + NoExplicitNull, ] = None target_format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -307,6 +333,7 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): max_length=50, min_length=1, ), + NoExplicitNull, ] = None target_capability_id: Annotated[ str | None, @@ -314,6 +341,7 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): description='Canonical 3.2 single-output selector. Matches exactly one `get_adcp_capabilities.creative.supported_formats[].capability_id` advertised by this creative agent. The matched entry supplies the canonical `format` declaration used to validate inputs and the returned manifest. Mutually exclusive with `target_capability_ids` and the deprecated target_format_id fields.', pattern='^[a-zA-Z0-9_-]+$', ), + NoExplicitNull, ] = None target_capability_ids: Annotated[ list[TargetCapabilityId] | None, @@ -322,36 +350,42 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): max_length=50, min_length=1, ), + NoExplicitNull, ] = None transformer_id: Annotated[ str | None, Field( description="Selects an account-scoped transformer (discovered via list_transformers) to perform the build. One transformer per call. When present, the build uses this transformer and target_capability_id/target_capability_ids select which of its outputs to produce — they MUST be a subset of the transformer's output_capability_ids. Deprecated target_format_id fields use the legacy output_format_ids compatibility path. Render configuration goes in `config`." ), + NoExplicitNull, ] = None config: Annotated[ dict[str, Any] | None, Field( description='Typed render configuration for the selected transformer, keyed by each param\'s `field` (from the transformer\'s params[] in list_transformers). Example: { "voice": "isaac", "speaking_rate": 1.1, "mastering_preset": "podcast" }. The agent MUST validate `config` against the transformer\'s live params for this account and reject unrecognized keys and out-of-range / non-enumerated values with a field-attributed error (e.g. `config.voice`) rather than silently ignoring them — config drives a paid render. Genuinely vendor-specific or experimental knobs not declared as params belong in `ext`, not here. (The schema leaves this object open because legal keys are dynamic per transformer; strict validation is a normative agent obligation.) When `refine_from_build_variant_id` is set, `config` is applied as a DELTA over the parent leaf\'s config.' ), + NoExplicitNull, ] = None refine_from_build_variant_id: Annotated[ str | None, Field( description='Refine a previously produced variant and return new lineage-linked variants. The transformer and target capability are inherited from the parent leaf. A refinement request MUST omit transformer_id, target_capability_id(s), and deprecated target_format_id(s); changing transformer or output format is a new transformation build, not refinement. Requires creative.supports_refinement.' ), + NoExplicitNull, ] = None mode: Annotated[ Mode | None, Field( description="`execute` (default) produces and bills the creative(s). `estimate` is a DRY RUN: the agent produces nothing and bills nothing, and returns a BuildCreativeEstimate with a projected cost band (cost_low/cost_high) computed against THIS request's actual inputs (script length, brief, catalog size, max_creatives × max_variants) — the band the buyer cannot derive itself, since per_unit gives the rate but not the unit count. Requires the agent to advertise `creative.supports_spend_controls`; otherwise rejected with `UNSUPPORTED_FEATURE`." ), + NoExplicitNull, ] = Mode.execute max_spend: Annotated[ MaxSpend | None, Field( description='Hard per-call spend ceiling. The agent produces leaves until the NEXT leaf would push the run\'s aggregate vendor_cost over `amount`, then STOPS and returns the partial BuildCreativeVariantSuccess produced so far with `budget_status: "capped"` (every returned leaf is real, trafficable, and billed — nothing produced is discarded; the leaf shortfall is `leaves_returned` < `leaves_total`). If even the first leaf would exceed the cap, the call fails with BUDGET_CAP_REACHED. `currency` MUST match the rate card\'s currency (the agent does not FX-convert) or the request is rejected with INVALID_REQUEST (error.field `max_spend.currency`). Requires `creative.supports_spend_controls`. Caps a SINGLE call — to bound a refinement loop, track aggregate vendor_cost across calls and stop issuing them (buyer responsibility in this revision). max_spend bounds only build-time vendor_cost: CPM-priced builds (estimate basis `cpm_deferred`) have build-time vendor_cost 0 and accrue at serve time, so max_spend never engages for them — bound a CPM fan-out with max_creatives instead.' ), + NoExplicitNull, ] = None max_creatives: Annotated[ SchemaInt | None, @@ -359,6 +393,7 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): description='Caps how many DISTINCT creatives to produce along the catalog/item fan-out axis — one creative per catalog item. Use it to sample a large catalog (e.g. send 150 job openings, set max_creatives: 5 to preview five). Distinct from item_limit, which caps how many catalog items a SINGLE creative consumes (DCO-style). Omitted with a catalog input means one creative per item up to the catalog/format bound; omitted without a catalog collapses to a single creative. Large fan-outs may return asynchronously. Mutually exclusive with `refine_from_build_variant_id` (refinement targets one prior creative, not a catalog fan-out). Supported only when the agent advertises `creative.multiplicity.supports_catalog_fanout`; values above `max_creatives_limit` are clamped. Pair with `max_spend` to bound the bill of a large fan-out.', ge=1, ), + NoExplicitNull, ] = None signal_conditions: Annotated[ list[SignalCondition] | None, @@ -366,6 +401,7 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): description="Advisory keep-all PRODUCTION axis: produce one distinct creative group per signal condition, each kept and trafficked with its own signal targeting (e.g. a rain creative AND a sun creative). Sibling to max_creatives (catalog axis), NOT a variant_axis value (which is choose-among). Each item reuses SignalTargeting (value_type-discriminated binary/categorical/numeric over signal_ref) so the produced group's signal_condition resolves condition identity through the SAME schema the sales-side package targeting uses, plus an optional signal_agent_segment_id carrying the RESOLVED-segment identity (vs signal_ref's definition identity) — echo a provider-exposed handle verbatim; it is the primary trafficking-compatibility key, with categorical signal_ref+value as the weaker fallback. Per #5280 this is an ADVISORY context pointer — it informs production and MUST NOT hard-block at the build_creative layer; trafficking-compatibility (a sun creative MUST NOT serve into rain-targeted packages) is enforced reject-at-trafficking on the sales side (SIGNAL_TARGETING_INCOMPATIBLE), not here. Triggers the BuildCreativeVariantSuccess shape. Supported only when the agent advertises creative.multiplicity.supports_signal_fanout; condition counts above max_signal_conditions_limit are CLAMPED (not rejected), consistent with max_creatives. Composes with max_creatives (catalog × conditions cross-product) and max_variants (variants per group).", min_length=1, ), + NoExplicitNull, ] = None max_variants: Annotated[ SchemaInt | None, @@ -373,48 +409,56 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): description='Caps how many ALTERNATIVES to produce per creative (different voices, themes, best-of-N, etc.). Default 1 preserves single-output behavior. Each variant is a real, independently-billed build (you pay for all produced); the buyer keeps one or many. When variant_axis.values[] is provided, its length is authoritative over max_variants. Resolutions/quality tiers are NOT variants — request them as additional target formats.', ge=1, ), + NoExplicitNull, ] = 1 variant_axis: Annotated[ VariantAxis | None, Field( description='Declares the dimension along which variants differ. When `values` is provided, the agent produces exactly one variant per value (e.g. an A/B of two voices). When only `dimension` is provided, the agent chooses up to max_variants variants along that dimension (e.g. best-of-N, themes).' ), + NoExplicitNull, ] = None keep_mode: Annotated[ KeepMode | None, Field( description='Advisory hint for how the buyer intends to use the variants. `keep_one` (best-of-N) and `keep_some` signal the agent to set `recommended`/`rank` on returned variants. Advisory only — it does not change what is returned or billed; every produced variant is returned and charged. Keeping is a client act of trafficking the chosen build_variant_id(s).' ), + NoExplicitNull, ] = KeepMode.keep_all selection_strategy: Annotated[ creative_selection_strategy.CreativeSelectionStrategy | None, Field( description='Governs HOW the agent samples when max_creatives < items_total (folds #5262). audience_relevance draws its ranking input from the SAME signal_ref pointers in signal_conditions / package targeting — NOT a parallel signals[] array. proximity takes a location input (geo shape TBD — WG open). inventory_priority is seller-side catalog metadata (margin/overstock/promo; no buyer input). random is the status-quo default. Per-creative selection ordering surfaces on the existing rank / recommended fields of creatives[].variants[], not a new selection_rank. Advisory; absent => agent default (random).' ), + NoExplicitNull, ] = None account: Annotated[ account_ref.AccountReference | None, Field( description='Account reference for pricing and billing. When present, the creative agent applies account-specific pricing from the rate card, records the build against the account for billing, and can enforce account-level quotas or entitlements. Required by creative agents that charge for their services.' ), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Brand reference for creative generation. Resolved to full brand identity (colors, logos, tone) at execution time.' ), + NoExplicitNull, ] = None quality: Annotated[ creative_quality.CreativeQuality | None, Field( description="Quality tier for generation. 'draft' produces fast, lower-fidelity output for iteration and review. 'production' produces full-quality output for final delivery. If omitted, the creative agent uses its own default. For non-generative transforms (e.g., format resizing), creative agents MAY ignore this field." ), + NoExplicitNull, ] = None evaluator: Annotated[ evaluator_spec.EvaluatorSpec | None, Field( description="Optional advisory evaluator (buyer-attached pointer, #5280) declaring how produced variants should be evaluated and ranked — the rank-side of the get_creative_features feature oracle. Experimental (x-status: experimental): the whole evaluator surface is new and unfrozen, and requires creative.supports_evaluator, which sellers MUST pair with `creative.evaluator` in experimental_features. Drives the producing agent's gate-then-rank pipeline over its best_of_n exploration: per leaf, evaluate (the chosen form) → optionally GATE (`evaluator.feature_requirement[]`, drop fails — internal pruning of which leaves the agent recommends, never an AdCP-layer block of an already-produced billable leaf) → RANK survivors (`evaluator.rank_by`, an explicit {feature_id, direction} ordering). Feature discovery uses get_adcp_capabilities governance.creative_features for rank_by, feature_requirement, and eval.features[]; evaluator_id is a pre-provisioned/account-arranged preset, not an ID discovered from that catalog. Populates a per-leaf `eval` block of creative-feature values (creative-feature-result[]) when supports_evaluator. When the evaluator names an external agent (`evaluator.feature_agent.agent_url` or the agent-form `agent_url`), that agent MUST appear in the seller's `creative_policy.accepted_verifiers[]` (the same allowlist #5280 established for provenance verify_agent); an off-list agent is rejected with `EVALUATOR_AGENT_NOT_ACCEPTED`. The outbound evaluator call authenticates on the transport (request signing/JWKS, mTLS, or a pre-provisioned static credential); credentials and caller-supplied trust material MUST NOT appear in evaluator, context, ext, or creative payload fields, and credential- or trust-material keys should be rejected with `CREDENTIAL_IN_ARGS`. With no `feature_requirement`, evaluation is advisory only and does not change what is produced or billed; an unreachable/unknown on-list agent degrades to seller-default ranking (advisory errors[] note), not a failure. Requires creative.supports_evaluator; otherwise ignored." ), + NoExplicitNull, ] = None item_limit: Annotated[ SchemaInt | None, @@ -422,12 +466,14 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): description="Maximum number of catalog items a SINGLE creative consumes when generating (DCO-style — e.g. how many items fill one carousel/feed creative). When a catalog asset contains more items than this limit, the creative agent selects the top items based on relevance or catalog ordering. When item_limit exceeds the format's max_items, the creative agent SHOULD use the lesser of the two. Ignored when the manifest contains no catalog assets. Distinct from `max_creatives`, which fans OUT across catalog items to produce one distinct creative per item.", ge=1, ), + NoExplicitNull, ] = None include_preview: Annotated[ StrictBool | None, Field( description="When true, requests the creative agent to include preview renders in the response alongside the manifest. Agents that support this return a 'preview' object in the response using the same structure as preview_creative. Agents that do not support inline preview simply omit the field. This avoids a separate preview_creative round trip for platforms that generate previews as a byproduct of building." ), + NoExplicitNull, ] = None preview_inputs: Annotated[ list[PreviewInput] | None, @@ -435,24 +481,28 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): description='Input sets for preview generation when include_preview is true. Supported with a single target_capability_id; multi-capability requests generate one default preview per output. Deprecated target-format selectors retain equivalent compatibility behavior.', min_length=1, ), + NoExplicitNull, ] = None preview_quality: Annotated[ creative_quality.CreativeQuality | None, Field( description="Render quality for inline preview when include_preview is true. 'draft' produces fast, lower-fidelity renderings. 'production' produces full-quality renderings. Independent of the build quality parameter — you can build at draft quality and preview at production quality, or vice versa. If omitted, the creative agent uses its own default. Ignored when include_preview is false or omitted." ), + NoExplicitNull, ] = None preview_output_format: Annotated[ preview_output_format_1.PreviewOutputFormat | None, Field( description="Output format for preview renders when include_preview is true. 'url' returns preview_url (iframe-embeddable URL), 'html' returns preview_html (raw HTML). Ignored when include_preview is false or omitted." ), + NoExplicitNull, ] = preview_output_format_1.PreviewOutputFormat.url macro_values: Annotated[ dict[str, str] | None, Field( description="Raw concrete values offered for build-time binding, keyed by AdCP universal semantic (for example CLICK_URL or CACHEBUSTER). With declarations, a value binds only a verified-universal `resolve_value` occurrence performed_by `creative_agent`, using that declaration's exact context and encoding; callers MUST NOT pre-encode it. The selected `creative.supported_formats[]` route's macro_resolution_capabilities is the binding build/preview capability set; seller-wide and product sets apply only on the sales execution path. Values never short-circuit `translate_to_native`: translation emits its target declaration and the complete target capability chain remains required. For creative_representation_set, selection happens before binding and the complete representation set remains byte-identical. Without declarations, the 3.x legacy path remains: creative agents may translate recognized AdCP tokens using the existing `translateUniversalMacros` contract, preserve omitted placeholders for the sales agent, and ignore unknown keys. Existing unmapped/frozen-consent diagnostics remain required." ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -468,6 +518,7 @@ class BuildCreativeRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Optional webhook configuration for async terminal completion/failure notifications on build_creative. Meaningful only when the request enters the async lifecycle and returns a Submitted envelope. Submitted envelopes with `task_id` remain pollable through `get_task_status` (legacy `tasks/get`) whether or not this field is present. If a request includes this field and the agent returns a Submitted envelope, the agent MUST deliver at least the terminal completion/failure notification to the configured URL; intermediate progress notifications are MAY. If the agent cannot honor the webhook channel, it MUST reject the request with a structured error instead of silently accepting. This field does not change response timing semantics: agents MUST NOT route a request through the async/Submitted arm or emit async delivery solely because `push_notification_config` is present; requests that can be completed inline still return the synchronous success shape.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/build_creative_response.py b/src/adcp/types/domains/media_buy/build_creative_response.py index 46eea9ddf..e7f676575 100644 --- a/src/adcp/types/domains/media_buy/build_creative_response.py +++ b/src/adcp/types/domains/media_buy/build_creative_response.py @@ -202,7 +202,7 @@ class BuildCreativeResponse5(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope class BuildCreativeResponse6(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): - model_config = ConfigDict(extra='allow', validate_default=True) + model_config = ConfigDict(extra='allow') status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted task_id: str message: Annotated[str, StringConstraints(max_length=2000)] | None = None diff --git a/src/adcp/types/domains/media_buy/buy_products_request.py b/src/adcp/types/domains/media_buy/buy_products_request.py index 325f68938..148df0d5d 100644 --- a/src/adcp/types/domains/media_buy/buy_products_request.py +++ b/src/adcp/types/domains/media_buy/buy_products_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/buy_products_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import ( @@ -65,6 +65,7 @@ class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -74,6 +75,7 @@ class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, Field(max_length=255, min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$') @@ -86,6 +88,7 @@ class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None account: Annotated[ canonical_account_ref.CanonicalAccountReference, @@ -98,12 +101,14 @@ class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Brand source required when account is ID-only. Omit when account already contains brand and operator.' ), + NoExplicitNull, ] = None advertiser_industry: Annotated[ advertiser_industry_1.AdvertiserIndustry | None, Field( description='Industry classification for this campaign. Sellers may infer it from the resolved brand manifest when omitted.' ), + NoExplicitNull, ] = None feed_version: Annotated[ str, @@ -118,21 +123,24 @@ class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): description='list_products pricing_version containing the accepted rate. Buyers MUST include this whenever list_products returned one; omission means the seller does not version pricing separately.', min_length=1, ), + NoExplicitNull, ] = None purchases: Annotated[list[product_purchase_input.ProductPurchaseInput], Field(min_length=1)] - total_budget: TotalBudget | None = None + total_budget: Annotated[TotalBudget | None, NoExplicitNull] = None daily_budget_cap: Annotated[ StrictFloat | None, Field( description="Optional hard aggregate daily spend ceiling in total_budget.currency or the media buy's derived currency. It bounds the shared daily spend pool without allocating or reserving amounts for purchases.", ge=0.0, ), + NoExplicitNull, ] = None frequency_cap: Annotated[ media_buy_frequency_cap.MediaBuyFrequencyCap | None, Field( description='Optional max-impression cap using one counter across purchases. Buyers send it only when aggregate_frequency_capping is advertised. Every selected product must declare compatible media_buy_support; otherwise the purchase is rejected atomically with UNSUPPORTED_FEATURE before any mutation, never clamped.' ), + NoExplicitNull, ] = None budget_cap_timezone: Annotated[ str | None, @@ -140,8 +148,11 @@ class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): description='Optional IANA timezone override shared by every aggregate and purchase daily cap. Requires buyer_timezone_override support; otherwise rejected with UNSUPPORTED_FEATURE. When omitted, budget_capping.timezone_basis selects Account.timezone or fixed_timezone.', min_length=1, ), + NoExplicitNull, + ] = None + budget_allocation: Annotated[ + canonical_budget_allocation.CanonicalBudgetAllocation | None, NoExplicitNull ] = None - budget_allocation: canonical_budget_allocation.CanonicalBudgetAllocation | None = None start_time: start_timing.StartTiming end_time: AwareDatetime pacing: Annotated[ @@ -149,28 +160,38 @@ class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Aggregate media-buy pacing. In seller-optimized allocation, a seller declaring media_buy.features.seller_optimized_budget MUST accept omission and `even`; it MAY reject `asap` or `front_loaded` with UNSUPPORTED_FEATURE (error.field `pacing`) before any provider mutation and MUST NOT silently coerce them to `even`. Fixed-allocation semantics are unchanged.' ), + NoExplicitNull, + ] = None + bidding: Annotated[bidding_policy.BiddingPolicy | None, NoExplicitNull] = None + paused: Annotated[StrictBool | None, NoExplicitNull] = False + purchase_order_ref: Annotated[ + str | None, Field(max_length=255, min_length=1), NoExplicitNull ] = None - bidding: bidding_policy.BiddingPolicy | None = None - paused: StrictBool | None = False - purchase_order_ref: Annotated[str | None, Field(max_length=255, min_length=1)] = None - agency_estimate_number: Annotated[str | None, Field(max_length=100)] = None + agency_estimate_number: Annotated[str | None, Field(max_length=100), NoExplicitNull] = None invoice_recipient: Annotated[ business_entity.BusinessEntity | None, Field(description='Authorized per-buy billing entity override.'), + NoExplicitNull, + ] = None + governance_context: Annotated[ + str | None, Field(max_length=4096, min_length=1), NoExplicitNull + ] = None + push_notification_config: Annotated[ + push_notification_config_1.PushNotificationConfig | None, NoExplicitNull ] = None - governance_context: Annotated[str | None, Field(max_length=4096, min_length=1)] = None - push_notification_config: push_notification_config_1.PushNotificationConfig | None = None reporting_webhook: Annotated[ reporting_webhook_1.ReportingWebhook | None, Field( description='Optional reporting delivery configuration established atomically with the MediaBuy. This is execution metadata and is not part of the immutable product pricing terms.' ), + NoExplicitNull, ] = None opportunity: Annotated[ Opportunity | None, Field( description='Optional planning-cycle closure for a direct product purchase. Success infers closed with accepted_with_seller when status is omitted; an explicit status MUST carry that same closure.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/buy_products_response.py b/src/adcp/types/domains/media_buy/buy_products_response.py index 5a09de7ab..2db9dd14b 100644 --- a/src/adcp/types/domains/media_buy/buy_products_response.py +++ b/src/adcp/types/domains/media_buy/buy_products_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/buy_products_response.json -# timestamp: 2026-10-04T04:34:38+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import canonical_media_buy_action @@ -54,9 +54,9 @@ class BuyProductsResponse2(AdCPBaseModel): ) status: Literal['failed'] = 'failed' errors: Annotated[list[error.Error], Field(min_length=1)] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class BuyProductsResponse3(AdCPBaseModel): @@ -65,11 +65,11 @@ class BuyProductsResponse3(AdCPBaseModel): ) status: Literal['submitted'] = 'submitted' task_id: Annotated[str, Field(min_length=1)] - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class BuyProductsResponse6(AdcpResponse, BuyProductsResponse2, BuyProductsResponse4): @@ -87,6 +87,7 @@ class Warning(Warning_1): description='Closed warning vocabulary for the negotiated AdCP release. A non-blocking condition observed while an operation still succeeded. New values are added through normal protocol releases; buyers validate against the negotiated release.', title='Warning Code', ), + NoExplicitNull, ] = None @@ -118,9 +119,10 @@ class BuyProductsResponse1(AdCPBaseModel): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None revision: Annotated[SchemaInt, Field(ge=1)] - media_buy_status: media_buy_status_1.MediaBuyStatus | None = None + media_buy_status: Annotated[media_buy_status_1.MediaBuyStatus | None, NoExplicitNull] = None confirmed_at: AwareDatetime | None = None accepted_proposal: AcceptedProposal purchase_bindings: Annotated[ @@ -137,10 +139,11 @@ class BuyProductsResponse1(AdCPBaseModel): description='Non-blocking observations about this completed commitment. The MediaBuy was still created or amended exactly as represented. Continuing conditions also appear as indicators on get_media_buys.', min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class BuyProductsResponse5(AdcpResponse, BuyProductsResponse1, BuyProductsResponse4): diff --git a/src/adcp/types/domains/media_buy/change_term.py b/src/adcp/types/domains/media_buy/change_term.py index 538ea99ed..a63956f46 100644 --- a/src/adcp/types/domains/media_buy/change_term.py +++ b/src/adcp/types/domains/media_buy/change_term.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/change_term.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, model_validator @@ -42,12 +42,14 @@ class MediaBuyChangeTerm(AdCPBaseModel): description='Non-terminal MediaBuy statuses in which this negotiated right may be exercised. When absent, the right applies in every non-terminal status where the canonical action itself is meaningful. This field describes contractual lifecycle scope; available_actions[] remains authoritative for the current instant.', min_length=1, ), + NoExplicitNull, ] = None processing_sla: Annotated[ sla_window.SlaWindow | None, Field( description='Binding elapsed-time acknowledgement and completion commitment. Sellers account for weekends and non-working periods when declaring the maximum.' ), + NoExplicitNull, ] = None conditions: Annotated[ list[Condition] | None, @@ -55,12 +57,14 @@ class MediaBuyChangeTerm(AdCPBaseModel): description='Opaque stable condition identifiers defined by terms_ref or bilateral commercial documentation. Implementations compare identifiers; they MUST NOT execute or interpret them as instructions.', min_length=1, ), + NoExplicitNull, ] = None constraints: Annotated[ change_term_constraints.MediaBuyChangeTermConstraints | None, Field( description='Portable bounds that buyer and seller SDKs can preflight. Omission means no machine-readable bound was promised; opaque conditions remain unevaluated.' ), + NoExplicitNull, ] = None terms_ref: Annotated[ str | None, @@ -69,6 +73,7 @@ class MediaBuyChangeTerm(AdCPBaseModel): max_length=1000, min_length=1, ), + NoExplicitNull, ] = None description: Annotated[ str | None, @@ -77,8 +82,9 @@ class MediaBuyChangeTerm(AdCPBaseModel): max_length=1000, min_length=1, ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _validate_constraint_action(self) -> MediaBuyChangeTerm: diff --git a/src/adcp/types/domains/media_buy/change_term_constraints.py b/src/adcp/types/domains/media_buy/change_term_constraints.py index af8157059..d4f93a7d5 100644 --- a/src/adcp/types/domains/media_buy/change_term_constraints.py +++ b/src/adcp/types/domains/media_buy/change_term_constraints.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/change_term_constraints.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..core import duration @@ -20,15 +20,19 @@ class MediaBuyChangeTermConstraints3(AdCPBaseModel): max_additions: Annotated[ SchemaInt | None, Field(description='Maximum packages that may be added by one exercise of the right.', ge=0), + NoExplicitNull, ] = None max_removals: Annotated[ SchemaInt | None, Field( description='Maximum packages that may be removed by one exercise of the right.', ge=0 ), + NoExplicitNull, ] = None max_result_count: Annotated[ - SchemaInt | None, Field(description='Maximum active package count after the change.', ge=0) + SchemaInt | None, + Field(description='Maximum active package count after the change.', ge=0), + NoExplicitNull, ] = None @@ -57,6 +61,7 @@ class MediaBuyChangeTermConstraints1(AdCPBaseModel): Field( description='Maximum absolute amount by which the affected budget may change in the direction named by the action.' ), + NoExplicitNull, ] = None max_delta_percent: Annotated[ StrictFloat | None, @@ -64,12 +69,17 @@ class MediaBuyChangeTermConstraints1(AdCPBaseModel): description='Maximum percentage change relative to the current committed value. Values above 100 are valid for increases greater than the current value.', ge=0.0, ), + NoExplicitNull, ] = None min_result_amount: Annotated[ - Money | None, Field(description='Minimum resulting committed value after the change.') + Money | None, + Field(description='Minimum resulting committed value after the change.'), + NoExplicitNull, ] = None max_result_amount: Annotated[ - Money | None, Field(description='Maximum resulting committed value after the change.') + Money | None, + Field(description='Maximum resulting committed value after the change.'), + NoExplicitNull, ] = None @@ -90,20 +100,24 @@ class MediaBuyChangeTermConstraints2(AdCPBaseModel): Field( description='Maximum extension, shortening, or shift in the direction named by the action.' ), + NoExplicitNull, ] = None earliest_result: Annotated[ AwareDatetime | None, Field(description='Earliest resulting start or end timestamp accepted for this action.'), + NoExplicitNull, ] = None latest_result: Annotated[ AwareDatetime | None, Field(description='Latest resulting start or end timestamp accepted for this action.'), + NoExplicitNull, ] = None minimum_notice: Annotated[ duration.Duration | None, Field( description='Minimum elapsed notice before the requested flight change may take effect.' ), + NoExplicitNull, ] = None @@ -124,9 +138,10 @@ class MediaBuyChangeTermConstraints4(AdCPBaseModel): Field( description='Minimum elapsed notice before pause, resume, cancellation, or another operational action may take effect.' ), + NoExplicitNull, ] = None - earliest_effective_at: AwareDatetime | None = None - latest_effective_at: AwareDatetime | None = None + earliest_effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + latest_effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/media_buy/commercial_terms.py b/src/adcp/types/domains/media_buy/commercial_terms.py index e9eff5bf8..e77ed00f9 100644 --- a/src/adcp/types/domains/media_buy/commercial_terms.py +++ b/src/adcp/types/domains/media_buy/commercial_terms.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/commercial_terms.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator from ..core import ( @@ -40,8 +40,8 @@ class CancellationTerms(AdCPBaseModel): extra='forbid', ) effective_at: AwareDatetime - fee: Fee | None = None - reason: Annotated[str | None, Field(max_length=500, min_length=1)] = None + fee: Annotated[Fee | None, NoExplicitNull] = None + reason: Annotated[str | None, Field(max_length=500, min_length=1), NoExplicitNull] = None class ReportingCommitment(AdCPBaseModel): @@ -64,6 +64,7 @@ class CommercialTerms(AdCPBaseModel): description='Wholesale product feed version against which direct published offers were accepted. Omitted when the seller authored terms outside a wholesale snapshot.', min_length=1, ), + NoExplicitNull, ] = None source_pricing_version: Annotated[ str | None, @@ -71,9 +72,12 @@ class CommercialTerms(AdCPBaseModel): description='Pricing-layer version against which published rates were accepted.', min_length=1, ), + NoExplicitNull, ] = None brand: brand_key.BrandKey - advertiser_industry: advertiser_industry_1.AdvertiserIndustry | None = None + advertiser_industry: Annotated[ + advertiser_industry_1.AdvertiserIndustry | None, NoExplicitNull + ] = None purchases: Annotated[ list[product_purchase.ProductPurchase], Field( @@ -83,19 +87,21 @@ class CommercialTerms(AdCPBaseModel): ] start_time: start_timing.StartTiming end_time: AwareDatetime - total_budget: TotalBudget | None = None + total_budget: Annotated[TotalBudget | None, NoExplicitNull] = None daily_budget_cap: Annotated[ StrictFloat | None, Field( description='Hard aggregate daily spend ceiling accepted as part of these terms. It bounds total spend without creating purchase allocations.', ge=0.0, ), + NoExplicitNull, ] = None frequency_cap: Annotated[ media_buy_frequency_cap.MediaBuyFrequencyCap | None, Field( description='Hard MediaBuy-level cap accepted as part of these terms. One counter aggregates exposures across every purchase; purchase targeting caps remain independently binding.' ), + NoExplicitNull, ] = None budget_cap_timezone: Annotated[ str | None, @@ -103,32 +109,40 @@ class CommercialTerms(AdCPBaseModel): description='Shared IANA calendar-day boundary for aggregate and purchase daily caps in these terms.', min_length=1, ), + NoExplicitNull, + ] = None + budget_allocation: Annotated[ + canonical_budget_allocation.CanonicalBudgetAllocation | None, NoExplicitNull ] = None - budget_allocation: canonical_budget_allocation.CanonicalBudgetAllocation | None = None - pacing: pacing_1.Pacing | None = None + pacing: Annotated[pacing_1.Pacing | None, NoExplicitNull] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, Field( description="Media-buy bidding policy. A proposal answering criteria.outcome_target.cost_per states here the cost the seller can plan to, which the buyer adopts on acceptance: the requested strength, and an amount greater than or equal to the ask (the ask when the seller can forecast goal volume under it within the buyer's budget, otherwise the lowest such amount), denominated in the purchases' pricing currency, which equals cost_per.currency. It is an execution control, not an expected price; when the planned spend at that amount is below total_budget, forecast points carry metrics.spend. See outcome-target.json for goal binding." ), + NoExplicitNull, + ] = None + invoice_recipient: Annotated[business_entity.BusinessEntity | None, NoExplicitNull] = None + purchase_order_ref: Annotated[ + str | None, Field(max_length=255, min_length=1), NoExplicitNull ] = None - invoice_recipient: business_entity.BusinessEntity | None = None - purchase_order_ref: Annotated[str | None, Field(max_length=255, min_length=1)] = None - agency_estimate_number: Annotated[str | None, Field(max_length=100)] = None + agency_estimate_number: Annotated[str | None, Field(max_length=100), NoExplicitNull] = None reporting_commitments: Annotated[ list[ReportingCommitment] | None, Field( description='Binding reporting contract keyed by position in purchases. Amendments preserve prior entries and add metrics with effective_at; seller-assigned package IDs live in the execution binding, outside this digest.', min_length=1, ), + NoExplicitNull, ] = None - cancellation_terms: CancellationTerms | None = None + cancellation_terms: Annotated[CancellationTerms | None, NoExplicitNull] = None change_terms: Annotated[ list[change_term.MediaBuyChangeTerm] | None, Field( description='Binding buyer change rights included in the commercial envelope and therefore covered by terms_digest. Entries are uniquely keyed by action. When this field is present, an omitted action is not a negotiated change right. Omission of the entire field means legacy-unspecified rights, not a prohibition.', min_length=1, ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/media_buy/control_media_buy_request.py b/src/adcp/types/domains/media_buy/control_media_buy_request.py index e1a100112..161005b50 100644 --- a/src/adcp/types/domains/media_buy/control_media_buy_request.py +++ b/src/adcp/types/domains/media_buy/control_media_buy_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/control_media_buy_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import bidding_policy, canonical_account_ref, canonical_budget_allocation @@ -44,6 +44,7 @@ class ControlMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -53,6 +54,7 @@ class ControlMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, Field(max_length=255, min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$') @@ -74,16 +76,20 @@ class ControlMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None - paused: StrictBool | None = None + paused: Annotated[StrictBool | None, NoExplicitNull] = None canceled: Annotated[ Literal[True] | None, Field( description='Exercise an already-accepted unilateral cancellation right. A cancellation requiring seller agreement is requested by refining the accepted proposal.' ), + NoExplicitNull, ] = None - cancellation_reason: Annotated[str | None, Field(max_length=500, min_length=1)] = None - total_budget: TotalBudget | None = None + cancellation_reason: Annotated[ + str | None, Field(max_length=500, min_length=1), NoExplicitNull + ] = None + total_budget: Annotated[TotalBudget | None, NoExplicitNull] = None daily_budget_cap: Annotated[ StrictFloat | None, Field( @@ -104,12 +110,15 @@ class ControlMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, ), ] = None - budget_allocation: canonical_budget_allocation.CanonicalBudgetAllocation | None = None + budget_allocation: Annotated[ + canonical_budget_allocation.CanonicalBudgetAllocation | None, NoExplicitNull + ] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Replace aggregate media-buy pacing. In seller-optimized allocation, a seller declaring media_buy.features.seller_optimized_budget MUST accept omission and `even`; it MAY reject `asap` or `front_loaded` with UNSUPPORTED_FEATURE (error.field `pacing`) before any provider mutation and MUST NOT silently coerce them to `even`. Fixed-allocation semantics are unchanged.' ), + NoExplicitNull, ] = None bidding: bidding_policy.BiddingPolicy | None = None packages: Annotated[ @@ -118,12 +127,17 @@ class ControlMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): description='Operational patches keyed by package_id. Each package_id MUST appear at most once; sellers reject duplicate IDs atomically.', min_length=1, ), + NoExplicitNull, + ] = None + reporting_webhook: Annotated[reporting_webhook_1.ReportingWebhook | None, NoExplicitNull] = None + governance_context: Annotated[ + str | None, Field(max_length=4096, min_length=1), NoExplicitNull + ] = None + push_notification_config: Annotated[ + push_notification_config_1.PushNotificationConfig | None, NoExplicitNull ] = None - reporting_webhook: reporting_webhook_1.ReportingWebhook | None = None - governance_context: Annotated[str | None, Field(max_length=4096, min_length=1)] = None - push_notification_config: push_notification_config_1.PushNotificationConfig | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> ControlMediaBuyRequest: diff --git a/src/adcp/types/domains/media_buy/control_media_buy_response.py b/src/adcp/types/domains/media_buy/control_media_buy_response.py index 1636b92ad..4571fdcc1 100644 --- a/src/adcp/types/domains/media_buy/control_media_buy_response.py +++ b/src/adcp/types/domains/media_buy/control_media_buy_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/control_media_buy_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -38,9 +38,9 @@ class ControlMediaBuyResponse2(AdcpResponse, AdCPBaseModel): ) status: Literal['failed'] = 'failed' errors: Annotated[list[error.Error], Field(min_length=1)] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class ControlMediaBuyResponse3(AdcpResponse, AdCPBaseModel): @@ -49,11 +49,11 @@ class ControlMediaBuyResponse3(AdcpResponse, AdCPBaseModel): ) status: Literal['submitted'] = 'submitted' task_id: Annotated[str, Field(min_length=1)] - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class Warning(Warning_1): @@ -73,20 +73,23 @@ class ControlMediaBuyResponse1(AdcpResponse, AdCPBaseModel): status: Literal['completed'] = 'completed' media_buy_id: Annotated[str, Field(min_length=1)] revision: Annotated[SchemaInt, Field(ge=1)] - media_buy_status: media_buy_status_1.MediaBuyStatus | None = None + media_buy_status: Annotated[media_buy_status_1.MediaBuyStatus | None, NoExplicitNull] = None implementation_date: AwareDatetime | None = None - affected_package_ids: list[AffectedPackageId] | None = None - available_actions: list[canonical_media_buy_action.CanonicalMediaBuyAction] | None = None + affected_package_ids: Annotated[list[AffectedPackageId] | None, NoExplicitNull] = None + available_actions: Annotated[ + list[canonical_media_buy_action.CanonicalMediaBuyAction] | None, NoExplicitNull + ] = None warnings: Annotated[ list[Warning] | None, Field( description='Non-blocking observations about this completed control. The control was still applied exactly as represented. Continuing conditions also appear as indicators on get_media_buys.', min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None ControlMediaBuyResponse = Annotated[ diff --git a/src/adcp/types/domains/media_buy/create_media_buy_async_response_input_required.py b/src/adcp/types/domains/media_buy/create_media_buy_async_response_input_required.py index 414b45bfb..77d81f7d5 100644 --- a/src/adcp/types/domains/media_buy/create_media_buy_async_response_input_required.py +++ b/src/adcp/types/domains/media_buy/create_media_buy_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/create_media_buy_async_response_input_required.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -25,13 +25,16 @@ class CreateMediaBuyInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why input is needed') + Reason | None, + Field(description='Reason code indicating why input is needed'), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional validation errors or warnings for debugging purposes. Helps explain why input is required.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/create_media_buy_async_response_submitted.py b/src/adcp/types/domains/media_buy/create_media_buy_async_response_submitted.py index ff9d898ff..828ebd37f 100644 --- a/src/adcp/types/domains/media_buy/create_media_buy_async_response_submitted.py +++ b/src/adcp/types/domains/media_buy/create_media_buy_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/create_media_buy_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -36,12 +36,14 @@ class CreateMediaBuySubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Awaiting IO signature from sales team; typical turnaround 2–4 hours.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/create_media_buy_async_response_working.py b/src/adcp/types/domains/media_buy/create_media_buy_async_response_working.py index d7feb5362..7cdcd9893 100644 --- a/src/adcp/types/domains/media_buy/create_media_buy_async_response_working.py +++ b/src/adcp/types/domains/media_buy/create_media_buy_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/create_media_buy_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..core import context as context_1 @@ -18,14 +18,20 @@ class CreateMediaBuyWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ - str | None, Field(description='Current step or phase of the operation') + str | None, Field(description='Current step or phase of the operation'), NoExplicitNull ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/create_media_buy_request.py b/src/adcp/types/domains/media_buy/create_media_buy_request.py index da945f09b..2d168f6c8 100644 --- a/src/adcp/types/domains/media_buy/create_media_buy_request.py +++ b/src/adcp/types/domains/media_buy/create_media_buy_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/create_media_buy_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from collections.abc import Sequence from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import account_ref, bidding_policy, brand_ref @@ -69,6 +69,7 @@ class IoAcceptance(AdCPBaseModel): Field( description='Reference to the electronic signature from the signing service, when signing_url was used' ), + NoExplicitNull, ] = None @@ -114,6 +115,7 @@ class ArtifactWebhook(AdCPBaseModel): description='Optional client-provided token for webhook validation. Echoed back in webhook payload to validate request authenticity.', min_length=16, ), + NoExplicitNull, ] = None authentication: Annotated[ Authentication, @@ -133,6 +135,7 @@ class ArtifactWebhook(AdCPBaseModel): Field( description="For batched delivery, how often to push artifacts. Required when delivery_mode is 'batched'." ), + NoExplicitNull, ] = None sampling_rate: Annotated[ StrictFloat | None, @@ -141,6 +144,7 @@ class ArtifactWebhook(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None @@ -165,6 +169,7 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -181,6 +186,7 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): deprecated=True, description='DEPRECATED on seller-facing requests. New buyers send the approved governance_context on the protocol envelope; the seller forwards that opaque context and does not need the plan identifier. If both are present, the governance agent MUST reject a mismatch. Removed in 4.0.', ), + NoExplicitNull, ] = None account: Annotated[ account_ref.AccountReference, @@ -193,18 +199,21 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="ID of the exact committed proposal snapshot to execute. With total_budget, the publisher creates packages using the proposal's fixed percentages or seller-optimized constraints. Mutually exclusive: provide packages or proposal_id, not both. AdCP 3.2 request_proposals and ordinary refine_proposals revisions issue drafts; refine_proposals action finalize creates the executable committed hold. Sellers reject draft, declined, or previously executed snapshots, while exact retries with the original idempotency key replay historical success. Changed commercial terms are issued under a new proposal_id, so no separate proposal version is required." ), + NoExplicitNull, ] = None opportunity: Annotated[ Opportunity | None, Field( description='Optional planning-cycle closure. Sellers infer successful proposal execution as closed with close_reason accepted_with_seller when status is omitted; when status is present it MUST be closed with that reason. If the proposal was issued under an opportunity_id, a supplied ID MUST match it.' ), + NoExplicitNull, ] = None total_budget: Annotated[ TotalBudget | None, Field( description='Hard aggregate lifetime budget for the media buy. Required when executing a proposal and for seller-optimized explicit packages. Optional in fixed explicit-package mode; when present there, amount MUST equal the sum of package budgets. For a fixed proposal, the publisher applies allocation percentages to this amount. For a seller-optimized proposal or explicit buy, packages draw dynamically from this shared total.' ), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -212,12 +221,14 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): description='Optional hard aggregate daily spend ceiling in the media-buy currency. It limits total spend without allocating package amounts. Package caps are subordinate and need not sum to it. Requires advertised media_buy budget-capping scope; otherwise rejected with UNSUPPORTED_FEATURE.', ge=0.0, ), + NoExplicitNull, ] = None frequency_cap: Annotated[ media_buy_frequency_cap.MediaBuyFrequencyCap | None, Field( description='Optional max-impression cap using one counter across explicit packages. Requires advertised aggregate_frequency_capping and compatible media_buy_support on every product; otherwise sellers MUST reject with UNSUPPORTED_FEATURE before any mutation and MUST NOT silently drop, soften, or clamp the cap. Omit when executing proposal_id because accepted terms are authoritative.' ), + NoExplicitNull, ] = None budget_cap_timezone: Annotated[ str | None, @@ -225,12 +236,14 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): description='Optional shared IANA day boundary override for all caps. Requires buyer_timezone_override; otherwise rejected with UNSUPPORTED_FEATURE. When omitted, budget_capping.timezone_basis selects Account.timezone or the advertised fixed_timezone.', min_length=1, ), + NoExplicitNull, ] = None budget_allocation: Annotated[ budget_allocation_1.BudgetAllocation | None, Field( description='How budget is allocated across explicit packages. Omission means fixed allocation (legacy-compatible). Buyer agents SHOULD send budget_allocation explicitly ({mode: "fixed"} or seller_optimized) rather than rely on omission, and when the principal\'s instruction does not determine whether the budget is one shared pool across packages or split per package, SHOULD ask the principal rather than guess. seller_optimized requires advertised media_buy.features.seller_optimized_budget; inside it, package budget caps, min_spend_target, and package pacing each require their own advertised sub-capability (seller_optimized_package_budgets, seller_optimized_min_spend_targets, seller_optimized_package_pacing), otherwise the request is rejected with UNSUPPORTED_FEATURE before any over-subscription validation. In proposal mode the committed proposal supplies this configuration and callers MUST omit it here.' ), + NoExplicitNull, ] = None packages: Annotated[ Sequence[package_request.PackageRequest] | None, @@ -238,6 +251,7 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): description='Array of package configurations. Required when not using proposal_id. Mutually exclusive: provide packages or proposal_id, not both. Fixed allocation requires budget on every package. Seller-optimized allocation permits package budget to be omitted or to act as a hard cap. When executing a proposal, omit packages; the seller derives them from the committed proposal.', min_length=1, ), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference, @@ -250,20 +264,25 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="Industry classification for this specific campaign. A brand may operate across multiple industries (brand.json industries field), but each media buy targets one. For example, a consumer health company running a wellness campaign sends 'healthcare.wellness', not 'cpg'. Sellers map this to platform-native codes (e.g., Spotify ADV categories, LinkedIn industry IDs). When omitted, sellers may infer from the brand manifest's industries field." ), + NoExplicitNull, ] = None invoice_recipient: Annotated[ business_entity.BusinessEntity | None, Field( description="Override the account's default billing entity for this specific buy. When provided, the seller invoices this entity instead. The seller MUST validate the invoice recipient is authorized for this account. When governance_agents are configured, the seller MUST include invoice_recipient in the check_governance request." ), + NoExplicitNull, ] = None io_acceptance: Annotated[ IoAcceptance | None, Field( description="Acceptance of an insertion order from a committed proposal. Required when the proposal's insertion_order has requires_signature: true. References the io_id from the proposal's insertion_order." ), + NoExplicitNull, + ] = None + po_number: Annotated[ + str | None, Field(description='Purchase order number for tracking'), NoExplicitNull ] = None - po_number: Annotated[str | None, Field(description='Purchase order number for tracking')] = None name: Annotated[ str | None, Field( @@ -272,6 +291,7 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None agency_estimate_number: Annotated[ str | None, @@ -279,6 +299,7 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): description="Agency estimate or authorization number. Primary financial reference for broadcast buys — links the order to the agency's media plan and billing system. Travels with the order and creative traffic identifiers through the transaction lifecycle.", max_length=100, ), + NoExplicitNull, ] = None start_time: start_timing.StartTiming end_time: Annotated[ @@ -289,37 +310,43 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="Aggregate pacing strategy for the media-buy budget across the media-buy flight. This controls how much the buy spends over time. Package pacing is subordinate and influences which package receives the aggregate spend; package pacing MUST NOT cause aggregate delivery to exceed this strategy. Defaults to even when total_budget is present. When executing a proposal that carries pacing, omit this field or send the identical value; the seller MUST reject a conflicting override with TERMS_REJECTED. In a seller-optimized buy, a seller declaring media_buy.features.seller_optimized_budget MUST accept omission and `even`; it MAY reject `asap` or `front_loaded` with UNSUPPORTED_FEATURE (error.field `pacing`) before any provider mutation and MUST NOT silently coerce them to `even`. Pacing carried by the seller's own committed proposal is not subject to that rejection. Fixed-allocation semantics are unchanged." ), + NoExplicitNull, ] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, Field( description="Complete media-buy bidding default inherited by packages that omit package.bidding. `{automatic:true}` records an explicit automatic policy. In seller-optimized mode, cost_per/roas bind to the primary budget_allocation_1.optimization_goals goal. In fixed mode, inherited cost_per is valid only when inheriting package primary-goal result units are compatible; inherited roas requires value-bearing primary goals. Every monetary field uses total_budget.currency or the single currency derived for the media buy, and every affected pricing option MUST declare that currency. Sellers MUST reject incompatible units, combinations, currency, or overrides before mutation with BIDDING_PLACEMENT_CONFLICT. Media-buy bidding combined with any inheriting package's legacy bid_price or legacy monetary optimization-goal target is ambiguous and MUST be rejected with AMBIGUOUS_BIDDING_POLICY." ), + NoExplicitNull, ] = None paused: Annotated[ StrictBool | None, Field( description="Create the media buy in a paused delivery state. When true, and the buy would otherwise be active because creatives are assigned and the flight has started, the seller returns media_buy_status 'paused'. Setup blockers still take precedence: a buy with no creatives remains 'pending_creatives', and a future-dated buy remains 'pending_start' until its flight can start. Defaults to false." ), + NoExplicitNull, ] = False push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Optional webhook configuration for async task status notifications. Publisher will send webhooks when status changes (working, input-required, completed, failed, canceled). Buyers SHOULD supply `push_notification_config_1.operation_id` as the canonical correlation value; publishers echo that field back verbatim in webhook payloads and MUST NOT parse the URL to derive it.' ), + NoExplicitNull, ] = None reporting_webhook: Annotated[ reporting_webhook_1.ReportingWebhook | None, Field(description='Optional webhook configuration for automated reporting delivery'), + NoExplicitNull, ] = None artifact_webhook: Annotated[ ArtifactWebhook | None, Field( description='Optional webhook configuration for content artifact delivery. Used by governance agents to validate content adjacency. Seller pushes artifacts to this endpoint; orchestrator forwards to governance agent for validation.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> CreateMediaBuyRequest: diff --git a/src/adcp/types/domains/media_buy/create_media_buy_response.py b/src/adcp/types/domains/media_buy/create_media_buy_response.py index d3ccf3dd0..d9cd4d726 100644 --- a/src/adcp/types/domains/media_buy/create_media_buy_response.py +++ b/src/adcp/types/domains/media_buy/create_media_buy_response.py @@ -89,7 +89,7 @@ class CreateMediaBuyResponse2(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelop class CreateMediaBuyResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): - model_config = ConfigDict(extra='allow', validate_default=True) + model_config = ConfigDict(extra='allow') status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted task_id: str message: Annotated[str, StringConstraints(max_length=2000)] | None = None diff --git a/src/adcp/types/domains/media_buy/decline_proposals_request.py b/src/adcp/types/domains/media_buy/decline_proposals_request.py index f6562aefa..2247b2d22 100644 --- a/src/adcp/types/domains/media_buy/decline_proposals_request.py +++ b/src/adcp/types/domains/media_buy/decline_proposals_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/decline_proposals_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -28,6 +29,7 @@ class DeclineProposalsRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -37,6 +39,7 @@ class DeclineProposalsRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None context_id: Annotated[ str | None, @@ -44,10 +47,15 @@ class DeclineProposalsRequest(AdcpRequest, AdcpVersionEnvelope): description='MCP compatibility field: servers ignore this value; A2A uses transport-native Message/Task contextId.', min_length=1, ), + NoExplicitNull, + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + governance_context: Annotated[ + str | None, Field(max_length=4096, min_length=1), NoExplicitNull + ] = None + push_notification_config: Annotated[ + push_notification_config_1.PushNotificationConfig | None, NoExplicitNull ] = None - context: context_1.ContextObject | None = None - governance_context: Annotated[str | None, Field(max_length=4096, min_length=1)] = None - push_notification_config: push_notification_config_1.PushNotificationConfig | None = None idempotency_key: Annotated[ str, Field( @@ -70,4 +78,5 @@ class DeclineProposalsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Optional planning-cycle update. Every named proposal MUST belong to this opportunity_id. Sellers apply the update only when every result is declined; if any result is unable, the opportunity remains unchanged. Use status closed when these declines end the broader opportunity.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/media_buy/decline_proposals_response.py b/src/adcp/types/domains/media_buy/decline_proposals_response.py index aeb50bc62..ae8dead85 100644 --- a/src/adcp/types/domains/media_buy/decline_proposals_response.py +++ b/src/adcp/types/domains/media_buy/decline_proposals_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/decline_proposals_response.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -35,6 +35,7 @@ class Results1(AdCPBaseModel): description='Why the seller could not apply a decline. Present only for outcome unable.', min_length=1, ), + NoExplicitNull, ] = None @@ -79,26 +80,26 @@ class DeclineProposalsResponse1(AdcpResponse, AdCPBaseModel): ) results: Annotated[list[Results], Field(min_length=1)] status: Literal['submitted'] = 'submitted' - task_id: Annotated[str | None, Field(min_length=1)] = None - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + task_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class DeclineProposalsResponse2(AdcpResponse, CompactTaskSubmitted): model_config = ConfigDict( extra='forbid', ) - results: Annotated[list[Results3] | None, Field(min_length=1)] = None + results: Annotated[list[Results3] | None, Field(min_length=1), NoExplicitNull] = None status: Literal['submitted'] = 'submitted' - task_id: Annotated[str | None, Field(min_length=1)] = None - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + task_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None DeclineProposalsResponse = Annotated[ diff --git a/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py b/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py index 482f387ee..a25708388 100644 --- a/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py +++ b/src/adcp/types/domains/media_buy/get_media_buy_delivery_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buy_delivery_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -11,7 +11,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, RootModel, StrictBool from ..core import account_ref @@ -38,16 +38,21 @@ class AttributionWindow(AdCPBaseModel): extra='allow', ) post_click: Annotated[ - duration.Duration | None, Field(description='Post-click attribution window to apply.') + duration.Duration | None, + Field(description='Post-click attribution window to apply.'), + NoExplicitNull, ] = None post_view: Annotated[ - duration.Duration | None, Field(description='Post-view attribution window to apply.') + duration.Duration | None, + Field(description='Post-view attribution window to apply.'), + NoExplicitNull, ] = None model: Annotated[ attribution_model.AttributionModel | None, Field( description='Attribution model to use. When omitted, the seller applies their default model.' ), + NoExplicitNull, ] = None @@ -61,6 +66,7 @@ class Spot(AdCPBaseModel): description='Optional maximum number of spot rows to return. When the response is incomplete because of this limit or a seller-imposed maximum, by_spot_truncated is true.', ge=1, ), + NoExplicitNull, ] = None @@ -84,18 +90,21 @@ class CatalogItem(AdCPBaseModel): description='Maximum number of catalog_item entries to return. When omitted, the seller returns its automatic default set.', ge=1, ), + NoExplicitNull, ] = None sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc @@ -109,18 +118,21 @@ class Creative(AdCPBaseModel): description='Maximum number of creative entries to return. When omitted, the seller returns its automatic default set.', ge=1, ), + NoExplicitNull, ] = None sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc @@ -134,18 +146,21 @@ class Keyword(AdCPBaseModel): description='Maximum number of keyword entries to return. When omitted, the seller returns its automatic default set.', ge=1, ), + NoExplicitNull, ] = None sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc @@ -165,6 +180,7 @@ class Geo(AdCPBaseModel): Field( description="Optional classification system for metro or postal_area levels. Metro uses metro-system values (e.g., 'nielsen_dma'); native postal_area uses country-local postal-system values with country (e.g., country 'US', system 'zip'); deprecated legacy postal_area requests may use legacy-postal-system values such as 'us_zip'. Omit to request the level without selecting a specific system." ), + NoExplicitNull, ] = None country: Annotated[ str | None, @@ -172,6 +188,7 @@ class Geo(AdCPBaseModel): description='ISO 3166-1 alpha-2 country code. Required for native postal_area requests; omitted for legacy postal_area and non-postal geo requests.', pattern='^[A-Z]{2}$', ), + NoExplicitNull, ] = None limit: Annotated[ SchemaInt | None, @@ -179,18 +196,21 @@ class Geo(AdCPBaseModel): description='Maximum number of geo entries to return. Defaults to 25. When truncated, by_geo_truncated is true in the response.', ge=1, ), + NoExplicitNull, ] = 25 sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc @@ -204,24 +224,28 @@ class DeviceType(AdCPBaseModel): description='Maximum number of entries to return. When omitted, all entries are returned (the enum is small and bounded).', ge=1, ), + NoExplicitNull, ] = None sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc cursor: Annotated[ str | None, Field( description="Opaque cursor from a previous response's by_device_type_pagination to fetch the next page of a truncated by_device_type breakdown. Omit for the first page. limit, sort_by, and sort_direction MUST be repeated unchanged across paged requests for the same logical query." ), + NoExplicitNull, ] = None @@ -235,24 +259,28 @@ class DevicePlatform(AdCPBaseModel): description='Maximum number of entries to return. When omitted, all entries are returned (the enum is small and bounded).', ge=1, ), + NoExplicitNull, ] = None sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc cursor: Annotated[ str | None, Field( description="Opaque cursor from a previous response's by_device_platform_pagination to fetch the next page of a truncated by_device_platform breakdown. Omit for the first page. limit, sort_by, and sort_direction MUST be repeated unchanged across paged requests for the same logical query." ), + NoExplicitNull, ] = None @@ -266,18 +294,21 @@ class Format(AdCPBaseModel): description='Maximum number of format rows to return. When omitted, all rows are returned because the canonical format-kind vocabulary is small and bounded.', ge=1, ), + NoExplicitNull, ] = None sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc @@ -288,24 +319,28 @@ class Audience(AdCPBaseModel): limit: Annotated[ SchemaInt | None, Field(description='Maximum number of entries to return. Defaults to 25.', ge=1), + NoExplicitNull, ] = 25 sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc cursor: Annotated[ str | None, Field( description="Opaque cursor from a previous response's by_audience_pagination to fetch the next page of a truncated by_audience breakdown. Omit for the first page. limit, sort_by, and sort_direction MUST be repeated unchanged across paged requests for the same logical query." ), + NoExplicitNull, ] = None @@ -319,22 +354,26 @@ class Demographic(AdCPBaseModel): description="Optional canonical age ranges to return as distinct rows. Copying an applied targeting predicate here requests aligned reporting only when the reporting capability supports that exact predicate. Omit to request the product's declared native demographic breakdown.", min_length=1, ), + NoExplicitNull, ] = None limit: Annotated[ SchemaInt | None, Field(description='Maximum number of demographic entries to return. Defaults to 25.', ge=1), + NoExplicitNull, ] = 25 sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc @@ -345,24 +384,28 @@ class Placement(AdCPBaseModel): limit: Annotated[ SchemaInt | None, Field(description='Maximum number of entries to return. Defaults to 25.', ge=1), + NoExplicitNull, ] = 25 sort_by: Annotated[ sort_metric.SortMetric | None, Field( description="Metric to sort breakdown rows by, in `sort_direction` order (descending by default). Falls back to 'spend' when the seller does not report the requested metric at this breakdown's row grain; on fallback the sort direction resets to 'desc'. Rows lacking a value for the applied sort metric order last regardless of direction. The applied sort is echoed in the response." ), + NoExplicitNull, ] = sort_metric.SortMetric.spend sort_direction: Annotated[ sort_direction_1.SortDirection | None, Field( description="Direction for sort_by ordering. Defaults to 'desc' (largest first). 'asc' enables bottom-N queries (e.g., the 25 worst placements by viewable_rate) that cannot be recovered from a truncated descending pull. Sellers MUST apply the requested direction to the applied sort metric — direction has no availability fallback." ), + NoExplicitNull, ] = sort_direction_1.SortDirection.desc cursor: Annotated[ str | None, Field( description="Opaque cursor from a previous response's by_placement_pagination to fetch the next page of a truncated by_placement breakdown. Omit for the first page. limit, sort_by, and sort_direction MUST be repeated unchanged across paged requests for the same logical query." ), + NoExplicitNull, ] = None @@ -375,88 +418,105 @@ class ReportingDimensions(AdCPBaseModel): Field( description='Request a negotiated catalog_item breakdown. Omitting this key preserves the automatic behavior — sellers return catalog_item rows at their discretion with no truncation contract. Including it (even as {}) makes the truncation disclosure and applied-sort echo binding.' ), + NoExplicitNull, ] = None creative: Annotated[ Creative | None, Field( description='Request a negotiated creative breakdown. Omitting this key preserves the automatic behavior — sellers return creative rows at their discretion with no truncation contract. Including it (even as {}) makes the truncation disclosure and applied-sort echo binding.' ), + NoExplicitNull, ] = None keyword: Annotated[ Keyword | None, Field( description='Request a negotiated keyword breakdown. Omitting this key preserves the automatic behavior — sellers return keyword rows at their discretion with no truncation contract. Including it (even as {}) makes the truncation disclosure and applied-sort echo binding.' ), + NoExplicitNull, ] = None geo: Annotated[ Geo | None, Field( description='Request geographic breakdown. Check reporting_capabilities.supports_geo_breakdown for available levels and systems.' ), + NoExplicitNull, ] = None device_type: Annotated[ - DeviceType | None, Field(description='Request device type breakdown.') + DeviceType | None, Field(description='Request device type breakdown.'), NoExplicitNull ] = None device_platform: Annotated[ - DevicePlatform | None, Field(description='Request device platform breakdown.') + DevicePlatform | None, + Field(description='Request device platform breakdown.'), + NoExplicitNull, ] = None format: Annotated[ Format | None, Field( description='Request delivery broken down by canonical creative format kind. This dimension is negotiated on the GET path. Reporting webhook configuration does not negotiate or guarantee dimensional breakdowns, although a webhook payload may carry the same fields as an extension.' ), + NoExplicitNull, ] = None audience: Annotated[ - Audience | None, Field(description='Request audience segment breakdown.') + Audience | None, Field(description='Request audience segment breakdown.'), NoExplicitNull ] = None demographic: Annotated[ Demographic | None, Field( description="Request delivery broken down by demographic. Check the product's reporting_capabilities.supports_demographic_breakdown independently from demographic_targeting. When age_ranges is present, every requested range MUST be exactly supported by exact_predicates or equal one of the declared enumerated_intervals; sellers MUST reject unsupported ranges with UNSUPPORTED_FEATURE rather than silently widen or narrow them." ), + NoExplicitNull, ] = None spot: Annotated[ Spot | None, Field( description='Request a spot-level as-run airing log for broadcast TV, radio, or other scheduled inventory. Rows are ordered by aired_at ascending. When limit is omitted, sellers SHOULD return the complete log for the requested reporting period.' ), + NoExplicitNull, + ] = None + placement: Annotated[ + Placement | None, Field(description='Request placement breakdown.'), NoExplicitNull ] = None - placement: Annotated[Placement | None, Field(description='Request placement breakdown.')] = None property: Annotated[ delivery_breakdown_controls.DeliveryBreakdownControls | None, Field( description='Request delivery by canonical publisher property. Check reporting_capabilities.supports_property_breakdown.' ), + NoExplicitNull, ] = None collection: Annotated[ delivery_breakdown_controls.DeliveryBreakdownControls | None, Field( description='Request delivery by canonical content collection. Check reporting_capabilities.supports_collection_breakdown.' ), + NoExplicitNull, ] = None installment: Annotated[ delivery_breakdown_controls.DeliveryBreakdownControls | None, Field( description='Request delivery by canonical installment within a collection. Check reporting_capabilities.supports_installment_breakdown.' ), + NoExplicitNull, ] = None collection_property: Annotated[ delivery_breakdown_controls.DeliveryBreakdownControls | None, Field( description='Request delivery at the collection × property intersection. This is the reporting grain that can prove a collection delivered on a particular host service; independent collection and property marginals cannot. Check reporting_capabilities.supports_collection_property_breakdown.' ), + NoExplicitNull, ] = None installment_property: Annotated[ delivery_breakdown_controls.DeliveryBreakdownControls | None, Field( description='Request delivery at the installment × property intersection. This is the reporting grain that can prove a specific airing, episode, issue, or programming block delivered on a particular host service; independent installment and property marginals cannot. Check reporting_capabilities.supports_installment_property_breakdown.' ), + NoExplicitNull, ] = None placement_property: Annotated[ delivery_breakdown_controls.DeliveryBreakdownControls | None, Field( description='Request delivery at the placement × property intersection. Use when a placement may span multiple web, app, social, or assistant properties. Check reporting_capabilities.supports_placement_property_breakdown.' ), + NoExplicitNull, ] = None @@ -469,10 +529,12 @@ class GetMediaBuyDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Filter delivery data to a specific account. When omitted, returns data across all accessible accounts.' ), + NoExplicitNull, ] = None media_buy_ids: Annotated[ list[str] | None, Field(description='Array of media buy IDs to get delivery data for', min_length=1), + NoExplicitNull, ] = None reporting_revision_id: Annotated[ str | None, @@ -482,16 +544,19 @@ class GetMediaBuyDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None pagination: Annotated[ pagination_request.PaginationRequest | None, Field( description='Cursor pagination. With reporting_revision_id, pages immutable authoritative rows while all revision metadata repeats on every page.' ), + NoExplicitNull, ] = None status_filter: Annotated[ media_buy_status.MediaBuyStatus | StatusFilter | None, Field(description='Filter by status. Can be a single status or array of statuses'), + NoExplicitNull, ] = None start_date: Annotated[ str | None, @@ -499,6 +564,7 @@ class GetMediaBuyDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): description="Inclusive start date for the reporting period (YYYY-MM-DD), as a calendar date in the reporting timezone: the reporting_capabilities.timezone of the products behind the in-scope packages. It is a UTC day only when that timezone is UTC. When omitted along with end_date, returns campaign lifetime data. Only accepted when the product's reporting_capabilities.date_range_support is 'date_range'. A date-bounded request whose in-scope packages span more than one reporting timezone MUST be rejected with VALIDATION_ERROR. The buyer narrows media_buy_ids to buys that share one reporting timezone, or omits both dates when a single buy's packages span reporting timezones.", pattern='^\\d{4}-\\d{2}-\\d{2}$', ), + NoExplicitNull, ] = None end_date: Annotated[ str | None, @@ -506,12 +572,14 @@ class GetMediaBuyDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): description="Exclusive end date for the reporting period (YYYY-MM-DD), as a calendar date in the same reporting timezone as start_date. Must be later than start_date. When omitted along with start_date, returns campaign lifetime data. Only accepted when the product's reporting_capabilities.date_range_support is 'date_range'.", pattern='^\\d{4}-\\d{2}-\\d{2}$', ), + NoExplicitNull, ] = None include_package_daily_breakdown: Annotated[ StrictBool | None, Field( description='When true, include daily_breakdown arrays within each package in by_package. Useful for per-package pacing analysis and line-item monitoring. Omit or set false to reduce response size — package daily data can be large for multi-package buys over long flights.' ), + NoExplicitNull, ] = False requested_metrics: Annotated[ list[available_metric.AvailableMetric] | None, @@ -519,33 +587,38 @@ class GetMediaBuyDeliveryRequest(AdcpRequest, AdcpVersionEnvelope): description="Optional list of metrics to include in the response. When omitted, all available metrics are included (unchanged behavior). Applies to every metrics-bearing object in the response: totals, by_package, daily and window slices, and breakdown rows. impressions and spend are always included regardless of this list, except that a legacy or externally created mixed-currency buy MUST omit monetary and money-derived values from media-buy and window totals, MUST omit daily_breakdown, and MUST report monetary values only on currency-qualified package rows (including window package rows). Requesting a leaf metric identity returns its canonical nested carrier — e.g. requesting viewable_rate returns the viewability object, requesting quartile_75 returns quartile_data — never a flat duplicate. Metrics requested but not available for this buy are omitted from the response without error; contract accountability is unchanged — missing_metrics still reconciles against committed_metrics, but sellers MUST NOT list a metric in missing_metrics when its absence is solely due to this narrowing. Must be a subset of the product's reporting_capabilities.available_metrics; values outside the declared set are ignored. Subset evaluation follows the container-subsumption rule in enums/available-metric.json. Sort is evaluated before narrowing: excluding a metric from this list never triggers the sort_by fallback, and breakdown rows may be ordered by a metric absent from the narrowed payload — the applied-sort echo still names it. Same narrowing semantics as reporting_webhook.requested_metrics, with one shape difference: this field requires at least one entry when present (omit it entirely for full payloads), while the webhook field permits an empty array with the same meaning as omission.", min_length=1, ), + NoExplicitNull, ] = None time_granularity: Annotated[ reporting_frequency.ReportingFrequency | None, Field( description="Per-window slice granularity for the pull, using the same vocabulary as reporting_webhook.reporting_frequency. When set, the seller returns per-window delivery slices over the date range — useful for reconstructing data a buyer's webhook receiver missed, since the slice payload is shape-aligned with what reporting_webhook would have delivered for the same window. Capability-scoped: the value MUST be one of the seller's declared reporting_capabilities.windowed_pull_granularities; otherwise the seller MUST return UNSUPPORTED_GRANULARITY. When set to daily, weekly, monthly, or quarterly and the in-scope packages span more than one reporting timezone, the seller MUST return VALIDATION_ERROR. When omitted, behavior is unchanged (cumulative aggregates plus optional daily breakdowns per existing fields)." ), + NoExplicitNull, ] = None include_window_breakdown: Annotated[ StrictBool | None, Field( description="When true, the response includes media_buy_deliveries[].windows[] — an array of per-window delivery slices over the date range at the requested time_granularity. Ignored when time_granularity is omitted. Each window's payload mirrors what reporting_webhook would have delivered for the same window, enabling lossless GET-path recovery for buyers who missed webhook fires. Omit or set false to reduce response size when only cumulative aggregates are needed." ), + NoExplicitNull, ] = False attribution_window: Annotated[ AttributionWindow | None, Field( description='Attribution window to apply for conversion metrics. When provided, the seller returns conversion data using the requested lookback windows instead of their platform default. The seller echoes the applied window in the response. Sellers that do not support configurable windows ignore this field and return their default. Check get_adcp_capabilities conversion_tracking.attribution_windows for available options.' ), + NoExplicitNull, ] = None reporting_dimensions: Annotated[ ReportingDimensions | None, Field( description='Request dimensional breakdowns in delivery reporting. Each key enables a specific breakdown dimension within by_package — include as an empty object (e.g., "device_type": {}) to activate with defaults. Omit entirely for no breakdowns (backward compatible). Unsupported dimensions are silently omitted from the response. For every requested dimension that the product declares supported, the seller MUST return the corresponding array (possibly empty) and its truncated flag. Metric-sorted dimensions also return their applied-sort echoes; demographic and property-grain arrays also return their suppressed flag. Spot uses aired_at ordering and has no sort echoes. Note: keyword, catalog_item, and creative breakdowns are returned automatically when the seller supports them; including their keys here is optional and upgrades them to this negotiated contract without changing the automatic default.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _validate_delivery_selector_mode(self) -> GetMediaBuyDeliveryRequest: diff --git a/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py b/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py index 624db149a..ecb618592 100644 --- a/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py +++ b/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buy_delivery_response.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import attribution_window as attribution_window_1 @@ -87,6 +87,7 @@ class ReportingPeriod(AdCPBaseModel): examples=['UTC', 'America/New_York'], min_length=1, ), + NoExplicitNull, ] = None @@ -119,10 +120,14 @@ class DailyBreakdownItem(AdCPBaseModel): ] spend: Annotated[StrictFloat, Field(description='Daily spend for this package', ge=0.0)] conversions: Annotated[ - StrictFloat | None, Field(description='Daily conversions for this package', ge=0.0) + StrictFloat | None, + Field(description='Daily conversions for this package', ge=0.0), + NoExplicitNull, ] = None conversion_value: Annotated[ - StrictFloat | None, Field(description='Daily conversion value for this package', ge=0.0) + StrictFloat | None, + Field(description='Daily conversion value for this package', ge=0.0), + NoExplicitNull, ] = None commissionable_value: Annotated[ StrictFloat | None, @@ -130,10 +135,12 @@ class DailyBreakdownItem(AdCPBaseModel): description='Daily settled conversion value eligible for revenue-share commission for this package', ge=0.0, ), + NoExplicitNull, ] = None roas: Annotated[ StrictFloat | None, Field(description='Daily return on ad spend (conversion_value / spend)', ge=0.0), + NoExplicitNull, ] = None new_to_brand_rate: Annotated[ StrictFloat | None, @@ -142,6 +149,7 @@ class DailyBreakdownItem(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None @@ -158,11 +166,11 @@ class DailyBreakdownItem1(AdCPBaseModel): ] impressions: Annotated[StrictFloat, Field(description='Daily impressions', ge=0.0)] spend: Annotated[StrictFloat, Field(description='Daily spend', ge=0.0)] - conversions: Annotated[StrictFloat | None, Field(description='Daily conversions', ge=0.0)] = ( - None - ) + conversions: Annotated[ + StrictFloat | None, Field(description='Daily conversions', ge=0.0), NoExplicitNull + ] = None conversion_value: Annotated[ - StrictFloat | None, Field(description='Daily conversion value', ge=0.0) + StrictFloat | None, Field(description='Daily conversion value', ge=0.0), NoExplicitNull ] = None commissionable_value: Annotated[ StrictFloat | None, @@ -170,10 +178,12 @@ class DailyBreakdownItem1(AdCPBaseModel): description='Daily settled conversion value eligible for revenue-share commission', ge=0.0, ), + NoExplicitNull, ] = None roas: Annotated[ StrictFloat | None, Field(description='Daily return on ad spend (conversion_value / spend)', ge=0.0), + NoExplicitNull, ] = None new_to_brand_rate: Annotated[ StrictFloat | None, @@ -182,6 +192,7 @@ class DailyBreakdownItem1(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None @@ -214,6 +225,7 @@ class AggregatedTotals(AdCPBaseModel): clicks: Annotated[ StrictFloat | None, Field(description='Total clicks across all media buys (if applicable)', ge=0.0), + NoExplicitNull, ] = None completed_views: Annotated[ StrictFloat | None, @@ -221,18 +233,22 @@ class AggregatedTotals(AdCPBaseModel): description='Total audio/video completions across all media buys (if applicable)', ge=0.0, ), + NoExplicitNull, ] = None views: Annotated[ StrictFloat | None, Field(description='Total views across all media buys (if applicable)', ge=0.0), + NoExplicitNull, ] = None conversions: Annotated[ StrictFloat | None, Field(description='Total conversions across all media buys (if applicable)', ge=0.0), + NoExplicitNull, ] = None conversion_value: Annotated[ StrictFloat | None, Field(description='Total conversion value across all media buys (if applicable)', ge=0.0), + NoExplicitNull, ] = None commissionable_value: Annotated[ StrictFloat | None, @@ -240,6 +256,7 @@ class AggregatedTotals(AdCPBaseModel): description='Total settled conversion value eligible for revenue-share commission across all media buys (if applicable)', ge=0.0, ), + NoExplicitNull, ] = None roas: Annotated[ StrictFloat | None, @@ -247,6 +264,7 @@ class AggregatedTotals(AdCPBaseModel): description='Aggregate return on ad spend across all media buys (total conversion_value / total spend)', ge=0.0, ), + NoExplicitNull, ] = None new_to_brand_rate: Annotated[ StrictFloat | None, @@ -255,6 +273,7 @@ class AggregatedTotals(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None cost_per_acquisition: Annotated[ StrictFloat | None, @@ -262,6 +281,7 @@ class AggregatedTotals(AdCPBaseModel): description='Aggregate cost per conversion across all media buys (total spend / total conversions)', ge=0.0, ), + NoExplicitNull, ] = None completion_rate: Annotated[ StrictFloat | None, @@ -277,18 +297,21 @@ class AggregatedTotals(AdCPBaseModel): description='Reach across all media buys. Only present when all media buys share the same reach_unit. Omitted when reach units are heterogeneous — use per-buy reach values instead. The optional reach_aggregation field declares whether this value is deduplicated across buys or is a sum of constituent reach values.', ge=0.0, ), + NoExplicitNull, ] = None reach_aggregation: Annotated[ reach_aggregation_1.ReachAggregation | None, Field( description='How reach was combined across the media buys in this aggregate. When omitted, legacy reach semantics are unknown and consumers MUST NOT use reach as the denominator for frequency.' ), + NoExplicitNull, ] = None reach_unit: Annotated[ reach_unit_1.ReachUnit | None, Field( description='Unit of measurement for reach. Only present when all aggregated media buys use the same reach_unit.' ), + NoExplicitNull, ] = None frequency: Annotated[ StrictFloat | None, @@ -296,6 +319,7 @@ class AggregatedTotals(AdCPBaseModel): description='Average frequency per reach unit across all media buys (impressions / reach). In new payloads, only present when reach is present and reach_aggregation is deduplicated. MUST be omitted when reach_aggregation is sum_of_constituent_reach. Legacy payloads that omit reach_aggregation remain schema-valid, but consumers MUST NOT treat their reach as a safe frequency denominator.', ge=0.0, ), + NoExplicitNull, ] = None media_buy_count: Annotated[ SchemaInt, Field(description='Number of media buys included in the response', ge=0) @@ -333,6 +357,7 @@ class AggregatedTotals(AdCPBaseModel): ] ], ), + NoExplicitNull, ] = None @@ -343,6 +368,7 @@ class Totals(DeliveryMetrics): description="Effective rate paid per unit based on pricing_model (e.g., actual CPM for 'cpm', actual cost per completed view for 'cpcv', actual cost per point for 'cpp')", ge=0.0, ), + NoExplicitNull, ] = None @@ -388,7 +414,7 @@ class ByAudienceItem(DeliveryMetrics): ), ] audience_name: Annotated[ - str | None, Field(description='Human-readable audience segment name') + str | None, Field(description='Human-readable audience segment name'), NoExplicitNull ] = None impressions: Any spend: Any @@ -412,6 +438,7 @@ class ByDemographicItem(DeliveryMetrics): Field( description="Canonical age interval represented by this row. Required by protocol semantics for age ranges requested through reporting_dimensions.demographic.age_ranges and for intervals declared in the product's demographic reporting capability." ), + NoExplicitNull, ] = None impressions: Any spend: Any @@ -431,6 +458,7 @@ class BySpotItem(DeliveryMetrics): description="Optional identifier for the creative that aired in this spot occurrence, matching the package's creative asset or assignment. For library-backed sellers this is the sync_creatives identifier; inline-only sellers use the packages[].creatives creative_id.", min_length=1, ), + NoExplicitNull, ] = None aired_at: Annotated[ AwareDatetime, @@ -444,6 +472,7 @@ class BySpotItem(DeliveryMetrics): description="Optional network or syndicated service name, for example 'USA Network' or 'Westwood One'.", min_length=1, ), + NoExplicitNull, ] = None station: Annotated[ str | None, @@ -451,6 +480,7 @@ class BySpotItem(DeliveryMetrics): description="Optional station identifier or call sign, for example 'WABC-TV' or 'WNYC-FM'.", min_length=1, ), + NoExplicitNull, ] = None daypart: Annotated[ str | None, @@ -458,6 +488,7 @@ class BySpotItem(DeliveryMetrics): description="Optional seller-defined daypart label, for example 'prime_time' or 'morning_drive'.", min_length=1, ), + NoExplicitNull, ] = None @@ -469,6 +500,7 @@ class ByPackageItem1(DeliveryMetrics): description="ISO 4217 denomination for this package window's monetary values. Required by the runtime currency-coherence rule when neither the parent media-buy row nor deprecated response-wide field supplies a currency and this package window contains monetary values; otherwise it MUST match the row or response currency when present.", pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None @@ -496,12 +528,14 @@ class Window(AdCPBaseModel): Field( description='Per-package metrics for this window slice, using the same metric envelope and package identity as the parent media_buy_deliveries[].by_package row but scoped to the window. Requested reporting_dimensions do not apply to these webhook-aligned recovery rows; sellers may include dimensional fields only as webhook payload extensions, not as a guaranteed result of the GET request. Sellers MAY omit by_package when per-package window-level data is unavailable; when present, package_id values MUST match the parent by_package entries.' ), + NoExplicitNull, ] = None is_final: Annotated[ StrictBool | None, Field( description='Whether the slice data is final for this window. Same semantics as the per-package is_final flag — when false, a later read may return revised numbers for the same window as measurement matures (broadcast C3 → C7, post-IVT filtering, etc.).' ), + NoExplicitNull, ] = None measurement_window: Annotated[ str | None, @@ -509,6 +543,7 @@ class Window(AdCPBaseModel): description="Which measurement-window stage this slice represents (e.g., 'live', 'c3', 'c7'), referencing a window_id from the product's reporting_capabilities.measurement_windows. Absent when the data is not phased.", max_length=50, ), + NoExplicitNull, ] = None @@ -520,6 +555,7 @@ class ByPackageItem(DeliveryMetrics): description='Package delivery pace relative to its package-level pacing plan (1.0 = on track, <1.0 = behind, >1.0 = ahead). In seller-optimized mode this is a subordinate diagnostic and may be absent when no package pacing preference exists.', ge=0.0, ), + NoExplicitNull, ] = None pricing_model: Annotated[ pricing_model_1.PricingModel, @@ -546,22 +582,26 @@ class ByPackageItem(DeliveryMetrics): Field( description="System-reported operational state of this package. Reflects actual delivery state independent of buyer pause control. 'not_delivering' means zero impressions were recorded for the entire reporting_period while the package was in-flight. Sellers SHOULD only report 'not_delivering' once the package's data is_final for the period — a provisional (is_final: false) zero may still be measurement catching up, not genuine non-delivery." ), + NoExplicitNull, ] = None paused: Annotated[ StrictBool | None, Field(description='Whether this package is currently paused by the buyer'), + NoExplicitNull, ] = None is_final: Annotated[ StrictBool | None, Field( description="Whether this delivery data is final for the reporting period. When false, the data may be updated as measurement matures (e.g., broadcast C7 window accumulating DVR playback) or as processing completes (e.g., IVT filtering, deduplication). When true, the seller considers this data closed — no further updates for this period — and is willing to invoice on it subject to the buy's `measurement_terms.billing_measurement`. Absent means the seller does not distinguish provisional from final data." ), + NoExplicitNull, ] = None finalized_at: Annotated[ AwareDatetime | None, Field( description="ISO 8601 timestamp at which this package's data became final. Present only when `is_final: true`. Anchors reconciliation and (when later defined) dispute-window clocks against the buy's `measurement_terms.billing_measurement.measurement_window`." ), + NoExplicitNull, ] = None measurement_window: Annotated[ str | None, @@ -570,6 +610,7 @@ class ByPackageItem(DeliveryMetrics): examples=['live', 'c3', 'c7'], max_length=50, ), + NoExplicitNull, ] = None supersedes_window: Annotated[ str | None, @@ -578,6 +619,7 @@ class ByPackageItem(DeliveryMetrics): examples=['live', 'c3'], max_length=50, ), + NoExplicitNull, ] = None missing_metrics: Annotated[ list[missing_metric.MissingMetric] | None, @@ -596,466 +638,544 @@ class ByPackageItem(DeliveryMetrics): ], ], ), + NoExplicitNull, ] = None metric_values: Annotated[ list[package_delivery_metric_value.PackageDeliveryMetricValue] | None, Field( description='Qualified standard delivery values for this package. Each entry is the delivered counterpart to a standard-scope package.committed_metrics or by_package[].missing_metrics row, using the same atomic key (scope, metric_id, qualifier). Sellers report one row per full qualifier set at package grain; when a metric appears here, its flat scalar counterpart on the package MUST be omitted to avoid two sources of truth. Vendor-scope values continue to use vendor_metric_values. Buyers reconcile rows directly and perform any compatible cross-package aggregation themselves.' ), + NoExplicitNull, ] = None by_catalog_item: Annotated[ list[catalog_item_delivery_metrics.CatalogItemDeliveryMetrics] | None, Field( description='Delivery by catalog item within this package. Available for catalog-driven packages when the seller supports item-level reporting.' ), + NoExplicitNull, ] = None by_catalog_item_truncated: Annotated[ StrictBool | None, Field( description='Whether by_catalog_item was truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_catalog_item is present and the request included reporting_dimensions.catalog_item (false means the list is complete). When the breakdown was returned automatically without a request key, the flag is RECOMMENDED but not required — automatic rows carry no completeness contract.' ), + NoExplicitNull, ] = None by_catalog_item_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_catalog_item rows. Sellers MUST return this field whenever by_catalog_item is present and the request included reporting_dimensions.catalog_item. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric. When the breakdown was returned automatically without a request key, the field is RECOMMENDED but not required — automatic rows carry no completeness contract." ), + NoExplicitNull, ] = None by_catalog_item_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_catalog_item ordering. Sellers MUST return this field whenever by_catalog_item is present and the request included reporting_dimensions.catalog_item, alongside by_catalog_item_sorted_by. When the breakdown was returned automatically without a request key, the field is RECOMMENDED but not required — automatic rows carry no completeness contract.' ), + NoExplicitNull, ] = None by_creative: Annotated[ list[creative_delivery_metrics.CreativeDeliveryMetrics] | None, Field( description='Metrics broken down by creative within this package. Available when the seller supports creative-level reporting.' ), + NoExplicitNull, ] = None by_format: Annotated[ list[ByFormatItem] | None, Field( description="Delivery by canonical creative format kind within this package. Negotiated on the GET path when the buyer requests reporting_dimensions.format and the product declares supports_format_breakdown; reporting webhook configuration does not negotiate or guarantee this breakdown. Each row aggregates every served creative of that format kind. Sellers MUST aggregate all delivery using adopter-defined shapes into one format_kind 'custom' row. When by_format_truncated is false, additive metrics such as impressions and spend across the rows SHOULD reconcile to the corresponding package totals, subject to the measurement and attribution semantics of each metric. Buyers MUST NOT expect row-level correspondence between by_format and by_creative because the two breakdowns are independently produced at different grains." ), + NoExplicitNull, ] = None by_format_truncated: Annotated[ StrictBool | None, Field( description='Whether by_format was truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_format is present (false means the list is complete).' ), + NoExplicitNull, ] = None by_format_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_format rows. Sellers MUST return this field whenever by_format is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." ), + NoExplicitNull, ] = None by_format_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_format ordering. Sellers MUST return this field whenever by_format is present.' ), + NoExplicitNull, ] = None by_creative_truncated: Annotated[ StrictBool | None, Field( description='Whether by_creative was truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_creative is present and the request included reporting_dimensions.creative (false means the list is complete). When the breakdown was returned automatically without a request key, the flag is RECOMMENDED but not required — automatic rows carry no completeness contract.' ), + NoExplicitNull, ] = None by_creative_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_creative rows. Sellers MUST return this field whenever by_creative is present and the request included reporting_dimensions.creative. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric. When the breakdown was returned automatically without a request key, the field is RECOMMENDED but not required — automatic rows carry no completeness contract." ), + NoExplicitNull, ] = None by_creative_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_creative ordering. Sellers MUST return this field whenever by_creative is present and the request included reporting_dimensions.creative, alongside by_creative_sorted_by. When the breakdown was returned automatically without a request key, the field is RECOMMENDED but not required — automatic rows carry no completeness contract.' ), + NoExplicitNull, ] = None by_keyword: Annotated[ list[keyword_delivery_metrics.KeywordDeliveryMetrics] | None, Field( description='Metrics broken down by keyword within this package. One row per (keyword, match_type) pair — the same keyword with different match types appears as separate rows. Keyword-grain only: rows reflect aggregate performance of each targeted keyword, not individual search queries. Rows may not sum to package totals when a single impression is attributed to the triggering keyword only. Available for search and retail media packages when the seller supports keyword-level reporting.' ), + NoExplicitNull, ] = None by_keyword_truncated: Annotated[ StrictBool | None, Field( description='Whether by_keyword was truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_keyword is present and the request included reporting_dimensions.keyword (false means the list is complete). When the breakdown was returned automatically without a request key, the flag is RECOMMENDED but not required — automatic rows carry no completeness contract.' ), + NoExplicitNull, ] = None by_keyword_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_keyword rows. Sellers MUST return this field whenever by_keyword is present and the request included reporting_dimensions.keyword. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric. When the breakdown was returned automatically without a request key, the field is RECOMMENDED but not required — automatic rows carry no completeness contract." ), + NoExplicitNull, ] = None by_keyword_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_keyword ordering. Sellers MUST return this field whenever by_keyword is present and the request included reporting_dimensions.keyword, alongside by_keyword_sorted_by. When the breakdown was returned automatically without a request key, the field is RECOMMENDED but not required — automatic rows carry no completeness contract.' ), + NoExplicitNull, ] = None by_geo: Annotated[ list[geo_delivery_metrics.GeoDeliveryMetrics] | None, Field( description="Delivery by geographic area within this package. Available when the buyer requests geo breakdown via reporting_dimensions and the seller supports it. Each dimension's rows are independent slices that should sum to the package total." ), + NoExplicitNull, ] = None by_geo_truncated: Annotated[ StrictBool | None, Field( description='Whether by_geo was truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_geo is present (false means the list is complete).' ), + NoExplicitNull, ] = None by_geo_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_geo rows. Sellers MUST return this field whenever by_geo is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." ), + NoExplicitNull, ] = None by_geo_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_geo ordering. Sellers MUST return this field whenever by_geo is present.' ), + NoExplicitNull, ] = None by_device_type: Annotated[ list[ByDeviceTypeItem] | None, Field( description='Delivery by device form factor within this package. Available when the buyer requests device_type breakdown via reporting_dimensions and the seller supports it.' ), + NoExplicitNull, ] = None by_device_type_truncated: Annotated[ StrictBool | None, Field( description='Whether by_device_type was truncated. Sellers MUST return this flag whenever by_device_type is present (false means the list is complete).' ), + NoExplicitNull, ] = None by_device_type_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_device_type rows. Sellers MUST return this field whenever by_device_type is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." ), + NoExplicitNull, ] = None by_device_type_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_device_type ordering. Sellers MUST return this field whenever by_device_type is present.' ), + NoExplicitNull, ] = None by_device_type_pagination: Annotated[ pagination_response.PaginationResponse | None, Field( description="Cursor to retrieve the remaining by_device_type rows when by_device_type_truncated is true. Sellers MUST return this field whenever by_device_type_truncated is true; omit when false or when by_device_type is absent. Pass the cursor back in the corresponding request's reporting_dimensions.device_type_1.cursor to fetch the next page; other reporting_dimensions.device_type request fields (limit, sort_by, sort_direction) MUST be repeated unchanged across paged requests." ), + NoExplicitNull, ] = None by_device_platform: Annotated[ list[ByDevicePlatformItem] | None, Field( description='Delivery by operating system within this package. Available when the buyer requests device_platform breakdown via reporting_dimensions and the seller supports it. Useful for CTV campaigns where tvOS vs Roku OS vs Fire OS matters.' ), + NoExplicitNull, ] = None by_device_platform_truncated: Annotated[ StrictBool | None, Field( description='Whether by_device_platform was truncated. Sellers MUST return this flag whenever by_device_platform is present (false means the list is complete).' ), + NoExplicitNull, ] = None by_device_platform_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_device_platform rows. Sellers MUST return this field whenever by_device_platform is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." ), + NoExplicitNull, ] = None by_device_platform_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_device_platform ordering. Sellers MUST return this field whenever by_device_platform is present.' ), + NoExplicitNull, ] = None by_device_platform_pagination: Annotated[ pagination_response.PaginationResponse | None, Field( description="Cursor to retrieve the remaining by_device_platform rows when by_device_platform_truncated is true. Sellers MUST return this field whenever by_device_platform_truncated is true; omit when false or when by_device_platform is absent. Pass the cursor back in the corresponding request's reporting_dimensions.device_platform_1.cursor to fetch the next page; other reporting_dimensions.device_platform request fields (limit, sort_by, sort_direction) MUST be repeated unchanged across paged requests." ), + NoExplicitNull, ] = None by_audience: Annotated[ list[ByAudienceItem] | None, Field( description="Delivery by audience segment within this package. Available when the buyer requests audience breakdown via reporting_dimensions and the seller supports it. Only 'synced' audiences are directly targetable via the targeting overlay; other sources are informational." ), + NoExplicitNull, ] = None by_audience_truncated: Annotated[ StrictBool | None, Field( description='Whether by_audience was truncated. Sellers MUST return this flag whenever by_audience is present (false means the list is complete).' ), + NoExplicitNull, ] = None by_audience_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_audience rows. Sellers MUST return this field whenever by_audience is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." ), + NoExplicitNull, ] = None by_audience_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_audience ordering. Sellers MUST return this field whenever by_audience is present.' ), + NoExplicitNull, ] = None by_audience_pagination: Annotated[ pagination_response.PaginationResponse | None, Field( description="Cursor to retrieve the remaining by_audience rows when by_audience_truncated is true. Sellers MUST return this field whenever by_audience_truncated is true; omit when false or when by_audience is absent. Pass the cursor back in the corresponding request's reporting_dimensions.audience.cursor to fetch the next page; other reporting_dimensions.audience request fields (limit, sort_by, sort_direction) MUST be repeated unchanged across paged requests." ), + NoExplicitNull, ] = None by_demographic: Annotated[ list[ByDemographicItem] | None, Field( description='Delivery by demographic within this package. Available when the buyer requests demographic breakdown and the product declares supports_demographic_breakdown. A free-form measurement code does not prove alignment with buyer targeting. When age is present it is the authoritative machine-comparable interval; for requested age_ranges, sellers MUST echo the exact requested interval and MUST NOT substitute a wider or narrower native bucket.' ), + NoExplicitNull, ] = None by_demographic_truncated: Annotated[ StrictBool | None, Field( description='Whether non-suppressed by_demographic rows were truncated due to the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_demographic is present. False means every non-suppressed row is present; inspect by_demographic_suppressed separately before reconciling rows to package totals.' ), + NoExplicitNull, ] = None by_demographic_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_demographic rows. Sellers MUST return this field whenever by_demographic is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." ), + NoExplicitNull, ] = None by_demographic_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_demographic ordering. Sellers MUST return this field whenever by_demographic is present.' ), + NoExplicitNull, ] = None by_demographic_suppressed: Annotated[ StrictBool | None, Field( description='Whether one or more otherwise reportable demographic rows were omitted due to privacy, policy, or measurement thresholds. Sellers MUST return this flag whenever by_demographic is present. False means no rows were threshold-suppressed.' ), + NoExplicitNull, ] = None by_placement: Annotated[ list[placement_delivery_metrics.PlacementDeliveryMetrics] | None, Field( description='Delivery by placement within this package. placement_id remains required for 3.1 compatibility. New 3.2 sellers also emit placement_identity, whose discriminator separates publisher-catalog identity from sales-agent-defined inline identity.' ), + NoExplicitNull, ] = None by_placement_truncated: Annotated[ StrictBool | None, Field( description='Whether by_placement was truncated. Sellers MUST return this flag whenever by_placement is present (false means the list is complete).' ), + NoExplicitNull, ] = None by_placement_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description="The metric actually used to order by_placement rows. Sellers MUST return this field whenever by_placement is present. When the seller cannot sort by the requested sort_by metric it falls back to 'spend'; this echo makes the fallback visible instead of silently returning rows the buyer will misread as ordered by the requested metric." ), + NoExplicitNull, ] = None by_placement_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_placement ordering. Sellers MUST return this field whenever by_placement is present.' ), + NoExplicitNull, ] = None by_placement_pagination: Annotated[ pagination_response.PaginationResponse | None, Field( description="Cursor to retrieve the remaining by_placement rows when by_placement_truncated is true. Sellers MUST return this field whenever by_placement_truncated is true; omit when false or when by_placement is absent. Pass the cursor back in the corresponding request's reporting_dimensions.placement.cursor to fetch the next page; other reporting_dimensions.placement request fields (limit, sort_by, sort_direction) MUST be repeated unchanged across paged requests." ), + NoExplicitNull, ] = None by_property: Annotated[ list[property_delivery_metrics.PropertyDeliveryMetrics] | None, Field( description='Delivery by publisher property within this package. Each row identifies the actual surface with an operational identifier and adds property_ref when it resolves to a canonical publisher catalog entry. Rows are independent of by_collection.' ), + NoExplicitNull, ] = None by_property_truncated: Annotated[ StrictBool | None, Field( description='Whether non-suppressed by_property rows were truncated. Sellers MUST return this flag whenever by_property is present. False means every non-suppressed row is present; inspect by_property_suppressed before reconciling rows to package totals.' ), + NoExplicitNull, ] = None by_property_suppressed: Annotated[ StrictBool | None, Field( description='Whether one or more otherwise reportable by_property rows were omitted from this response due to privacy, policy, or measurement thresholds. Sellers MUST return this flag whenever by_property is present. False means no rows were threshold-suppressed. Both suppression and truncation may be true.' ), + NoExplicitNull, ] = None by_property_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description='The metric actually used to order by_property rows. Sellers MUST return this field whenever by_property is present.' ), + NoExplicitNull, ] = None by_property_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_property rows. Sellers MUST return this field whenever by_property is present.' ), + NoExplicitNull, ] = None by_collection: Annotated[ list[collection_delivery_metrics.CollectionDeliveryMetrics] | None, Field( description='Delivery by publisher-scoped collection within this package. This is a marginal breakdown and does not by itself prove which property carried a collection.' ), + NoExplicitNull, ] = None by_collection_truncated: Annotated[ StrictBool | None, Field( description='Whether by_collection was truncated. Sellers MUST return this flag whenever by_collection is present.' ), + NoExplicitNull, ] = None by_collection_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description='The metric actually used to order by_collection rows. Sellers MUST return this field whenever by_collection is present.' ), + NoExplicitNull, ] = None by_collection_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_collection rows. Sellers MUST return this field whenever by_collection is present.' ), + NoExplicitNull, ] = None by_installment: Annotated[ list[installment_delivery_metrics.InstallmentDeliveryMetrics] | None, Field(description='Delivery by canonically identified installment within this package.'), + NoExplicitNull, ] = None by_installment_truncated: Annotated[ StrictBool | None, Field( description='Whether by_installment was truncated. Sellers MUST return this flag whenever by_installment is present.' ), + NoExplicitNull, ] = None by_installment_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description='The metric actually used to order by_installment rows. Sellers MUST return this field whenever by_installment is present.' ), + NoExplicitNull, ] = None by_installment_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_installment rows. Sellers MUST return this field whenever by_installment is present.' ), + NoExplicitNull, ] = None by_collection_property: Annotated[ list[collection_property_delivery_metrics.CollectionPropertyDeliveryMetrics] | None, Field( description='Delivery at the collection × property intersection. A row is affirmative delivery evidence that the referenced collection ran on the referenced property; it is not merely a carriage or catalog assertion.' ), + NoExplicitNull, ] = None by_collection_property_truncated: Annotated[ StrictBool | None, Field( description='Whether non-suppressed by_collection_property rows were truncated. Sellers MUST return this flag whenever by_collection_property is present.' ), + NoExplicitNull, ] = None by_collection_property_suppressed: Annotated[ StrictBool | None, Field( description='Whether one or more otherwise reportable by_collection_property rows were omitted from this response due to privacy, policy, or measurement thresholds. Sellers MUST return this flag whenever by_collection_property is present. False means no rows were threshold-suppressed. Both suppression and truncation may be true.' ), + NoExplicitNull, ] = None by_collection_property_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description='The metric actually used to order by_collection_property rows. Sellers MUST return this field whenever by_collection_property is present.' ), + NoExplicitNull, ] = None by_collection_property_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_collection_property rows. Sellers MUST return this field whenever by_collection_property is present.' ), + NoExplicitNull, ] = None by_installment_property: Annotated[ list[installment_property_delivery_metrics.InstallmentPropertyDeliveryMetrics] | None, Field( description='Delivery at the installment × property intersection. A row is affirmative delivery evidence that the referenced airing, episode, issue, or programming block ran on the referenced property; it is not inferred from collection carriage or independent marginals.' ), + NoExplicitNull, ] = None by_installment_property_truncated: Annotated[ StrictBool | None, Field( description='Whether non-suppressed by_installment_property rows were truncated. Sellers MUST return this flag whenever by_installment_property is present.' ), + NoExplicitNull, ] = None by_installment_property_suppressed: Annotated[ StrictBool | None, Field( description='Whether one or more otherwise reportable by_installment_property rows were omitted from this response due to privacy, policy, or measurement thresholds. Sellers MUST return this flag whenever by_installment_property is present. False means no rows were threshold-suppressed. Both suppression and truncation may be true.' ), + NoExplicitNull, ] = None by_installment_property_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description='The metric actually used to order by_installment_property rows. Sellers MUST return this field whenever by_installment_property is present.' ), + NoExplicitNull, ] = None by_installment_property_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_installment_property rows. Sellers MUST return this field whenever by_installment_property is present.' ), + NoExplicitNull, ] = None by_placement_property: Annotated[ list[placement_property_delivery_metrics.PlacementPropertyDeliveryMetrics] | None, Field( description='Delivery at the placement × property intersection. This proves which actual property carried a placement that may span more than one property.' ), + NoExplicitNull, ] = None by_placement_property_truncated: Annotated[ StrictBool | None, Field( description='Whether non-suppressed by_placement_property rows were truncated. Sellers MUST return this flag whenever by_placement_property is present.' ), + NoExplicitNull, ] = None by_placement_property_suppressed: Annotated[ StrictBool | None, Field( description='Whether one or more otherwise reportable by_placement_property rows were omitted from this response due to privacy, policy, or measurement thresholds. Sellers MUST return this flag whenever by_placement_property is present. False means no rows were threshold-suppressed. Both suppression and truncation may be true.' ), + NoExplicitNull, ] = None by_placement_property_sorted_by: Annotated[ sort_metric.SortMetric | None, Field( description='The metric actually used to order by_placement_property rows. Sellers MUST return this field whenever by_placement_property is present.' ), + NoExplicitNull, ] = None by_placement_property_sort_direction: Annotated[ sort_direction.SortDirection | None, Field( description='The direction actually applied to by_placement_property rows. Sellers MUST return this field whenever by_placement_property is present.' ), + NoExplicitNull, ] = None by_spot: Annotated[ list[BySpotItem] | None, Field( description='Spot-level as-run airing records for broadcast TV, radio, or other scheduled inventory. Available when the buyer requests spot breakdown and the product declares supports_spot_breakdown. Sellers MUST order rows by aired_at ascending. The same spot_id is reused when a later package measurement_window adds or revises metrics. Network and station are optional so station-direct radio and network-level TV records use the same channel-neutral shape. Sellers SHOULD populate creative_id whenever they can associate a specific airing with a creative, particularly when the package could serve more than one creative during any part of the reporting period, including a mid-period replacement. Sellers that cannot make that association MUST omit creative_id rather than emit a default or placeholder. Buyers MUST NOT aggregate by_spot rows by creative_id and expect the result to equal by_creative impressions for the same creative: the two dimensions are independently produced at different granularities and are subject to different metric-maturation and attribution semantics within the package measurement_window. by_creative is the authoritative creative-performance aggregate; creative_id on a by_spot row identifies which creative aired, not an independent metric roll-up source.' ), + NoExplicitNull, ] = None by_spot_truncated: Annotated[ StrictBool | None, Field( description='Whether by_spot is incomplete because of the requested limit or a seller-imposed maximum. Sellers MUST return this flag whenever by_spot is present (false means the as-run log is complete for the requested reporting period).' ), + NoExplicitNull, ] = None daily_breakdown: Annotated[ list[DailyBreakdownItem] | None, Field( description='Day-by-day delivery for this package. Only present when include_package_daily_breakdown is true in the request. Enables per-package pacing analysis and line-item monitoring.' ), + NoExplicitNull, ] = None spend: Any @@ -1071,6 +1191,7 @@ class MediaBuyDelivery(AdCPBaseModel): description="ISO 4217 denomination for monetary values in this media-buy delivery row, including totals, package spend, and currency-denominated package rates. Sellers SHOULD populate this field whenever all monetary values in the row share one currency. For AdCP-authored buys it MUST equal the media-buy currency, and every by_package[].currency MUST equal it. For a legacy or externally created mixed-currency buy, omit this field, daily_breakdown, and all monetary or money-derived values from row and window totals; report those values only at package grain with each package's own currency. AdCP does not perform currency conversion.", pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None status: Annotated[ Status, @@ -1083,28 +1204,33 @@ class MediaBuyDelivery(AdCPBaseModel): Field( description='When delayed data is expected to be available (only present when status is reporting_delayed)' ), + NoExplicitNull, ] = None is_adjusted: Annotated[ StrictBool | None, Field( description='Indicates this delivery contains updated data for a previously reported period. Buyer should replace previous period data with these totals.' ), + NoExplicitNull, ] = None is_final: Annotated[ StrictBool | None, Field( description="Whether this row's delivery data is final for the reporting period. The row does not carry its own `measurement_window` — that lives on each `by_package[*]` entry. Reconciliation joins on per-package `measurement_window`; this row-level flag is a convenience roll-up. Sellers MUST NOT emit `is_final: true` at the row level unless every entry in `by_package` has `is_final: true` for the same `measurement_window` as the buy's `measurement_terms.billing_measurement.measurement_window` (or for the row's natural window when no `billing_measurement.measurement_window` is set). On any disagreement between row-level and package-level finality, package-level is authoritative. When true, the seller considers these numbers closed and is willing to invoice on them subject to `measurement_terms.billing_measurement`. When false, numbers may still move as measurement matures (broadcast C3 → C7) or processing completes (IVT scrubbing, dedup). When absent, the seller does not distinguish provisional from final at the row level — consult per-package `is_final`." ), + NoExplicitNull, ] = None finalized_at: Annotated[ AwareDatetime | None, Field( description="ISO 8601 timestamp at which this row became final. Present only when `is_final: true`. Anchors the buyer's reconciliation and (when later defined) dispute-window clocks against the buy's `measurement_terms.billing_measurement`. Computed as the latest `finalized_at` across the row's packages for the reconciliation window." ), + NoExplicitNull, ] = None pricing_model: Annotated[ pricing_model_1.PricingModel | None, Field(description='Pricing model used for this media buy'), + NoExplicitNull, ] = None pacing_index: Annotated[ StrictFloat | None, @@ -1112,6 +1238,7 @@ class MediaBuyDelivery(AdCPBaseModel): description='Aggregate media-buy delivery pace relative to the media-buy pacing plan (1.0 = on track, <1.0 = behind, >1.0 = ahead). This is the authoritative pacing signal for seller-optimized buys; package pacing indexes are subordinate diagnostics.', ge=0.0, ), + NoExplicitNull, ] = None totals: Totals by_package: Annotated[list[ByPackageItem], Field(description='Metrics broken down by package')] @@ -1120,12 +1247,14 @@ class MediaBuyDelivery(AdCPBaseModel): Field( description="Per-window delivery slices over the reporting period at the requested time_granularity. Only present when the request set time_granularity and include_window_breakdown: true. Each slice mirrors what reporting_webhook would have delivered for the same window — buyers who missed webhook fires can reconstruct identical data by reading this array. Slice rows are ordered by window_start ascending; consecutive rows are contiguous (each row's window_end equals the next row's window_start) and partition the requested date range at the chosen granularity. For a legacy or external mixed-currency media buy, monetary and money-derived values MUST be omitted from each window totals object and reported only in currency-qualified windows[].by_package rows. Sellers MUST exclude this field when time_granularity is omitted; when set, sellers MUST honor pulls at any granularity in reporting_capabilities.windowed_pull_granularities (otherwise return UNSUPPORTED_GRANULARITY). See snapshot-and-log Rule 4 for the two-paths-parity contract this surface anchors." ), + NoExplicitNull, ] = None daily_breakdown: Annotated[ list[DailyBreakdownItem1] | None, Field( description="Day-by-day delivery for a media-buy row with one currency. Sellers MUST omit this aggregate breakdown for a legacy or externally created mixed-currency buy because these rows have no package currency field. Sellers MUST also omit this aggregate breakdown when the media buy's packages span more than one reporting timezone; package-level daily_breakdown remains, each in its own product's reporting timezone." ), + NoExplicitNull, ] = None @@ -1138,12 +1267,14 @@ class GetMediaBuyDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv Field( description='Type of webhook notification (only present in webhook deliveries): scheduled = regular periodic update, final = campaign completed, delayed = data not yet available, adjusted = resending period with corrected data (same window), window_update = resending period with a wider measurement window (e.g., C3 superseding live, C7 superseding C3)' ), + NoExplicitNull, ] = None partial_data: Annotated[ StrictBool | None, Field( description='Indicates if any media buys in this webhook have missing/delayed data (only present in webhook deliveries)' ), + NoExplicitNull, ] = None unavailable_count: Annotated[ SchemaInt | None, @@ -1151,6 +1282,7 @@ class GetMediaBuyDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv description='Number of media buys with reporting_delayed or failed status (only present in webhook deliveries when partial_data is true)', ge=0, ), + NoExplicitNull, ] = None sequence_number: Annotated[ SchemaInt | None, @@ -1158,12 +1290,14 @@ class GetMediaBuyDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv description='Sequential notification number (only present in webhook deliveries, starts at 1)', ge=1, ), + NoExplicitNull, ] = None next_expected_at: Annotated[ AwareDatetime | None, Field( description="ISO 8601 timestamp for next expected notification (only present in webhook deliveries when notification_type is not 'final')" ), + NoExplicitNull, ] = None reporting_period: Annotated[ ReportingPeriod, @@ -1176,24 +1310,28 @@ class GetMediaBuyDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv Field( description='Present only for a reporting_revision_id selector. Binds the complete ordered reporting_rows sequence obtained by concatenating every cursor page to one immutable Reliable Reporting revision; consumers verify content_sha256 over RFC 8785 JCS of {reporting_revision_id,row_count,control_totals,reporting_rows} before using it as revision evidence.' ), + NoExplicitNull, ] = None reporting_revision: Annotated[ reporting_revision_1.ReportingRevision | None, Field( description='Full immutable revision metadata, identical to the get_reporting_status revision record.' ), + NoExplicitNull, ] = None reporting_rows: Annotated[ list[dict[str, Any]] | None, Field( description="One page of authoritative logical rows for an exact reporting_revision_id read. Validate every row against the revision's digest-pinned schema; concatenate pages in cursor order before verifying the binding digest." ), + NoExplicitNull, ] = None pagination: Annotated[ pagination_response.PaginationResponse | None, Field( description='Exact revision pages are frozen for the cursor walk. total_count is mandatory for reporting_revision_id and equals reporting_revision_1.row_count and reporting_revision_binding.row_count; it counts the complete ordered row sequence, not this page.' ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -1202,12 +1340,14 @@ class GetMediaBuyDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv description='Deprecated in AdCP 3.2 and removed in AdCP 4.0. Optional legacy response-wide ISO 4217 currency code. It may be used only when every monetary value in the response has that denomination. A delivery response can contain media buys with different currencies, so buyers MUST NOT interpret this field as an aggregation currency or evidence of currency conversion. Prefer media_buy_deliveries[].currency when present and package-level currency otherwise.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None attribution_window: Annotated[ attribution_window_1.AttributionWindow | None, Field( description='Attribution methodology and lookback windows used for conversion metrics in this response. All media buys from a single seller share the same attribution methodology. Enables cross-platform comparison (e.g., Amazon 14-day click vs. Criteo 30-day click).' ), + NoExplicitNull, ] = None aggregated_totals: Annotated[ AggregatedTotals | None, @@ -1215,6 +1355,7 @@ class GetMediaBuyDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv deprecated=True, description='Deprecated in AdCP 3.2 and removed in AdCP 4.0. Legacy combined metrics across all returned media buys. When this field is present, the deprecated response-wide currency is required and denominates its spend. Cross-buy totals are unsafe when currencies, metric qualifiers, measurement windows, finality, or deduplication semantics differ. Sellers SHOULD omit this field; buyers SHOULD aggregate media_buy_deliveries[] only when the relevant row semantics are compatible.', ), + NoExplicitNull, ] = None media_buy_deliveries: Annotated[ Sequence[MediaBuyDelivery], @@ -1227,10 +1368,12 @@ class GetMediaBuyDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv Field( description='Task-specific errors and warnings (e.g., missing delivery data, reporting platform issues)' ), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field(description='When true, this response contains simulated data from sandbox mode.'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_media_buys_request.py b/src/adcp/types/domains/media_buy/get_media_buys_request.py index 4a1d6c6e9..d4f37b89f 100644 --- a/src/adcp/types/domains/media_buy/get_media_buys_request.py +++ b/src/adcp/types/domains/media_buy/get_media_buys_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buys_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, RootModel, StrictBool from ..core import account_ref @@ -37,6 +38,7 @@ class GetMediaBuysRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account to retrieve media buys for. When omitted, returns data across all accessible accounts.' ), + NoExplicitNull, ] = None media_buy_ids: Annotated[ list[str] | None, @@ -44,12 +46,14 @@ class GetMediaBuysRequest(AdcpRequest, AdcpVersionEnvelope): description='Array of media buy IDs to retrieve. When omitted, returns a paginated set of accessible media buys matching status_filter.', min_length=1, ), + NoExplicitNull, ] = None status_filter: Annotated[ media_buy_status.MediaBuyStatus | StatusFilter | None, Field( description='Filter by status. Can be a single status or array of statuses. Defaults to ["active"] when media_buy_ids is omitted. When media_buy_ids is provided, no implicit status filter is applied.' ), + NoExplicitNull, ] = None indicator_types: Annotated[ list[indicator_type.IndicatorType] | None, @@ -57,12 +61,14 @@ class GetMediaBuysRequest(AdcpRequest, AdcpVersionEnvelope): description='Return media buys with at least one matching current indicator on the media buy, a package, or a package–creative assignment. Values within this field use OR logic; this field composes with status_filter using AND logic. Buyers MUST NOT send this filter unless the seller advertises every requested type in get_adcp_capabilities.media_buy.supported_indicator_types. A seller MAY reject a request that violates this precondition with UNSUPPORTED_FEATURE rather than silently returning an unfiltered superset.', min_length=1, ), + NoExplicitNull, ] = None include_snapshot: Annotated[ StrictBool | None, Field( description='When true, include a near-real-time delivery snapshot for each package. Snapshots reflect the latest available entity-level stats from the platform (e.g., updated every ~15 minutes on GAM, ~1 hour on batch-only platforms). The staleness_seconds field on each snapshot indicates data freshness. If a snapshot cannot be returned, package.snapshot_unavailable_reason explains why. Defaults to false.' ), + NoExplicitNull, ] = False include_history: Annotated[ SchemaInt | None, @@ -71,12 +77,14 @@ class GetMediaBuysRequest(AdcpRequest, AdcpVersionEnvelope): ge=0, le=1000, ), + NoExplicitNull, ] = 0 include_webhook_activity: Annotated[ StrictBool | None, Field( description="When true, each returned media buy includes a `webhook_activity` array describing recent delivery-report webhook fires for the calling principal. Used by buyer agents to verify whether a publisher actually fired against the buyer's registered endpoint and what the endpoint returned — closes the operator-ticket loop for webhook debugging. Scoped to the calling principal: a buyer sees only fires targeting its own endpoint, even when multiple principals share visibility into the same media buy. Defaults to false. See `webhook_activity_limit` for the per-buy cap." ), + NoExplicitNull, ] = False webhook_activity_limit: Annotated[ SchemaInt | None, @@ -85,12 +93,14 @@ class GetMediaBuysRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=200, ), + NoExplicitNull, ] = 50 pagination: Annotated[ pagination_request.PaginationRequest | None, Field( description='Cursor-based pagination controls. Strongly recommended when querying broad scopes (for example, all active media buys in an account).' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_media_buys_response.py b/src/adcp/types/domains/media_buy/get_media_buys_response.py index f502e3021..f11e593c3 100644 --- a/src/adcp/types/domains/media_buy/get_media_buys_response.py +++ b/src/adcp/types/domains/media_buy/get_media_buys_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buys_response.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import account as account_1 @@ -74,7 +74,9 @@ class Cancellation(AdCPBaseModel): canceled_by_1.CanceledBy, Field(description='Which party initiated the cancellation.') ] reason: Annotated[ - str | None, Field(description='Reason the media buy was canceled.', max_length=500) + str | None, + Field(description='Reason the media buy was canceled.', max_length=500), + NoExplicitNull, ] = None @@ -91,6 +93,7 @@ class HistoryItem(AdCPBaseModel): Field( description='Identity of who made the change — derived from authentication context, not caller-provided. Format is seller-defined (e.g., agent URL, user email, API key label).' ), + NoExplicitNull, ] = None action: Annotated[ str, @@ -104,12 +107,14 @@ class HistoryItem(AdCPBaseModel): description="Human-readable summary of the change (e.g., 'Budget increased from $5,000 to $7,500 on pkg_abc').", max_length=500, ), + NoExplicitNull, ] = None package_id: Annotated[ str | None, Field(description='Package affected, when the change targeted a specific package.'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class IndicatorTypesEvaluatedEnum1(StrEnum): @@ -139,7 +144,9 @@ class Cancellation1(AdCPBaseModel): Field(description='Which party initiated the package cancellation.'), ] reason: Annotated[ - str | None, Field(description='Reason the package was canceled.', max_length=500) + str | None, + Field(description='Reason the package was canceled.', max_length=500), + NoExplicitNull, ] = None @@ -186,10 +193,12 @@ class Snapshot(AdCPBaseModel): description='ISO 4217 currency code for spend in this snapshot. Optional when unchanged from package.currency or media_buy.currency.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None clicks: Annotated[ StrictFloat | None, Field(description='Total clicks since package start (when available)', ge=0.0), + NoExplicitNull, ] = None pacing_index: Annotated[ StrictFloat | None, @@ -197,16 +206,19 @@ class Snapshot(AdCPBaseModel): description='Current delivery pace relative to expected (1.0 = on track, <1.0 = behind, >1.0 = ahead). Absent when pacing cannot be determined.', ge=0.0, ), + NoExplicitNull, ] = None delivery_status: Annotated[ delivery_status_1.DeliveryStatus | None, Field( description="Operational delivery state of this package. 'not_delivering' means the package is within its scheduled flight but has delivered zero impressions for at least one full staleness cycle — the signal for automated price adjustments or buyer alerts. Implementers must not return 'not_delivering' until at least staleness_seconds have elapsed since package activation." ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field(description='Optional extension object for seller-specific snapshot fields.'), + NoExplicitNull, ] = None @@ -226,6 +238,7 @@ class Indicator(AdCPBaseModel): Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), + NoExplicitNull, ] = None scope: Annotated[ list[indicator_scope.IndicatorScope] | None, @@ -233,12 +246,14 @@ class Indicator(AdCPBaseModel): description='Optional narrower publisher or placement scope within the enclosing media buy, package, or package–creative assignment. Omit only when the seller evaluated and asserts the indicator across the whole enclosing resource/relationship. When partial indicators_evaluated_scope is declared, every returned indicator MUST include scope and every entry MUST fall within that coverage. This is scope, not source: the responding seller remains the source.', min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Seller- or provider-specific detail such as scores, thresholds, evaluation windows, methodology identifiers, or upstream attribution. Core consumers must not need ext to understand the broad meaning of type.' ), + NoExplicitNull, ] = None @@ -258,6 +273,7 @@ class Indicator1(AdCPBaseModel): Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), + NoExplicitNull, ] = None scope: Annotated[ list[indicator_scope.IndicatorScope] | None, @@ -265,12 +281,14 @@ class Indicator1(AdCPBaseModel): description='Optional narrower publisher or placement scope within the enclosing media buy, package, or package–creative assignment. Omit only when the seller evaluated and asserts the indicator across the whole enclosing resource/relationship. When partial indicators_evaluated_scope is declared, every returned indicator MUST include scope and every entry MUST fall within that coverage. This is scope, not source: the responding seller remains the source.', min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Seller- or provider-specific detail such as scores, thresholds, evaluation windows, methodology identifiers, or upstream attribution. Core consumers must not need ext to understand the broad meaning of type.' ), + NoExplicitNull, ] = None @@ -290,6 +308,7 @@ class Indicator2(AdCPBaseModel): Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), + NoExplicitNull, ] = None scope: Annotated[ list[indicator_scope.IndicatorScope] | None, @@ -297,12 +316,14 @@ class Indicator2(AdCPBaseModel): description='Optional narrower publisher or placement scope within the enclosing media buy, package, or package–creative assignment. Omit only when the seller evaluated and asserts the indicator across the whole enclosing resource/relationship. When partial indicators_evaluated_scope is declared, every returned indicator MUST include scope and every entry MUST fall within that coverage. This is scope, not source: the responding seller remains the source.', min_length=1, ), + NoExplicitNull, ] = None ext: Annotated[ ext_1.ExtensionObject | None, Field( description='Seller- or provider-specific detail such as scores, thresholds, evaluation windows, methodology identifiers, or upstream attribution. Core consumers must not need ext to understand the broad meaning of type.' ), + NoExplicitNull, ] = None @@ -316,12 +337,14 @@ class CreativeApproval(IndicatorBearingResourceState): description='Indicator types covered by this snapshot. Required whenever indicators is present. Types omitted from this list remain unknown even when indicators is empty. Every returned indicator.type MUST appear in this list.', min_length=1, ), + NoExplicitNull, ] = None indicators: Annotated[ list[Indicator2] | None, Field( description='Current seller assertions for the indicator types and publisher/placement coverage named by the sibling evaluation fields. Omitted means unknown or not evaluated. A present empty array means evaluated with no current assertion for indicator_types_evaluated in the evaluated scope.' ), + NoExplicitNull, ] = None creative_id: Annotated[str, Field(description='Creative identifier')] approval_status: creative_approval_status.CreativeApprovalStatus @@ -330,6 +353,7 @@ class CreativeApproval(IndicatorBearingResourceState): Field( description="Human-readable explanation of why the creative was rejected. Present only when approval_status is 'rejected'." ), + NoExplicitNull, ] = None approval_scopes: Annotated[ list[creative_approval_scope.ScopedCreativeApproval] | None, @@ -337,12 +361,14 @@ class CreativeApproval(IndicatorBearingResourceState): description='Complete, disjoint publisher/placement approval partition when approval_status is partially_approved. A normalized scope appears once. For one publisher, use either one publisher-wide row or placement-specific rows, never both. Omit when one approval_status applies uniformly to the whole assignment. The same scoped outcomes are mirrored on list_creatives.', min_length=2, ), + NoExplicitNull, ] = None indicators_as_of: Annotated[ AwareDatetime | None, Field( description='When the seller last completed the evaluation represented by indicators for this relationship. Required whenever indicators is present, including an empty array.' ), + NoExplicitNull, ] = None indicators_evaluated_scope: Annotated[ list[indicator_scope.IndicatorScope] | None, @@ -350,6 +376,7 @@ class CreativeApproval(IndicatorBearingResourceState): description='Optional publisher or placement scopes covered by this evaluation. Omit when indicators covers the whole package–creative assignment. When present, scopes not listed remain unknown; every returned indicator.scope entry MUST be contained by this set.', min_length=1, ), + NoExplicitNull, ] = None @@ -377,12 +404,14 @@ class Package(IndicatorBearingResourceState): description='Indicator types covered by this snapshot. Required whenever indicators is present. Types omitted from this list remain unknown even when indicators is empty. Every returned indicator.type MUST appear in this list.', min_length=1, ), + NoExplicitNull, ] = None indicators: Annotated[ list[Indicator1] | None, Field( description='Current seller assertions for the indicator types and publisher/placement coverage named by the sibling evaluation fields. Omitted means unknown or not evaluated. A present empty array means evaluated with no current assertion for indicator_types_evaluated in the evaluated scope.' ), + NoExplicitNull, ] = None package_id: Annotated[str, Field(description="Seller's package identifier")] product_id: Annotated[ @@ -390,6 +419,7 @@ class Package(IndicatorBearingResourceState): Field( description="Product identifier this package is purchased from. For packages created from an explicit create_media_buy package request, sellers MUST echo the request package's product_id on every response package object that represents that requested package." ), + NoExplicitNull, ] = None budget: Annotated[ StrictFloat | None, @@ -397,6 +427,7 @@ class Package(IndicatorBearingResourceState): description='Hard lifetime package spend cap denominated in media_buy.currency. In seller-optimized mode this is not a current allocation.', ge=0.0, ), + NoExplicitNull, ] = None min_spend_target: Annotated[ StrictFloat | None, @@ -404,6 +435,7 @@ class Package(IndicatorBearingResourceState): description='Accepted soft lifetime spend target for this package under seller-optimized allocation.', ge=0.0, ), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -411,6 +443,7 @@ class Package(IndicatorBearingResourceState): description='Current hard package spend ceiling per shared media-buy cap day, denominated in media_buy.currency. It is a subordinate ceiling, not a reserved allocation; media_buy.budget_cap_timezone defines the day boundary.', ge=0.0, ), + NoExplicitNull, ] = None currency: Annotated[ str | None, @@ -418,6 +451,7 @@ class Package(IndicatorBearingResourceState): description='Legacy/readback package denomination for buys created outside the canonical 3.2 path. For AdCP-authored media buys this MUST equal media_buy.currency; canonical package budget and BiddingPolicy values always use media_buy.currency. Snapshot currency may still identify externally reported spend denomination.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None bid_price: Annotated[ StrictFloat | None, @@ -426,12 +460,14 @@ class Package(IndicatorBearingResourceState): description='DEPRECATED legacy bid representation. 3.2 sellers SHOULD normalize and echo package.bidding instead.', ge=0.0, ), + NoExplicitNull, ] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, Field( description='Package-authored bidding override. `{automatic:true}` explicitly overrides media_buy.bidding with provider automatic delivery. Omitted when this package inherits; sellers MUST NOT copy an inherited block here. Monetary fields use media_buy.currency.' ), + NoExplicitNull, ] = None optimization_goals: Annotated[ list[optimization_goal.OptimizationGoal] | None, @@ -439,6 +475,7 @@ class Package(IndicatorBearingResourceState): description='Current package objective functions. Currency-bearing execution controls are returned separately in package.bidding or inherited from media_buy.bidding.', min_length=1, ), + NoExplicitNull, ] = None format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -447,6 +484,7 @@ class Package(IndicatorBearingResourceState): description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Legacy named-format IDs supplied for this package on create_media_buy. Sellers SHOULD echo this field whenever the request included it, including dual-emission cases where another selector won precedence.', min_length=1, ), + NoExplicitNull, ] = None format_option_refs: Annotated[ list[format_option_ref.FormatOptionReference] | None, @@ -454,81 +492,97 @@ class Package(IndicatorBearingResourceState): description='Structured 3.1+ format option references supplied for this package on create_media_buy. Sellers SHOULD echo this field whenever the request included it.', min_length=1, ), + NoExplicitNull, ] = None format_kind: Annotated[ str | None, Field( description='Direct canonical selector supplied for this package on create_media_buy. Sellers SHOULD echo this field whenever the request included it, including informational-echo cases where another selector won precedence.' ), + NoExplicitNull, ] = None params: Annotated[ dict[str, Any] | None, Field( description='Parameters for the direct canonical selector in `format_kind`, echoed from the create_media_buy request whenever the request included it. Requires `format_kind`.' ), + NoExplicitNull, ] = None impressions: Annotated[ StrictFloat | None, Field(description='Goal impression count for impression-based packages', ge=0.0), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Package-level pacing preference. Under seller-optimized allocation this is subordinate to the media-buy aggregate pacing.' ), + NoExplicitNull, ] = None targeting_overlay: Annotated[ targeting.TargetingOverlay | None, Field( description='Complete effective targeting applied to this package, including configured-product targeting and the most recent package-specific overlay. Sellers SHOULD echo persisted targeting so buyers can verify stored state without replaying requests. Sellers MUST echo geo_places and geo_places_exclude whenever either was persisted, including the exact applied system_version and normalized values, so buyers can audit catalog-backed targeting. Sellers using placement, property-list, or collection-list targeting MUST include the committed inventory selection here. placement_selection mode default SHOULD resolve to mode selected with committed refs when enumerable; collection_selection follows the same rule, materializing the committed selectors even when the selection was produced through collection_list references.' ), + NoExplicitNull, ] = None targeting_resolution: Annotated[ package_targeting_resolution.PackageTargetingResolution | None, Field( description='Execution details for accepted package targeting. Sellers MUST include targeting_resolution.demographics whenever demographic targeting was requested or applied; its applied predicate and execution fields report the effective booked state.' ), + NoExplicitNull, ] = None start_time: Annotated[ AwareDatetime | None, Field( description='ISO 8601 flight start time for this package. Use to determine whether the package is within its scheduled flight before interpreting delivery status.' ), + NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, Field(description='ISO 8601 flight end time for this package') + AwareDatetime | None, + Field(description='ISO 8601 flight end time for this package'), + NoExplicitNull, ] = None paused: Annotated[ StrictBool | None, Field(description='Whether this package is currently paused by the buyer'), + NoExplicitNull, ] = None canceled: Annotated[ StrictBool | None, Field( description='Whether this package has been canceled. Canceled packages stop delivery and cannot be reactivated.' ), + NoExplicitNull, ] = None cancellation: Annotated[ Cancellation1 | None, Field(description='Cancellation metadata. Present only when canceled is true.'), + NoExplicitNull, ] = None creative_deadline: Annotated[ AwareDatetime | None, Field( description="ISO 8601 timestamp for creative upload or change deadline for this package. After this deadline, creative changes are rejected. When absent, the media buy's creative_deadline applies." ), + NoExplicitNull, ] = None context: Annotated[ context_1.ContextObject | None, Field( description='Opaque package-level correlation data echoed unchanged from the create_media_buy package request. Sellers MUST include persisted package context on read surfaces when the package was created through AdCP with context, so buyers can reconcile seller-assigned package_id values with their own line items; this is the legacy-safe fallback when an older seller did not echo product_id on the create response. Sellers MAY omit context for packages created outside AdCP or created without context. Sellers MUST NOT parse this object for business logic.' ), + NoExplicitNull, ] = None creative_approvals: Annotated[ list[CreativeApproval] | None, Field( description='Approval status for each creative assigned to this package. Absent when no creatives have been assigned.' ), + NoExplicitNull, ] = None formats_to_provide: Annotated[ list[package_format_snapshot.PackageFormatSnapshot] | None, @@ -536,12 +590,14 @@ class Package(IndicatorBearingResourceState): description='The immutable PackageFormatSnapshot checklist established for this package at booking time. Contract-bearing snapshots remain present after creative coverage is complete so readback, assignment, and serving never fall back to a mutable live Product declaration.', min_length=1, ), + NoExplicitNull, ] = None formats_pending: Annotated[ list[package_format_snapshot.PackageFormatSnapshot] | None, Field( description='PackageFormatSnapshot entries from formats_to_provide that do not yet have creative coverage. Each entry MUST be canonically equal to the corresponding checklist snapshot and, when product_snapshot_digest is present, carry the identical digest. An empty emitted array means all requirements are covered; absence means readiness was not reported.' ), + NoExplicitNull, ] = None format_ids_to_provide: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -549,6 +605,7 @@ class Package(IndicatorBearingResourceState): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format projection of formats_to_provide retained for older 3.x peers. New sellers emit canonical formats_to_provide declarations.', ), + NoExplicitNull, ] = None format_ids_pending: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -556,20 +613,23 @@ class Package(IndicatorBearingResourceState): deprecated=True, description='**DEPRECATED in 3.2.** Legacy named-format projection of formats_pending retained for older 3.x peers. New sellers emit canonical formats_pending declarations. An empty emitted array means every projected requirement is covered. Absence means legacy readiness was not reported and MUST NOT be interpreted as full coverage.', ), + NoExplicitNull, ] = None snapshot_unavailable_reason: Annotated[ snapshot_unavailable_reason_1.SnapshotUnavailableReason | None, Field( description='Machine-readable reason the snapshot is omitted. Present only when include_snapshot was true and snapshot is unavailable for this package.' ), + NoExplicitNull, ] = None snapshot: Annotated[ Snapshot | None, Field( description='Near-real-time delivery snapshot for this package. Only present when include_snapshot was true in the request. Represents the latest available entity-level stats from the platform — not billing-grade data.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class MediaBuy(IndicatorBearingResourceState): @@ -582,12 +642,14 @@ class MediaBuy(IndicatorBearingResourceState): description='Indicator types covered by this snapshot. Required whenever indicators is present. Types omitted from this list remain unknown even when indicators is empty. Every returned indicator.type MUST appear in this list.', min_length=1, ), + NoExplicitNull, ] = None indicators: Annotated[ list[Indicator] | None, Field( description='Current seller assertions for the indicator types and publisher/placement coverage named by the sibling evaluation fields. Omitted means unknown or not evaluated. A present empty array means evaluated with no current assertion for indicator_types_evaluated in the evaluated scope.' ), + NoExplicitNull, ] = None media_buy_id: Annotated[str, Field(description="Seller's unique identifier for the media buy")] name: Annotated[ @@ -598,6 +660,7 @@ class MediaBuy(IndicatorBearingResourceState): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None accepted_proposal_id: Annotated[ str | None, @@ -606,6 +669,7 @@ class MediaBuy(IndicatorBearingResourceState): max_length=255, min_length=1, ), + NoExplicitNull, ] = None accepted_proposal_terms_digest: Annotated[ str | None, @@ -613,21 +677,26 @@ class MediaBuy(IndicatorBearingResourceState): description='Digest of the current accepted proposal commercial_terms.', pattern='^sha256:[A-Za-z0-9_-]{43}$', ), + NoExplicitNull, ] = None accepted_proposal: Annotated[ AcceptedProposal | None, Field( description='Current accepted compact proposal, including the complete digested commercial envelope. Required whenever accepted_proposal_id is present so restarted SDKs can recover control-versus-refinement routing without reconstructing historical offers.' ), + NoExplicitNull, ] = None account: Annotated[ - account_1.Account | None, Field(description='Account billed for this media buy') + account_1.Account | None, + Field(description='Account billed for this media buy'), + NoExplicitNull, ] = None invoice_recipient: Annotated[ business_entity.BusinessEntity | None, Field( description='Per-buy invoice recipient when provided at creation. Confirms the seller accepted the billing override. Bank details are omitted (write-only).' ), + NoExplicitNull, ] = None status: media_buy_status.MediaBuyStatus status_as_of: Annotated[ @@ -641,18 +710,21 @@ class MediaBuy(IndicatorBearingResourceState): Field( description='Dependency health of the media buy, orthogonal to `status`. `ok` (default) when no upstream resource that this buy depends on is in an offline state. `impaired` when at least one such resource (audience, creative, catalog_item, event_source, property) is offline and affects delivery for one or more packages — `impairments[]` MUST be non-empty in that case. On terminal-status buys, the seller MAY leave this field in whatever state held at the terminal transition. See lifecycle.mdx § Compliance and the impairment.coherence assertion.' ), + NoExplicitNull, ] = media_buy_health.MediaBuyHealth.ok impairments: Annotated[ list[impairment.Impairment] | None, Field( description='Open impairments — upstream dependency state changes that affect delivery for at least one package on this buy. Empty when `health` is `ok`; non-empty iff `health` is `impaired` (health-iff rule on non-terminal buys). Sellers MUST add an entry on the next read after a referenced resource transitions to an offline state, and MUST remove the entry when the resource returns to a serviceable state or stops being a dependency (e.g., via assignment swap via update_media_buy). Staleness budget: the snapshot MUST reflect the impairment within 5 minutes of `impairment.observed_at` regardless of buyer poll cadence — sellers cannot rely on rare buyer polls to defer write propagation. See impairment.coherence assertion for the cross-resource invariant.' ), + NoExplicitNull, ] = None rejection_reason: Annotated[ str | None, Field( description="Reason provided by the seller when status is 'rejected'. Present only when status is 'rejected'." ), + NoExplicitNull, ] = None currency: Annotated[ str, @@ -673,49 +745,59 @@ class MediaBuy(IndicatorBearingResourceState): description='Current hard aggregate spend ceiling per calendar day, denominated in media_buy.currency. It bounds total spend without allocating or reserving package spend.', ge=0.0, ), + NoExplicitNull, ] = None frequency_cap: Annotated[ media_buy_frequency_cap.MediaBuyFrequencyCap | None, Field( description='Current hard MediaBuy-level cap. Sellers MUST echo it whenever set, separately from package targeting-overlay caps.' ), + NoExplicitNull, ] = None budget_cap_timezone: Annotated[ str | None, Field( description='IANA timezone defining the shared calendar-day boundary for every aggregate and package daily cap. Present whenever any daily cap is set on the media buy.' ), + NoExplicitNull, ] = None budget_allocation: Annotated[ budget_allocation_1.BudgetAllocation | None, Field( description='Current cross-package allocation configuration. Omitted means fixed allocation for legacy buys.' ), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field(description='Aggregate pacing strategy for the media-buy budget.'), + NoExplicitNull, ] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, Field( description='Current media-buy-authored bidding policy with scope-specific goal binding and media-buy-currency denomination. Package entries omit bidding when they inherit this block; explicit package automatic overrides remain visible as `{automatic:true}`.' ), + NoExplicitNull, ] = None start_time: Annotated[ AwareDatetime | None, Field( description='ISO 8601 flight start time for this media buy (earliest package start_time). Avoids requiring buyers to compute min(packages[].start_time).' ), + NoExplicitNull, ] = None end_time: Annotated[ AwareDatetime | None, Field( description='ISO 8601 flight end time for this media buy (latest package end_time). Avoids requiring buyers to compute max(packages[].end_time).' ), + NoExplicitNull, ] = None creative_deadline: Annotated[ - AwareDatetime | None, Field(description='ISO 8601 timestamp for creative upload deadline') + AwareDatetime | None, + Field(description='ISO 8601 timestamp for creative upload deadline'), + NoExplicitNull, ] = None confirmed_at: Annotated[ AwareDatetime | None, @@ -726,6 +808,7 @@ class MediaBuy(IndicatorBearingResourceState): cancellation: Annotated[ Cancellation | None, Field(description="Cancellation metadata. Present only when status is 'canceled'."), + NoExplicitNull, ] = None revision: Annotated[ SchemaInt, @@ -734,13 +817,18 @@ class MediaBuy(IndicatorBearingResourceState): ge=1, ), ] - created_at: Annotated[AwareDatetime | None, Field(description='Creation timestamp')] = None - updated_at: Annotated[AwareDatetime | None, Field(description='Last update timestamp')] = None + created_at: Annotated[ + AwareDatetime | None, Field(description='Creation timestamp'), NoExplicitNull + ] = None + updated_at: Annotated[ + AwareDatetime | None, Field(description='Last update timestamp'), NoExplicitNull + ] = None context: Annotated[ context_1.ContextObject | None, Field( description='Opaque media-buy-level correlation data echoed unchanged from the create_media_buy request. Sellers MUST include persisted context on read surfaces when the media buy was created through AdCP with context, so buyers can reconcile seller-assigned media_buy_id values with their own tracking state. Sellers MAY omit context for media buys created outside AdCP or created without context. Sellers MUST NOT parse this object for business logic.' ), + NoExplicitNull, ] = None valid_actions: Annotated[ list[media_buy_valid_action.MediaBuyValidAction] | None, @@ -748,6 +836,7 @@ class MediaBuy(IndicatorBearingResourceState): deprecated=True, description='Flat-vocabulary actions the buyer can perform on this media buy in its current state. Eliminates the need for agents to internalize the state machine — the seller declares what is permitted right now. Deprecated in favor of `available_actions[]`, which carries mode, optional SLA, and a 3.2 change_term_id link. Sellers SHOULD populate both during the 3.x deprecation window; consumers MUST prefer `available_actions[]` when both are present. Removed in 4.0.', ), + NoExplicitNull, ] = None available_actions: Annotated[ list[ @@ -758,6 +847,7 @@ class MediaBuy(IndicatorBearingResourceState): Field( description="Structured per-buy resolution of the actions buyer can perform right now. Authoritative — divergence from product `allowed_actions[]` is expected because accepted proposal terms, current state, authorization, and governance delegation are buy-specific. Each entry carries the resolved mode, optional SLA commitment, and in 3.2 an optional change_term_id linking the accepted proposal right. Deprecated 3.1 terms_ref remains readable as an opaque compatibility pointer. Predicate queries via #4425's `requires` grammar address fields by dotted path, e.g. `available_actions.extend_flight.sla.response_max`. Absent SLA means no commitment, not zero commitment — callers composing duration predicates MUST also compose with `present: true` to avoid silently matching sellers who never declared one." ), + NoExplicitNull, ] = None webhook_activity: Annotated[ list[webhook_activity_record.WebhookActivityRecord] | None, @@ -765,12 +855,14 @@ class MediaBuy(IndicatorBearingResourceState): description='Recent webhook fires relevant to this buy for the calling principal, most-recent first. Includes per-buy delivery/health fires and account-anchored indicators.changed or creative.assignment_changed invalidations whose payload names this media_buy_id. Present only when include_webhook_activity was true and the seller surfaces this debug capability. Account-anchored records MUST include subscriber_id. Three-state presence and the 30-day retention floor follow snapshot-and-log.mdx § Webhook activity log pattern.', max_length=200, ), + NoExplicitNull, ] = None history: Annotated[ list[HistoryItem] | None, Field( description='Revision history entries, most recent first. Only present when include_history > 0 in the request. Each entry represents a state change or update to the media buy. Entries are append-only: sellers MUST NOT modify or delete previously emitted history entries. Callers MAY cache entries by revision number. Returns min(N, available entries) when include_history exceeds the total.' ), + NoExplicitNull, ] = None packages: Annotated[ Sequence[Package], @@ -778,7 +870,7 @@ class MediaBuy(IndicatorBearingResourceState): description='Packages within this media buy, augmented with creative approval status and optional delivery snapshots' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class GetMediaBuysResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): @@ -796,14 +888,17 @@ class GetMediaBuysResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): Field( description='Task-specific errors. A read may return media_buys plus nonfatal resource-state errors. If a pinned place target becomes unexecutable, sellers MUST include PLACE_TARGET_UNAVAILABLE with recovery=correctable, field pointing to the exact media_buys[N].packages[M].targeting_overlay.geo_places[_exclude][A].values[V] response path, and details containing media_buy_id, package_id, system, system_version, country, place_type, and value. The persisted target remains echoed until an intentional update replaces it.' ), + NoExplicitNull, ] = None pagination: Annotated[ pagination_response.PaginationResponse | None, Field(description='Pagination metadata for the media_buys array.'), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field(description='When true, this response contains simulated data from sandbox mode.'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_products_async_response_input_required.py b/src/adcp/types/domains/media_buy/get_products_async_response_input_required.py index 30c6eba03..cc700a75f 100644 --- a/src/adcp/types/domains/media_buy/get_products_async_response_input_required.py +++ b/src/adcp/types/domains/media_buy/get_products_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_async_response_input_required.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -25,14 +25,19 @@ class GetProductsInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why input is needed') + Reason | None, + Field(description='Reason code indicating why input is needed'), + NoExplicitNull, ] = None partial_results: Annotated[ list[product.Product] | None, Field(description='Partial product results that may help inform the clarification'), + NoExplicitNull, ] = None suggestions: Annotated[ - list[str] | None, Field(description='Suggested values or options for the required input') + list[str] | None, + Field(description='Suggested values or options for the required input'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py b/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py index 32193d55f..1699e6a90 100644 --- a/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py +++ b/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import context as context_1 @@ -36,15 +36,19 @@ class GetProductsSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Custom curation queued; typical turnaround 10–30 minutes.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None estimated_completion: Annotated[ - AwareDatetime | None, Field(description='Estimated completion time for the search') + AwareDatetime | None, + Field(description='Estimated completion time for the search'), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_products_async_response_working.py b/src/adcp/types/domains/media_buy/get_products_async_response_working.py index b34f235a2..1f011b9e8 100644 --- a/src/adcp/types/domains/media_buy/get_products_async_response_working.py +++ b/src/adcp/types/domains/media_buy/get_products_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..core import context as context_1 @@ -20,18 +20,22 @@ class GetProductsWorking(AdCPBaseModel): percentage: Annotated[ StrictFloat | None, Field(description='Progress percentage of the search operation', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ str | None, Field( description="Current step in the search process (e.g., 'searching_inventory', 'validating_availability')" ), + NoExplicitNull, ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the search process') + SchemaInt | None, + Field(description='Total number of steps in the search process'), + NoExplicitNull, ] = None step_number: Annotated[ - SchemaInt | None, Field(description='Current step number (1-indexed)') + SchemaInt | None, Field(description='Current step number (1-indexed)'), NoExplicitNull ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_products_rejected.py b/src/adcp/types/domains/media_buy/get_products_rejected.py index 4b1326afe..52e1a2d8f 100644 --- a/src/adcp/types/domains/media_buy/get_products_rejected.py +++ b/src/adcp/types/domains/media_buy/get_products_rejected.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_rejected.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations -from adcp.types._scalar import ScalarStr - from typing import Annotated, Literal +from adcp.types.base import NoExplicitNull +from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -46,6 +46,7 @@ class GetProductsRejected(AdcpVersionEnvelope, ProtocolEnvelope): max_length=20, min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_products_request.py b/src/adcp/types/domains/media_buy/get_products_request.py index 292ed4955..874479ba1 100644 --- a/src/adcp/types/domains/media_buy/get_products_request.py +++ b/src/adcp/types/domains/media_buy/get_products_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref, brand_ref @@ -76,6 +76,7 @@ class Refine2(AdCPBaseModel): Field( description="'include' (default): return this product with updated pricing and data. 'omit': exclude this product from the response. 'more_like_this': find additional products similar to this one (the original is also returned). Optional — when omitted, the seller treats the entry as action: 'include'." ), + NoExplicitNull, ] = Action.include ask: Annotated[ str | None, @@ -83,6 +84,7 @@ class Refine2(AdCPBaseModel): description="What the buyer is asking for on this product. For 'include': specific changes to request (e.g., 'add 16:9 format'). For 'more_like_this': what 'similar' means (e.g., 'same audience but video format'). Ignored when action is 'omit'.", min_length=1, ), + NoExplicitNull, ] = None @@ -107,6 +109,7 @@ class Refine3(AdCPBaseModel): Field( description="'include' (default): return this proposal with updated allocations and pricing. 'omit': exclude this proposal from the response. 'finalize': request firm pricing and inventory hold. New callers use refine_proposals with action revise for a draft successor or action finalize for a committed held successor; terminal feedback is available through decline_proposals.\n\nLegacy finalize is exclusive within the parent `refine[]` array: see the array-level description for the finalize-exclusivity rule (mixing finalize with non-finalize entries is rejected) and multi-finalize atomicity contract." ), + NoExplicitNull, ] = Action9.include ask: Annotated[ str | None, @@ -114,6 +117,7 @@ class Refine3(AdCPBaseModel): description="What the buyer is asking for on this proposal (e.g., 'shift more budget toward video', 'reduce total by 10%'). Ignored when action is omit.", min_length=1, ), + NoExplicitNull, ] = None @@ -213,6 +217,7 @@ class GetProductsRequest(AdcpRequest, AdcpVersionEnvelope): min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$', ), + NoExplicitNull, ] = None buying_mode: Annotated[ BuyingMode, @@ -225,6 +230,7 @@ class GetProductsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="Natural language description of campaign requirements. Required when buying_mode is 'brief'. Must not be provided when buying_mode is 'wholesale' or 'refine'. Buyers SHOULD use structured fields for every requirement that can be expressed structurally, and reserve brief prose for goals, context, preferences, and requirements without a structured representation. Sellers MUST apply explicit hard requirements stated in the brief even when the buyer did not duplicate them in a structured field. When a seller translates hard prose into structured targeting that materially affects product eligibility, pricing, or forecasting, it MUST confirm that interpretation once in GetProductsResponse.targeting_resolution.brief_targeting; otherwise confirmation remains a best practice. If hard prose contradicts a structured field, sellers MUST reject the request with INVALID_REQUEST rather than choose one interpretation or return an unexplained empty result." ), + NoExplicitNull, ] = None refine: Annotated[ list[Refine] | None, @@ -232,30 +238,35 @@ class GetProductsRequest(AdcpRequest, AdcpVersionEnvelope): description="Array of change requests for iterating on products and proposals from a previous get_products response. Each entry declares a scope (request, product, or proposal) and what the buyer is asking for. Only valid when buying_mode is 'refine'. The seller responds to each entry via refinement_applied in the response, matched by position.\n\nFinalize-exclusivity rule: if any entry has `action: 'finalize'`, ALL entries in the array MUST be proposal-scoped with `action: 'finalize'` — mixing finalize entries with `include`/`omit` entries or with request- / product-scoped entries MUST be rejected by the seller with `INVALID_REQUEST`. Finalize is a commit, not a refinement; the buyer expressing intent to commit means refinements have already converged. Buyers needing to refine AND commit in close succession sequence the calls: first a refine call (no finalize), then a finalize call against the resulting `proposal_id`(s).\n\nMulti-finalize semantics: multiple finalize entries against different `proposal_id` values in a single call are allowed and MUST be **atomic at the observation point** — sellers MUST NOT return a success response unless every named proposal has both completed and been persisted as committed. Pre-commit validation runs before any side-effects (inventory pull, terms lock, governance attestation); if any proposal fails validation, the seller MUST reject the entire call without committing any of the named proposals. There is no rollback operation in the spec — an `unfinalize` would itself be a new mutation surface; the atomicity guarantee runs entirely on the seller's pre-commit validation gate, not on post-commit reversal. Sellers that cannot guarantee atomic pre-commit validation MUST reject multi-finalize arrays with `MULTI_FINALIZE_UNSUPPORTED` (preferred — distinguishes seller-side capability gap from a malformed request) or `INVALID_REQUEST` (acceptable fallback for sellers on a pre-3.1 error catalog). If a mid-commit failure occurs *after* validation passed but before all proposals persist (e.g., a downstream ad server fails between commits one and two), the seller MUST return `INTERNAL_ERROR` with `refinement_applied[]` carrying per-position outcomes — the spec does NOT define a recovery path for this case, and buyers SHOULD treat the resulting state as undefined and re-read via `get_media_buys` / equivalent before retrying. Buyers MUST NOT assume multi-finalize support without a successful first attempt — there is no capability flag for this; the failure response is the discovery surface. Buyers whose intent specifically requires atomic commit (e.g., budget-shared proposals where one finalizing without the other is incoherent) MUST be prepared to abandon the intent if the seller returns `MULTI_FINALIZE_UNSUPPORTED` — there is no recovery for that loss of buyer intent beyond sequencing single-finalize calls and accepting the looser commit guarantee.", min_length=1, ), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Brand reference for product discovery context. Resolved to full brand identity at execution time.' ), + NoExplicitNull, ] = None acceptance_context: Annotated[ acceptance_context_1.AcceptanceContext | None, Field( description='Structured campaign and advertiser facts for seller acceptance-policy preflight. Sellers may infer omitted facts from brand and brief, but uncertainty never implies acceptance.' ), + NoExplicitNull, ] = None catalog: Annotated[ catalog_1.Catalog | None, Field( description='Catalog of items the buyer wants to promote. The seller matches catalog items against its inventory and returns products where matches exist. Supports all catalog types: a job catalog finds job ad products, a product catalog finds sponsored product slots. Reference a synced catalog by catalog_id, or provide inline items.' ), + NoExplicitNull, ] = None account: Annotated[ account_ref.AccountReference | None, Field( description="Account for product lookup. Returns products with pricing specific to this account's rate card. The account must already be provisioned: a reference that does not resolve returns ACCOUNT_NOT_FOUND rather than public products, unless the seller provisions lazily. Omit account (and send brand for discovery context) until sync_accounts succeeds. See /docs/accounts/overview#account-references-before-provisioning." ), + NoExplicitNull, ] = None preferred_delivery_types: Annotated[ list[delivery_type.DeliveryType] | None, @@ -263,36 +274,42 @@ class GetProductsRequest(AdcpRequest, AdcpVersionEnvelope): description='Delivery types the buyer prefers, in priority order. Unlike filters.delivery_type which excludes non-matching products, this signals preference for curation — the publisher may still include other delivery types when they match the brief well.', min_length=1, ), + NoExplicitNull, ] = None filters: Annotated[ product_filters.ProductFilters | None, Field( description="Offer filters. Valid in brief, wholesale, and refine modes. In every mode, sellers MUST exclude products that do not satisfy them: brief controls curation, wholesale controls feed behavior, and refine controls iteration, but none changes filter semantics. On refine, presence is the complete replacement filter state; when omitted, each referenced product's bound discovery constraints remain in force. Targeting-like legacy fields remain accepted during migration but are deprecated in favor of targeting_overlay and required_overlay_support." ), + NoExplicitNull, ] = None targeting_overlay: Annotated[ targeting.TargetingOverlay | None, Field( description="Concrete delivery constraints the buyer expects to carry into create_media_buy. Buyers SHOULD use this field instead of putting equivalent exact targeting only in brief prose. Sellers evaluate these constraints during discovery and scope returned pricing and forecasts to the effective targeting. If a product cannot honor the request exactly, the seller may omit it or return a request-scoped configured product with Product targeting_resolution modifications. Absence of Product targeting_resolution means exact acceptance of this structured overlay; it does not confirm how targeting in the brief was interpreted. On refine, presence is complete replacement state for returned configurations; when omitted, each referenced product's bound targeting remains in force." ), + NoExplicitNull, ] = None media_buy_frequency_cap: Annotated[ media_buy_frequency_cap_1.MediaBuyFrequencyCap | None, Field( description='Concrete aggregate max-impression cap intended for the MediaBuy root. Every returned product must participate in one shared counter for this exact value. Legacy proposals echo it as proposals[].frequency_cap; direct buyers repeat it at purchase.' ), + NoExplicitNull, ] = None required_overlay_support: Annotated[ targeting_overlay_requirements.TargetingOverlayRequirements | None, Field( description="Minimum product-scoped targeting dimensions the buyer must be able to set independently on packages later. This requests selectable capability, not current targeting values, value-specific availability or forecasts, and not one product per possible value. A requirement true matches support true or any valid support object; an object requirement matches support true or an object containing every requested boolean and requested array subset. The backward-compatible daypart_targets support true is the exception: it satisfies inventory_local requirements only, not iana requirements. Unrequested object fields and numeric limits do not participate. Missing or unknown requirement fields do not match. Seller limit fields are response-only and cannot be requested here. On refine, presence is complete replacement state; when omitted, each referenced product's bound future-support requirements remain in force." ), + NoExplicitNull, ] = None required_media_buy_support: Annotated[ media_buy_support_requirements.ProductMediaBuySupportRequirements | None, Field( description='Minimum product participation in shared MediaBuy-level controls. For a frequency cap, every returned product must be composable in one seller-maintained aggregate counter for every requested per unit. On refine, presence is complete replacement state.' ), + NoExplicitNull, ] = None property_list: Annotated[ property_list_ref.PropertyListReference | None, @@ -300,6 +317,7 @@ class GetProductsRequest(AdcpRequest, AdcpVersionEnvelope): deprecated=True, description='DEPRECATED discovery-only property filter. On compact discovery tasks use criteria.offer_filters.property_list to preserve product eligibility. Use targeting_overlay.property_list only when the buyer intends a delivery constraint, or required_overlay_support.property_list for future selectability. A facade MUST NOT convert this discovery filter into delivery targeting.', ), + NoExplicitNull, ] = None fields: Annotated[ list[product_fields.ProductResponseField | Fields] | None, @@ -307,42 +325,49 @@ class GetProductsRequest(AdcpRequest, AdcpVersionEnvelope): description='Specific product fields to include in the response. When omitted, all fields are returned. Use for lightweight discovery calls where only a subset of product data is needed. product_id and name are always included. `format_ids` is a deprecated 3.x compatibility projection; new integrations request canonical `format_options`. Safety-critical request-specific fields override projection: Product.targeting_resolution and expires_at MUST be included whenever the seller returns modifications, overlay_support MUST be included when required_overlay_support was requested, media_buy_support MUST be included when required_media_buy_support or media_buy_frequency_cap was requested, list_applications MUST be included when a property or collection list is in the effective targeting, and audience_evidence_selections MUST be included when filters.audience_evidence_requirements affects eligibility or ranking. fields controls the optional audience_evidence payload, not either decision receipt. Response-level brief targeting confirmation is not a projected product field.', min_length=1, ), + NoExplicitNull, ] = None time_budget: Annotated[ duration.Duration | None, Field( description='Maximum time the buyer will commit to this request. The seller returns the best results achievable within this budget and does not start processes (human approvals, expensive external queries) that cannot complete in time. When omitted, the seller decides timing.' ), + NoExplicitNull, ] = None push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Optional webhook configuration for async terminal completion/failure notifications on curated discovery. Meaningful only for `buying_mode: "brief"` and `buying_mode: "refine"` requests that enter the async lifecycle. Submitted envelopes with `task_id` remain pollable through `get_task_status` (legacy `tasks/get`) whether or not this field is present. If a brief/refine request includes this field and the seller returns a Submitted envelope, the seller MUST deliver at least the terminal completion/failure notification to the configured URL; intermediate progress notifications are MAY. If the seller cannot honor the webhook channel, it MUST reject the request with a structured error instead of silently accepting. This field does not change wholesale timing semantics: sellers MUST NOT route `buying_mode: "wholesale"` requests through the async/Submitted arm or emit async delivery solely because `push_notification_config` is present; partial wholesale completion is reported via `incomplete[]`.' ), + NoExplicitNull, ] = None pagination: Annotated[ pagination_request.PaginationRequest | None, Field( description="Cursor-based pagination controls for get_products. Valid in all buying modes. In brief mode, pagination bounds the seller's returned products[] for the curated answer to the brief and is not an exhaustive catalog-enumeration contract. In refine mode, pagination bounds the refined products[] result implied by refine[] and filters; proposals may accompany a page as plan metadata but are not independently counted by this pagination envelope. In wholesale mode, pagination walks the wholesale product feed and may be combined with wholesale feed versioning." ), + NoExplicitNull, ] = None if_wholesale_feed_version: Annotated[ str | None, Field( description="Opaque wholesale_feed_version token returned by a prior wholesale-mode get_products response from this agent. Only valid when buying_mode is wholesale. When provided, the seller compares against its current wholesale product feed version for the buyer's cache_scope and MAY return an unchanged: true response (with products omitted) if nothing has changed. The token is scope-keyed: buyers cache `(cache_scope, wholesale_feed_version)` pairs. Scoping dimensions: (agent, buying_mode, filters, targeting_overlay, media_buy_frequency_cap, required_overlay_support, required_media_buy_support, deprecated property_list, catalog) for cache_scope: 'public'; that tuple plus account identity for cache_scope: 'account'. pagination.cursor is NOT part of the scoping tuple. Backward-compatible: pre-v3.1 agents that ignore this field simply return the full payload, same as the unchanged-server path. See specs/wholesale-feed-webhooks.md for the full sync pattern." ), + NoExplicitNull, ] = None if_pricing_version: Annotated[ str | None, Field( description="Opaque pricing_version token from a prior get_products response. MUST only be sent together with if_wholesale_feed_version — pricing version has no structural baseline to compare against on its own. Evaluation order: (1) if_wholesale_feed_version mismatch → seller returns the full payload (pricing is implicitly stale); (2) if_wholesale_feed_version matches but if_pricing_version mismatches → seller returns the full payload so the buyer sees updated pricing_options; (3) both match → seller MAY return unchanged: true. Agents that don't track pricing separately ignore if_pricing_version and fall back to if_wholesale_feed_version semantics. Useful for storefronts that re-price compositions far more often than they re-render product mirrors." ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None required_policies: Annotated[ list[str] | None, Field( description='Registry policy IDs that the buyer requires to be enforced for products in this response. Sellers filter products to only those that comply with or already enforce the requested policies.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_products_response.py b/src/adcp/types/domains/media_buy/get_products_response.py index 06e34d9e9..81608c267 100644 --- a/src/adcp/types/domains/media_buy/get_products_response.py +++ b/src/adcp/types/domains/media_buy/get_products_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool, StringConstraints @@ -43,8 +43,9 @@ class Extensions(AdCPBaseModel): Field( description='Semantic version of the extension definition. Distinct from the digest — version is human-readable; digest is the integrity check.' ), + NoExplicitNull, ] = None - description: str | None = None + description: Annotated[str | None, NoExplicitNull] = None class Suggestion(ScalarStr): @@ -77,6 +78,7 @@ class RefinementApplied1(AdCPBaseModel): Field( description="Seller explanation of what was done, what couldn't be done, or why. Recommended when status is 'partial' or 'unable'." ), + NoExplicitNull, ] = None @@ -102,6 +104,7 @@ class RefinementApplied2(AdCPBaseModel): Field( description="Seller explanation of what was done, what couldn't be done, or why. Recommended when status is 'partial' or 'unable'." ), + NoExplicitNull, ] = None @@ -127,6 +130,7 @@ class RefinementApplied3(AdCPBaseModel): Field( description="Seller explanation of what was done, what couldn't be done, or why. Recommended when status is 'partial' or 'unable'." ), + NoExplicitNull, ] = None @@ -162,6 +166,7 @@ class IncompleteItem(AdCPBaseModel): Field( description='How much additional time is expected to resolve this scope. Allows the buyer to decide whether to make a new attempt with a larger time_budget; its presence is not an automatic-retry instruction.' ), + NoExplicitNull, ] = None @@ -187,12 +192,14 @@ class ExcludedBy(AdCPBaseModel): Field( description='Optional list of the specific filter values that contributed to exclusions, when meaningful. For `required_metrics`: the metric names that excluded products (strings). For `required_vendor_metrics`: the vendor/metric pin entries (objects). Item shape is filter-specific; the schema admits string OR object items. Buyers without filter-specific knowledge SHOULD treat as opaque.' ), + NoExplicitNull, ] = None notes: Annotated[ str | None, Field( description="Optional human-readable note about why this filter narrowed the set (e.g., 'no products in this brief support DV viewability at the requested threshold')." ), + NoExplicitNull, ] = None @@ -205,6 +212,7 @@ class FilterDiagnostics(AdCPBaseModel): Field( description="How `excluded_by[*].count` values are computed across multiple filters. `only`: counts products that would have been included if not for THIS filter alone (deterministic; the right value for 'which filter killed my result set' triage — recommended when feasible). `any`: counts products excluded by ANY filter (so multiple filters' counts may overlap and sum to more than `total_candidates`). `approximate`: sellers SHOULD use this when their pipeline can't cleanly attribute exclusions to a single filter. Buyers SHOULD inspect `semantics` before doing arithmetic on counts." ), + NoExplicitNull, ] = None total_candidates: Annotated[ SchemaInt | None, @@ -212,12 +220,14 @@ class FilterDiagnostics(AdCPBaseModel): description='Number of products the seller considered before applying `filters`. Baseline for interpreting per-filter exclusion counts. Approximate — sellers MAY return a sampled or capped count when their candidate pool is large. Optional; sellers whose baseline candidate set size is sensitive (revealing market posture or competitive density) MAY omit this while still emitting `excluded_by`.', ge=0, ), + NoExplicitNull, ] = None excluded_by: Annotated[ dict[str, ExcludedBy] | None, Field( description="Per-filter exclusion counts, keyed by the filter property name as it appears in the request's `filters` object (e.g., `pricing_currencies`, `required_metrics`, `required_vendor_metrics`, `required_geo_targeting`, `budget_range`). Values are objects carrying `count` and optional filter-specific detail. Only filters that actually narrowed the set need appear here; absence of a key means that filter did not exclude anything (or was not in the request)." ), + NoExplicitNull, ] = None @@ -231,29 +241,35 @@ class GetProductsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): extra='allow', ) products: Annotated[ - list[product.Product] | None, Field(description='Array of matching products') + list[product.Product] | None, + Field(description='Array of matching products'), + NoExplicitNull, ] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description='Request-level confirmation of structured hard targeting inferred from the brief. Sellers MUST include this when their structured interpretation of hard prose materially affects product eligibility, pricing, or forecasting; otherwise inclusion is a best practice. Omitted when no hard targeting was inferred from the brief.' ), + NoExplicitNull, ] = None extensions: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^https?://[^@]+@sha256:[a-f0-9]{64}$')], Extensions] | None, Field( description='Bundled platform-extension definitions referenced by any product in `products`. Keyed by `@` (e.g., `https://creative.adcontextprotocol.org/translated/meta/extensions/meta_pixel@sha256:abc...`). When present, lets buyers resolve `platform_extensions` references on product format declarations without a separate fetch. Buyer SDKs cache by URI@digest; subsequent get_products responses MAY omit definitions the buyer already has cached and rely on the digest match. Each value is an extension definition with `extends` (the canonical concept it extends, e.g., `tracking`), `fields` (the schema for additional fields the extension contributes), `version`, and optional `description`.' ), + NoExplicitNull, ] = None proposals: Annotated[ list[proposal.Proposal] | None, Field( description='Optional legacy proposed media plans. When the request carries media_buy_frequency_cap, every returned proposal echoes the bound value as proposal.frequency_cap. Buyers may refine or execute a committed proposal by ID.' ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field(description='Task-specific errors and warnings (e.g., product filtering issues)'), + NoExplicitNull, ] = None reason: Annotated[ str | None, @@ -262,6 +278,7 @@ class GetProductsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): max_length=2000, min_length=1, ), + NoExplicitNull, ] = None suggestions: Annotated[ list[Suggestion] | None, @@ -269,24 +286,28 @@ class GetProductsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): description='Actionable alternatives available only on the GetProductsRejected arm.', max_length=20, ), + NoExplicitNull, ] = None property_list_applied: Annotated[ StrictBool | None, Field( description='[AdCP 3.0] Indicates whether deprecated top-level property_list filtering was applied. True if the agent filtered products based on the provided property_list; every returned product also carries the corresponding property/include list_applications receipt. Absent or false if property_list was not provided or not supported by this agent.' ), + NoExplicitNull, ] = None catalog_applied: Annotated[ StrictBool | None, Field( description='Whether the seller filtered results based on the provided catalog. True if the seller matched catalog items against its inventory. Absent or false if no catalog was provided or the seller does not support catalog matching.' ), + NoExplicitNull, ] = None refinement_applied: Annotated[ list[RefinementApplied] | None, Field( description="Seller's response to each change request in the refine array, matched by position. Each entry acknowledges whether the corresponding ask was applied, partially applied, or unable to be fulfilled. MUST contain the same number of entries in the same order as the request's refine array. Only present when the request used buying_mode: 'refine'. Each entry MUST echo the request entry's scope and — for product and proposal scopes — the matching id field (product_id or proposal_id), so orchestrators can cross-validate alignment." ), + NoExplicitNull, ] = None incomplete: Annotated[ list[IncompleteItem] | None, @@ -294,6 +315,7 @@ class GetProductsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): description="Declares what the seller could not finish within the buyer's time_budget or due to internal limits while still returning a usable response. Each entry identifies a scope that is missing or partial. Absent when the response is fully complete. This field does not classify the condition as retryable; retryability is carried by error.recovery on the error channel.", min_length=1, ), + NoExplicitNull, ] = None filter_diagnostics: Annotated[ FilterDiagnostics | None, @@ -312,40 +334,47 @@ class GetProductsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): } ], ), + NoExplicitNull, ] = None pagination: Annotated[ pagination_response.PaginationResponse | None, Field( description="Cursor metadata for paginated get_products responses. In brief/refine mode, continuation pages bound returned products[] for the seller's curated or refined answer; proposals may accompany a page as plan metadata but are not independently counted by this pagination envelope, and pagination does not convert the response into an exhaustive feed contract. In wholesale mode, continuation pages walk the wholesale product feed." ), + NoExplicitNull, ] = None wholesale_feed_version: Annotated[ str | None, Field( description="Opaque token representing the version of the wholesale product feed state used to compose this response. Sellers that implement conditional-fetch (if_wholesale_feed_version) MUST return this on every wholesale-mode response so buyers can cache and probe later. Buyers MUST treat the value as opaque — no format, no ordering, no inspection. The token is scope-keyed: it describes a version for the cache_scope declared on this response, NOT a global agent version. A buyer caches `(cache_scope, wholesale_feed_version)` pairs and presents the matching token on the next request. Scoping dimensions: (agent, buying_mode, filters, targeting_overlay, media_buy_frequency_cap, required_overlay_support, required_media_buy_support, deprecated property_list, catalog) for cache_scope: 'public'; that tuple plus account_id for cache_scope: 'account'. pagination.cursor is NOT part of the scoping tuple. See specs/wholesale-feed-webhooks.md for the full cache layering model." ), + NoExplicitNull, ] = None pricing_version: Annotated[ str | None, Field( description='Opaque token representing the version of the pricing layer, including product pricing_options and nested signal_targeting_options pricing_options. When the seller supports independent pricing versioning, pricing_version changes when prices move but wholesale_feed_version changes only when structure/metadata moves. Same cache_scope keying as wholesale_feed_version. Sellers not separating these MAY omit pricing_version and use wholesale_feed_version for both.' ), + NoExplicitNull, ] = None cache_scope: Annotated[ CacheScope | None, Field( description="Declares whether the wholesale_feed_version and pricing_version on this response describe a universal layer or an account-specific overlay. REQUIRED on every 3.1+ response (the 3.1 schema enforces this — the safety property of the two-layer cache model depends on it). 'public': this response describes the seller's published rate card; the buyer MAY dedupe under (agent, buying_mode, filters, targeting_overlay, media_buy_frequency_cap, required_overlay_support, required_media_buy_support, deprecated property_list, catalog) without scoping by account. 'account': this response includes account-specific overrides; the buyer MUST cache the version under that tuple plus account_id. When the request did NOT include `account`, the seller MUST return `cache_scope: 'public'`. When the request included `account`, the seller MUST return either: 'public' (this account prices off the public rate card — buyer dedupes) or 'account' (account-specific overrides exist — buyer caches under the account key). Sellers MAY return 'public' on an account-scoped request that previously had overrides — buyers SHOULD interpret this as a downgrade and drop their account-overlay. Without schema-required cache_scope, a seller silently omitting the field on an account-scoped response would cause buyers to mis-key the cache and serve account-overlay payloads to other accounts. **Backward-compatibility note for 3.1 validators:** SDKs that validate strictly against the 3.1 schema MUST select the validator based on the server-declared `adcp_version` (release-precision version negotiation, 3.1). For responses with `adcp_version` starting `3.0`, the 3.1 cache_scope-required constraint MUST be relaxed." ), + NoExplicitNull, ] = CacheScope.public unchanged: Annotated[ Literal[True] | None, Field( description="Present and `true` ONLY on wholesale-mode responses when the request carried if_wholesale_feed_version (and/or if_pricing_version) matching the seller's current version for the buyer's cache_scope, in which case products[] MUST be omitted; wholesale_feed_version (echoed), cache_scope (echoed), and pricing_version (echoed when used) MUST still be present. Buyers receiving unchanged: true MUST NOT mutate their local wholesale product mirror. **One shape per state:** sellers MUST NOT emit `unchanged: false` — the absence of the field IS the signal that the response carries products. Two shapes ({ unchanged: false, products: [...] } vs. { products: [...] }) for the same state would let some sellers always emit the field and some never would, creating an inconsistency the wire shouldn't carry. **Cross-scope isolation:** the comparator that decides `unchanged` MUST be keyed on `(cache_scope, wholesale_feed_version)`, not on the token value alone. A seller MUST NOT emit `unchanged: true` when it resolves the request to a different `cache_scope` than the one whose token the buyer echoed in `if_wholesale_feed_version` (and/or `if_pricing_version`): because the token is scope-keyed, a value minted for `cache_scope: 'public'` cannot match the seller's current token for `cache_scope: 'account'` (or vice-versa), so such a request MUST return the full feed for the resolved scope with that scope's own token." ), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field(description='When true, this response contains simulated data from sandbox mode.'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_products_targeting_resolution.py b/src/adcp/types/domains/media_buy/get_products_targeting_resolution.py index 91dc300cb..21b50e6a1 100644 --- a/src/adcp/types/domains/media_buy/get_products_targeting_resolution.py +++ b/src/adcp/types/domains/media_buy/get_products_targeting_resolution.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_targeting_resolution.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import ext as ext_1 @@ -23,4 +23,4 @@ class ProductDiscoveryTargetingResolution(AdCPBaseModel): description="Structured delivery constraints the seller resolved from explicit hard targeting in the brief. This is the seller's literal interpretation, not a counteroffer: products that cannot honor it are omitted. Sellers MUST emit this confirmation when the interpretation materially affects product eligibility, pricing, or forecasting; otherwise it is a best practice. Unchanged structured targeting_overlay values are not repeated." ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_reporting_status_request.py b/src/adcp/types/domains/media_buy/get_reporting_status_request.py index 5c1c57281..140437f82 100644 --- a/src/adcp/types/domains/media_buy/get_reporting_status_request.py +++ b/src/adcp/types/domains/media_buy/get_reporting_status_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_reporting_status_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -53,6 +53,7 @@ class GetReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): max_length=100, min_length=1, ), + NoExplicitNull, ] = None delivery_config_ids: Annotated[ list[DeliveryConfigId] | None, @@ -61,6 +62,7 @@ class GetReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): max_length=16, min_length=1, ), + NoExplicitNull, ] = None feed_purposes: Annotated[ list[reporting_delivery_offering.ReportingFeedPurpose] | None, @@ -68,12 +70,14 @@ class GetReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): description='Optional summary/periods feed filter. The response echoes exact resolved configuration generations so this never creates an opaque aggregate.', min_length=1, ), + NoExplicitNull, ] = None period: Annotated[ Period | None, Field( description="Half-open summary/periods horizon. Omit for the seller's documented operational default horizon; the response always echoes the evaluated scope." ), + NoExplicitNull, ] = None health: Annotated[ list[reporting_health.ReportingHealth] | None, @@ -81,10 +85,11 @@ class GetReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): description='Periods-view result filter only; it never changes summary health.', min_length=1, ), + NoExplicitNull, + ] = None + finality: Annotated[ + list[reporting_finality.ReportingFinality] | None, Field(min_length=1), NoExplicitNull ] = None - finality: Annotated[list[reporting_finality.ReportingFinality] | None, Field(min_length=1)] = ( - None - ) reporting_revision_id: Annotated[ str | None, Field( @@ -93,6 +98,7 @@ class GetReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None changes_after: Annotated[ str | None, @@ -101,12 +107,14 @@ class GetReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): max_length=2048, min_length=1, ), + NoExplicitNull, ] = None pagination: Annotated[ pagination_request.PaginationRequest | None, Field( description='Periods or revision-view pagination. Cursors are bound to the authenticated caller, account, filters, and ledger snapshot.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/get_reporting_status_response.py b/src/adcp/types/domains/media_buy/get_reporting_status_response.py index 299fce0b3..6a7922c9a 100644 --- a/src/adcp/types/domains/media_buy/get_reporting_status_response.py +++ b/src/adcp/types/domains/media_buy/get_reporting_status_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_reporting_status_response.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator from ..core import context as context_1 @@ -53,6 +53,7 @@ class ObligationCounts(AdCPBaseModel): description="Obligations in this scope whose elapsed expected period has passed its consumer-status deadline — expected_at plus automated_recovery_window_seconds — without a current consumer status from the authenticated caller. A chain with any unsuperseded leaf counts as current whatever that leaf says; only an empty chain is pending. Because it counts obligations, a period the seller omitted entirely has no obligation and is not counted here — the buyer's independently derived denominator, not this field, remains the authority on omitted periods. It is a visibility count over the caller's own silence, never a health input: it MUST NOT change health, any other count, or seller-advertised reliability_statistics, and it overlaps the health counts rather than partitioning them. Required when the seller advertises consumer_status_task.", ge=0, ), + NoExplicitNull, ] = None @@ -80,7 +81,9 @@ class Scope(AdCPBaseModel): StrictBool, Field(description='True only when no new obligation can enter this evaluated scope.'), ] - media_buy_ids: list[reporting_coverage.ReportingMediaBuyId] | None = None + media_buy_ids: Annotated[ + list[reporting_coverage.ReportingMediaBuyId] | None, NoExplicitNull + ] = None all_accessible_media_buys: Annotated[ StrictBool, Field( @@ -113,7 +116,7 @@ class GetReportingStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve model_config = ConfigDict( extra='allow', ) - view: get_reporting_status_request.ReportingStatusView | None = None + view: Annotated[get_reporting_status_request.ReportingStatusView | None, NoExplicitNull] = None ledger_snapshot_id: Annotated[ str | None, Field( @@ -121,12 +124,14 @@ class GetReportingStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve max_length=255, min_length=1, ), + NoExplicitNull, ] = None ledger_as_of: Annotated[ AwareDatetime | None, Field( description='Exclusive observation boundary for ledger_snapshot_id. Revisions committed later appear only in a later reconciliation.' ), + NoExplicitNull, ] = None changes_checkpoint: Annotated[ str | None, @@ -135,23 +140,27 @@ class GetReportingStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve max_length=2048, min_length=1, ), + NoExplicitNull, ] = None account_id: Annotated[ str | None, Field(description='Resolved seller/storefront account identifier.', min_length=1), + NoExplicitNull, ] = None scope: Annotated[ Scope | None, Field( description='Exact denominator evaluated for summary or periods health. complete is valid only when scope_closed is true.' ), + NoExplicitNull, ] = None - health: reporting_health.ReportingHealth | None = None + health: Annotated[reporting_health.ReportingHealth | None, NoExplicitNull] = None coverage: Annotated[ reporting_coverage.ReportingCoverage | None, Field( description='Aggregated effective coverage for the exact selected scope. This remains independent of reporting health and finality so a fresh covered subset cannot look like complete campaign reporting.' ), + NoExplicitNull, ] = None data_through: Annotated[ AwareDatetime | None, @@ -164,46 +173,56 @@ class GetReportingStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve Field( description='Next obligation due time for an open scope. In a complete summary (view: summary, health: complete), the nearest future period start, strictly after ledger_as_of, across all active committed configuration generations in scope.delivery_config_generations. Sellers MUST populate it when such a scheduled period exists outside the closed evaluated scope, and omit it when none exists. This complete-scope projection applies only to summary responses. It is derived from the configuration schedule and does not represent an open obligation in the evaluated scope; its presence does not indicate that the scope is still open. Projecting it MUST NOT create, expose, lease, count, or alter an obligation whose period has not closed, or change obligation_counts, scope, or coverage. Obligation expected_at remains period.end plus schedule.delivery_sla; get_media_buy_delivery.next_expected_at remains the next webhook notification time.' ), + NoExplicitNull, ] = None - obligation_counts: ObligationCounts | None = None - issues: list[reporting_status_issue.ReportingStatusIssue] | None = None - periods: list[reporting_obligation.ReportingObligation] | None = None + obligation_counts: Annotated[ObligationCounts | None, NoExplicitNull] = None + issues: Annotated[list[reporting_status_issue.ReportingStatusIssue] | None, NoExplicitNull] = ( + None + ) + periods: Annotated[list[reporting_obligation.ReportingObligation] | None, NoExplicitNull] = None revisions: Annotated[ list[reporting_revision.ReportingRevision] | None, Field( description='Revision ledger records on this page. Pagination is over the flat union of obligations, revisions, adjustments, materializations, consumer status statements, revision receipts, and adjustment receipts, avoiding unbounded nested history.' ), + NoExplicitNull, ] = None adjustments: Annotated[ list[reporting_adjustment.ReportingAdjustment] | None, Field( description='Immutable post-official accounting corrections on this page. They preserve the original invoice-to-revision binding and are included in flat ledger pagination.' ), + NoExplicitNull, ] = None consumer_statuses: Annotated[ list[reporting_consumer_status.ReportingConsumerStatus] | None, Field( description="Authenticated caller's append-only reporting status history on this page. Current leaves are identified by obligation current_consumer_status_id or, for a missing seller obligation, by the supersession chain over configuration generation, report definition, and period. No other consumer's status is disclosed." ), + NoExplicitNull, ] = None adjustment_receipts: Annotated[ list[reporting_adjustment_receipt.ReportingAdjustmentReceipt] | None, Field( description='Authenticated Reconciled Billing outcomes for adjustments on this page.' ), + NoExplicitNull, + ] = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None + revision: Annotated[reporting_revision.ReportingRevision | None, NoExplicitNull] = None + materializations: Annotated[ + list[reporting_materialization.ReportingMaterialization] | None, NoExplicitNull ] = None - pagination: pagination_response.PaginationResponse | None = None - revision: reporting_revision.ReportingRevision | None = None - materializations: list[reporting_materialization.ReportingMaterialization] | None = None receipts: Annotated[ list[reporting_receipt.ReportingReceipt] | None, Field( description="Authenticated caller's durable reconciliation receipts. Receipts from another consumer principal are never disclosed." ), + NoExplicitNull, ] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None status: Status @model_validator(mode='after') diff --git a/src/adcp/types/domains/media_buy/list_creative_formats_request.py b/src/adcp/types/domains/media_buy/list_creative_formats_request.py index 5b22fa3ab..1a87b3c27 100644 --- a/src/adcp/types/domains/media_buy/list_creative_formats_request.py +++ b/src/adcp/types/domains/media_buy/list_creative_formats_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/list_creative_formats_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref @@ -33,6 +34,7 @@ class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Return only these specific named-format IDs (for example, from a 3.x get_products response). Use canonical-format discovery in 4.0.', min_length=1, ), + NoExplicitNull, ] = None asset_types: Annotated[ list[asset_content_type.AssetContentType] | None, @@ -40,39 +42,47 @@ class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): description="Filter to formats that include these asset types. For third-party tags, search for 'html' or 'javascript'. For published-post reference formats, search for 'published_post'. E.g., ['image', 'text'] returns formats with images and text, ['javascript'] returns formats accepting JavaScript tags.", min_length=1, ), + NoExplicitNull, ] = None max_width: Annotated[ SchemaInt | None, Field( description='Maximum width in pixels (inclusive). Returns formats where ANY render has width <= this value. For multi-render formats, matches if at least one render fits.' ), + NoExplicitNull, ] = None max_height: Annotated[ SchemaInt | None, Field( description='Maximum height in pixels (inclusive). Returns formats where ANY render has height <= this value. For multi-render formats, matches if at least one render fits.' ), + NoExplicitNull, ] = None min_width: Annotated[ SchemaInt | None, Field( description='Minimum width in pixels (inclusive). Returns formats where ANY render has width >= this value.' ), + NoExplicitNull, ] = None min_height: Annotated[ SchemaInt | None, Field( description='Minimum height in pixels (inclusive). Returns formats where ANY render has height >= this value.' ), + NoExplicitNull, ] = None is_responsive: Annotated[ StrictBool | None, Field( description='Filter for responsive formats that adapt to container size. When true, returns formats without fixed dimensions.' ), + NoExplicitNull, ] = None name_search: Annotated[ - str | None, Field(description='Search for formats by name (case-insensitive partial match)') + str | None, + Field(description='Search for formats by name (case-insensitive partial match)'), + NoExplicitNull, ] = None publisher_domain: Annotated[ str | None, @@ -81,18 +91,21 @@ class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): description="Deprecated compatibility filter for older 3.x callers. A compatibility implementation MAY project publisher-origin or community-catalog declarations obtained through the registry publisher lookup, but MUST NOT synthesize a publisher catalog from seller products. New callers use `GET /api/registry/publisher?domain=...` for publisher acceptance and `get_products` for this seller's deliverability. The pattern below is a syntactic floor, not an SSRF guard.", pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None property_id: Annotated[ property_id_1.PropertyId | None, Field( description="Filter to formats supported on the named property within the publisher's catalog. Resolves to a property in the publisher's `adagents.json` `properties[]`; the agent returns only `formats[]` entries whose `applies_to_property_ids` includes this property (or entries with no scope, which apply to all properties). Typically used in combination with `publisher_domain`." ), + NoExplicitNull, ] = None wcag_level: Annotated[ wcag_level_1.WcagLevel | None, Field( description='Filter to formats that meet at least this WCAG conformance level (A < AA < AAA)' ), + NoExplicitNull, ] = None disclosure_positions: Annotated[ list[disclosure_position.DisclosurePosition] | None, @@ -100,6 +113,7 @@ class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): description="Filter to formats that support all of these disclosure positions. When a format has disclosure_capabilities, match against those positions. Otherwise fall back to supported_disclosure_positions. Use to find formats compatible with a brief's compliance requirements.", min_length=1, ), + NoExplicitNull, ] = None disclosure_persistence: Annotated[ list[disclosure_persistence_1.DisclosurePersistence] | None, @@ -107,6 +121,7 @@ class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): description='Filter to formats where each requested persistence mode is supported by at least one position in disclosure_capabilities. Different positions may satisfy different modes. Use to find formats compatible with jurisdiction-specific persistence requirements (e.g., continuous for EU AI Act).', min_length=1, ), + NoExplicitNull, ] = None output_format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -114,6 +129,7 @@ class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): description="Filter to formats whose output_format_ids includes any of these format IDs. Returns formats that can produce these outputs — inspect each result's input_format_ids to see what inputs they accept.", min_length=1, ), + NoExplicitNull, ] = None input_format_ids: Annotated[ list[format_id.FormatReferenceStructuredObject] | None, @@ -121,6 +137,7 @@ class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): description="Filter to formats whose input_format_ids includes any of these format IDs. Returns formats that accept these creatives as input — inspect each result's output_format_ids to see what they can produce.", min_length=1, ), + NoExplicitNull, ] = None account: Annotated[ account_ref.AccountReference | None, @@ -128,7 +145,8 @@ class ListCreativeFormatsRequest(AdcpRequest, AdcpVersionEnvelope): deprecated=True, description="**DEPRECATED with `list_creative_formats` in 3.2. Removed at 4.0.** Use `get_products` with `account`; each `Product.format_options[]` lists the formats this seller can deliver for that account. *Legacy 3.x behavior:* scopes the returned formats to this account. Sellers that keep account-specific format catalogs (including sandbox accounts) return that account's formats; sellers with a single catalog MAY ignore this field.", ), + NoExplicitNull, ] = None - pagination: pagination_request.PaginationRequest | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/list_creative_formats_response.py b/src/adcp/types/domains/media_buy/list_creative_formats_response.py index e38f6bfd3..945755279 100644 --- a/src/adcp/types/domains/media_buy/list_creative_formats_response.py +++ b/src/adcp/types/domains/media_buy/list_creative_formats_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/list_creative_formats_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -35,11 +35,12 @@ class CreativeAgent(AdCPBaseModel): ), ] agent_name: Annotated[ - str | None, Field(description='Human-readable name for the creative agent') + str | None, Field(description='Human-readable name for the creative agent'), NoExplicitNull ] = None capabilities: Annotated[ list[creative_agent_capability.CreativeAgentCapability] | None, Field(description='Capabilities this creative agent provides'), + NoExplicitNull, ] = None @@ -60,6 +61,7 @@ class ListCreativeFormatsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv deprecated=True, description='Deprecated compatibility provenance. `publisher` means publisher-origin catalog; `aao_mirror` means community catalog; `agent_derived` is retained only to parse historical 3.x responses and MUST NOT be produced by a new 3.2 implementation because seller products are not publisher authority.', ), + NoExplicitNull, ] = None creative_agents: Annotated[ list[CreativeAgent] | None, @@ -67,15 +69,18 @@ class ListCreativeFormatsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv deprecated=True, description="Deprecated recursive discovery projection retained for historical 3.x responses. New buyers query the registry's canonical creative capability index and confirm candidates with get_adcp_capabilities; they do not recursively walk agent-provided lists.", ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field(description='Task-specific errors and warnings (e.g., format availability issues)'), + NoExplicitNull, ] = None - pagination: pagination_response.PaginationResponse | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None sandbox: Annotated[ StrictBool | None, Field(description='When true, this response contains simulated data from sandbox mode.'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/list_products_request.py b/src/adcp/types/domains/media_buy/list_products_request.py index 74a08a216..683830153 100644 --- a/src/adcp/types/domains/media_buy/list_products_request.py +++ b/src/adcp/types/domains/media_buy/list_products_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/list_products_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import brand_key, canonical_account_ref @@ -28,6 +29,7 @@ class ListProductsRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -37,6 +39,7 @@ class ListProductsRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str | None, @@ -46,6 +49,7 @@ class ListProductsRequest(AdcpRequest, AdcpVersionEnvelope): min_length=16, pattern='^[A-Za-z0-9_.:-]{16,255}$', ), + NoExplicitNull, ] = None context_id: Annotated[ str | None, @@ -53,35 +57,44 @@ class ListProductsRequest(AdcpRequest, AdcpVersionEnvelope): description='MCP compatibility field: servers ignore this value; A2A uses transport-native Message/Task contextId.', min_length=1, ), + NoExplicitNull, + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + governance_context: Annotated[ + str | None, Field(max_length=4096, min_length=1), NoExplicitNull ] = None - context: context_1.ContextObject | None = None - governance_context: Annotated[str | None, Field(max_length=4096, min_length=1)] = None push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Uniform per-call envelope field accepted for SDK compatibility. This does not register a wholesale feed subscription; durable product.* and wholesale_feed.bulk_change subscribers are registered through sync_accounts notification_configs.' ), + NoExplicitNull, ] = None account: Annotated[ canonical_account_ref.CanonicalAccountReference | None, Field( description='Account scope for pricing and availability. A natural-key account is the single brand source and MUST NOT be combined with top-level brand.' ), + NoExplicitNull, + ] = None + brand: Annotated[brand_key.BrandKey | None, NoExplicitNull] = None + criteria: Annotated[ + product_discovery_criteria.ProductDiscoveryCriteria | None, NoExplicitNull ] = None - brand: brand_key.BrandKey | None = None - criteria: product_discovery_criteria.ProductDiscoveryCriteria | None = None - fields: product_fields.ProductResponseFields | None = None - cursor: Annotated[str | None, Field(min_length=1)] = None - max_results: Annotated[SchemaInt | None, Field(ge=1, le=100)] = 25 + fields: Annotated[product_fields.ProductResponseFields | None, NoExplicitNull] = None + cursor: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + max_results: Annotated[SchemaInt | None, Field(ge=1, le=100), NoExplicitNull] = 25 if_feed_version: Annotated[ str | None, Field( description='Opaque feed version returned by a prior list_products response or wholesale product-feed webhook for the same cache scope and canonicalized selection. Used for repair and conditional reconciliation, not routine polling when webhooks are active.' ), + NoExplicitNull, ] = None if_pricing_version: Annotated[ str | None, Field( description='Opaque pricing version returned by a prior list_products response. Valid only with if_feed_version.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/media_buy/list_products_response.py b/src/adcp/types/domains/media_buy/list_products_response.py index 329429744..0fe3e2579 100644 --- a/src/adcp/types/domains/media_buy/list_products_response.py +++ b/src/adcp/types/domains/media_buy/list_products_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/list_products_response.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import canonical_product @@ -41,7 +41,7 @@ class IncompleteItem(AdCPBaseModel): ) scope: Scope description: Annotated[str, Field(min_length=1)] - estimated_wait: duration.Duration | None = None + estimated_wait: Annotated[duration.Duration | None, NoExplicitNull] = None class IncompleteItem3(IncompleteItem): @@ -65,6 +65,7 @@ class ListProductsResponse1(AdcpResponse, AdCPBaseModel): description='Cursor for the next page. Omitted when this is the last page.', min_length=1, ), + NoExplicitNull, ] = None feed_version: Annotated[ str, @@ -73,7 +74,9 @@ class ListProductsResponse1(AdcpResponse, AdCPBaseModel): ), ] pricing_version: Annotated[ - str | None, Field(description='Opaque version of the selected pricing layer.') + str | None, + Field(description='Opaque version of the selected pricing layer.'), + NoExplicitNull, ] = None cache_scope: CacheScope incomplete: Annotated[ @@ -82,10 +85,11 @@ class ListProductsResponse1(AdcpResponse, AdCPBaseModel): description='Usable partial response with explicitly missing product-list scopes.', min_length=1, ), + NoExplicitNull, ] = None - replayed: Literal[True] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class ListProductsResponse2(AdcpResponse, AdCPBaseModel): @@ -98,13 +102,14 @@ class ListProductsResponse2(AdcpResponse, AdCPBaseModel): description='Whether this response carries a product page or confirms that the selected feed has not changed.' ), ] = 'unchanged' - products: list[canonical_product.CanonicalProduct] | None = None + products: Annotated[list[canonical_product.CanonicalProduct] | None, NoExplicitNull] = None next_cursor: Annotated[ str | None, Field( description='Cursor for the next page. Omitted when this is the last page.', min_length=1, ), + NoExplicitNull, ] = None feed_version: Annotated[ str, @@ -113,7 +118,9 @@ class ListProductsResponse2(AdcpResponse, AdCPBaseModel): ), ] pricing_version: Annotated[ - str | None, Field(description='Opaque version of the selected pricing layer.') + str | None, + Field(description='Opaque version of the selected pricing layer.'), + NoExplicitNull, ] = None cache_scope: CacheScope incomplete: Annotated[ @@ -122,10 +129,11 @@ class ListProductsResponse2(AdcpResponse, AdCPBaseModel): description='Usable partial response with explicitly missing product-list scopes.', min_length=1, ), + NoExplicitNull, ] = None - replayed: Literal[True] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None ListProductsResponse = Annotated[ diff --git a/src/adcp/types/domains/media_buy/log_event_request.py b/src/adcp/types/domains/media_buy/log_event_request.py index 0daa8bc3a..cf10fbdcd 100644 --- a/src/adcp/types/domains/media_buy/log_event_request.py +++ b/src/adcp/types/domains/media_buy/log_event_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/log_event_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -28,6 +29,7 @@ class LogEventRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="Test event code for validation without affecting production data. Events with this code appear in the platform's test events UI." ), + NoExplicitNull, ] = None events: Annotated[ list[event.Event], Field(description='Events to log', max_length=10000, min_length=1) @@ -41,5 +43,5 @@ class LogEventRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/media_buy_commitment_response.py b/src/adcp/types/domains/media_buy/media_buy_commitment_response.py index fe7103178..869389c64 100644 --- a/src/adcp/types/domains/media_buy/media_buy_commitment_response.py +++ b/src/adcp/types/domains/media_buy/media_buy_commitment_response.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/media_buy_commitment_response.json -# timestamp: 2026-10-03T14:21:15+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from ..core import canonical_media_buy_action @@ -48,9 +48,9 @@ class MediaBuyCommitmentResponse2(AdCPBaseModel): ) status: Literal['failed'] = 'failed' errors: Annotated[list[error.Error], Field(min_length=1)] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class MediaBuyCommitmentResponse3(AdCPBaseModel): @@ -59,11 +59,11 @@ class MediaBuyCommitmentResponse3(AdCPBaseModel): ) status: Literal['submitted'] = 'submitted' task_id: Annotated[str, Field(min_length=1)] - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class Warning(Warning_1): @@ -73,6 +73,7 @@ class Warning(Warning_1): description='Closed warning vocabulary for the negotiated AdCP release. A non-blocking condition observed while an operation still succeeded. New values are added through normal protocol releases; buyers validate against the negotiated release.', title='Warning Code', ), + NoExplicitNull, ] = None @@ -104,9 +105,10 @@ class MediaBuyCommitmentResponse1(AdCPBaseModel): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None revision: Annotated[SchemaInt, Field(ge=1)] - media_buy_status: media_buy_status_1.MediaBuyStatus | None = None + media_buy_status: Annotated[media_buy_status_1.MediaBuyStatus | None, NoExplicitNull] = None confirmed_at: AwareDatetime | None = None accepted_proposal: AcceptedProposal purchase_bindings: Annotated[ @@ -123,10 +125,11 @@ class MediaBuyCommitmentResponse1(AdCPBaseModel): description='Non-blocking observations about this completed commitment. The MediaBuy was still created or amended exactly as represented. Continuing conditions also appear as indicators on get_media_buys.', min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None MediaBuyCommitmentResponse = Annotated[ diff --git a/src/adcp/types/domains/media_buy/media_buy_delivery_webhook_result.py b/src/adcp/types/domains/media_buy/media_buy_delivery_webhook_result.py index 5ac17133c..aad82084f 100644 --- a/src/adcp/types/domains/media_buy/media_buy_delivery_webhook_result.py +++ b/src/adcp/types/domains/media_buy/media_buy_delivery_webhook_result.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/media_buy_delivery_webhook_result.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import attribution_window as attribution_window_1 @@ -41,6 +41,7 @@ class ReportingPeriod(AdCPBaseModel): examples=['UTC', 'America/New_York'], min_length=1, ), + NoExplicitNull, ] = None @@ -58,43 +59,47 @@ class Status(StrEnum): class Totals(DeliveryMetrics): - effective_rate: Annotated[StrictFloat | None, Field(ge=0.0)] = None + effective_rate: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None class ByPackageItem(DeliveryMetrics): - package_id: str | None = None + package_id: Annotated[str | None, NoExplicitNull] = None pacing_index: Annotated[ StrictFloat | None, Field( description='Subordinate package pacing diagnostic. May be absent for seller-optimized packages without package-level pacing.', ge=0.0, ), + NoExplicitNull, ] = None - pricing_model: pricing_model_1.PricingModel | None = None - rate: Annotated[StrictFloat | None, Field(ge=0.0)] = None + pricing_model: Annotated[pricing_model_1.PricingModel | None, NoExplicitNull] = None + rate: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None currency: Annotated[ str | None, Field( description="ISO 4217 currency code for this package's spend and currency-denominated pricing rate. The rate for revenue_share is a dimensionless commission fraction, but attributed monetary values still use this currency. When the enclosing media_buy_deliveries[].currency is present, this value MUST equal it. For AdCP-authored buys both values MUST equal the media-buy currency. A different package currency is permitted only for a legacy or externally created mixed-currency buy whose row currency and row-level monetary totals are omitted. When both row and deprecated response-wide currency are absent, a package with monetary values MUST include this field. AdCP does not perform currency conversion.", pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None delivery_status: Annotated[ delivery_status_1.DeliveryStatus | None, Field( description="System-reported operational state of this package. Reflects actual delivery state independent of buyer pause control. 'not_delivering' means zero impressions were recorded for the entire reporting_period while the package was in-flight. Sellers SHOULD only report 'not_delivering' once the package's data is_final for the period — a provisional (is_final: false) zero may still be measurement catching up, not genuine non-delivery." ), + NoExplicitNull, ] = None - paused: StrictBool | None = None - is_final: StrictBool | None = None - finalized_at: AwareDatetime | None = None - measurement_window: Annotated[str | None, Field(max_length=50)] = None - supersedes_window: Annotated[str | None, Field(max_length=50)] = None + paused: Annotated[StrictBool | None, NoExplicitNull] = None + is_final: Annotated[StrictBool | None, NoExplicitNull] = None + finalized_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + measurement_window: Annotated[str | None, Field(max_length=50), NoExplicitNull] = None + supersedes_window: Annotated[str | None, Field(max_length=50), NoExplicitNull] = None metric_values: Annotated[ list[package_delivery_metric_value.PackageDeliveryMetricValue] | None, Field( description='Qualified standard delivery values for this package. Each entry is the delivered counterpart to a standard-scope package.committed_metrics or by_package[].missing_metrics row, using the same atomic key (scope, metric_id, qualifier). Sellers report one row per full qualifier set at package grain; when a metric appears here, its flat scalar counterpart on the package MUST be omitted to avoid two sources of truth. Vendor-scope values continue to use vendor_metric_values. Buyers reconcile rows directly and perform any compatible cross-package aggregation themselves.' ), + NoExplicitNull, ] = None spend: Any @@ -110,6 +115,7 @@ class MediaBuyDelivery(AdCPBaseModel): description="ISO 4217 denomination for monetary values in this media-buy delivery row, including totals, package spend, and currency-denominated package rates. Sellers SHOULD populate this field whenever all monetary values in the row share one currency. For AdCP-authored buys it MUST equal the media-buy currency, and every by_package[].currency MUST equal it. For a legacy or externally created mixed-currency buy, omit this field and all monetary or money-derived values from row totals; report those values only at package level with each package's own currency. AdCP does not perform currency conversion.", pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None status: Annotated[ Status, @@ -122,30 +128,35 @@ class MediaBuyDelivery(AdCPBaseModel): Field( description='When delayed data is expected to be available. Present when status is reporting_delayed.' ), + NoExplicitNull, ] = None is_adjusted: Annotated[ StrictBool | None, Field( description='Indicates this row contains updated data for a previously reported period.' ), + NoExplicitNull, ] = None is_final: Annotated[ StrictBool | None, Field(description="Whether this row's delivery data is final for the reporting period."), + NoExplicitNull, ] = None finalized_at: Annotated[ AwareDatetime | None, Field( description='Timestamp when this row became final. Present only when is_final is true.' ), + NoExplicitNull, ] = None - pricing_model: pricing_model_1.PricingModel | None = None + pricing_model: Annotated[pricing_model_1.PricingModel | None, NoExplicitNull] = None pacing_index: Annotated[ StrictFloat | None, Field( description='Aggregate media-buy delivery pace relative to the media-buy pacing plan. Authoritative for seller-optimized buys.', ge=0.0, ), + NoExplicitNull, ] = None totals: Totals by_package: Annotated[list[ByPackageItem], Field(description='Metrics broken down by package.')] @@ -166,6 +177,7 @@ class MediaBuyDeliveryWebhookResult(AdCPBaseModel): Field( description='Indicates if any media buys in this webhook have missing or delayed data.' ), + NoExplicitNull, ] = None unavailable_count: Annotated[ SchemaInt | None, @@ -173,18 +185,21 @@ class MediaBuyDeliveryWebhookResult(AdCPBaseModel): description='Number of media buys with reporting_delayed or failed status when partial_data is true.', ge=0, ), + NoExplicitNull, ] = None sequence_number: Annotated[ SchemaInt | None, Field( description='Sequential notification number for this reporting webhook stream.', ge=1 ), + NoExplicitNull, ] = None next_expected_at: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp for the next expected notification. Omitted on final notifications.' ), + NoExplicitNull, ] = None reporting_period: Annotated[ ReportingPeriod, @@ -199,12 +214,14 @@ class MediaBuyDeliveryWebhookResult(AdCPBaseModel): description='Deprecated in AdCP 3.2 and removed in AdCP 4.0. Optional legacy report-wide ISO 4217 currency code. It may be used only when every monetary value in the report has that denomination. A report can contain media buys with different currencies, so buyers MUST NOT interpret this field as an aggregation currency or evidence of currency conversion. Prefer media_buy_deliveries[].currency when present and package-level currency otherwise.', pattern='^[A-Z]{3}$', ), + NoExplicitNull, ] = None attribution_window: Annotated[ attribution_window_1.AttributionWindow | None, Field( description='Attribution methodology and lookback windows used for conversion metrics in this report.' ), + NoExplicitNull, ] = None media_buy_deliveries: Annotated[ list[MediaBuyDelivery], @@ -213,8 +230,10 @@ class MediaBuyDeliveryWebhookResult(AdCPBaseModel): ), ] errors: Annotated[ - list[error.Error] | None, Field(description='Task-specific delivery errors or warnings.') + list[error.Error] | None, + Field(description='Task-specific delivery errors or warnings.'), + NoExplicitNull, ] = None - sandbox: StrictBool | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + sandbox: Annotated[StrictBool | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/outcome_target.py b/src/adcp/types/domains/media_buy/outcome_target.py index e91193301..d1e657b3b 100644 --- a/src/adcp/types/domains/media_buy/outcome_target.py +++ b/src/adcp/types/domains/media_buy/outcome_target.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/outcome_target.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..core import outcome_target_cost_per @@ -47,6 +47,7 @@ class Goal1(AdCPBaseModel): description="Required when event_type is 'custom'. Platform-specific name for the custom event.", min_length=1, ), + NoExplicitNull, ] = None @@ -67,8 +68,9 @@ class OutcomeTarget(AdCPBaseModel): description="Desired total volume of the goal's metric or event across the planned flight. Alone, the seller solves for budget and answers with total_budget_guidance and a forecast. With cost_per, the seller plans toward the volume at the cost: under a cap it SHOULD keep the buyer's amount and forecast the lower volume it can deliver, unless no volume can be planned at that amount; under a target the ask is plannable when the seller can forecast the volume around it.", gt=0.0, ), + NoExplicitNull, ] = None - cost_per: outcome_target_cost_per.OutcomeTargetCostPer | None = None + cost_per: Annotated[outcome_target_cost_per.OutcomeTargetCostPer | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> OutcomeTarget: diff --git a/src/adcp/types/domains/media_buy/package_control.py b/src/adcp/types/domains/media_buy/package_control.py index d70a2f583..00d48411b 100644 --- a/src/adcp/types/domains/media_buy/package_control.py +++ b/src/adcp/types/domains/media_buy/package_control.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/package_control.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator @@ -48,27 +48,32 @@ class PackageControl(AdCPBaseModel): ge=0.0, ), ] = None - impressions: Annotated[StrictFloat | None, Field(ge=0.0)] = None + impressions: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Replace package pacing, subordinate to media-buy pacing. In a resulting seller-optimized buy, pacing that differs from the media-buy pacing requires advertised media_buy.features.seller_optimized_package_pacing; otherwise rejected with UNSUPPORTED_FEATURE.' ), + NoExplicitNull, ] = None bidding: bidding_policy.BiddingPolicy | None = None - paused: StrictBool | None = None + paused: Annotated[StrictBool | None, NoExplicitNull] = None canceled: Annotated[ Literal[True] | None, Field( description='Exercise an already-accepted unilateral package cancellation right. If seller agreement is required, refine the accepted proposal with change_kind amendment; cancellation proposals terminate the whole MediaBuy.' ), + NoExplicitNull, + ] = None + cancellation_reason: Annotated[ + str | None, Field(max_length=500, min_length=1), NoExplicitNull ] = None - cancellation_reason: Annotated[str | None, Field(max_length=500, min_length=1)] = None targeting_overlay: Annotated[ targeting_input.TargetingOverlayInput | None, Field( description='Per-dimension targeting patch. Omission preserves the current dimension, a non-null value replaces it, and null clears it. The resulting effective targeting must remain within the accepted commercial envelope and is read back without null dimensions.' ), + NoExplicitNull, ] = None catalog_ids: Annotated[ list[CatalogId] | None, @@ -76,21 +81,24 @@ class PackageControl(AdCPBaseModel): description="Replace the package's promoted catalogs with references previously managed through sync_catalogs.", min_length=1, ), + NoExplicitNull, ] = None keyword_targets_add: Annotated[ - list[keyword_target.KeywordTarget] | None, Field(min_length=1) + list[keyword_target.KeywordTarget] | None, Field(min_length=1), NoExplicitNull ] = None keyword_targets_remove: Annotated[ - list[keyword_target.KeywordTarget] | None, Field(min_length=1) + list[keyword_target.KeywordTarget] | None, Field(min_length=1), NoExplicitNull ] = None negative_keywords_add: Annotated[ - list[keyword_target.KeywordTarget] | None, Field(min_length=1) + list[keyword_target.KeywordTarget] | None, Field(min_length=1), NoExplicitNull ] = None negative_keywords_remove: Annotated[ - list[keyword_target.KeywordTarget] | None, Field(min_length=1) + list[keyword_target.KeywordTarget] | None, Field(min_length=1), NoExplicitNull ] = None optimization_goals: Annotated[ - list[canonical_optimization_goal.CanonicalOptimizationGoal] | None, Field(min_length=1) + list[canonical_optimization_goal.CanonicalOptimizationGoal] | None, + Field(min_length=1), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/media_buy/package_request.py b/src/adcp/types/domains/media_buy/package_request.py index 2882032e5..366f00c3d 100644 --- a/src/adcp/types/domains/media_buy/package_request.py +++ b/src/adcp/types/domains/media_buy/package_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/package_request.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from collections.abc import Sequence from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import audience_evidence_pin @@ -32,11 +32,15 @@ class Qualifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - viewability_standard: viewability_standard_1.ViewabilityStandard | None = None - completion_source: completion_source_1.CompletionSource | None = None - attribution_methodology: attribution_methodology_1.AttributionMethodology | None = None - attribution_window: duration.Duration | None = None - lift_dimension: lift_dimension_1.LiftDimension | None = None + viewability_standard: Annotated[ + viewability_standard_1.ViewabilityStandard | None, NoExplicitNull + ] = None + completion_source: Annotated[completion_source_1.CompletionSource | None, NoExplicitNull] = None + attribution_methodology: Annotated[ + attribution_methodology_1.AttributionMethodology | None, NoExplicitNull + ] = None + attribution_window: Annotated[duration.Duration | None, NoExplicitNull] = None + lift_dimension: Annotated[lift_dimension_1.LiftDimension | None, NoExplicitNull] = None class CommittedMetrics1(AdCPBaseModel): @@ -58,6 +62,7 @@ class CommittedMetrics1(AdCPBaseModel): Field( description='Disambiguator — same shape as on the response-side `committed_metrics`. Required when the buyer wants to pin a specific measurement path: `viewability_standard` for MRC vs GroupM viewability; `completion_source` for seller- vs vendor-attested `completion_rate`; `attribution_methodology` for how attribution was computed (deterministic_purchase, probabilistic, panel_based, modeled); `attribution_window` for the time window over which outcomes are attributed. See response-side description for full semantics.' ), + NoExplicitNull, ] = None @@ -86,12 +91,14 @@ class CommittedMetrics2(AdCPBaseModel): Field( description="Optional buyer-proposed pin of the vendor's `measurement.metrics[].methodology_version`. The seller accepts by echoing it on the confirmed package's `committed_metrics` entry, normalizes to the version it can actually deliver, or rejects with `TERMS_REJECTED`. Opaque string — equality comparison only." ), + NoExplicitNull, ] = None qualifier: Annotated[ Qualifier | None, Field( description='Optional disambiguator for vendor metrics committed under more than one methodology or window — same closed key set as standard-scope entries.' ), + NoExplicitNull, ] = None @@ -118,6 +125,7 @@ class PackageRequest(AdcpVersionEnvelope): description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Legacy named-format selector retained for older 3.x peers. New buyers MUST NOT emit this field. Sellers MUST normalize every entry through the canonical mapping path before product satisfaction checks; an entry that cannot be normalized is rejected with `UNSUPPORTED_FEATURE` before any equivalence check. When this field coexists with `format_option_refs` or `format_kind` plus `params`, sellers MUST compare the product option sets selected by each resolved route. Legacy parameter compatibility follows the asymmetric v2-narrows-v1 relation defined by canonical formats, not raw object equality. Different format shapes, selected option sets, or incompatible dimensions are rejected with `CONFLICTING_SELECTORS`; sellers MUST NOT silently ignore the legacy projection. Equivalent dual emission remains valid during the 3.x compatibility window. If omitted and no canonical selector is present, all formats supported by the product are active.', min_length=1, ), + NoExplicitNull, ] = None format_option_refs: Annotated[ list[format_option_ref.FormatOptionReference] | None, @@ -125,18 +133,21 @@ class PackageRequest(AdcpVersionEnvelope): description='Canonical 3.2 format-option selector. Each reference matches one target product `format_options[]` entry. Publisher-backed options match `{ scope: "publisher", publisher_domain, format_option_id }`; product-local options match `{ scope: "product", format_option_id }`. Sellers reject unresolved options with `UNSUPPORTED_FEATURE` and a field path to the failing entry before comparing co-present routes. New buyers MUST use this route by itself and MUST NOT dual-emit either a direct canonical selector or deprecated `format_ids`. Receivers handling older 3.x multi-route requests MUST resolve every present route, require each route to select the same product option set, and reject disagreement with `CONFLICTING_SELECTORS` before treating `format_option_refs` as authoritative.', min_length=1, ), + NoExplicitNull, ] = None format_kind: Annotated[ str | None, Field( description='Canonical 3.2 direct selector. Names the canonical format shape this package targets when the buyer is not selecting a published `format_option_ref`. Pair with `params` for dimensions, duration, codecs, or other constraints. New buyers MUST NOT combine this route with `format_option_refs` or deprecated `format_ids`. Receivers handling older 3.x multi-route requests MUST equivalence-check every present route before applying precedence and reject disagreement with `CONFLICTING_SELECTORS`. Product satisfaction is directional: broad `{ format_kind: "image" }` does not satisfy a fixed-size product declaration.' ), + NoExplicitNull, ] = None params: Annotated[ dict[str, Any] | None, Field( description="Parameters for the direct canonical selector in `format_kind`. Shape follows the selected canonical's parameter vocabulary: dimensions (`width`, `height`, `sizes`), duration (`duration_ms_exact`, `duration_ms_range`), codecs, asset-source and slot narrowing, or other canonical-specific constraints. Requires `format_kind`. For fixed-size image selectors, `width` and `height` MUST co-occur; a selector containing only one dimension is schema-invalid. New buyers omit `params` when selecting by `format_option_refs` or `format_ids`; older multi-route requests are accepted only when every route selects the same product option set." ), + NoExplicitNull, ] = None budget: Annotated[ StrictFloat | None, @@ -144,6 +155,7 @@ class PackageRequest(AdcpVersionEnvelope): description="Hard lifetime spend cap for this package in the media buy's currency. Required in fixed allocation mode. Optional in seller-optimized mode; when omitted, the package is bounded by the shared total_budget and any other package constraints. In seller-optimized mode this is a ceiling, not a reserved or current allocation, and requires advertised media_buy.features.seller_optimized_package_budgets; otherwise rejected with UNSUPPORTED_FEATURE before any over-subscription validation.", ge=0.0, ), + NoExplicitNull, ] = None min_spend_target: Annotated[ StrictFloat | None, @@ -151,12 +163,14 @@ class PackageRequest(AdcpVersionEnvelope): description="Soft lifetime spend target for this package in the media buy's currency. Only valid with seller-optimized budget allocation. Requires advertised media_buy.features.seller_optimized_min_spend_targets; otherwise rejected with UNSUPPORTED_FEATURE before any over-subscription validation. The seller SHOULD attempt to deliver at least this amount before allocating incremental spend elsewhere, but inventory, policy, optimization targets, or other delivery constraints may prevent it. This is not a billing guarantee. Must not exceed the package budget when both are present; a seller advertising both seller_optimized_min_spend_targets and seller_optimized_package_budgets MUST reject a violation with `INVALID_REQUEST`, while a seller missing either capability rejects the undeclared control with UNSUPPORTED_FEATURE before any over-subscription validation.", ge=0.0, ), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Package pacing, subordinate to media-buy pacing. In seller-optimized mode, pacing that differs from the media-buy pacing requires advertised media_buy.features.seller_optimized_package_pacing; otherwise rejected with UNSUPPORTED_FEATURE.' ), + NoExplicitNull, ] = None pricing_option_id: Annotated[ str, @@ -171,15 +185,19 @@ class PackageRequest(AdcpVersionEnvelope): description='DEPRECATED in 3.2 and removed in the next major. Use bidding.bid_amount or bidding.max_bid. Legacy normalization: selected pricing_option.max_bid=true maps to bidding.max_bid; otherwise maps to bidding.bid_amount. A package MUST NOT supply both representations.', ge=0.0, ), + NoExplicitNull, ] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, Field( description='Package-authored bidding policy. This complete block replaces, rather than field-merges with, any media-buy bidding policy for this package. `{automatic:true}` explicitly overrides a media-buy policy with provider automatic bidding; omission inherits the complete media-buy block. Monetary fields use the media-buy currency, while the selected pricing option supplies only the auction unit and MUST declare that same currency. Sellers MUST reject a new bidding block combined with legacy bid_price or legacy monetary optimization-goal targets on the same effective package with AMBIGUOUS_BIDDING_POLICY.' ), + NoExplicitNull, ] = None impressions: Annotated[ - StrictFloat | None, Field(description='Impression goal for this package', ge=0.0) + StrictFloat | None, + Field(description='Impression goal for this package', ge=0.0), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -187,30 +205,35 @@ class PackageRequest(AdcpVersionEnvelope): description="Optional hard package daily spend ceiling in the media-buy currency. It is subordinate, not a reserved allocation; package caps need not sum to the aggregate cap. Uses the media buy's cap timezone. Requires advertised package budget-capping scope; otherwise rejected with UNSUPPORTED_FEATURE.", ge=0.0, ), + NoExplicitNull, ] = None start_time: Annotated[ AwareDatetime | None, Field( description="Flight start date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's start_time. Must fall within the media buy's date range." ), + NoExplicitNull, ] = None end_time: Annotated[ AwareDatetime | None, Field( description="Flight end date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's end_time. Must fall within the media buy's date range." ), + NoExplicitNull, ] = None paused: Annotated[ StrictBool | None, Field( description='Whether this package should be created in a paused state. Paused packages do not deliver impressions. Defaults to false.' ), + NoExplicitNull, ] = False catalogs: Annotated[ list[catalog.Catalog] | None, Field( description='Catalogs this package promotes. Each catalog MUST have a distinct type (e.g., one product catalog, one store catalog). This constraint is enforced at the application level — sellers MUST reject requests containing multiple catalogs of the same type with a validation_error. Makes the package catalog-driven: one budget envelope, platform optimizes across items.' ), + NoExplicitNull, ] = None optimization_goals: Annotated[ list[optimization_goal.OptimizationGoal] | None, @@ -218,18 +241,21 @@ class PackageRequest(AdcpVersionEnvelope): description='Optimization targets for this package. The seller optimizes delivery toward these goals in priority order. Common pattern: event goals (purchase, install) as primary targets at priority 1; metric goals (clicks, views) as secondary proxy signals at priority 2+.', min_length=1, ), + NoExplicitNull, ] = None targeting_overlay: Annotated[ targeting_input.TargetingOverlayInput | None, Field( description="Optional package-specific targeting input with three states per dimension. Omission inherits targeting already bound to the configured product, a non-null value replaces that dimension, and null explicitly suppresses the configured/product default for that dimension. Null cannot remove inherent product scope: sellers reject a clear the product cannot execute rather than silently retaining the default. Non-null fields MUST be declared in the product's overlay_support unless they were already accepted during discovery. The one fixed-inventory restatement exception is placement_selection equal to the product's complete, explicitly enumerated mode: included placement set: that set is an inherent exact match across discovery, create, and update and does not require overlay_support.placement_selection; partial selection still requires a selectable product. Opaque property_list and collection_list references have no equivalent exception because their membership can change independently and cannot be proven equal from the product wire representation. Package readback echoes the complete effective targeting without null dimensions. A supported value with no current inventory returns PRODUCT_UNAVAILABLE rather than a silent substitute or reprice." ), + NoExplicitNull, ] = None audience_evidence_requirements: Annotated[ audience_evidence_requirements_1.AudienceEvidenceRequirements | None, Field( description='Buyer policy that the selected product and constructed package MUST satisfy using product audience evidence. This remains planning and suitability evidence, not a targeting instruction. Sellers MUST reject an unsatisfied required policy rather than silently drop it, and a confirmed package MUST include every evidence snapshot used to satisfy this policy in audience_evidence_selections with decision_use package_construction.' ), + NoExplicitNull, ] = None audience_evidence_pins: Annotated[ list[audience_evidence_pin.AudienceEvidencePin] | None, @@ -237,12 +263,14 @@ class PackageRequest(AdcpVersionEnvelope): description="Exact immutable evidence snapshots selected by the buyer during discovery. The seller MUST match evidence_id, snapshot_id, version, and content_digest against one published snapshot and MUST reject catalog mutation, snapshot reuse, missing snapshots, or substitutions. Every accepted pin MUST be echoed in the confirmed package's audience_evidence_selections with decision_use package_construction.", min_length=1, ), + NoExplicitNull, ] = None measurement_terms: Annotated[ measurement_terms_1.MeasurementTerms | None, Field( description="Buyer's proposed billing measurement and makegood terms. Overrides product defaults. Seller accepts (echoed on confirmed package), rejects with TERMS_REJECTED, or adjusts. When absent, product's measurement_terms apply." ), + NoExplicitNull, ] = None performance_standards: Annotated[ list[performance_standard.PerformanceStandard] | None, @@ -250,6 +278,7 @@ class PackageRequest(AdcpVersionEnvelope): description="Buyer's proposed performance standards for this package. Overrides product defaults. Seller accepts, rejects with TERMS_REJECTED, or adjusts. When absent, product's performance_standards apply.", min_length=1, ), + NoExplicitNull, ] = None committed_metrics: Annotated[ list[CommittedMetrics] | None, @@ -257,6 +286,7 @@ class PackageRequest(AdcpVersionEnvelope): description="Buyer's proposed reporting contract for this package — the metrics the buyer wants the seller to commit to populating in delivery reports. Same negotiation pattern as `measurement_terms` and `performance_standards`: seller accepts (echoes on confirmed package with `committed_at` stamped), rejects with `TERMS_REJECTED` (with explanation of which entries were unworkable), or normalizes (echoes a different but compatible list — buyer can accept by retrying with the normalized terms). When absent, the seller decides what to commit based on the product's `available_metrics` and the buyer's `required_metrics` filter on `get_products`. Each entry uses an explicit `scope` discriminator (`standard` or `vendor`) and identifies the metric — request-side entries do NOT carry `committed_at`; that timestamp is stamped by the seller on accept. Constraints on what the buyer MAY propose: each `scope: standard` entry's `metric_id` MUST be in the product's `available_metrics`, and each `scope: vendor` entry's `(vendor, metric_id)` MUST appear in the product's `vendor_metrics` — sellers SHOULD reject with `TERMS_REJECTED` and reference the offending entry when the proposal exceeds product capability.", min_length=1, ), + NoExplicitNull, ] = None creative_assignments: Annotated[ list[creative_assignment.CreativeAssignment] | None, @@ -264,6 +294,7 @@ class PackageRequest(AdcpVersionEnvelope): description='Assign existing library creatives to this package with optional rotation, grouping, weights, and placement targeting. rotation_mode is package-scoped: omission resolves to weighted, and every assignment MUST resolve to the same effective mode. In sequential mode, sequence_position MUST be unique within each package-local group. Sellers reject conflicts with VALIDATION_ERROR before creating the package.', min_length=1, ), + NoExplicitNull, ] = None creatives: Annotated[ Sequence[creative_asset.CreativeAsset] | None, @@ -272,6 +303,7 @@ class PackageRequest(AdcpVersionEnvelope): max_length=100, min_length=1, ), + NoExplicitNull, ] = None agency_estimate_number: Annotated[ str | None, @@ -279,14 +311,16 @@ class PackageRequest(AdcpVersionEnvelope): description='Agency estimate or authorization number for this package. Overrides the media buy-level estimate number when different packages correspond to different agency estimates (e.g., different stations or flights within the same buy).', max_length=100, ), + NoExplicitNull, ] = None context: Annotated[ context_1.ContextObject | None, Field( description='Opaque package-level correlation data echoed unchanged in the package response, webhooks, and read surfaces. Buyers targeting mixed seller populations SHOULD include a per-package correlation value here, commonly context_1.buyer_ref, so responses from legacy sellers that do not echo product_id can still be mapped back to the requested product or line item. Do not use deprecated top-level buyer_ref for v3 correlation.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _validate_format_params(self) -> PackageRequest: diff --git a/src/adcp/types/domains/media_buy/package_update.py b/src/adcp/types/domains/media_buy/package_update.py index 4146ac47c..f3ec451d4 100644 --- a/src/adcp/types/domains/media_buy/package_update.py +++ b/src/adcp/types/domains/media_buy/package_update.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/package_update.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import bidding_policy, catalog @@ -30,6 +30,7 @@ class KeywordTargetsAddItem(AdCPBaseModel): description="Per-keyword bid price. Inherits currency and max_bid interpretation from the package's pricing option.", ge=0.0, ), + NoExplicitNull, ] = None @@ -81,6 +82,7 @@ class PackageUpdate(AdCPBaseModel): Field( description='Updated package pacing, subordinate to media-buy pacing. In a resulting seller-optimized buy, pacing that differs from the media-buy pacing requires advertised media_buy.features.seller_optimized_package_pacing; otherwise rejected with UNSUPPORTED_FEATURE.' ), + NoExplicitNull, ] = None bid_price: Annotated[ StrictFloat | None, @@ -89,6 +91,7 @@ class PackageUpdate(AdCPBaseModel): description='DEPRECATED in 3.2 and removed in the next major. Use bidding. A package update MUST NOT supply both a non-null bidding object and bid_price. During migration, bidding:null MAY accompany bid_price to clear the canonical block and set the legacy representation atomically.', ge=0.0, ), + NoExplicitNull, ] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, @@ -97,7 +100,9 @@ class PackageUpdate(AdCPBaseModel): ), ] = None impressions: Annotated[ - StrictFloat | None, Field(description='Updated impression goal for this package', ge=0.0) + StrictFloat | None, + Field(description='Updated impression goal for this package', ge=0.0), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -111,25 +116,31 @@ class PackageUpdate(AdCPBaseModel): Field( description="Updated flight start date/time for this package in ISO 8601 format. Must fall within the media buy's date range." ), + NoExplicitNull, ] = None end_time: Annotated[ AwareDatetime | None, Field( description="Updated flight end date/time for this package in ISO 8601 format. Must fall within the media buy's date range." ), + NoExplicitNull, ] = None paused: Annotated[ StrictBool | None, Field(description='Pause/resume specific package (true = paused, false = active)'), + NoExplicitNull, ] = None canceled: Annotated[ Literal[True] | None, Field( description='Cancel this specific package. Cancellation is irreversible — canceled packages stop delivery and cannot be reactivated. When true, package cancellation takes precedence over sibling fields on this package: the seller applies only canceled and cancellation_reason for this package and SHOULD return a structured warning naming ignored sibling fields. Root fields and other package updates still participate in the same atomic update when root canceled is absent. Sellers MAY reject with NOT_CANCELLABLE.' ), + NoExplicitNull, ] = None cancellation_reason: Annotated[ - str | None, Field(description='Reason for canceling this package.', max_length=500) + str | None, + Field(description='Reason for canceling this package.', max_length=500), + NoExplicitNull, ] = None catalogs: Annotated[ list[catalog.Catalog] | None, @@ -137,6 +148,7 @@ class PackageUpdate(AdCPBaseModel): description='Replace the catalogs this package promotes. Uses replacement semantics — the provided array replaces the current list. Omit to leave catalogs unchanged.', min_length=1, ), + NoExplicitNull, ] = None optimization_goals: Annotated[ list[optimization_goal.OptimizationGoal] | None, @@ -144,12 +156,14 @@ class PackageUpdate(AdCPBaseModel): description='Replace all optimization goals for this package. Uses replacement semantics — omit to leave goals unchanged.', min_length=1, ), + NoExplicitNull, ] = None targeting_overlay: Annotated[ targeting_input.TargetingOverlayInput | None, Field( description='Per-dimension targeting patch for this package. Omit targeting_overlay, or omit an individual dimension inside it, to leave the corresponding effective targeting unchanged. A non-null dimension replaces its current value; null clears it, including a value inherited from configured-product selection or a product default. Every resulting effective overlay must remain executable by the product. Sellers reject unsupported or partially applicable changes and use REQUOTE_REQUIRED when a change, including a broader inventory set, falls outside the priced envelope. placement_selection is purchased-inventory targeting; mode default restores the product default, null clears the dimension when the product permits it, and successful readback echoes the committed selected set when enumerable. If a patch removes a placement referenced by an existing creative assignment, the seller MUST reject the update unless the same atomic package mutation supplies a compatible complete creative_assignments replacement. Sellers MUST NOT silently delete assignments or retain orphan placement refs.' ), + NoExplicitNull, ] = None keyword_targets_add: Annotated[ list[KeywordTargetsAddItem] | None, @@ -157,6 +171,7 @@ class PackageUpdate(AdCPBaseModel): description='Keyword targets to add or update on this package. Upserts by (keyword, match_type) identity: if the pair already exists, its bid_price is updated; if not, a new keyword target is added. Use targeting_overlay.keyword_targets in create_media_buy to set the initial list.', min_length=1, ), + NoExplicitNull, ] = None keyword_targets_remove: Annotated[ list[KeywordTargetsRemoveItem] | None, @@ -164,6 +179,7 @@ class PackageUpdate(AdCPBaseModel): description='Keyword targets to remove from this package. Removes matching (keyword, match_type) pairs. If a specified pair is not present, sellers SHOULD treat it as a no-op for that entry.', min_length=1, ), + NoExplicitNull, ] = None negative_keywords_add: Annotated[ list[NegativeKeywordsAddItem] | None, @@ -171,6 +187,7 @@ class PackageUpdate(AdCPBaseModel): description='Negative keywords to add to this package. Appends to the existing negative keyword list — does not replace it. If a keyword+match_type pair already exists, sellers SHOULD treat it as a no-op for that entry. Use targeting_overlay.negative_keywords in create_media_buy to set the initial list.', min_length=1, ), + NoExplicitNull, ] = None negative_keywords_remove: Annotated[ list[NegativeKeywordsRemoveItem] | None, @@ -178,12 +195,14 @@ class PackageUpdate(AdCPBaseModel): description='Negative keywords to remove from this package. Removes matching keyword+match_type pairs from the existing list. If a specified pair is not present, sellers SHOULD treat it as a no-op for that entry.', min_length=1, ), + NoExplicitNull, ] = None creative_assignments: Annotated[ list[creative_assignment.CreativeAssignment] | None, Field( description='Replace creative assignments for this package with optional rotation, grouping, weights, and placement routing. Uses replacement semantics - omit to leave assignments unchanged. rotation_mode is package-scoped: omission resolves to weighted, and every assignment MUST resolve to the same effective mode. In sequential mode, sequence_position MUST be unique within each package-local group. Sellers reject conflicts with VALIDATION_ERROR before mutation. When the same mutation narrows or clears targeting_overlay.placement_selection, this complete replacement MUST remove or reroute every assignment reference that would otherwise be orphaned; the seller validates both changes atomically.' ), + NoExplicitNull, ] = None creatives: Annotated[ list[creative_asset.CreativeAsset] | None, @@ -192,6 +211,7 @@ class PackageUpdate(AdCPBaseModel): max_length=100, min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/product_discovery_criteria.py b/src/adcp/types/domains/media_buy/product_discovery_criteria.py index 565df258f..c4019add4 100644 --- a/src/adcp/types/domains/media_buy/product_discovery_criteria.py +++ b/src/adcp/types/domains/media_buy/product_discovery_criteria.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/product_discovery_criteria.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -41,49 +41,57 @@ class ProductDiscoveryCriteria(AdCPBaseModel): description='Exact seller-issued products to retrieve or use as proposal candidates.', min_length=1, ), + NoExplicitNull, ] = None offer_filters: Annotated[ product_offer_filters.ProductOfferFilters | None, Field( description='Hard commercial and product-characteristic filters. Every returned product MUST satisfy them. These fields do not become package delivery targeting.' ), + NoExplicitNull, ] = None targeting_overlay: Annotated[ targeting.TargetingOverlay | None, Field( description='Concrete delivery constraints the buyer expects to carry into create_media_buy. Returned product pricing and forecasts MUST reflect the effective targeting. A product that cannot apply the request exactly is omitted or returned as a distinct configured product with sparse Product.targeting_resolution modifications; absence of that resolution means exact acceptance.' ), + NoExplicitNull, ] = None media_buy_frequency_cap: Annotated[ media_buy_frequency_cap_1.MediaBuyFrequencyCap | None, Field( description='Concrete aggregate max-impression cap expected at the MediaBuy root. Every returned product must participate in one shared counter for this exact value. Proposals carry it in commercial_terms.frequency_cap.' ), + NoExplicitNull, ] = None required_overlay_support: Annotated[ targeting_overlay_requirements.TargetingOverlayRequirements | None, Field( description='Minimum product-scoped targeting dimensions the buyer must be able to select independently on packages later. This requests capability, not current values, value-specific availability, a forecast for every later value, or one product per value.' ), + NoExplicitNull, ] = None required_media_buy_support: Annotated[ media_buy_support_requirements.ProductMediaBuySupportRequirements | None, Field( description='Minimum product participation in shared MediaBuy-level controls. For frequency caps, every returned product is promising composability in one aggregate seller counter for each requested per unit.' ), + NoExplicitNull, ] = None outcome_target: Annotated[ outcome_target_1.OutcomeTarget | None, Field( description='Reverse-forecast planning input for sellers declaring media_buy.outcome_target: a goal plus a volume, a cost target, or both, answered on proposals. Inert on list_products for every seller. See outcome-target.json.' ), + NoExplicitNull, ] = None acceptance_context: Annotated[ acceptance_context_1.AcceptanceContext | None, Field( description='Structured campaign and advertiser facts for coarse acceptance-policy matching. A matching product is not a guarantee of final acceptance.' ), + NoExplicitNull, ] = None - catalog: catalog_selection.CatalogSelection | None = None - policy_ids: Annotated[list[PolicyId] | None, Field(min_length=1)] = None - ext: dict[str, Any] | None = None + catalog: Annotated[catalog_selection.CatalogSelection | None, NoExplicitNull] = None + policy_ids: Annotated[list[PolicyId] | None, Field(min_length=1), NoExplicitNull] = None + ext: Annotated[dict[str, Any] | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/product_purchase.py b/src/adcp/types/domains/media_buy/product_purchase.py index 571908505..49f84e9fe 100644 --- a/src/adcp/types/domains/media_buy/product_purchase.py +++ b/src/adcp/types/domains/media_buy/product_purchase.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/product_purchase.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat @@ -40,6 +40,7 @@ class ProductPurchase(AdCPBaseModel): Field( description='Resolved selected pricing terms. Optional on buy_products input, where pricing_option_id plus the versioned feed identifies the offer; required inside accepted commercial_terms. Its pricing_option_id MUST match the sibling field.' ), + NoExplicitNull, ] = None format_option_refs: Annotated[ list[format_option_ref.FormatOptionReference] | None, @@ -47,6 +48,7 @@ class ProductPurchase(AdCPBaseModel): description='Canonical format options selected from the published product offer. Legacy named-format identifiers are not accepted.', min_length=1, ), + NoExplicitNull, ] = None catalog_ids: Annotated[ list[CatalogId] | None, @@ -54,6 +56,7 @@ class ProductPurchase(AdCPBaseModel): description='Previously synchronized account catalog IDs promoted by this selection. Callers manage catalog bodies through sync_catalogs rather than inlining them here.', min_length=1, ), + NoExplicitNull, ] = None budget: Annotated[ StrictFloat | None, @@ -61,6 +64,7 @@ class ProductPurchase(AdCPBaseModel): description='Hard spend cap for this selection in the media-buy currency. In seller-optimized allocation it is an optional ceiling, not a reserved allocation, and requires advertised media_buy.features.seller_optimized_package_budgets; otherwise rejected with UNSUPPORTED_FEATURE before any over-subscription validation.', ge=0.0, ), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -68,6 +72,7 @@ class ProductPurchase(AdCPBaseModel): description="Optional hard daily spend ceiling for this purchase. It is subordinate to the media-buy aggregate daily cap and is not a reserved daily allocation. Its day boundary is the media buy's budget_cap_timezone.", ge=0.0, ), + NoExplicitNull, ] = None min_spend_target: Annotated[ StrictFloat | None, @@ -75,37 +80,44 @@ class ProductPurchase(AdCPBaseModel): description='Soft lifetime spend target for seller-optimized allocation. Requires advertised media_buy.features.seller_optimized_min_spend_targets; otherwise rejected with UNSUPPORTED_FEATURE before any over-subscription validation.', ge=0.0, ), + NoExplicitNull, ] = None impressions: Annotated[ - StrictFloat | None, Field(ge=0.0, title='Product Purchase Impressions') + StrictFloat | None, Field(ge=0.0, title='Product Purchase Impressions'), NoExplicitNull ] = None start_time: Annotated[ AwareDatetime | None, Field( description='Resolved package flight start. On direct-purchase input, omission inherits the MediaBuy start; accepted proposal snapshots carry the resolved timestamp.' ), + NoExplicitNull, ] = None end_time: Annotated[ AwareDatetime | None, Field( description='Resolved package flight end. On direct-purchase input, omission inherits the MediaBuy end; accepted proposal snapshots carry the resolved timestamp.' ), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Purchase pacing, subordinate to media-buy pacing. In seller-optimized allocation, pacing that differs from the media-buy pacing requires advertised media_buy.features.seller_optimized_package_pacing; otherwise rejected with UNSUPPORTED_FEATURE.' ), + NoExplicitNull, ] = None - bidding: bidding_policy.BiddingPolicy | None = None + bidding: Annotated[bidding_policy.BiddingPolicy | None, NoExplicitNull] = None targeting_overlay: Annotated[ targeting.TargetingOverlay | None, Field( description="Resolved effective buyer-selected targeting, including compatible wholesale signal selections, applied within the product's published targeting contract. Cleared dimensions are omitted; null is invalid in an accepted snapshot." ), + NoExplicitNull, ] = None optimization_goals: Annotated[ - list[canonical_optimization_goal.CanonicalOptimizationGoal] | None, Field(min_length=1) + list[canonical_optimization_goal.CanonicalOptimizationGoal] | None, + Field(min_length=1), + NoExplicitNull, ] = None audience_evidence_requirements: Annotated[ product_audience_evidence_requirements.ProductAudienceEvidenceRequirements | None, @@ -113,6 +125,7 @@ class ProductPurchase(AdCPBaseModel): description='Buyer evidence-admissibility policy carried into the accepted purchase snapshot.', title='Product Purchase Audience Evidence Requirements', ), + NoExplicitNull, ] = None audience_evidence_pins: Annotated[ list[audience_evidence_pin.AudienceEvidencePin] | None, @@ -120,26 +133,30 @@ class ProductPurchase(AdCPBaseModel): description='Exact immutable audience-evidence snapshots selected for package construction.', min_length=1, ), + NoExplicitNull, ] = None agency_estimate_number: Annotated[ str | None, Field( description='Package-level agency estimate or authorization reference.', max_length=100 ), + NoExplicitNull, ] = None context: Annotated[ context_1.ContextObject | None, Field( description='Opaque buyer package correlation preserved in the accepted snapshot and readback.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None measurement_terms: Annotated[ canonical_measurement_terms.CanonicalMeasurementTerms | None, Field( description='Published or negotiated billing-measurement and makegood terms for this purchase. Direct buyers may omit this to inherit the product default; accepted proposal snapshots preserve the resolved terms.', title='Product Purchase Measurement Terms', ), + NoExplicitNull, ] = None performance_standards: Annotated[ list[canonical_performance_standard.CanonicalPerformanceStandard] | None, @@ -147,4 +164,5 @@ class ProductPurchase(AdCPBaseModel): description='Published or negotiated metric thresholds and measurement vendors. Direct buyers may omit this to inherit the product defaults; accepted proposal snapshots preserve every applicable standard.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/media_buy/product_purchase_input.py b/src/adcp/types/domains/media_buy/product_purchase_input.py index 4c213947d..76c19ffbc 100644 --- a/src/adcp/types/domains/media_buy/product_purchase_input.py +++ b/src/adcp/types/domains/media_buy/product_purchase_input.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/product_purchase_input.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat @@ -40,6 +40,7 @@ class ProductPurchaseInput(AdCPBaseModel): Field( description='Resolved selected pricing terms. Optional on buy_products input, where pricing_option_id plus the versioned feed identifies the offer; required inside accepted commercial_terms. Its pricing_option_id MUST match the sibling field.' ), + NoExplicitNull, ] = None format_option_refs: Annotated[ list[format_option_ref.FormatOptionReference] | None, @@ -47,6 +48,7 @@ class ProductPurchaseInput(AdCPBaseModel): description='Canonical format options selected from the published product offer. Legacy named-format identifiers are not accepted.', min_length=1, ), + NoExplicitNull, ] = None catalog_ids: Annotated[ list[CatalogId] | None, @@ -54,6 +56,7 @@ class ProductPurchaseInput(AdCPBaseModel): description='Previously synchronized account catalog IDs promoted by this selection. Callers manage catalog bodies through sync_catalogs rather than inlining them here.', min_length=1, ), + NoExplicitNull, ] = None budget: Annotated[ StrictFloat | None, @@ -61,6 +64,7 @@ class ProductPurchaseInput(AdCPBaseModel): description='Hard spend cap for this selection in the media-buy currency. In seller-optimized allocation it is an optional ceiling, not a reserved allocation, and requires advertised media_buy.features.seller_optimized_package_budgets; otherwise rejected with UNSUPPORTED_FEATURE before any over-subscription validation.', ge=0.0, ), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -68,6 +72,7 @@ class ProductPurchaseInput(AdCPBaseModel): description="Optional hard daily spend ceiling for this purchase. It is subordinate to the media-buy aggregate daily cap and is not a reserved daily allocation. Its day boundary is the media buy's budget_cap_timezone.", ge=0.0, ), + NoExplicitNull, ] = None min_spend_target: Annotated[ StrictFloat | None, @@ -75,37 +80,44 @@ class ProductPurchaseInput(AdCPBaseModel): description='Soft lifetime spend target for seller-optimized allocation. Requires advertised media_buy.features.seller_optimized_min_spend_targets; otherwise rejected with UNSUPPORTED_FEATURE before any over-subscription validation.', ge=0.0, ), + NoExplicitNull, ] = None impressions: Annotated[ - StrictFloat | None, Field(ge=0.0, title='Product Purchase Impressions') + StrictFloat | None, Field(ge=0.0, title='Product Purchase Impressions'), NoExplicitNull ] = None start_time: Annotated[ AwareDatetime | None, Field( description='Resolved package flight start. On direct-purchase input, omission inherits the MediaBuy start; accepted proposal snapshots carry the resolved timestamp.' ), + NoExplicitNull, ] = None end_time: Annotated[ AwareDatetime | None, Field( description='Resolved package flight end. On direct-purchase input, omission inherits the MediaBuy end; accepted proposal snapshots carry the resolved timestamp.' ), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Purchase pacing, subordinate to media-buy pacing. In seller-optimized allocation, pacing that differs from the media-buy pacing requires advertised media_buy.features.seller_optimized_package_pacing; otherwise rejected with UNSUPPORTED_FEATURE.' ), + NoExplicitNull, ] = None - bidding: bidding_policy.BiddingPolicy | None = None + bidding: Annotated[bidding_policy.BiddingPolicy | None, NoExplicitNull] = None targeting_overlay: Annotated[ targeting_input.TargetingOverlayInput | None, Field( description="Per-dimension targeting input. Omission inherits the selected product's configured/default dimension, a non-null value replaces it, and null explicitly suppresses it. A clear cannot remove inherent product scope." ), + NoExplicitNull, ] = None optimization_goals: Annotated[ - list[canonical_optimization_goal.CanonicalOptimizationGoal] | None, Field(min_length=1) + list[canonical_optimization_goal.CanonicalOptimizationGoal] | None, + Field(min_length=1), + NoExplicitNull, ] = None audience_evidence_requirements: Annotated[ product_audience_evidence_requirements.ProductAudienceEvidenceRequirements | None, @@ -113,6 +125,7 @@ class ProductPurchaseInput(AdCPBaseModel): description='Buyer evidence-admissibility policy carried into the accepted purchase snapshot.', title='Product Purchase Audience Evidence Requirements', ), + NoExplicitNull, ] = None audience_evidence_pins: Annotated[ list[audience_evidence_pin.AudienceEvidencePin] | None, @@ -120,26 +133,30 @@ class ProductPurchaseInput(AdCPBaseModel): description='Exact immutable audience-evidence snapshots selected for package construction.', min_length=1, ), + NoExplicitNull, ] = None agency_estimate_number: Annotated[ str | None, Field( description='Package-level agency estimate or authorization reference.', max_length=100 ), + NoExplicitNull, ] = None context: Annotated[ context_1.ContextObject | None, Field( description='Opaque buyer package correlation preserved in the accepted snapshot and readback.' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None measurement_terms: Annotated[ canonical_measurement_terms.CanonicalMeasurementTerms | None, Field( description='Published or negotiated billing-measurement and makegood terms for this purchase. Direct buyers may omit this to inherit the product default; accepted proposal snapshots preserve the resolved terms.', title='Product Purchase Measurement Terms', ), + NoExplicitNull, ] = None performance_standards: Annotated[ list[canonical_performance_standard.CanonicalPerformanceStandard] | None, @@ -147,4 +164,5 @@ class ProductPurchaseInput(AdCPBaseModel): description='Published or negotiated metric thresholds and measurement vendors. Direct buyers may omit this to inherit the product defaults; accepted proposal snapshots preserve every applicable standard.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/media_buy/product_refinement.py b/src/adcp/types/domains/media_buy/product_refinement.py index d04a34d79..8e4614063 100644 --- a/src/adcp/types/domains/media_buy/product_refinement.py +++ b/src/adcp/types/domains/media_buy/product_refinement.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/product_refinement.json -# timestamp: 2026-10-03T00:36:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, RootModel @@ -36,11 +36,13 @@ class ProductRefinementRequests3(AdCPBaseModel): str, Field(description='Product ID from a previous product-discovery response.', min_length=1), ] - action: Annotated[Action | None, Field(description='Requested product-level action.')] = ( - Action.include - ) + action: Annotated[ + Action | None, Field(description='Requested product-level action.'), NoExplicitNull + ] = Action.include ask: Annotated[ - str | None, Field(description='What the buyer is asking for on this product.', min_length=1) + str | None, + Field(description='What the buyer is asking for on this product.', min_length=1), + NoExplicitNull, ] = None @@ -61,12 +63,13 @@ class ProductRefinementRequests4(AdCPBaseModel): str, Field(description='Proposal ID from a previous product-discovery response.', min_length=1), ] - action: Annotated[Action11 | None, Field(description='Requested proposal-level action.')] = ( - Action11.include - ) + action: Annotated[ + Action11 | None, Field(description='Requested proposal-level action.'), NoExplicitNull + ] = Action11.include ask: Annotated[ str | None, Field(description='What the buyer is asking for on this proposal.', min_length=1), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/media_buy/proposal_budget_constraint.py b/src/adcp/types/domains/media_buy/proposal_budget_constraint.py index fdbc10b80..977d4ee66 100644 --- a/src/adcp/types/domains/media_buy/proposal_budget_constraint.py +++ b/src/adcp/types/domains/media_buy/proposal_budget_constraint.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/proposal_budget_constraint.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat, model_validator @@ -17,8 +17,8 @@ class ProposalBudgetConstraint(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - min: Annotated[StrictFloat | None, Field(ge=0.0)] = None - max: Annotated[StrictFloat | None, Field(ge=0.0)] = None + min: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + max: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] @model_validator(mode='after') diff --git a/src/adcp/types/domains/media_buy/proposal_decline.py b/src/adcp/types/domains/media_buy/proposal_decline.py index 971962596..66425271a 100644 --- a/src/adcp/types/domains/media_buy/proposal_decline.py +++ b/src/adcp/types/domains/media_buy/proposal_decline.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/proposal_decline.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..enums import proposal_decline_reason @@ -32,4 +32,5 @@ class ProposalDecline(AdCPBaseModel): max_length=500, min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/media_buy/proposal_refinement.py b/src/adcp/types/domains/media_buy/proposal_refinement.py index 7370dfeca..daa42b41c 100644 --- a/src/adcp/types/domains/media_buy/proposal_refinement.py +++ b/src/adcp/types/domains/media_buy/proposal_refinement.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/proposal_refinement.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T12:09:21+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat from ..core import product_change_map @@ -43,8 +43,8 @@ class Flight(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start_no_later_than: AwareDatetime | None = None - end_no_earlier_than: AwareDatetime | None = None + start_no_later_than: Annotated[AwareDatetime | None, NoExplicitNull] = None + end_no_earlier_than: Annotated[AwareDatetime | None, NoExplicitNull] = None class Constraints(AdCPBaseModel): @@ -56,24 +56,28 @@ class Constraints(AdCPBaseModel): Field( description='Inclusive bounds checked against commercial_terms.total_budget. Satisfied only when commercial_terms.total_budget is present, its currency matches, and its amount falls within every supplied bound.' ), + NoExplicitNull, ] = None cpm: Annotated[ Cpm | None, Field( description='Hard ceiling on fixed CPM rates. Satisfied only when every entry of commercial_terms.purchases carries pricing with pricing_model cpm or vcpm, a matching currency, and a fixed_price no greater than max. Auction-priced, missing, or non-CPM pricing on any purchase leaves the constraint unsatisfied.' ), + NoExplicitNull, ] = None impressions: Annotated[ Impressions | None, Field( description='Hard minimum contracted impression volume. Satisfied only when every entry of commercial_terms.purchases carries impressions and their sum is at least min.' ), + NoExplicitNull, ] = None flight: Annotated[ Flight | None, Field( description='Hard flight-window bounds checked against commercial_terms.start_time and end_time. start_no_later_than is satisfied only by a concrete start_time at or before the bound; an asap start is unverifiable and therefore unsatisfied. end_no_earlier_than is satisfied only by an end_time at or after the bound.' ), + NoExplicitNull, ] = None @@ -135,24 +139,28 @@ class ProposalRefinement2(AdCPBaseModel): Field( description='Desired successor proposal. cancellation is valid only when the source proposal is accepted and attached to a non-terminal MediaBuy.' ), + NoExplicitNull, ] = ChangeKind.amendment constraints: Annotated[ Constraints | None, Field( description='Hard requirements for every revised proposal. Keys are echoed in unsatisfied_constraints on partial or unable results. A draft that fails any present constraint MUST NOT be returned as revised: the result is partial or unable with reason_code constraint_unsatisfiable — which takes precedence over every other reason code — and the failing keys in unsatisfied_constraints.' ), + NoExplicitNull, ] = None product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Product IDs mapped to membership actions checked against commercial_terms.purchases. include requires presence; omit requires absence. The verbs match legacy get_products refinement.' ), + NoExplicitNull, ] = None alternatives: Annotated[ Alternatives | None, Field( description='Request drafts with distinct commercial terms by count. Every returned alternative MUST carry distinct commercial_terms and therefore a unique terms_digest; diversity preferences remain in ask.' ), + NoExplicitNull, ] = None ask: Annotated[ str | None, @@ -160,18 +168,21 @@ class ProposalRefinement2(AdCPBaseModel): description='What the buyer is asking for on this proposal, or the cancellation reason for action revise. The seller returns a new draft proposal snapshot; this text never directly mutates the source proposal or MediaBuy.', min_length=1, ), + NoExplicitNull, ] = None criteria: Annotated[ product_discovery_criteria.ProductDiscoveryCriteria | None, Field( description='Structured discovery changes for action revise. Each present field replaces that criterion from the source proposal; omitted fields inherit. Concrete targeting, future targeting support, and offer filters belong here rather than being repeated in ask.' ), + NoExplicitNull, ] = None remove_media_buy_frequency_cap: Annotated[ Literal[True] | None, Field( description='Request a revised draft whose commercial terms omit the existing MediaBuy frequency cap. Omission inherits it. To replace it, provide criteria.media_buy_frequency_cap instead.' ), + NoExplicitNull, ] = None @@ -191,6 +202,7 @@ class ProposalRefinement3(AdCPBaseModel): Field( description='Desired successor proposal. cancellation is valid only when the source proposal is accepted and attached to a non-terminal MediaBuy.' ), + NoExplicitNull, ] = ChangeKind.amendment constraints: Annotated[ Constraints1, @@ -203,12 +215,14 @@ class ProposalRefinement3(AdCPBaseModel): Field( description='Product IDs mapped to membership actions checked against commercial_terms.purchases. include requires presence; omit requires absence. The verbs match legacy get_products refinement.' ), + NoExplicitNull, ] = None alternatives: Annotated[ Alternatives | None, Field( description='Request drafts with distinct commercial terms by count. Every returned alternative MUST carry distinct commercial_terms and therefore a unique terms_digest; diversity preferences remain in ask.' ), + NoExplicitNull, ] = None ask: Annotated[ str | None, @@ -216,18 +230,21 @@ class ProposalRefinement3(AdCPBaseModel): description='What the buyer is asking for on this proposal, or the cancellation reason for action revise. The seller returns a new draft proposal snapshot; this text never directly mutates the source proposal or MediaBuy.', min_length=1, ), + NoExplicitNull, ] = None criteria: Annotated[ product_discovery_criteria.ProductDiscoveryCriteria | None, Field( description='Structured discovery changes for action revise. Each present field replaces that criterion from the source proposal; omitted fields inherit. Concrete targeting, future targeting support, and offer filters belong here rather than being repeated in ask.' ), + NoExplicitNull, ] = None remove_media_buy_frequency_cap: Annotated[ Literal[True] | None, Field( description='Request a revised draft whose commercial terms omit the existing MediaBuy frequency cap. Omission inherits it. To replace it, provide criteria.media_buy_frequency_cap instead.' ), + NoExplicitNull, ] = None @@ -247,12 +264,14 @@ class ProposalRefinement4(AdCPBaseModel): Field( description='Desired successor proposal. cancellation is valid only when the source proposal is accepted and attached to a non-terminal MediaBuy.' ), + NoExplicitNull, ] = ChangeKind.amendment constraints: Annotated[ Constraints2 | None, Field( description='Hard requirements for every revised proposal. Keys are echoed in unsatisfied_constraints on partial or unable results. A draft that fails any present constraint MUST NOT be returned as revised: the result is partial or unable with reason_code constraint_unsatisfiable — which takes precedence over every other reason code — and the failing keys in unsatisfied_constraints.' ), + NoExplicitNull, ] = None product_changes: Annotated[ product_change_map.ProductChangeMap, @@ -265,6 +284,7 @@ class ProposalRefinement4(AdCPBaseModel): Field( description='Request drafts with distinct commercial terms by count. Every returned alternative MUST carry distinct commercial_terms and therefore a unique terms_digest; diversity preferences remain in ask.' ), + NoExplicitNull, ] = None ask: Annotated[ str | None, @@ -272,18 +292,21 @@ class ProposalRefinement4(AdCPBaseModel): description='What the buyer is asking for on this proposal, or the cancellation reason for action revise. The seller returns a new draft proposal snapshot; this text never directly mutates the source proposal or MediaBuy.', min_length=1, ), + NoExplicitNull, ] = None criteria: Annotated[ product_discovery_criteria.ProductDiscoveryCriteria | None, Field( description='Structured discovery changes for action revise. Each present field replaces that criterion from the source proposal; omitted fields inherit. Concrete targeting, future targeting support, and offer filters belong here rather than being repeated in ask.' ), + NoExplicitNull, ] = None remove_media_buy_frequency_cap: Annotated[ Literal[True] | None, Field( description='Request a revised draft whose commercial terms omit the existing MediaBuy frequency cap. Omission inherits it. To replace it, provide criteria.media_buy_frequency_cap instead.' ), + NoExplicitNull, ] = None @@ -303,18 +326,21 @@ class ProposalRefinement5(AdCPBaseModel): Field( description='Desired successor proposal. cancellation is valid only when the source proposal is accepted and attached to a non-terminal MediaBuy.' ), + NoExplicitNull, ] = ChangeKind.amendment constraints: Annotated[ Constraints3 | None, Field( description='Hard requirements for every revised proposal. Keys are echoed in unsatisfied_constraints on partial or unable results. A draft that fails any present constraint MUST NOT be returned as revised: the result is partial or unable with reason_code constraint_unsatisfiable — which takes precedence over every other reason code — and the failing keys in unsatisfied_constraints.' ), + NoExplicitNull, ] = None product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Product IDs mapped to membership actions checked against commercial_terms.purchases. include requires presence; omit requires absence. The verbs match legacy get_products refinement.' ), + NoExplicitNull, ] = None alternatives: Annotated[ Alternatives, @@ -328,18 +354,21 @@ class ProposalRefinement5(AdCPBaseModel): description='What the buyer is asking for on this proposal, or the cancellation reason for action revise. The seller returns a new draft proposal snapshot; this text never directly mutates the source proposal or MediaBuy.', min_length=1, ), + NoExplicitNull, ] = None criteria: Annotated[ product_discovery_criteria.ProductDiscoveryCriteria | None, Field( description='Structured discovery changes for action revise. Each present field replaces that criterion from the source proposal; omitted fields inherit. Concrete targeting, future targeting support, and offer filters belong here rather than being repeated in ask.' ), + NoExplicitNull, ] = None remove_media_buy_frequency_cap: Annotated[ Literal[True] | None, Field( description='Request a revised draft whose commercial terms omit the existing MediaBuy frequency cap. Omission inherits it. To replace it, provide criteria.media_buy_frequency_cap instead.' ), + NoExplicitNull, ] = None @@ -359,24 +388,28 @@ class ProposalRefinement6(AdCPBaseModel): Field( description='Desired successor proposal. cancellation is valid only when the source proposal is accepted and attached to a non-terminal MediaBuy.' ), + NoExplicitNull, ] = ChangeKind.amendment constraints: Annotated[ Constraints4 | None, Field( description='Hard requirements for every revised proposal. Keys are echoed in unsatisfied_constraints on partial or unable results. A draft that fails any present constraint MUST NOT be returned as revised: the result is partial or unable with reason_code constraint_unsatisfiable — which takes precedence over every other reason code — and the failing keys in unsatisfied_constraints.' ), + NoExplicitNull, ] = None product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Product IDs mapped to membership actions checked against commercial_terms.purchases. include requires presence; omit requires absence. The verbs match legacy get_products refinement.' ), + NoExplicitNull, ] = None alternatives: Annotated[ Alternatives | None, Field( description='Request drafts with distinct commercial terms by count. Every returned alternative MUST carry distinct commercial_terms and therefore a unique terms_digest; diversity preferences remain in ask.' ), + NoExplicitNull, ] = None ask: Annotated[ str, @@ -390,12 +423,14 @@ class ProposalRefinement6(AdCPBaseModel): Field( description='Structured discovery changes for action revise. Each present field replaces that criterion from the source proposal; omitted fields inherit. Concrete targeting, future targeting support, and offer filters belong here rather than being repeated in ask.' ), + NoExplicitNull, ] = None remove_media_buy_frequency_cap: Annotated[ Literal[True] | None, Field( description='Request a revised draft whose commercial terms omit the existing MediaBuy frequency cap. Omission inherits it. To replace it, provide criteria.media_buy_frequency_cap instead.' ), + NoExplicitNull, ] = None @@ -415,24 +450,28 @@ class ProposalRefinement7(AdCPBaseModel): Field( description='Desired successor proposal. cancellation is valid only when the source proposal is accepted and attached to a non-terminal MediaBuy.' ), + NoExplicitNull, ] = ChangeKind.amendment constraints: Annotated[ Constraints5 | None, Field( description='Hard requirements for every revised proposal. Keys are echoed in unsatisfied_constraints on partial or unable results. A draft that fails any present constraint MUST NOT be returned as revised: the result is partial or unable with reason_code constraint_unsatisfiable — which takes precedence over every other reason code — and the failing keys in unsatisfied_constraints.' ), + NoExplicitNull, ] = None product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Product IDs mapped to membership actions checked against commercial_terms.purchases. include requires presence; omit requires absence. The verbs match legacy get_products refinement.' ), + NoExplicitNull, ] = None alternatives: Annotated[ Alternatives | None, Field( description='Request drafts with distinct commercial terms by count. Every returned alternative MUST carry distinct commercial_terms and therefore a unique terms_digest; diversity preferences remain in ask.' ), + NoExplicitNull, ] = None ask: Annotated[ str | None, @@ -440,6 +479,7 @@ class ProposalRefinement7(AdCPBaseModel): description='What the buyer is asking for on this proposal, or the cancellation reason for action revise. The seller returns a new draft proposal snapshot; this text never directly mutates the source proposal or MediaBuy.', min_length=1, ), + NoExplicitNull, ] = None criteria: Annotated[ product_discovery_criteria.ProductDiscoveryCriteria, @@ -452,6 +492,7 @@ class ProposalRefinement7(AdCPBaseModel): Field( description='Request a revised draft whose commercial terms omit the existing MediaBuy frequency cap. Omission inherits it. To replace it, provide criteria.media_buy_frequency_cap instead.' ), + NoExplicitNull, ] = None @@ -471,24 +512,28 @@ class ProposalRefinement8(AdCPBaseModel): Field( description='Desired successor proposal. cancellation is valid only when the source proposal is accepted and attached to a non-terminal MediaBuy.' ), + NoExplicitNull, ] = ChangeKind.amendment constraints: Annotated[ Constraints6 | None, Field( description='Hard requirements for every revised proposal. Keys are echoed in unsatisfied_constraints on partial or unable results. A draft that fails any present constraint MUST NOT be returned as revised: the result is partial or unable with reason_code constraint_unsatisfiable — which takes precedence over every other reason code — and the failing keys in unsatisfied_constraints.' ), + NoExplicitNull, ] = None product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Product IDs mapped to membership actions checked against commercial_terms.purchases. include requires presence; omit requires absence. The verbs match legacy get_products refinement.' ), + NoExplicitNull, ] = None alternatives: Annotated[ Alternatives | None, Field( description='Request drafts with distinct commercial terms by count. Every returned alternative MUST carry distinct commercial_terms and therefore a unique terms_digest; diversity preferences remain in ask.' ), + NoExplicitNull, ] = None ask: Annotated[ str | None, @@ -496,12 +541,14 @@ class ProposalRefinement8(AdCPBaseModel): description='What the buyer is asking for on this proposal, or the cancellation reason for action revise. The seller returns a new draft proposal snapshot; this text never directly mutates the source proposal or MediaBuy.', min_length=1, ), + NoExplicitNull, ] = None criteria: Annotated[ product_discovery_criteria.ProductDiscoveryCriteria | None, Field( description='Structured discovery changes for action revise. Each present field replaces that criterion from the source proposal; omitted fields inherit. Concrete targeting, future targeting support, and offer filters belong here rather than being repeated in ask.' ), + NoExplicitNull, ] = None remove_media_buy_frequency_cap: Annotated[ Literal[True], @@ -533,18 +580,21 @@ class ProposalRefinement9(AdCPBaseModel): Field( description='Hard requirements for every revised proposal. Keys are echoed in unsatisfied_constraints on partial or unable results. A draft that fails any present constraint MUST NOT be returned as revised: the result is partial or unable with reason_code constraint_unsatisfiable — which takes precedence over every other reason code — and the failing keys in unsatisfied_constraints.' ), + NoExplicitNull, ] = None product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Product IDs mapped to membership actions checked against commercial_terms.purchases. include requires presence; omit requires absence. The verbs match legacy get_products refinement.' ), + NoExplicitNull, ] = None alternatives: Annotated[ Alternatives | None, Field( description='Request drafts with distinct commercial terms by count. Every returned alternative MUST carry distinct commercial_terms and therefore a unique terms_digest; diversity preferences remain in ask.' ), + NoExplicitNull, ] = None ask: Annotated[ str | None, @@ -552,18 +602,21 @@ class ProposalRefinement9(AdCPBaseModel): description='What the buyer is asking for on this proposal, or the cancellation reason for action revise. The seller returns a new draft proposal snapshot; this text never directly mutates the source proposal or MediaBuy.', min_length=1, ), + NoExplicitNull, ] = None criteria: Annotated[ product_discovery_criteria.ProductDiscoveryCriteria | None, Field( description='Structured discovery changes for action revise. Each present field replaces that criterion from the source proposal; omitted fields inherit. Concrete targeting, future targeting support, and offer filters belong here rather than being repeated in ask.' ), + NoExplicitNull, ] = None remove_media_buy_frequency_cap: Annotated[ Literal[True] | None, Field( description='Request a revised draft whose commercial terms omit the existing MediaBuy frequency cap. Omission inherits it. To replace it, provide criteria.media_buy_frequency_cap instead.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/media_buy/provide_performance_feedback_request.py b/src/adcp/types/domains/media_buy/provide_performance_feedback_request.py index f4583aacd..78d7cd726 100644 --- a/src/adcp/types/domains/media_buy/provide_performance_feedback_request.py +++ b/src/adcp/types/domains/media_buy/provide_performance_feedback_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/provide_performance_feedback_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -29,5 +30,5 @@ class ProvidePerformanceFeedbackRequest(AdcpRequest, AdcpVersionEnvelope, Perfor pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/refine_proposals_request.py b/src/adcp/types/domains/media_buy/refine_proposals_request.py index 4c726c0b5..8aae650d2 100644 --- a/src/adcp/types/domains/media_buy/refine_proposals_request.py +++ b/src/adcp/types/domains/media_buy/refine_proposals_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/refine_proposals_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -32,6 +32,7 @@ class RefineProposalsRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -41,6 +42,7 @@ class RefineProposalsRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None context_id: Annotated[ str | None, @@ -48,10 +50,15 @@ class RefineProposalsRequest(AdcpRequest, AdcpVersionEnvelope): description='MCP compatibility field: servers ignore this value; A2A uses transport-native Message/Task contextId.', min_length=1, ), + NoExplicitNull, + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + governance_context: Annotated[ + str | None, Field(max_length=4096, min_length=1), NoExplicitNull + ] = None + push_notification_config: Annotated[ + push_notification_config_1.PushNotificationConfig | None, NoExplicitNull ] = None - context: context_1.ContextObject | None = None - governance_context: Annotated[str | None, Field(max_length=4096, min_length=1)] = None - push_notification_config: push_notification_config_1.PushNotificationConfig | None = None idempotency_key: Annotated[ str, Field( diff --git a/src/adcp/types/domains/media_buy/refine_proposals_response.py b/src/adcp/types/domains/media_buy/refine_proposals_response.py index 3a5ea7f32..bb2621ddc 100644 --- a/src/adcp/types/domains/media_buy/refine_proposals_response.py +++ b/src/adcp/types/domains/media_buy/refine_proposals_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/refine_proposals_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat @@ -48,9 +48,9 @@ class TotalBudgetGuidance(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - min: Annotated[StrictFloat | None, Field(ge=0.0)] = None - recommended: Annotated[StrictFloat | None, Field(ge=0.0)] = None - max: Annotated[StrictFloat | None, Field(ge=0.0)] = None + min: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + recommended: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None + max: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] @@ -86,7 +86,7 @@ class Proposal(AdCPBaseModel): min_length=1, ), ] - media_buy_id: Annotated[str | None, Field(min_length=1)] = None + media_buy_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None opportunity_id: Annotated[ str | None, Field( @@ -95,8 +95,9 @@ class Proposal(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None - base_media_buy_revision: Annotated[SchemaInt | None, Field(ge=1)] = None + base_media_buy_revision: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None proposal_status: Annotated[ Literal['draft'], Field( @@ -104,16 +105,17 @@ class Proposal(AdCPBaseModel): title='Proposal Status', ), ] = 'draft' - accepted_at: AwareDatetime | None = None + accepted_at: Annotated[AwareDatetime | None, NoExplicitNull] = None expires_at: Annotated[ AwareDatetime | None, Field( description='For a draft, the indicative-terms freshness deadline. For a committed proposal, the inventory-hold deadline.' ), + NoExplicitNull, ] = None name: Annotated[str, Field(max_length=500, min_length=1)] - description: Annotated[str | None, Field(max_length=2000)] = None - brief_alignment: Annotated[str | None, Field(max_length=2000)] = None + description: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + brief_alignment: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None commercial_terms: commercial_terms_1.CommercialTerms terms_digest: Annotated[ str, @@ -122,18 +124,20 @@ class Proposal(AdCPBaseModel): pattern='^sha256:[A-Za-z0-9_-]{43}$', ), ] - insertion_order: insertion_order_1.InsertionOrder | None = None + insertion_order: Annotated[insertion_order_1.InsertionOrder | None, NoExplicitNull] = None total_budget_guidance: Annotated[ TotalBudgetGuidance | None, Field( description="Optional budget guidance for this proposal — the planning answer to criteria.outcome_target and to open-budget briefs. commercial_terms.total_budget remains the concrete figure the plan is priced at; this band expresses the seller's recommended range around it. When criteria.outcome_target carries cost_per, the cost answer is commercial_terms.bidding.cost_per and this band's currency equals cost_per.currency." ), + NoExplicitNull, ] = None forecast: Annotated[ canonical_delivery_forecast.CanonicalDeliveryForecast | None, Field( description="Aggregate forecasted delivery for the proposal. For outcome_target requests, points carry the goal's metric or event key in metrics; with cost_per, that is the goal volume planned under the commercial_terms.bidding policy, and currency equals cost_per.currency." ), + NoExplicitNull, ] = None @@ -155,7 +159,7 @@ class Proposal3(AdCPBaseModel): min_length=1, ), ] - media_buy_id: Annotated[str | None, Field(min_length=1)] = None + media_buy_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None opportunity_id: Annotated[ str | None, Field( @@ -164,8 +168,9 @@ class Proposal3(AdCPBaseModel): min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$', ), + NoExplicitNull, ] = None - base_media_buy_revision: Annotated[SchemaInt | None, Field(ge=1)] = None + base_media_buy_revision: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None proposal_status: Annotated[ Literal['committed'], Field( @@ -173,7 +178,7 @@ class Proposal3(AdCPBaseModel): title='Proposal Status', ), ] = 'committed' - accepted_at: AwareDatetime | None = None + accepted_at: Annotated[AwareDatetime | None, NoExplicitNull] = None expires_at: Annotated[ AwareDatetime, Field( @@ -181,8 +186,8 @@ class Proposal3(AdCPBaseModel): ), ] name: Annotated[str, Field(max_length=500, min_length=1)] - description: Annotated[str | None, Field(max_length=2000)] = None - brief_alignment: Annotated[str | None, Field(max_length=2000)] = None + description: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + brief_alignment: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None commercial_terms: commercial_terms_1.CommercialTerms terms_digest: Annotated[ str, @@ -191,18 +196,20 @@ class Proposal3(AdCPBaseModel): pattern='^sha256:[A-Za-z0-9_-]{43}$', ), ] - insertion_order: insertion_order_1.InsertionOrder | None = None + insertion_order: Annotated[insertion_order_1.InsertionOrder | None, NoExplicitNull] = None total_budget_guidance: Annotated[ TotalBudgetGuidance | None, Field( description="Optional budget guidance for this proposal — the planning answer to criteria.outcome_target and to open-budget briefs. commercial_terms.total_budget remains the concrete figure the plan is priced at; this band expresses the seller's recommended range around it. When criteria.outcome_target carries cost_per, the cost answer is commercial_terms.bidding.cost_per and this band's currency equals cost_per.currency." ), + NoExplicitNull, ] = None forecast: Annotated[ canonical_delivery_forecast.CanonicalDeliveryForecast | None, Field( description="Aggregate forecasted delivery for the proposal. For outcome_target requests, points carry the goal's metric or event key in metrics; with cost_per, that is the goal volume planned under the commercial_terms.bidding policy, and currency equals cost_per.currency." ), + NoExplicitNull, ] = None @@ -224,7 +231,7 @@ class Results9(AdCPBaseModel): ) source_proposal_id: Annotated[str, Field(min_length=1)] outcome: Literal['revised'] = 'revised' - proposal: canonical_proposal.CanonicalProposal | None = None + proposal: Annotated[canonical_proposal.CanonicalProposal | None, NoExplicitNull] = None proposals: Annotated[ list[Proposal], Field( @@ -237,27 +244,31 @@ class Results9(AdCPBaseModel): Field( description='Single most-significant code for this result. constraint_unsatisfiable takes precedence over every other code; an alternatives shortfall alongside an unsatisfied constraint remains visible through proposals.length.' ), + NoExplicitNull, ] = None - reason: Annotated[str | None, Field(min_length=1)] = None + reason: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None unsatisfied_constraints: Annotated[ list[UnsatisfiedConstraint] | None, Field( description='Stable keys from the request constraints object that were not satisfied by every returned draft. A result carrying any key here MUST use outcome partial or unable, never revised.', min_length=1, ), + NoExplicitNull, ] = None unsatisfied_product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Requested product actions not satisfied by every returned draft. This is a subset of the request product_changes map and is valid only on partial or unable results.' ), + NoExplicitNull, ] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description="Confirmation of structured targeting interpreted from this refinement's explicit hard prose instructions. Required when that interpretation materially affects the revised proposal's eligibility, pricing, or forecast; otherwise a best practice." ), + NoExplicitNull, ] = None @@ -267,7 +278,7 @@ class Results10(AdCPBaseModel): ) source_proposal_id: Annotated[str, Field(min_length=1)] outcome: Literal['partial'] = 'partial' - proposal: canonical_proposal.CanonicalProposal | None = None + proposal: Annotated[canonical_proposal.CanonicalProposal | None, NoExplicitNull] = None proposals: Annotated[ list[Proposal2], Field( @@ -288,19 +299,22 @@ class Results10(AdCPBaseModel): description='Stable keys from the request constraints object that were not satisfied by every returned draft. A result carrying any key here MUST use outcome partial or unable, never revised.', min_length=1, ), + NoExplicitNull, ] = None unsatisfied_product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Requested product actions not satisfied by every returned draft. This is a subset of the request product_changes map and is valid only on partial or unable results.' ), + NoExplicitNull, ] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description="Confirmation of structured targeting interpreted from this refinement's explicit hard prose instructions. Required when that interpretation materially affects the revised proposal's eligibility, pricing, or forecast; otherwise a best practice." ), + NoExplicitNull, ] = None @@ -323,33 +337,38 @@ class Results11(AdCPBaseModel): description='Draft successors produced for a revision. Without alternatives this contains one proposal. With alternatives.count, revised contains exactly that many proposals with unique terms_digest values; fewer or commercially duplicate proposals require partial.', min_length=1, ), + NoExplicitNull, ] = None reason_code: Annotated[ proposal_refinement_reason.ProposalRefinementReason | None, Field( description='Single most-significant code for this result. constraint_unsatisfiable takes precedence over every other code; an alternatives shortfall alongside an unsatisfied constraint remains visible through proposals.length.' ), + NoExplicitNull, ] = None - reason: Annotated[str | None, Field(min_length=1)] = None + reason: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None unsatisfied_constraints: Annotated[ list[UnsatisfiedConstraint] | None, Field( description='Stable keys from the request constraints object that were not satisfied by every returned draft. A result carrying any key here MUST use outcome partial or unable, never revised.', min_length=1, ), + NoExplicitNull, ] = None unsatisfied_product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Requested product actions not satisfied by every returned draft. This is a subset of the request product_changes map and is valid only on partial or unable results.' ), + NoExplicitNull, ] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description="Confirmation of structured targeting interpreted from this refinement's explicit hard prose instructions. Required when that interpretation materially affects the revised proposal's eligibility, pricing, or forecast; otherwise a best practice." ), + NoExplicitNull, ] = None @@ -359,13 +378,14 @@ class Results12(AdCPBaseModel): ) source_proposal_id: Annotated[str, Field(min_length=1)] outcome: Literal['unable'] = 'unable' - proposal: canonical_proposal.CanonicalProposal | None = None + proposal: Annotated[canonical_proposal.CanonicalProposal | None, NoExplicitNull] = None proposals: Annotated[ list[canonical_proposal.CanonicalProposal] | None, Field( description='Draft successors produced for a revision. Without alternatives this contains one proposal. With alternatives.count, revised contains exactly that many proposals with unique terms_digest values; fewer or commercially duplicate proposals require partial.', min_length=1, ), + NoExplicitNull, ] = None reason_code: Annotated[ proposal_refinement_reason.ProposalRefinementReason, @@ -380,19 +400,22 @@ class Results12(AdCPBaseModel): description='Stable keys from the request constraints object that were not satisfied by every returned draft. A result carrying any key here MUST use outcome partial or unable, never revised.', min_length=1, ), + NoExplicitNull, ] = None unsatisfied_product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Requested product actions not satisfied by every returned draft. This is a subset of the request product_changes map and is valid only on partial or unable results.' ), + NoExplicitNull, ] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description="Confirmation of structured targeting interpreted from this refinement's explicit hard prose instructions. Required when that interpretation materially affects the revised proposal's eligibility, pricing, or forecast; otherwise a best practice." ), + NoExplicitNull, ] = None @@ -408,7 +431,7 @@ class Results15(AdCPBaseModel): ) source_proposal_id: Annotated[str, Field(min_length=1)] outcome: Literal['revised'] = 'revised' - proposal: canonical_proposal.CanonicalProposal | None = None + proposal: Annotated[canonical_proposal.CanonicalProposal | None, NoExplicitNull] = None proposals: Annotated[ list[Proposal4], Field( @@ -421,27 +444,31 @@ class Results15(AdCPBaseModel): Field( description='Single most-significant code for this result. constraint_unsatisfiable takes precedence over every other code; an alternatives shortfall alongside an unsatisfied constraint remains visible through proposals.length.' ), + NoExplicitNull, ] = None - reason: Annotated[str | None, Field(min_length=1)] = None + reason: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None unsatisfied_constraints: Annotated[ list[UnsatisfiedConstraint] | None, Field( description='Stable keys from the request constraints object that were not satisfied by every returned draft. A result carrying any key here MUST use outcome partial or unable, never revised.', min_length=1, ), + NoExplicitNull, ] = None unsatisfied_product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Requested product actions not satisfied by every returned draft. This is a subset of the request product_changes map and is valid only on partial or unable results.' ), + NoExplicitNull, ] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description="Confirmation of structured targeting interpreted from this refinement's explicit hard prose instructions. Required when that interpretation materially affects the revised proposal's eligibility, pricing, or forecast; otherwise a best practice." ), + NoExplicitNull, ] = None @@ -451,7 +478,7 @@ class Results16(AdCPBaseModel): ) source_proposal_id: Annotated[str, Field(min_length=1)] outcome: Literal['partial'] = 'partial' - proposal: canonical_proposal.CanonicalProposal | None = None + proposal: Annotated[canonical_proposal.CanonicalProposal | None, NoExplicitNull] = None proposals: Annotated[ list[Proposal5], Field( @@ -472,19 +499,22 @@ class Results16(AdCPBaseModel): description='Stable keys from the request constraints object that were not satisfied by every returned draft. A result carrying any key here MUST use outcome partial or unable, never revised.', min_length=1, ), + NoExplicitNull, ] = None unsatisfied_product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Requested product actions not satisfied by every returned draft. This is a subset of the request product_changes map and is valid only on partial or unable results.' ), + NoExplicitNull, ] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description="Confirmation of structured targeting interpreted from this refinement's explicit hard prose instructions. Required when that interpretation materially affects the revised proposal's eligibility, pricing, or forecast; otherwise a best practice." ), + NoExplicitNull, ] = None @@ -507,33 +537,38 @@ class Results17(AdCPBaseModel): description='Draft successors produced for a revision. Without alternatives this contains one proposal. With alternatives.count, revised contains exactly that many proposals with unique terms_digest values; fewer or commercially duplicate proposals require partial.', min_length=1, ), + NoExplicitNull, ] = None reason_code: Annotated[ proposal_refinement_reason.ProposalRefinementReason | None, Field( description='Single most-significant code for this result. constraint_unsatisfiable takes precedence over every other code; an alternatives shortfall alongside an unsatisfied constraint remains visible through proposals.length.' ), + NoExplicitNull, ] = None - reason: Annotated[str | None, Field(min_length=1)] = None + reason: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None unsatisfied_constraints: Annotated[ list[UnsatisfiedConstraint] | None, Field( description='Stable keys from the request constraints object that were not satisfied by every returned draft. A result carrying any key here MUST use outcome partial or unable, never revised.', min_length=1, ), + NoExplicitNull, ] = None unsatisfied_product_changes: Annotated[ product_change_map.ProductChangeMap | None, Field( description='Requested product actions not satisfied by every returned draft. This is a subset of the request product_changes map and is valid only on partial or unable results.' ), + NoExplicitNull, ] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description="Confirmation of structured targeting interpreted from this refinement's explicit hard prose instructions. Required when that interpretation materially affects the revised proposal's eligibility, pricing, or forecast; otherwise a best practice." ), + NoExplicitNull, ] = None @@ -558,6 +593,7 @@ class RefineProposalsResponse1(AdcpResponse, AdCPBaseModel): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None results: Annotated[ list[Results8] | Results, @@ -573,12 +609,12 @@ class RefineProposalsResponse1(AdcpResponse, AdCPBaseModel): ), ] status: Literal['completed'] = 'completed' - task_id: Annotated[str | None, Field(min_length=1)] = None - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + task_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class RefineProposalsResponse2(AdcpResponse, CompactTaskSubmitted): @@ -592,6 +628,7 @@ class RefineProposalsResponse2(AdcpResponse, CompactTaskSubmitted): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None results: Annotated[ list[Results14] | Results | None, @@ -599,20 +636,22 @@ class RefineProposalsResponse2(AdcpResponse, CompactTaskSubmitted): description="Ordered results. If any result is finalized, every result MUST be finalized; a finalize batch either creates every requested hold or none. Every returned proposal carries parent_proposal_id equal to the result's source_proposal_id, making negotiation lineage reconstructible from the proposals alone.", min_length=1, ), + NoExplicitNull, ] = None products: Annotated[ list[canonical_product.CanonicalProduct] | None, Field( description="Canonical products needed to evaluate the resulting terms. For revised or partial results whose effective criteria contain property or collection lists, each affected product MUST carry fresh list_applications receipts from the revision's product reevaluation. Finalization changes no terms and MAY repeat the receipts already bound to the source proposal rather than reevaluating them." ), + NoExplicitNull, ] = None - status: Status | None = None - task_id: Annotated[str | None, Field(min_length=1)] = None - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + status: Annotated[Status | None, NoExplicitNull] = None + task_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None RefineProposalsResponse = Annotated[ diff --git a/src/adcp/types/domains/media_buy/request_proposals_request.py b/src/adcp/types/domains/media_buy/request_proposals_request.py index d16ee042f..7a1b7187b 100644 --- a/src/adcp/types/domains/media_buy/request_proposals_request.py +++ b/src/adcp/types/domains/media_buy/request_proposals_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/request_proposals_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,6 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, model_validator from ..core import brand_key, canonical_account_ref @@ -47,6 +48,7 @@ class RequestProposalsRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -56,6 +58,7 @@ class RequestProposalsRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None context_id: Annotated[ str | None, @@ -63,10 +66,15 @@ class RequestProposalsRequest(AdcpRequest, AdcpVersionEnvelope): description='MCP compatibility field: servers ignore this value; A2A uses transport-native Message/Task contextId.', min_length=1, ), + NoExplicitNull, + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + governance_context: Annotated[ + str | None, Field(max_length=4096, min_length=1), NoExplicitNull + ] = None + push_notification_config: Annotated[ + push_notification_config_1.PushNotificationConfig | None, NoExplicitNull ] = None - context: context_1.ContextObject | None = None - governance_context: Annotated[str | None, Field(max_length=4096, min_length=1)] = None - push_notification_config: push_notification_config_1.PushNotificationConfig | None = None idempotency_key: Annotated[ str, Field( @@ -81,12 +89,14 @@ class RequestProposalsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Alternative brand source for proposal terms. Provide either this natural-key account containing brand and operator or top-level brand, not both.' ), + NoExplicitNull, ] = None brand: Annotated[ brand_key.BrandKey | None, Field( description='Alternative brand source for proposal terms. Provide either top-level brand or a natural-key account containing brand and operator, not both.' ), + NoExplicitNull, ] = None brief: Annotated[ str, @@ -95,12 +105,15 @@ class RequestProposalsRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, ), ] - criteria: product_discovery_criteria.ProductDiscoveryCriteria | None = None + criteria: Annotated[ + product_discovery_criteria.ProductDiscoveryCriteria | None, NoExplicitNull + ] = None opportunity: Annotated[ Opportunity | None, Field( description='Optional planning-cycle context that the seller associates with every proposal created by this request.' ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/media_buy/request_proposals_response.py b/src/adcp/types/domains/media_buy/request_proposals_response.py index a83da6b01..5f7b864b6 100644 --- a/src/adcp/types/domains/media_buy/request_proposals_response.py +++ b/src/adcp/types/domains/media_buy/request_proposals_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/request_proposals_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AwareDatetime, ConfigDict, Field @@ -53,7 +53,7 @@ class IncompleteItem(AdCPBaseModel): ) scope: Scope description: Annotated[str, Field(min_length=1)] - estimated_wait: duration.Duration | None = None + estimated_wait: Annotated[duration.Duration | None, NoExplicitNull] = None class ProductId(Suggestion): @@ -86,6 +86,7 @@ class PurchaseContinuation(AdCPBaseModel): description='Real seller-issued pricing fence from the same account-scoped list_products response, when the seller versions pricing independently.', min_length=1, ), + NoExplicitNull, ] = None @@ -215,10 +216,11 @@ class RequestProposalsResponse1(AdcpResponse, AdCPBaseModel): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None outcome: Literal['proposed'] = 'proposed' - reason: Annotated[str | None, Field(min_length=1)] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None + reason: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None proposals: Annotated[list[Proposal], Field(min_length=1)] products: Annotated[list[canonical_product.CanonicalProduct], Field(min_length=1)] incomplete: Annotated[ @@ -227,6 +229,7 @@ class RequestProposalsResponse1(AdcpResponse, AdCPBaseModel): description='Usable partial discovery result retained from the established get_products response. Absence means the source response did not declare an incomplete scope; it does not authorize an adapter to infer missing proposal terms.', min_length=1, ), + NoExplicitNull, ] = None purchase_continuation: Annotated[ PurchaseContinuation | PurchaseContinuation1 | None, @@ -234,20 +237,22 @@ class RequestProposalsResponse1(AdcpResponse, AdCPBaseModel): deprecated=True, description='Deprecated AdCP 3.x projection instruction for purchasing products returned without a proposal. This is coordinator state, not a claim that the established seller implements a compact task. A native 3.2 seller MUST NOT emit it.', ), + NoExplicitNull, ] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description='Response-level confirmation of structured targeting interpreted from explicit hard requirements in the brief. Sellers MUST emit this when that interpretation materially affects eligibility, pricing, or forecasting; otherwise it remains a best practice. Product-specific changes to explicit criteria.targeting_overlay values remain on Product.targeting_resolution.' ), + NoExplicitNull, ] = None status: Literal['completed'] = 'completed' - task_id: Annotated[str | None, Field(min_length=1)] = None - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + task_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class RequestProposalsResponse2(AdcpResponse, AdCPBaseModel): @@ -261,11 +266,12 @@ class RequestProposalsResponse2(AdcpResponse, AdCPBaseModel): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None outcome: Literal['products_available'] = 'products_available' - reason: Annotated[str | None, Field(min_length=1)] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None - proposals: Annotated[list[Proposal] | None, Field(min_length=1)] = None + reason: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None + proposals: Annotated[list[Proposal] | None, Field(min_length=1), NoExplicitNull] = None products: Annotated[list[canonical_product.CanonicalProduct], Field(min_length=1)] incomplete: Annotated[ list[IncompleteItem5] | None, @@ -273,6 +279,7 @@ class RequestProposalsResponse2(AdcpResponse, AdCPBaseModel): description='Usable partial discovery result retained from the established get_products response. Absence means the source response did not declare an incomplete scope; it does not authorize an adapter to infer missing proposal terms.', min_length=1, ), + NoExplicitNull, ] = None purchase_continuation: Annotated[ PurchaseContinuation2 | PurchaseContinuation3, @@ -286,14 +293,15 @@ class RequestProposalsResponse2(AdcpResponse, AdCPBaseModel): Field( description='Response-level confirmation of structured targeting interpreted from explicit hard requirements in the brief. Sellers MUST emit this when that interpretation materially affects eligibility, pricing, or forecasting; otherwise it remains a best practice. Product-specific changes to explicit criteria.targeting_overlay values remain on Product.targeting_resolution.' ), + NoExplicitNull, ] = None status: Literal['completed'] = 'completed' - task_id: Annotated[str | None, Field(min_length=1)] = None - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + task_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class RequestProposalsResponse3(AdcpResponse, AdCPBaseModel): @@ -307,18 +315,22 @@ class RequestProposalsResponse3(AdcpResponse, AdCPBaseModel): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None outcome: Literal['rejected'] = 'rejected' reason: Annotated[str, Field(min_length=1)] - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None - proposals: Annotated[list[Proposal] | None, Field(min_length=1)] = None - products: Annotated[list[canonical_product.CanonicalProduct] | None, Field(min_length=1)] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None + proposals: Annotated[list[Proposal] | None, Field(min_length=1), NoExplicitNull] = None + products: Annotated[ + list[canonical_product.CanonicalProduct] | None, Field(min_length=1), NoExplicitNull + ] = None incomplete: Annotated[ list[IncompleteItem6] | None, Field( description='Usable partial discovery result retained from the established get_products response. Absence means the source response did not declare an incomplete scope; it does not authorize an adapter to infer missing proposal terms.', min_length=1, ), + NoExplicitNull, ] = None purchase_continuation: Annotated[ PurchaseContinuation4 | PurchaseContinuation5 | None, @@ -326,20 +338,22 @@ class RequestProposalsResponse3(AdcpResponse, AdCPBaseModel): deprecated=True, description='Deprecated AdCP 3.x projection instruction for purchasing products returned without a proposal. This is coordinator state, not a claim that the established seller implements a compact task. A native 3.2 seller MUST NOT emit it.', ), + NoExplicitNull, ] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description='Response-level confirmation of structured targeting interpreted from explicit hard requirements in the brief. Sellers MUST emit this when that interpretation materially affects eligibility, pricing, or forecasting; otherwise it remains a best practice. Product-specific changes to explicit criteria.targeting_overlay values remain on Product.targeting_resolution.' ), + NoExplicitNull, ] = None status: Literal['completed'] = 'completed' - task_id: Annotated[str | None, Field(min_length=1)] = None - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + task_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None class RequestProposalsResponse4(AdcpResponse, CompactTaskSubmitted): @@ -353,18 +367,22 @@ class RequestProposalsResponse4(AdcpResponse, CompactTaskSubmitted): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, + ] = None + outcome: Annotated[Outcome | None, NoExplicitNull] = None + reason: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + suggestions: Annotated[list[Suggestion] | None, Field(min_length=1), NoExplicitNull] = None + proposals: Annotated[list[Proposal] | None, Field(min_length=1), NoExplicitNull] = None + products: Annotated[ + list[canonical_product.CanonicalProduct] | None, Field(min_length=1), NoExplicitNull ] = None - outcome: Outcome | None = None - reason: Annotated[str | None, Field(min_length=1)] = None - suggestions: Annotated[list[Suggestion] | None, Field(min_length=1)] = None - proposals: Annotated[list[Proposal] | None, Field(min_length=1)] = None - products: Annotated[list[canonical_product.CanonicalProduct] | None, Field(min_length=1)] = None incomplete: Annotated[ list[IncompleteItem7] | None, Field( description='Usable partial discovery result retained from the established get_products response. Absence means the source response did not declare an incomplete scope; it does not authorize an adapter to infer missing proposal terms.', min_length=1, ), + NoExplicitNull, ] = None purchase_continuation: Annotated[ PurchaseContinuation6 | PurchaseContinuation7 | None, @@ -372,20 +390,22 @@ class RequestProposalsResponse4(AdcpResponse, CompactTaskSubmitted): deprecated=True, description='Deprecated AdCP 3.x projection instruction for purchasing products returned without a proposal. This is coordinator state, not a claim that the established seller implements a compact task. A native 3.2 seller MUST NOT emit it.', ), + NoExplicitNull, ] = None targeting_resolution: Annotated[ get_products_targeting_resolution.ProductDiscoveryTargetingResolution | None, Field( description='Response-level confirmation of structured targeting interpreted from explicit hard requirements in the brief. Sellers MUST emit this when that interpretation materially affects eligibility, pricing, or forecasting; otherwise it remains a best practice. Product-specific changes to explicit criteria.targeting_overlay values remain on Product.targeting_resolution.' ), + NoExplicitNull, ] = None - status: Status | None = None - task_id: Annotated[str | None, Field(min_length=1)] = None - message: Annotated[str | None, Field(max_length=2000)] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None - replayed: Literal[True] | None = None + status: Annotated[Status | None, NoExplicitNull] = None + task_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None + message: Annotated[str | None, Field(max_length=2000), NoExplicitNull] = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None + replayed: Annotated[Literal[True] | None, NoExplicitNull] = None RequestProposalsResponse = Annotated[ diff --git a/src/adcp/types/domains/media_buy/sync_audiences_request.py b/src/adcp/types/domains/media_buy/sync_audiences_request.py index dacc27e58..3db210466 100644 --- a/src/adcp/types/domains/media_buy/sync_audiences_request.py +++ b/src/adcp/types/domains/media_buy/sync_audiences_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/sync_audiences_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool @@ -41,24 +41,29 @@ class Audience(AdCPBaseModel): description="Buyer's identifier for this audience. Used to reference the audience in targeting overlays." ), ] - name: Annotated[str | None, Field(description='Human-readable name for this audience')] = None + name: Annotated[ + str | None, Field(description='Human-readable name for this audience'), NoExplicitNull + ] = None description: Annotated[ str | None, Field( description="Human-readable description of this audience's composition or purpose (e.g., 'High-value customers who purchased in the last 90 days')." ), + NoExplicitNull, ] = None audience_type: Annotated[ AudienceType | None, Field( description="Intended use for this audience. 'crm': target these users. 'suppression': exclude these users from delivery. 'lookalike_seed': use as a seed for the seller's lookalike modeling. Sellers may handle audiences differently based on type (e.g., suppression lists bypass minimum size requirements on some platforms)." ), + NoExplicitNull, ] = None tags: Annotated[ list[Tag] | None, Field( description="Buyer-defined tags for organizing and filtering audiences (e.g., 'holiday_2026', 'high_ltv'). Tags are stored by the seller and returned in discovery-only calls." ), + NoExplicitNull, ] = None add: Annotated[ list[audience_member.AudienceMember] | None, @@ -66,6 +71,7 @@ class Audience(AdCPBaseModel): description='Members to add to this audience. Hashed before sending — normalize emails to lowercase+trim, phones to E.164.', min_length=1, ), + NoExplicitNull, ] = None remove: Annotated[ list[audience_member.AudienceMember] | None, @@ -73,24 +79,28 @@ class Audience(AdCPBaseModel): description='Members to remove from this audience. If the same identifier appears in both add and remove in a single request, remove takes precedence.', min_length=1, ), + NoExplicitNull, ] = None source: Annotated[ audience_source.AudienceSource | None, Field( description='External source reference: the seller ingests membership from a shared dataset or vendor-distributed segment instead of inline member deltas. Mutually exclusive with add/remove — an audience is either buyer-pushed or externally sourced, and transport is fixed at creation: a cross-transport upsert (member deltas against a sourced audience, or source against a pushed one) is rejected with CONFLICT (error.field: audience_id); convert by delete-and-recreate. Only send source kinds whose activation pattern the seller declared via audience_activation (undeclared kinds are rejected with UNSUPPORTED_FEATURE). Experimental — see the media_buy.audience_activation feature.' ), + NoExplicitNull, ] = None delete: Annotated[ StrictBool | None, Field( description='When true, delete this audience from the account entirely. All other fields on this audience object are ignored. Use this to delete a specific audience without affecting others.' ), + NoExplicitNull, ] = None consent_basis: Annotated[ consent_basis_1.ConsentBasis | None, Field( description='GDPR lawful basis for processing this audience list. Informational — not validated by the protocol, but required by some sellers operating in regulated markets (e.g. EU). When omitted, the buyer asserts they have a lawful basis appropriate to their jurisdiction.' ), + NoExplicitNull, ] = None @@ -116,12 +126,14 @@ class SyncAudiencesRequest(AdcpRequest, AdcpVersionEnvelope): description='Audiences to sync (create or update). When omitted, the call is discovery-only and returns all existing audiences on the account without modification.', min_length=1, ), + NoExplicitNull, ] = None delete_missing: Annotated[ StrictBool | None, Field( description='When true, buyer-managed audiences on the account not included in this sync will be removed. Does not affect seller-managed audiences. Do not combine with an omitted audiences array or all buyer-managed audiences will be deleted.' ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/sync_audiences_response.py b/src/adcp/types/domains/media_buy/sync_audiences_response.py index 6dd1e2a27..02bc2dd2c 100644 --- a/src/adcp/types/domains/media_buy/sync_audiences_response.py +++ b/src/adcp/types/domains/media_buy/sync_audiences_response.py @@ -73,7 +73,7 @@ class SyncAudiencesResponse2(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope class SyncAudiencesResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): - model_config = ConfigDict(extra='allow', validate_default=True) + model_config = ConfigDict(extra='allow') status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted task_id: str message: Annotated[str, StringConstraints(max_length=2000)] | None = None diff --git a/src/adcp/types/domains/media_buy/sync_catalogs_async_response_input_required.py b/src/adcp/types/domains/media_buy/sync_catalogs_async_response_input_required.py index c6801acb0..0e2a5fab3 100644 --- a/src/adcp/types/domains/media_buy/sync_catalogs_async_response_input_required.py +++ b/src/adcp/types/domains/media_buy/sync_catalogs_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/sync_catalogs_async_response_input_required.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -30,6 +30,7 @@ class SyncCatalogsInputRequired(AdCPBaseModel): Field( description='Reason code indicating why buyer input is needed. APPROVAL_REQUIRED: platform requires explicit approval before activating the catalog. FEED_VALIDATION: feed URL returned unexpected format or schema errors. ITEM_REVIEW: platform flagged items for manual review. FEED_ACCESS: platform cannot access the feed URL (authentication, CORS, etc.).' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/sync_catalogs_async_response_submitted.py b/src/adcp/types/domains/media_buy/sync_catalogs_async_response_submitted.py index a2852c7fe..95e78e511 100644 --- a/src/adcp/types/domains/media_buy/sync_catalogs_async_response_submitted.py +++ b/src/adcp/types/domains/media_buy/sync_catalogs_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/sync_catalogs_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -36,12 +36,14 @@ class SyncCatalogsSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Catalog ingestion queued; typical turnaround 5–15 minutes.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/sync_catalogs_async_response_working.py b/src/adcp/types/domains/media_buy/sync_catalogs_async_response_working.py index d9691add8..f42da8036 100644 --- a/src/adcp/types/domains/media_buy/sync_catalogs_async_response_working.py +++ b/src/adcp/types/domains/media_buy/sync_catalogs_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/sync_catalogs_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..core import context as context_1 @@ -18,31 +18,44 @@ class SyncCatalogsWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ str | None, Field( description="Current step or phase of the operation (e.g., 'Fetching product feed', 'Validating items', 'Platform review')" ), + NoExplicitNull, ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, + ] = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None catalogs_processed: Annotated[ - SchemaInt | None, Field(description='Number of catalogs processed so far', ge=0) + SchemaInt | None, + Field(description='Number of catalogs processed so far', ge=0), + NoExplicitNull, ] = None catalogs_total: Annotated[ - SchemaInt | None, Field(description='Total number of catalogs to process', ge=0) + SchemaInt | None, + Field(description='Total number of catalogs to process', ge=0), + NoExplicitNull, ] = None items_processed: Annotated[ SchemaInt | None, Field(description='Total number of catalog items processed across all catalogs', ge=0), + NoExplicitNull, ] = None items_total: Annotated[ SchemaInt | None, Field(description='Total number of catalog items to process across all catalogs', ge=0), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/sync_catalogs_request.py b/src/adcp/types/domains/media_buy/sync_catalogs_request.py index df99e8e9f..6b5663e3f 100644 --- a/src/adcp/types/domains/media_buy/sync_catalogs_request.py +++ b/src/adcp/types/domains/media_buy/sync_catalogs_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/sync_catalogs_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import ( @@ -47,6 +48,7 @@ class SyncCatalogsRequest(AdcpRequest, AdcpVersionEnvelope): max_length=50, min_length=1, ), + NoExplicitNull, ] = None item_availability_updates: Annotated[ list[catalog_item_availability_update.CatalogItemAvailabilityUpdate] | None, @@ -55,6 +57,7 @@ class SyncCatalogsRequest(AdcpRequest, AdcpVersionEnvelope): max_length=1000, min_length=1, ), + NoExplicitNull, ] = None item_availability_queries: Annotated[ list[catalog_item_availability_ref.CatalogItemAvailabilityReference] | None, @@ -63,6 +66,7 @@ class SyncCatalogsRequest(AdcpRequest, AdcpVersionEnvelope): max_length=1000, min_length=1, ), + NoExplicitNull, ] = None catalog_ids: Annotated[ list[str] | None, @@ -71,30 +75,35 @@ class SyncCatalogsRequest(AdcpRequest, AdcpVersionEnvelope): max_length=50, min_length=1, ), + NoExplicitNull, ] = None delete_missing: Annotated[ StrictBool | None, Field( description='When true, buyer-managed catalogs on the account not included in this sync will be removed. Does not affect seller-managed catalogs. Requires catalogs; item_availability_updates alone cannot define deletion scope.' ), + NoExplicitNull, ] = False dry_run: Annotated[ StrictBool | None, Field( description='When true, preview catalog create, update, and delete changes without applying them. MUST NOT be combined with item_availability_updates.' ), + NoExplicitNull, ] = False validation_mode: Annotated[ validation_mode_1.ValidationMode | None, Field( description="Validation strictness for semantically valid-looking catalog and item entries. In strict mode (default), an unknown, inaccessible, unauthorized, or stale-generation catalog/item reference, a known seller-managed catalog, a stale expected_overlay_revision, or another per-entry error fails the entire operation before any catalog or availability mutation. In lenient mode, the seller returns a positionally matched failed result for each such item entry and processes the remaining valid entries. Unknown, inaccessible, unauthorized, and stale-generation references MUST be observationally equivalent: code REFERENCE_NOT_FOUND, message exactly 'Catalog item not found', recovery 'correctable', and no field, suggestion, retry_after, issues, details, or resource metadata. Authorization and lookup MUST use the same externally observable failure path and SHOULD avoid materially distinguishable timing. A known seller-managed catalog may use INVALID_REQUEST only after catalog access is authorized. Request-schema failures, duplicate identity tuples, unsupported capability, batch-limit excess, dry_run conflicts, and mixed requests that cannot commit synchronously and atomically are operation-level failures before lookup or mutation in both modes. A stale revision uses CONFLICT without mutation." ), + NoExplicitNull, ] = validation_mode_1.ValidationMode.strict push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Optional webhook configuration for async sync notifications. Publisher will send webhook when sync completes if operation takes longer than immediate response time (common for large feeds requiring platform review).' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/sync_catalogs_response.py b/src/adcp/types/domains/media_buy/sync_catalogs_response.py index 9337b697d..f6767bf71 100644 --- a/src/adcp/types/domains/media_buy/sync_catalogs_response.py +++ b/src/adcp/types/domains/media_buy/sync_catalogs_response.py @@ -67,7 +67,7 @@ class SyncCatalogsResponse2(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) class SyncCatalogsResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): - model_config = ConfigDict(extra='allow', validate_default=True) + model_config = ConfigDict(extra='allow') status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted task_id: str message: Annotated[str, StringConstraints(max_length=2000)] | None = None diff --git a/src/adcp/types/domains/media_buy/sync_event_sources_request.py b/src/adcp/types/domains/media_buy/sync_event_sources_request.py index 4a2abca4d..ef836dd84 100644 --- a/src/adcp/types/domains/media_buy/sync_event_sources_request.py +++ b/src/adcp/types/domains/media_buy/sync_event_sources_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/sync_event_sources_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, StrictBool @@ -31,15 +31,16 @@ class EventSource(AdCPBaseModel): extra='allow', ) event_source_id: Annotated[str, Field(description='Unique identifier for this event source')] - name: Annotated[str | None, Field(description='Human-readable name for this event source')] = ( - None - ) + name: Annotated[ + str | None, Field(description='Human-readable name for this event source'), NoExplicitNull + ] = None event_types: Annotated[ list[event_type.EventType] | None, Field( description='Event types this source handles (e.g. purchase, lead). If omitted, accepts all event types.', min_length=1, ), + NoExplicitNull, ] = None value_currencies: Annotated[ list[ValueCurrency] | None, @@ -47,22 +48,26 @@ class EventSource(AdCPBaseModel): description='ISO 4217 currencies this source accepts for monetary custom_data values. Required for a source used by canonical ROAS. Once declared, log_event monetary records for this source MUST include a currency from this list. Sources omitting the field retain legacy event compatibility but are ineligible for canonical ROAS. One source may list multiple currencies; each media buy consumes only records matching its own currency and no conversion occurs.', min_length=1, ), + NoExplicitNull, ] = None action_source: Annotated[ action_source_1.ActionSource | None, Field( description='Flat action-source category for this event source, such as website, app, in_store, system_generated, or other. Producers SHOULD also populate `surface` when the flat value is too coarse, and SHOULD keep this field aligned with `surface.category` when the category is also an ActionSource value.' ), + NoExplicitNull, ] = None allowed_domains: Annotated[ list[str] | None, Field(description='Domains authorized to send events for this event source', min_length=1), + NoExplicitNull, ] = None surface: Annotated[ event_surface.EventSurface | None, Field( description='Optional structured surface this source represents, such as an owned channel, profile, feed, podcast, newsletter list, website, app, or store. Complements the flat `action_source` returned by sellers and avoids putting optimization-relevant surface meaning only in `ext`.' ), + NoExplicitNull, ] = None @@ -88,10 +93,12 @@ class SyncEventSourcesRequest(AdcpRequest, AdcpVersionEnvelope): description='Event sources to sync (create or update). When omitted, the call is discovery-only and returns all existing event sources on the account without modification.', min_length=1, ), + NoExplicitNull, ] = None delete_missing: Annotated[ StrictBool | None, Field(description='When true, event sources not included in this sync will be removed'), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/sync_reporting_receipts_request.py b/src/adcp/types/domains/media_buy/sync_reporting_receipts_request.py index a04986daf..69d758f5f 100644 --- a/src/adcp/types/domains/media_buy/sync_reporting_receipts_request.py +++ b/src/adcp/types/domains/media_buy/sync_reporting_receipts_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/sync_reporting_receipts_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, model_validator from ..core import canonical_account_ref @@ -31,6 +32,7 @@ class SyncReportingReceiptsRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -40,6 +42,7 @@ class SyncReportingReceiptsRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None account: canonical_account_ref.CanonicalAccountReference idempotency_key: Annotated[ @@ -52,7 +55,9 @@ class SyncReportingReceiptsRequest(AdcpRequest, AdcpVersionEnvelope): ), ] receipts: Annotated[ - list[reporting_receipt.ReportingReceipt] | None, Field(max_length=100, min_length=1) + list[reporting_receipt.ReportingReceipt] | None, + Field(max_length=100, min_length=1), + NoExplicitNull, ] = None adjustment_receipts: Annotated[ list[reporting_adjustment_receipt.ReportingAdjustmentReceipt] | None, @@ -61,9 +66,10 @@ class SyncReportingReceiptsRequest(AdcpRequest, AdcpVersionEnvelope): max_length=100, min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> SyncReportingReceiptsRequest: diff --git a/src/adcp/types/domains/media_buy/sync_reporting_receipts_response.py b/src/adcp/types/domains/media_buy/sync_reporting_receipts_response.py index a5f174bdc..b3b9d7aab 100644 --- a/src/adcp/types/domains/media_buy/sync_reporting_receipts_response.py +++ b/src/adcp/types/domains/media_buy/sync_reporting_receipts_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/sync_reporting_receipts_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -76,5 +76,5 @@ class SyncReportingReceiptsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolE list[Results20 | Results21 | Results22 | Results23 | Results], Field(max_length=100, min_length=1), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/sync_reporting_status_request.py b/src/adcp/types/domains/media_buy/sync_reporting_status_request.py index 340281ea4..5d1d3f36d 100644 --- a/src/adcp/types/domains/media_buy/sync_reporting_status_request.py +++ b/src/adcp/types/domains/media_buy/sync_reporting_status_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/sync_reporting_status_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import canonical_account_ref @@ -28,6 +29,7 @@ class SyncReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): examples=['3.0', '3.1', '3.1-beta', '3.1-rc.1'], pattern='^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:-[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?)?$', ), + NoExplicitNull, ] = None adcp_major_version: Annotated[ SchemaInt | None, @@ -37,6 +39,7 @@ class SyncReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): ge=1, le=99, ), + NoExplicitNull, ] = None account: canonical_account_ref.CanonicalAccountReference idempotency_key: Annotated[ @@ -56,5 +59,5 @@ class SyncReportingStatusRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/sync_reporting_status_response.py b/src/adcp/types/domains/media_buy/sync_reporting_status_response.py index c0f57cbc1..79a03ddf0 100644 --- a/src/adcp/types/domains/media_buy/sync_reporting_status_response.py +++ b/src/adcp/types/domains/media_buy/sync_reporting_status_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/sync_reporting_status_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -57,5 +57,5 @@ class SyncReportingStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv ), ] = 'completed' results: Annotated[list[Results | Results26 | Results27], Field(max_length=100, min_length=1)] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/update_media_buy_async_response_input_required.py b/src/adcp/types/domains/media_buy/update_media_buy_async_response_input_required.py index c5d5034a9..0c048040f 100644 --- a/src/adcp/types/domains/media_buy/update_media_buy_async_response_input_required.py +++ b/src/adcp/types/domains/media_buy/update_media_buy_async_response_input_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/update_media_buy_async_response_input_required.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -24,7 +24,9 @@ class UpdateMediaBuyInputRequired(AdCPBaseModel): extra='allow', ) reason: Annotated[ - Reason | None, Field(description='Reason code indicating why input is needed') + Reason | None, + Field(description='Reason code indicating why input is needed'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/update_media_buy_async_response_submitted.py b/src/adcp/types/domains/media_buy/update_media_buy_async_response_submitted.py index 5b7d9c15e..922029bdb 100644 --- a/src/adcp/types/domains/media_buy/update_media_buy_async_response_submitted.py +++ b/src/adcp/types/domains/media_buy/update_media_buy_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/update_media_buy_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -36,12 +36,14 @@ class UpdateMediaBuySubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Awaiting operator re-approval; typical turnaround 2–4 hours.' Plain text only. Buyers MUST treat this as untrusted seller input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile seller may inject prompt-injection payloads aimed at the buyer's agent.", max_length=2000, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories, governance observations). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/update_media_buy_async_response_working.py b/src/adcp/types/domains/media_buy/update_media_buy_async_response_working.py index 1542e9dd8..6ef171dbf 100644 --- a/src/adcp/types/domains/media_buy/update_media_buy_async_response_working.py +++ b/src/adcp/types/domains/media_buy/update_media_buy_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/update_media_buy_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..core import context as context_1 @@ -18,14 +18,20 @@ class UpdateMediaBuyWorking(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ - str | None, Field(description='Current step or phase of the operation') + str | None, Field(description='Current step or phase of the operation'), NoExplicitNull ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull + ] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/update_media_buy_request.py b/src/adcp/types/domains/media_buy/update_media_buy_request.py index 0cf88c012..cfb718333 100644 --- a/src/adcp/types/domains/media_buy/update_media_buy_request.py +++ b/src/adcp/types/domains/media_buy/update_media_buy_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/update_media_buy_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from collections.abc import Sequence from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import account_ref, bidding_policy @@ -46,6 +46,7 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None account: Annotated[ account_ref.AccountReference, @@ -62,6 +63,7 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='\\S', ), + NoExplicitNull, ] = None revision: Annotated[ SchemaInt | None, @@ -69,16 +71,19 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): description="Expected current revision for optimistic concurrency. Optional for backward compatibility. When provided, sellers MUST reject the update with CONFLICT if the media buy's current revision does not match, and MUST enforce that comparison atomically with the write. Obtain from get_media_buys or the most recent create/update response.", ge=1, ), + NoExplicitNull, ] = None paused: Annotated[ StrictBool | None, Field(description='Pause/resume the entire media buy (true = paused, false = active)'), + NoExplicitNull, ] = None canceled: Annotated[ Literal[True] | None, Field( description='Cancel the entire media buy. Cancellation is irreversible — canceled media buys cannot be reactivated. Sellers MAY reject with NOT_CANCELLABLE if the media buy cannot be canceled in its current state.' ), + NoExplicitNull, ] = None cancellation_reason: Annotated[ str | None, @@ -86,16 +91,20 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): description='Reason for cancellation. Sellers SHOULD store this and return it in subsequent get_media_buys responses.', max_length=500, ), + NoExplicitNull, ] = None - start_time: start_timing.StartTiming | None = None + start_time: Annotated[start_timing.StartTiming | None, NoExplicitNull] = None end_time: Annotated[ - AwareDatetime | None, Field(description='New end date/time in ISO 8601 format') + AwareDatetime | None, + Field(description='New end date/time in ISO 8601 format'), + NoExplicitNull, ] = None total_budget: Annotated[ TotalBudget | None, Field( description='Updated hard aggregate lifetime budget. currency MUST equal the existing media-buy currency; an update does not redenominate a buy. When supplied alone (without packages or new_packages), in fixed mode the seller MUST atomically scale every active package budget in proportion to its current committed budget, rejecting the entire request if any derived budget cannot be accepted. When supplied with packages or new_packages, the amount MUST equal the resulting fixed-mode package sum; the seller applies the explicit package mutations and rejects with VALIDATION_ERROR if the total is inconsistent. In seller-optimized mode this changes the shared pool without converting package caps into allocations. Already-spent amounts still count against the new total.' ), + NoExplicitNull, ] = None daily_budget_cap: Annotated[ StrictFloat | None, @@ -122,12 +131,14 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Updated allocation configuration. Switching between fixed and seller-optimized modes is allowed only when update_budget_allocation is advertised in available_actions and the resulting package constraints are valid. A resulting seller_optimized allocation requires advertised media_buy.features.seller_optimized_budget, and any package budget caps, min_spend_target values, or package pacing it retains require their own advertised sub-capability; otherwise the update is rejected with UNSUPPORTED_FEATURE before any over-subscription validation.' ), + NoExplicitNull, ] = None pacing: Annotated[ pacing_1.Pacing | None, Field( description='Updated aggregate media-buy pacing. Package pacing remains subordinate to this aggregate strategy. When the resulting buy is seller-optimized, a seller declaring media_buy.features.seller_optimized_budget MUST accept `even`; it MAY reject `asap` or `front_loaded` with UNSUPPORTED_FEATURE (error.field `pacing`) before any provider mutation, including a switch to seller-optimized allocation that would retain such pacing, and MUST NOT silently coerce them to `even`. Fixed-allocation semantics are unchanged.' ), + NoExplicitNull, ] = None bidding: Annotated[ bidding_policy.BiddingPolicy | None, @@ -138,12 +149,14 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): packages: Annotated[ Sequence[package_update.PackageUpdate] | None, Field(description='Package-specific updates for existing packages', min_length=1), + NoExplicitNull, ] = None invoice_recipient: Annotated[ business_entity.BusinessEntity | None, Field( description="Update who receives the invoice for this buy. When provided, the seller invoices this entity instead of the account's default billing_entity. The seller MUST validate the invoice recipient is authorized for this account. When governance_agents are configured, the seller MUST include invoice_recipient in the check_governance request." ), + NoExplicitNull, ] = None new_packages: Annotated[ list[package_request.PackageRequest] | None, @@ -151,18 +164,21 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): description='New packages to add to this media buy. Uses the same schema as create_media_buy packages. When budget_allocation is omitted or fixed, every new package MUST carry budget and MUST NOT carry min_spend_target. To add a package without a hard cap to an existing seller-optimized buy, include its resulting seller_optimized budget_allocation block in the update so the allocation context is schema-visible. Repeating an unchanged allocation block does not itself switch modes. Sellers that support mid-flight package additions advertise `add_packages` in both `valid_actions[]` (deprecated) and as an entry in `available_actions[]` (authoritative). Sellers that do not support this MUST reject with ACTION_NOT_ALLOWED (preferred) or UNSUPPORTED_FEATURE (legacy). If the buy has a root frequency_cap, every added product must support that exact aggregate cap; otherwise the seller rejects atomically with UNSUPPORTED_FEATURE, leaving the whole update unapplied and preserving the buy and its counter history.', min_length=1, ), + NoExplicitNull, ] = None reporting_webhook: Annotated[ reporting_webhook_1.ReportingWebhook | None, Field( description='Optional webhook configuration for automated reporting delivery. Updates the reporting configuration for this media buy.' ), + NoExplicitNull, ] = None push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Optional webhook configuration for async update notifications. Publisher will send webhook when update completes if operation takes longer than immediate response time. This is separate from reporting_webhook which configures ongoing campaign reporting.' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -173,5 +189,5 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/update_media_buy_response.py b/src/adcp/types/domains/media_buy/update_media_buy_response.py index 61b6eb3b8..abc2f3484 100644 --- a/src/adcp/types/domains/media_buy/update_media_buy_response.py +++ b/src/adcp/types/domains/media_buy/update_media_buy_response.py @@ -85,7 +85,7 @@ class UpdateMediaBuyResponse2(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelop class UpdateMediaBuyResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): - model_config = ConfigDict(extra='allow', validate_default=True) + model_config = ConfigDict(extra='allow') status: Literal[task_status_1.TaskStatus.submitted] = task_status_1.TaskStatus.submitted task_id: str message: Annotated[str, StringConstraints(max_length=2000)] | None = None diff --git a/src/adcp/types/domains/pricing_options/cpa_option.py b/src/adcp/types/domains/pricing_options/cpa_option.py index 5b62dca51..d98c8dbfd 100644 --- a/src/adcp/types/domains/pricing_options/cpa_option.py +++ b/src/adcp/types/domains/pricing_options/cpa_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/cpa_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import adjustment_kind @@ -35,12 +35,14 @@ class CpaPricingOption(AdCPBaseModel): Field( description="Name of the custom event when event_type is 'custom'. Required when event_type is 'custom', ignored otherwise." ), + NoExplicitNull, ] = None event_source_id: Annotated[ str | None, Field( description='When present, only events from this specific event source count toward billing. Allows different CPA rates for different sources (e.g., online vs in-store purchases). Must match an event source configured via sync_event_sources.' ), + NoExplicitNull, ] = None currency: Annotated[ str, @@ -60,16 +62,19 @@ class CpaPricingOption(AdCPBaseModel): description='Minimum spend requirement per package using this pricing option, in the specified currency', ge=0.0, ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description='Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.' ), + NoExplicitNull, ] = None eligible_adjustments: Annotated[ list[adjustment_kind.PriceAdjustmentKind] | None, Field( description='Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/cpc_option.py b/src/adcp/types/domains/pricing_options/cpc_option.py index 6ca810aa5..31ca2ef3b 100644 --- a/src/adcp/types/domains/pricing_options/cpc_option.py +++ b/src/adcp/types/domains/pricing_options/cpc_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/cpc_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import adjustment_kind @@ -36,6 +36,7 @@ class CpcPricingOption(AdCPBaseModel): description='Fixed price per click. If present, this is fixed pricing. If absent, auction-based.', ge=0.0, ), + NoExplicitNull, ] = None floor_price: Annotated[ StrictFloat | None, @@ -43,6 +44,7 @@ class CpcPricingOption(AdCPBaseModel): description='Minimum acceptable bid for auction pricing (mutually exclusive with fixed_price). Bids below this value will be rejected.', ge=0.0, ), + NoExplicitNull, ] = None max_bid: Annotated[ StrictBool | None, @@ -50,10 +52,12 @@ class CpcPricingOption(AdCPBaseModel): deprecated=True, description='DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.', ), + NoExplicitNull, ] = False price_guidance: Annotated[ price_guidance_1.PriceGuidance | None, Field(description='Optional pricing guidance for auction-based bidding'), + NoExplicitNull, ] = None min_spend_per_package: Annotated[ StrictFloat | None, @@ -61,16 +65,19 @@ class CpcPricingOption(AdCPBaseModel): description='Minimum spend requirement per package using this pricing option, in the specified currency', ge=0.0, ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description='Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.' ), + NoExplicitNull, ] = None eligible_adjustments: Annotated[ list[adjustment_kind.PriceAdjustmentKind] | None, Field( description='Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/cpcv_option.py b/src/adcp/types/domains/pricing_options/cpcv_option.py index 744c699cb..dc4418cec 100644 --- a/src/adcp/types/domains/pricing_options/cpcv_option.py +++ b/src/adcp/types/domains/pricing_options/cpcv_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/cpcv_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import adjustment_kind @@ -38,6 +38,7 @@ class CpcvPricingOption(AdCPBaseModel): description='Fixed price per completed view. If present, this is fixed pricing. If absent, auction-based.', ge=0.0, ), + NoExplicitNull, ] = None floor_price: Annotated[ StrictFloat | None, @@ -45,6 +46,7 @@ class CpcvPricingOption(AdCPBaseModel): description='Minimum acceptable bid for auction pricing (mutually exclusive with fixed_price). Bids below this value will be rejected.', ge=0.0, ), + NoExplicitNull, ] = None max_bid: Annotated[ StrictBool | None, @@ -52,10 +54,12 @@ class CpcvPricingOption(AdCPBaseModel): deprecated=True, description='DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.', ), + NoExplicitNull, ] = False price_guidance: Annotated[ price_guidance_1.PriceGuidance | None, Field(description='Optional pricing guidance for auction-based bidding'), + NoExplicitNull, ] = None min_spend_per_package: Annotated[ StrictFloat | None, @@ -63,16 +67,19 @@ class CpcvPricingOption(AdCPBaseModel): description='Minimum spend requirement per package using this pricing option, in the specified currency', ge=0.0, ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description='Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.' ), + NoExplicitNull, ] = None eligible_adjustments: Annotated[ list[adjustment_kind.PriceAdjustmentKind] | None, Field( description='Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/cpm_option.py b/src/adcp/types/domains/pricing_options/cpm_option.py index 63c207f99..cf7296f88 100644 --- a/src/adcp/types/domains/pricing_options/cpm_option.py +++ b/src/adcp/types/domains/pricing_options/cpm_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/cpm_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import adjustment_kind @@ -36,6 +36,7 @@ class CpmPricingOption(AdCPBaseModel): description='Fixed price per unit. If present, this is fixed pricing. If absent, auction-based.', ge=0.0, ), + NoExplicitNull, ] = None floor_price: Annotated[ StrictFloat | None, @@ -43,6 +44,7 @@ class CpmPricingOption(AdCPBaseModel): description='Minimum acceptable bid for auction pricing (mutually exclusive with fixed_price). Bids below this value will be rejected.', ge=0.0, ), + NoExplicitNull, ] = None max_bid: Annotated[ StrictBool | None, @@ -50,10 +52,12 @@ class CpmPricingOption(AdCPBaseModel): deprecated=True, description='DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.', ), + NoExplicitNull, ] = False price_guidance: Annotated[ price_guidance_1.PriceGuidance | None, Field(description='Optional pricing guidance for auction-based bidding'), + NoExplicitNull, ] = None min_spend_per_package: Annotated[ StrictFloat | None, @@ -61,16 +65,19 @@ class CpmPricingOption(AdCPBaseModel): description='Minimum spend requirement per package using this pricing option, in the specified currency', ge=0.0, ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description='Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.' ), + NoExplicitNull, ] = None eligible_adjustments: Annotated[ list[adjustment_kind.PriceAdjustmentKind] | None, Field( description='Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/cpp_option.py b/src/adcp/types/domains/pricing_options/cpp_option.py index 53e5c30de..44e0619d0 100644 --- a/src/adcp/types/domains/pricing_options/cpp_option.py +++ b/src/adcp/types/domains/pricing_options/cpp_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/cpp_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import adjustment_kind @@ -24,6 +24,7 @@ class Parameters(AdCPBaseModel): Field( description='Measurement system for the demographic field. Defaults to nielsen when omitted.' ), + NoExplicitNull, ] = None demographic: Annotated[ str, @@ -32,7 +33,7 @@ class Parameters(AdCPBaseModel): ), ] min_points: Annotated[ - StrictFloat | None, Field(description='Minimum GRPs/TRPs required', ge=0.0) + StrictFloat | None, Field(description='Minimum GRPs/TRPs required', ge=0.0), NoExplicitNull ] = None @@ -58,6 +59,7 @@ class CppPricingOption(AdCPBaseModel): description='Fixed price per rating point. If present, this is fixed pricing. If absent, auction-based.', ge=0.0, ), + NoExplicitNull, ] = None floor_price: Annotated[ StrictFloat | None, @@ -65,10 +67,12 @@ class CppPricingOption(AdCPBaseModel): description='Minimum acceptable bid for auction pricing (mutually exclusive with fixed_price). Bids below this value will be rejected.', ge=0.0, ), + NoExplicitNull, ] = None price_guidance: Annotated[ price_guidance_1.PriceGuidance | None, Field(description='Optional pricing guidance for auction-based bidding'), + NoExplicitNull, ] = None parameters: Annotated[ Parameters, Field(description='CPP-specific parameters for demographic targeting') @@ -79,16 +83,19 @@ class CppPricingOption(AdCPBaseModel): description='Minimum spend requirement per package using this pricing option, in the specified currency', ge=0.0, ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description='Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.' ), + NoExplicitNull, ] = None eligible_adjustments: Annotated[ list[adjustment_kind.PriceAdjustmentKind] | None, Field( description='Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/cpv_option.py b/src/adcp/types/domains/pricing_options/cpv_option.py index 138e39a4c..5ff4604c6 100644 --- a/src/adcp/types/domains/pricing_options/cpv_option.py +++ b/src/adcp/types/domains/pricing_options/cpv_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/cpv_option.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarFloat from pydantic import ConfigDict, Field, StrictBool, StrictFloat @@ -59,6 +59,7 @@ class CpvPricingOption(AdCPBaseModel): description='Fixed price per view. If present, this is fixed pricing. If absent, auction-based.', ge=0.0, ), + NoExplicitNull, ] = None floor_price: Annotated[ StrictFloat | None, @@ -66,6 +67,7 @@ class CpvPricingOption(AdCPBaseModel): description='Minimum acceptable bid for auction pricing (mutually exclusive with fixed_price). Bids below this value will be rejected.', ge=0.0, ), + NoExplicitNull, ] = None max_bid: Annotated[ StrictBool | None, @@ -73,10 +75,12 @@ class CpvPricingOption(AdCPBaseModel): deprecated=True, description='DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.', ), + NoExplicitNull, ] = False price_guidance: Annotated[ price_guidance_1.PriceGuidance | None, Field(description='Optional pricing guidance for auction-based bidding'), + NoExplicitNull, ] = None parameters: Annotated[ Parameters, Field(description='CPV-specific parameters defining the view threshold') @@ -87,16 +91,19 @@ class CpvPricingOption(AdCPBaseModel): description='Minimum spend requirement per package using this pricing option, in the specified currency', ge=0.0, ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description='Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.' ), + NoExplicitNull, ] = None eligible_adjustments: Annotated[ list[adjustment_kind.PriceAdjustmentKind] | None, Field( description='Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/flat_rate_option.py b/src/adcp/types/domains/pricing_options/flat_rate_option.py index bf19ed369..1d36d7d1c 100644 --- a/src/adcp/types/domains/pricing_options/flat_rate_option.py +++ b/src/adcp/types/domains/pricing_options/flat_rate_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/flat_rate_option.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..enums import adjustment_kind @@ -28,6 +28,7 @@ class Parameters(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None slot_span: Annotated[ SchemaInt | None, @@ -35,6 +36,7 @@ class Parameters(AdCPBaseModel): description='Number of consecutive loop slots reserved by this seller-declared pre-packaged offer. Contiguity is guaranteed; position is not guaranteed unless loop_position is also present. This field does not carry a buyer-requested custom span for an offer the seller has not pre-packaged.', ge=1, ), + NoExplicitNull, ] = None loop_position: Annotated[ str | None, @@ -42,6 +44,7 @@ class Parameters(AdCPBaseModel): description='Seller-defined position label for the reserved span within the loop, such as any, first, last, or adjacent_to_content_break. This is deliberately an open string because network vocabularies differ. Consumers MUST accept unrecognized values as opaque and treat them as conveying no position guarantee the consumer understands; an unrecognized value is not a protocol error.', min_length=1, ), + NoExplicitNull, ] = None loop_duration_seconds: Annotated[ SchemaInt | None, @@ -50,15 +53,19 @@ class Parameters(AdCPBaseModel): description='Deprecated compatibility copy of the placement-level dooh_placement_attributes.loop_duration_seconds, which is the canonical source. Retained for backward compatibility; new integrations read the placement-level field. When both are present they MUST agree. A product that offers different loop durations under different prices MUST expose distinct placements or products rather than vary this compatibility copy by pricing option.', ge=1, ), + NoExplicitNull, ] = None min_plays_per_hour: Annotated[ - SchemaInt | None, Field(description='Minimum number of plays per hour guaranteed', ge=1) + SchemaInt | None, + Field(description='Minimum number of plays per hour guaranteed', ge=1), + NoExplicitNull, ] = None venue_package: Annotated[ str | None, Field( description='Named collection of DOOH venues or installed surfaces included in this buy' ), + NoExplicitNull, ] = None duration_hours: Annotated[ StrictFloat | None, @@ -66,10 +73,12 @@ class Parameters(AdCPBaseModel): description='Duration of the DOOH slot in hours (e.g., 24 for a full-day takeover)', ge=0.0, ), + NoExplicitNull, ] = None daypart: Annotated[ str | None, Field(description='Named daypart for this slot (e.g., morning_commute, evening_rush)'), + NoExplicitNull, ] = None estimated_impressions: Annotated[ SchemaInt | None, @@ -77,6 +86,7 @@ class Parameters(AdCPBaseModel): description='Estimated audience impressions for this slot (informational, not a delivery guarantee)', ge=0, ), + NoExplicitNull, ] = None @@ -104,6 +114,7 @@ class FlatRatePricingOption(AdCPBaseModel): description='Flat rate cost. If present, this is fixed pricing. If absent, auction-based.', ge=0.0, ), + NoExplicitNull, ] = None floor_price: Annotated[ StrictFloat | None, @@ -111,10 +122,12 @@ class FlatRatePricingOption(AdCPBaseModel): description='Minimum acceptable bid for auction pricing (mutually exclusive with fixed_price). Bids below this value will be rejected.', ge=0.0, ), + NoExplicitNull, ] = None price_guidance: Annotated[ price_guidance_1.PriceGuidance | None, Field(description='Optional pricing guidance for auction-based bidding'), + NoExplicitNull, ] = None parameters: Annotated[ Parameters | None, @@ -122,6 +135,7 @@ class FlatRatePricingOption(AdCPBaseModel): description='DOOH inventory allocation parameters. Sponsorship and takeover flat_rate options omit this field entirely — only include for digital out-of-home inventory.', title='DoohParameters', ), + NoExplicitNull, ] = None min_spend_per_package: Annotated[ StrictFloat | None, @@ -129,16 +143,19 @@ class FlatRatePricingOption(AdCPBaseModel): description='Minimum spend requirement per package using this pricing option, in the specified currency', ge=0.0, ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description='Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.' ), + NoExplicitNull, ] = None eligible_adjustments: Annotated[ list[adjustment_kind.PriceAdjustmentKind] | None, Field( description='Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/price_breakdown.py b/src/adcp/types/domains/pricing_options/price_breakdown.py index 88bfc3165..af38807cb 100644 --- a/src/adcp/types/domains/pricing_options/price_breakdown.py +++ b/src/adcp/types/domains/pricing_options/price_breakdown.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/price_breakdown.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import adjustment_kind @@ -43,6 +43,7 @@ class Adjustment(AdCPBaseModel): gt=0.0, lt=1.0, ), + NoExplicitNull, ] = None amount: Annotated[ StrictFloat | None, @@ -50,6 +51,7 @@ class Adjustment(AdCPBaseModel): description="Adjustment as a fixed monetary amount in the pricing option's currency. Always positive — kind determines the economic effect. Mutually exclusive with rate.", gt=0.0, ), + NoExplicitNull, ] = None description: Annotated[ str | None, @@ -57,6 +59,7 @@ class Adjustment(AdCPBaseModel): description="Human-readable description of this adjustment (e.g., 'Malstaffel 12x', '2% Skonto 10 Tage')", max_length=256, ), + NoExplicitNull, ] = None beneficiary: Annotated[ str | None, @@ -64,6 +67,7 @@ class Adjustment(AdCPBaseModel): description="Identifies who receives this adjustment's value. For commissions, the intermediary (e.g., a sellers.json domain, an AdCP account ID, or a human-readable party name). Optional but recommended for multi-intermediary transparency.", max_length=256, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/price_guidance.py b/src/adcp/types/domains/pricing_options/price_guidance.py index 8b96a35d9..151f03a2b 100644 --- a/src/adcp/types/domains/pricing_options/price_guidance.py +++ b/src/adcp/types/domains/pricing_options/price_guidance.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/price_guidance.json -# timestamp: 2026-10-03T15:09:44+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -15,14 +15,22 @@ class PriceGuidance(AdCPBaseModel): extra='allow', ) p25: Annotated[ - StrictFloat | None, Field(description='25th percentile of recent winning bids', ge=0.0) + StrictFloat | None, + Field(description='25th percentile of recent winning bids', ge=0.0), + NoExplicitNull, ] = None p50: Annotated[ - StrictFloat | None, Field(description='Median of recent winning bids', ge=0.0) + StrictFloat | None, + Field(description='Median of recent winning bids', ge=0.0), + NoExplicitNull, ] = None p75: Annotated[ - StrictFloat | None, Field(description='75th percentile of recent winning bids', ge=0.0) + StrictFloat | None, + Field(description='75th percentile of recent winning bids', ge=0.0), + NoExplicitNull, ] = None p90: Annotated[ - StrictFloat | None, Field(description='90th percentile of recent winning bids', ge=0.0) + StrictFloat | None, + Field(description='90th percentile of recent winning bids', ge=0.0), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/revenue_share_option.py b/src/adcp/types/domains/pricing_options/revenue_share_option.py index c9efe72d4..ee114325c 100644 --- a/src/adcp/types/domains/pricing_options/revenue_share_option.py +++ b/src/adcp/types/domains/pricing_options/revenue_share_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/revenue_share_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..enums import event_type as event_type_1 @@ -38,6 +38,7 @@ class RevenueSharePricingOption(AdCPBaseModel): description='Name of the custom event when event_type is custom. Required for custom events and absent otherwise.', min_length=1, ), + NoExplicitNull, ] = None event_source_id: Annotated[ str, diff --git a/src/adcp/types/domains/pricing_options/time_option.py b/src/adcp/types/domains/pricing_options/time_option.py index c8d6f7b26..e08c60594 100644 --- a/src/adcp/types/domains/pricing_options/time_option.py +++ b/src/adcp/types/domains/pricing_options/time_option.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: pricing_options/time_option.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..enums import adjustment_kind @@ -33,7 +33,9 @@ class Parameters(AdCPBaseModel): ), ] min_duration: Annotated[ - SchemaInt | None, Field(description='Minimum booking duration in time_units', ge=1) + SchemaInt | None, + Field(description='Minimum booking duration in time_units', ge=1), + NoExplicitNull, ] = None max_duration: Annotated[ SchemaInt | None, @@ -41,6 +43,7 @@ class Parameters(AdCPBaseModel): description='Maximum booking duration in time_units. Must be >= min_duration when both are present.', ge=1, ), + NoExplicitNull, ] = None @@ -69,6 +72,7 @@ class TimeBasedPricingOption(AdCPBaseModel): description='Cost per time unit. If present, this is fixed pricing. If absent, auction-based.', ge=0.0, ), + NoExplicitNull, ] = None floor_price: Annotated[ StrictFloat | None, @@ -76,10 +80,12 @@ class TimeBasedPricingOption(AdCPBaseModel): description='Minimum acceptable bid per time unit for auction pricing (mutually exclusive with fixed_price). Bids below this value will be rejected.', ge=0.0, ), + NoExplicitNull, ] = None price_guidance: Annotated[ price_guidance_1.PriceGuidance | None, Field(description='Optional pricing guidance for auction-based bidding'), + NoExplicitNull, ] = None parameters: Annotated[Parameters, Field(description='Time-based pricing parameters')] min_spend_per_package: Annotated[ @@ -88,16 +94,19 @@ class TimeBasedPricingOption(AdCPBaseModel): description='Minimum spend requirement per package using this pricing option, in the specified currency', ge=0.0, ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description='Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.' ), + NoExplicitNull, ] = None eligible_adjustments: Annotated[ list[adjustment_kind.PriceAdjustmentKind] | None, Field( description='Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/pricing_options/vcpm_option.py b/src/adcp/types/domains/pricing_options/vcpm_option.py index eaa2a6992..42c98ce22 100644 --- a/src/adcp/types/domains/pricing_options/vcpm_option.py +++ b/src/adcp/types/domains/pricing_options/vcpm_option.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: pricing_options/vcpm_option.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import adjustment_kind @@ -38,6 +38,7 @@ class VcpmPricingOption(AdCPBaseModel): description='Fixed price per unit. If present, this is fixed pricing. If absent, auction-based.', ge=0.0, ), + NoExplicitNull, ] = None floor_price: Annotated[ StrictFloat | None, @@ -45,6 +46,7 @@ class VcpmPricingOption(AdCPBaseModel): description='Minimum acceptable bid for auction pricing (mutually exclusive with fixed_price). Bids below this value will be rejected.', ge=0.0, ), + NoExplicitNull, ] = None max_bid: Annotated[ StrictBool | None, @@ -52,10 +54,12 @@ class VcpmPricingOption(AdCPBaseModel): deprecated=True, description='DEPRECATED in 3.2 and removed in the next major. Legacy hint used only to normalize package bid_price to bidding.max_bid (true) or bidding.bid_amount (false/absent). New buyers express intent directly in bidding.', ), + NoExplicitNull, ] = False price_guidance: Annotated[ price_guidance_1.PriceGuidance | None, Field(description='Optional pricing guidance for auction-based bidding'), + NoExplicitNull, ] = None min_spend_per_package: Annotated[ StrictFloat | None, @@ -63,16 +67,19 @@ class VcpmPricingOption(AdCPBaseModel): description='Minimum spend requirement per package using this pricing option, in the specified currency', ge=0.0, ), + NoExplicitNull, ] = None price_breakdown: Annotated[ price_breakdown_1.PriceBreakdown | None, Field( description='Breakdown of how fixed_price was derived from the list (rate card) price. Only meaningful when fixed_price is present.' ), + NoExplicitNull, ] = None eligible_adjustments: Annotated[ list[adjustment_kind.PriceAdjustmentKind] | None, Field( description='Adjustment kinds applicable to this pricing option. Tells buyer agents which adjustments are available before negotiation. When absent, no adjustments are pre-declared — the buyer should check price_breakdown if present.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/property/authorization_result.py b/src/adcp/types/domains/property/authorization_result.py index 91ca344ee..6f17f6337 100644 --- a/src/adcp/types/domains/property/authorization_result.py +++ b/src/adcp/types/domains/property/authorization_result.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/authorization_result.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field @@ -36,14 +36,17 @@ class AuthorizationResult(AdCPBaseModel): ), ] publisher_domain: Annotated[ - str | None, Field(description='The publisher domain where adagents.json was checked') + str | None, + Field(description='The publisher domain where adagents.json was checked'), + NoExplicitNull, ] = None sales_agent_url: Annotated[ - AnyUrl | None, Field(description='The sales agent URL that was validated') + AnyUrl | None, Field(description='The sales agent URL that was validated'), NoExplicitNull ] = None violation: Annotated[ Violation | None, Field( description='Details about the authorization failure (only present for unauthorized status)' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/property/create_property_list_request.py b/src/adcp/types/domains/property/create_property_list_request.py index ab99feb5a..61a07cb6c 100644 --- a/src/adcp/types/domains/property/create_property_list_request.py +++ b/src/adcp/types/domains/property/create_property_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/create_property_list_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref, brand_ref @@ -26,27 +27,31 @@ class CreatePropertyListRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account that will own the list. Pass a natural key (brand, operator, optional sandbox) or a seller-assigned account_id from list_accounts. When omitted, this task applies its task-local single-account shortcut: if exactly one account is accessible to the authenticated caller, the seller may assign the list to that account; otherwise it MUST return an account-required or ambiguous-account error. Omission MUST NOT mean an undocumented credential-local default account.' ), + NoExplicitNull, ] = None name: Annotated[str, Field(description='Human-readable name for the list')] - description: Annotated[str | None, Field(description="Description of the list's purpose")] = ( - None - ) + description: Annotated[ + str | None, Field(description="Description of the list's purpose"), NoExplicitNull + ] = None base_properties: Annotated[ list[base_property_source.BasePropertySource] | None, Field( description="Array of property sources to evaluate. Each entry is a discriminated union: publisher_tags (publisher_domain + tags), publisher_ids (publisher_domain + property_ids), or identifiers (direct identifiers). If omitted, queries the agent's entire property database.", min_length=1, ), + NoExplicitNull, ] = None filters: Annotated[ property_list_filters.PropertyListFilters | None, Field(description='Dynamic filters to apply when resolving the list'), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Brand reference. When provided, the agent automatically applies appropriate rules based on brand characteristics (industry, target_audience, etc.). Resolved at execution time.' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -57,5 +62,5 @@ class CreatePropertyListRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/create_property_list_response.py b/src/adcp/types/domains/property/create_property_list_response.py index 79517ce84..4881f6ecf 100644 --- a/src/adcp/types/domains/property/create_property_list_response.py +++ b/src/adcp/types/domains/property/create_property_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/create_property_list_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -33,6 +34,7 @@ class CreatePropertyListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/delete_property_list_request.py b/src/adcp/types/domains/property/delete_property_list_request.py index fc8d246cf..9ccb59065 100644 --- a/src/adcp/types/domains/property/delete_property_list_request.py +++ b/src/adcp/types/domains/property/delete_property_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/delete_property_list_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref @@ -26,9 +27,10 @@ class DeletePropertyListRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account that owns the list. Required when the authenticated agent has access to multiple accounts; optional otherwise.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None idempotency_key: Annotated[ str, Field( diff --git a/src/adcp/types/domains/property/delete_property_list_response.py b/src/adcp/types/domains/property/delete_property_list_response.py index 922e36f1c..456e2b969 100644 --- a/src/adcp/types/domains/property/delete_property_list_response.py +++ b/src/adcp/types/domains/property/delete_property_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/delete_property_list_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -27,6 +28,7 @@ class DeletePropertyListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/delivery_record.py b/src/adcp/types/domains/property/delivery_record.py index d09d59e17..05ba8887e 100644 --- a/src/adcp/types/domains/property/delivery_record.py +++ b/src/adcp/types/domains/property/delivery_record.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: property/delivery_record.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, ConfigDict, Field from ..core import ext as ext_1 @@ -29,11 +29,13 @@ class DeliveryRecord(AdCPBaseModel): Field( description='Optional client-provided ID for correlating results back to source data' ), + NoExplicitNull, ] = None sales_agent_url: Annotated[ AnyUrl | None, Field( description="URL of the sales agent that sold this inventory. If provided, authorization is validated against the property's adagents.json." ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/get_property_list_request.py b/src/adcp/types/domains/property/get_property_list_request.py index 3d32408f1..01fa88182 100644 --- a/src/adcp/types/domains/property/get_property_list_request.py +++ b/src/adcp/types/domains/property/get_property_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/get_property_list_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref @@ -24,10 +24,12 @@ class Pagination(AdCPBaseModel): max_results: Annotated[ SchemaInt | None, Field(description='Maximum number of identifiers to return per page', ge=1, le=10000), + NoExplicitNull, ] = 1000 cursor: Annotated[ str | None, Field(description='Opaque cursor from a previous response to fetch the next page'), + NoExplicitNull, ] = None @@ -41,18 +43,21 @@ class GetPropertyListRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account that owns the list. Required when the authenticated agent has access to multiple accounts and the list_id is not globally unique within that scope; optional otherwise.' ), + NoExplicitNull, ] = None resolve: Annotated[ StrictBool | None, Field( description='Whether to apply filters and return resolved identifiers (default: true)' ), + NoExplicitNull, ] = True pagination: Annotated[ Pagination | None, Field( description='Pagination parameters. Uses higher limits than standard pagination because property lists can contain tens of thousands of identifiers.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/get_property_list_response.py b/src/adcp/types/domains/property/get_property_list_response.py index 95d601ef9..be376c68d 100644 --- a/src/adcp/types/domains/property/get_property_list_response.py +++ b/src/adcp/types/domains/property/get_property_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/get_property_list_response.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import context as context_1 @@ -34,22 +35,25 @@ class GetPropertyListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelop Field( description='Resolved identifiers that passed filters (if resolve=true). Cache these locally for real-time use.' ), + NoExplicitNull, ] = None - pagination: pagination_response.PaginationResponse | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None resolved_at: Annotated[ - AwareDatetime | None, Field(description='When the list was resolved') + AwareDatetime | None, Field(description='When the list was resolved'), NoExplicitNull ] = None cache_valid_until: Annotated[ AwareDatetime | None, Field( description='Cache expiration timestamp. Re-fetch the list after this time to get updated identifiers.' ), + NoExplicitNull, ] = None coverage_gaps: Annotated[ dict[str, _list[identifier.Identifier]] | None, Field( description="Properties included in the list despite missing feature data. Only present when a feature_requirement has if_not_covered='include'. Maps feature_id to list of identifiers not covered for that feature." ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/list_property_lists_request.py b/src/adcp/types/domains/property/list_property_lists_request.py index dedb9247c..5ca7cdc51 100644 --- a/src/adcp/types/domains/property/list_property_lists_request.py +++ b/src/adcp/types/domains/property/list_property_lists_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/list_property_lists_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref @@ -26,10 +27,13 @@ class ListPropertyListsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Filter to lists owned by this account. When omitted, returns lists across all accounts accessible to the authenticated agent.' ), + NoExplicitNull, ] = None name_contains: Annotated[ - str | None, Field(description='Filter to lists whose name contains this string') + str | None, + Field(description='Filter to lists whose name contains this string'), + NoExplicitNull, ] = None - pagination: pagination_request.PaginationRequest | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/list_property_lists_response.py b/src/adcp/types/domains/property/list_property_lists_response.py index 0fc65146d..5be3ebe63 100644 --- a/src/adcp/types/domains/property/list_property_lists_response.py +++ b/src/adcp/types/domains/property/list_property_lists_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/list_property_lists_response.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -26,6 +27,6 @@ class ListPropertyListsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvel list[property_list.PropertyList], Field(description='Array of property lists (metadata only, not resolved properties)'), ] - pagination: pagination_response.PaginationResponse | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/property_error.py b/src/adcp/types/domains/property/property_error.py index d71392538..98d155a3c 100644 --- a/src/adcp/types/domains/property/property_error.py +++ b/src/adcp/types/domains/property/property_error.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/property_error.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import property as property_1 @@ -28,6 +28,8 @@ class PropertyError(AdCPBaseModel): ) code: Annotated[Code, Field(description='Error code')] property: Annotated[ - property_1.Property | None, Field(description='The property that caused the error') + property_1.Property | None, + Field(description='The property that caused the error'), + NoExplicitNull, ] = None message: Annotated[str, Field(description='Human-readable error message')] diff --git a/src/adcp/types/domains/property/property_feature.py b/src/adcp/types/domains/property/property_feature.py index 4d7a1cbef..ca2b2a042 100644 --- a/src/adcp/types/domains/property/property_feature.py +++ b/src/adcp/types/domains/property/property_feature.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: property/property_feature.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -19,4 +19,5 @@ class PropertyFeature(AdCPBaseModel): source: Annotated[ str | None, Field(description='Source of the feature data (e.g., app_store_privacy_label, tcf_string)'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/property/property_feature_definition.py b/src/adcp/types/domains/property/property_feature_definition.py index 56cbfd1fd..d97ab887e 100644 --- a/src/adcp/types/domains/property/property_feature_definition.py +++ b/src/adcp/types/domains/property/property_feature_definition.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/property_feature_definition.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field, StrictFloat from ..core import ext as ext_1 @@ -32,10 +32,14 @@ class Coverage(AdCPBaseModel): extra='forbid', ) property_types: Annotated[ - list[str] | None, Field(description='Property types this feature applies to') + list[str] | None, + Field(description='Property types this feature applies to'), + NoExplicitNull, ] = None countries: Annotated[ - list[str] | None, Field(description='Countries where this feature is available') + list[str] | None, + Field(description='Countries where this feature is available'), + NoExplicitNull, ] = None @@ -51,7 +55,9 @@ class PropertyFeatureDefinition(AdCPBaseModel): ] name: Annotated[str, Field(description='Human-readable name for the feature')] description: Annotated[ - str | None, Field(description='Description of what this feature measures or represents') + str | None, + Field(description='Description of what this feature measures or represents'), + NoExplicitNull, ] = None type: Annotated[ Type, @@ -60,13 +66,19 @@ class PropertyFeatureDefinition(AdCPBaseModel): ), ] range: Annotated[ - Range | None, Field(description='For quantitative features, the valid range of values') + Range | None, + Field(description='For quantitative features, the valid range of values'), + NoExplicitNull, ] = None allowed_values: Annotated[ - list[str] | None, Field(description='For categorical features, the set of valid values') + list[str] | None, + Field(description='For categorical features, the set of valid values'), + NoExplicitNull, ] = None coverage: Annotated[ - Coverage | None, Field(description='What this feature covers (empty arrays = all)') + Coverage | None, + Field(description='What this feature covers (empty arrays = all)'), + NoExplicitNull, ] = None methodology_url: Annotated[ AnyUrl, @@ -75,6 +87,8 @@ class PropertyFeatureDefinition(AdCPBaseModel): ), ] methodology_version: Annotated[ - str | None, Field(description='Version identifier for the methodology (for audit trails)') + str | None, + Field(description='Version identifier for the methodology (for audit trails)'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/property_feature_result.py b/src/adcp/types/domains/property/property_feature_result.py index 2fe9a72f7..e17b9b6d2 100644 --- a/src/adcp/types/domains/property/property_feature_result.py +++ b/src/adcp/types/domains/property/property_feature_result.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/property_feature_result.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import ext as ext_1 @@ -31,6 +31,7 @@ class PropertyFeatureResult(AdCPBaseModel): features: Annotated[ dict[str, property_feature_value.PropertyFeatureValue] | None, Field(description='Map of feature_id to feature value'), + NoExplicitNull, ] = None coverage_status: Annotated[ CoverageStatus, @@ -41,5 +42,6 @@ class PropertyFeatureResult(AdCPBaseModel): last_evaluated: Annotated[ AwareDatetime | None, Field(description='When features were last evaluated for this property'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/property_feature_value.py b/src/adcp/types/domains/property/property_feature_value.py index 0b2e7b40b..9c48e4841 100644 --- a/src/adcp/types/domains/property/property_feature_value.py +++ b/src/adcp/types/domains/property/property_feature_value.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: property/property_feature_value.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import ext as ext_1 @@ -27,25 +27,33 @@ class PropertyFeatureValue(AdCPBaseModel): Field( description="Unit of measurement for quantitative values (e.g., 'gCO2e/1000_impressions', 'percentage')" ), + NoExplicitNull, ] = None confidence: Annotated[ StrictFloat | None, Field(description='Confidence score for this value (0-1)', ge=0.0, le=1.0), + NoExplicitNull, ] = None measured_at: Annotated[ - AwareDatetime | None, Field(description='When this specific value was measured') + AwareDatetime | None, + Field(description='When this specific value was measured'), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='When this certification/value expires (for time-limited certifications)' ), + NoExplicitNull, ] = None methodology_version: Annotated[ - str | None, Field(description='Version of the methodology used to calculate this value') + str | None, + Field(description='Version of the methodology used to calculate this value'), + NoExplicitNull, ] = None details: Annotated[ dict[str, Any] | None, Field(description='Additional vendor-specific details about this measurement'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/property_list.py b/src/adcp/types/domains/property/property_list.py index d68a10c20..8be313aa0 100644 --- a/src/adcp/types/domains/property/property_list.py +++ b/src/adcp/types/domains/property/property_list.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: property/property_list.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field from ..core import account_ref, brand_ref, vendor_pricing_option @@ -19,34 +19,39 @@ class PropertyList(AdCPBaseModel): ) list_id: Annotated[str, Field(description='Unique identifier for this property list')] name: Annotated[str, Field(description='Human-readable name for the list')] - description: Annotated[str | None, Field(description="Description of the list's purpose")] = ( - None - ) + description: Annotated[ + str | None, Field(description="Description of the list's purpose"), NoExplicitNull + ] = None account: Annotated[ account_ref.AccountReference | None, Field( description='Account that owns this list. Returned as account_id form (seller-assigned identifier).' ), + NoExplicitNull, ] = None base_properties: Annotated[ list[base_property_source.BasePropertySource] | None, Field( description="Array of property sources to evaluate. Each entry is a discriminated union: publisher_tags (publisher_domain + tags), publisher_ids (publisher_domain + property_ids), or identifiers (direct identifiers). If omitted, queries the agent's entire property database." ), + NoExplicitNull, ] = None filters: Annotated[ property_list_filters.PropertyListFilters | None, Field(description='Dynamic filters applied when resolving the list'), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Brand reference used to automatically apply appropriate rules. Resolved to full brand identity at execution time.' ), + NoExplicitNull, ] = None webhook_url: Annotated[ AnyUrl | None, Field(description='URL to receive notifications when the resolved list changes'), + NoExplicitNull, ] = None cache_duration_hours: Annotated[ SchemaInt | None, @@ -54,16 +59,18 @@ class PropertyList(AdCPBaseModel): description='Recommended cache duration for resolved list. Consumers should re-fetch after this period.', ge=1, ), + NoExplicitNull, ] = 24 - created_at: Annotated[AwareDatetime | None, Field(description='When the list was created')] = ( - None - ) + created_at: Annotated[ + AwareDatetime | None, Field(description='When the list was created'), NoExplicitNull + ] = None updated_at: Annotated[ - AwareDatetime | None, Field(description='When the list was last modified') + AwareDatetime | None, Field(description='When the list was last modified'), NoExplicitNull ] = None property_count: Annotated[ SchemaInt | None, Field(description='Number of properties in the resolved list (at time of last resolution)'), + NoExplicitNull, ] = None pricing_options: Annotated[ list[vendor_pricing_option.VendorPricingOption] | None, @@ -71,4 +78,5 @@ class PropertyList(AdCPBaseModel): description='Pricing options for this property list. Present when the requesting account has a billing relationship with the list provider. The buyer passes the selected pricing_option_id in report_usage.', min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/property/property_list_changed_webhook.py b/src/adcp/types/domains/property/property_list_changed_webhook.py index 307f85d72..4392f3bfd 100644 --- a/src/adcp/types/domains/property/property_list_changed_webhook.py +++ b/src/adcp/types/domains/property/property_list_changed_webhook.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: property/property_list_changed_webhook.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AwareDatetime, ConfigDict, Field from ..core import ext as ext_1 @@ -17,13 +17,17 @@ class ChangeSummary(AdCPBaseModel): extra='forbid', ) properties_added: Annotated[ - SchemaInt | None, Field(description='Number of properties added since last resolution') + SchemaInt | None, + Field(description='Number of properties added since last resolution'), + NoExplicitNull, ] = None properties_removed: Annotated[ - SchemaInt | None, Field(description='Number of properties removed since last resolution') + SchemaInt | None, + Field(description='Number of properties removed since last resolution'), + NoExplicitNull, ] = None total_properties: Annotated[ - SchemaInt | None, Field(description='Total properties in the resolved list') + SchemaInt | None, Field(description='Total properties in the resolved list'), NoExplicitNull ] = None @@ -42,14 +46,19 @@ class PropertyListChangedWebhook(AdCPBaseModel): ] event: Annotated[Literal['property_list_changed'], Field(description='The event type')] = 'property_list_changed' list_id: Annotated[str, Field(description='ID of the property list that changed')] - list_name: Annotated[str | None, Field(description='Name of the property list')] = None + list_name: Annotated[ + str | None, Field(description='Name of the property list'), NoExplicitNull + ] = None change_summary: Annotated[ - ChangeSummary | None, Field(description='Summary of changes to the resolved list') + ChangeSummary | None, + Field(description='Summary of changes to the resolved list'), + NoExplicitNull, ] = None resolved_at: Annotated[AwareDatetime, Field(description='When the list was re-resolved')] cache_valid_until: Annotated[ AwareDatetime | None, Field(description='When the consumer should refresh from the governance agent'), + NoExplicitNull, ] = None signature: Annotated[ str, @@ -58,4 +67,4 @@ class PropertyListChangedWebhook(AdCPBaseModel): description='Deprecated 3.x compatibility marker. Property-list webhooks previously required this field but did not define an implementable signature algorithm, signed bytes, or key-discovery contract. RFC 9421 signatures are carried in the Signature and Signature-Input HTTP headers and bind the complete payload through Content-Digest. New 3.2 senders MUST set this field to the literal value `rfc9421`; receivers MUST accept any string from an older 3.x sender and MUST ignore it when authenticating a delivery. The field remains required through 3.x for schema compatibility and is removed in 4.0.', ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/property_list_filters.py b/src/adcp/types/domains/property/property_list_filters.py index 011cdfa59..a7114cf08 100644 --- a/src/adcp/types/domains/property/property_list_filters.py +++ b/src/adcp/types/domains/property/property_list_filters.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: property/property_list_filters.json -# timestamp: 2026-10-02T13:37:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field @@ -29,6 +29,7 @@ class PropertyListFilters(AdCPBaseModel): description='Property must have feature data for ALL listed countries (ISO codes). When omitted, no country restriction is applied.', min_length=1, ), + NoExplicitNull, ] = None channels_any: Annotated[ list[channels.MediaChannel] | None, @@ -36,10 +37,12 @@ class PropertyListFilters(AdCPBaseModel): description='Property must support ANY of the listed channels. When omitted, no channel restriction is applied.', min_length=1, ), + NoExplicitNull, ] = None property_types: Annotated[ list[property_type.PropertyType] | None, Field(description='Filter to these property types', min_length=1), + NoExplicitNull, ] = None feature_requirements: Annotated[ list[feature_requirement.FeatureRequirement] | None, @@ -47,8 +50,10 @@ class PropertyListFilters(AdCPBaseModel): description='Feature-based requirements. Property must pass ALL requirements (AND logic).', min_length=1, ), + NoExplicitNull, ] = None exclude_identifiers: Annotated[ list[identifier.Identifier] | None, Field(description='Identifiers to always exclude from results', min_length=1), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/property/update_property_list_request.py b/src/adcp/types/domains/property/update_property_list_request.py index ddd86196a..76d4fad1b 100644 --- a/src/adcp/types/domains/property/update_property_list_request.py +++ b/src/adcp/types/domains/property/update_property_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/update_property_list_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import AnyUrl, ConfigDict, Field from ..core import account_ref, brand_ref @@ -27,33 +28,38 @@ class UpdatePropertyListRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account that owns the list. Required when the authenticated agent has access to multiple accounts; optional otherwise.' ), + NoExplicitNull, ] = None - name: Annotated[str | None, Field(description='New name for the list')] = None - description: Annotated[str | None, Field(description='New description')] = None + name: Annotated[str | None, Field(description='New name for the list'), NoExplicitNull] = None + description: Annotated[str | None, Field(description='New description'), NoExplicitNull] = None base_properties: Annotated[ list[base_property_source.BasePropertySource] | None, Field( description='Complete replacement for the base properties list (not a patch). Each entry is a discriminated union: publisher_tags (publisher_domain + tags), publisher_ids (publisher_domain + property_ids), or identifiers (direct identifiers).' ), + NoExplicitNull, ] = None filters: Annotated[ property_list_filters.PropertyListFilters | None, Field(description='Complete replacement for the filters (not a patch)'), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Update brand reference. Resolved to full brand identity at execution time.' ), + NoExplicitNull, ] = None webhook_url: Annotated[ AnyUrl | None, Field( description='Update the webhook URL for list change notifications (set to empty string to remove)' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None idempotency_key: Annotated[ str, Field( diff --git a/src/adcp/types/domains/property/update_property_list_response.py b/src/adcp/types/domains/property/update_property_list_response.py index 27aca96d7..0bbeaca36 100644 --- a/src/adcp/types/domains/property/update_property_list_response.py +++ b/src/adcp/types/domains/property/update_property_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/update_property_list_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -27,6 +28,7 @@ class UpdatePropertyListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve Field( description="Set to true when this response was returned from the idempotency cache rather than from a fresh execution. Set to false (or omitted) when the request was executed fresh. Buyers use this to distinguish cached replays from new executions — matters for billing reconciliation, audit logs, state-machine routing (cached state-tracking fields are historical snapshots, not current state — re-read via the resource's read endpoint), and any downstream system that assumes exactly-once event semantics. `replayed` appears only when the request actually resolved through the idempotency cache. Pure reads may ignore an optional `idempotency_key`; when a seller voluntarily caches keyed reads, those responses use the same replay indicator and full cache contract." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/validate_property_delivery_request.py b/src/adcp/types/domains/property/validate_property_delivery_request.py index e4afe9e45..6ddfbe1a6 100644 --- a/src/adcp/types/domains/property/validate_property_delivery_request.py +++ b/src/adcp/types/domains/property/validate_property_delivery_request.py @@ -1,11 +1,12 @@ # generated by datamodel-codegen: # filename: property/validate_property_delivery_request.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref @@ -25,6 +26,7 @@ class ValidatePropertyDeliveryRequest(AdcpVersionEnvelope): Field( description='Account that owns the list. Required when the authenticated agent has access to multiple accounts; optional otherwise.' ), + NoExplicitNull, ] = None records: Annotated[ list[delivery_record.DeliveryRecord], @@ -39,6 +41,7 @@ class ValidatePropertyDeliveryRequest(AdcpVersionEnvelope): Field( description='Include compliant records in results (default: only return non_compliant, unmodeled, and unidentified)' ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/validate_property_delivery_response.py b/src/adcp/types/domains/property/validate_property_delivery_response.py index ccc3fd0f0..20f912b60 100644 --- a/src/adcp/types/domains/property/validate_property_delivery_response.py +++ b/src/adcp/types/domains/property/validate_property_delivery_response.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: property/validate_property_delivery_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import context as context_1 @@ -60,17 +60,22 @@ class Aggregate(AdCPBaseModel): score: Annotated[ StrictFloat | None, Field(description='Numeric score (0-100 scale typical, but agent-defined)'), + NoExplicitNull, ] = None grade: Annotated[ str | None, Field(description="Letter grade or category (e.g., 'A+', 'B-', 'Gold', 'Compliant')"), + NoExplicitNull, ] = None label: Annotated[ str | None, Field(description="Human-readable summary (e.g., '85% compliant', 'High quality')"), + NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, Field(description='URL explaining how this aggregate was calculated') + AnyUrl | None, + Field(description='URL explaining how this aggregate was calculated'), + NoExplicitNull, ] = None @@ -117,6 +122,7 @@ class ValidatePropertyDeliveryResponse(AdcpVersionEnvelope, ProtocolEnvelope): Field( description='Overall compliance flag for the submitted delivery — true when every record is compliant, false when any record is non_compliant. Derived from summary.non_compliant_records === 0 but surfaced at the root as a convenience signal for buyers. Agents MAY omit this field when the response represents a partial validation (e.g., when include_compliant is false and results only contains non_compliant records); consumers SHOULD fall back to summary counts if compliant is absent.' ), + NoExplicitNull, ] = None list_id: Annotated[str, Field(description='ID of the property list validated against')] summary: Annotated[Summary, Field(description='Aggregate validation statistics')] @@ -125,12 +131,14 @@ class ValidatePropertyDeliveryResponse(AdcpVersionEnvelope, ProtocolEnvelope): Field( description='Optional aggregate measurements computed by the governance agent. Format and meaning are agent-specific.' ), + NoExplicitNull, ] = None authorization_summary: Annotated[ AuthorizationSummary | None, Field( description='Aggregate authorization statistics. Only present if any records included sales_agent_url.' ), + NoExplicitNull, ] = None results: Annotated[ list[validation_result.ValidationResult], @@ -144,6 +152,7 @@ class ValidatePropertyDeliveryResponse(AdcpVersionEnvelope, ProtocolEnvelope): list_resolved_at: Annotated[ AwareDatetime | None, Field(description='Timestamp of the property list resolution used for validation'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/property/validation_result.py b/src/adcp/types/domains/property/validation_result.py index 416fdff27..515f8a0ad 100644 --- a/src/adcp/types/domains/property/validation_result.py +++ b/src/adcp/types/domains/property/validation_result.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/validation_result.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..core import ext as ext_1 @@ -28,13 +28,15 @@ class Requirement(AdCPBaseModel): extra='forbid', ) min_value: Annotated[ - StrictFloat | None, Field(description='Minimum value that was required') + StrictFloat | None, Field(description='Minimum value that was required'), NoExplicitNull ] = None max_value: Annotated[ - StrictFloat | None, Field(description='Maximum value that was allowed') + StrictFloat | None, Field(description='Maximum value that was allowed'), NoExplicitNull ] = None allowed_values: Annotated[ - list[Any] | None, Field(description='Values that would have been acceptable') + list[Any] | None, + Field(description='Values that would have been acceptable'), + NoExplicitNull, ] = None @@ -54,15 +56,19 @@ class Feature(AdCPBaseModel): Field( description='Optional attribution — when this feature was evaluated for the purpose of a specific policy, policy_id references the authorizing PolicyEntry. Property-list agents populate when the validation was motivated by a specific policy. See /docs/governance/policy-attribution.' ), + NoExplicitNull, ] = None explanation: Annotated[ - str | None, Field(description='Directional human-readable explanation of the result.') + str | None, + Field(description='Directional human-readable explanation of the result.'), + NoExplicitNull, ] = None requirement: Annotated[ Requirement | None, Field( description='The feature requirement that was not met. MAY be present on failed features when the caller authored the requirement (e.g., feature_requirements on a property list). The buyer set these thresholds — echoing them back enables fix-and-retry loops without looking up the list definition.' ), + NoExplicitNull, ] = None confidence: Annotated[ StrictFloat | None, @@ -71,6 +77,7 @@ class Feature(AdCPBaseModel): ge=0.0, le=1.0, ), + NoExplicitNull, ] = None @@ -82,7 +89,9 @@ class ValidationResult(AdCPBaseModel): identifier_1.Identifier, Field(description='The identifier that was validated') ] record_id: Annotated[ - str | None, Field(description='Client-provided ID from the delivery record (if provided)') + str | None, + Field(description='Client-provided ID from the delivery record (if provided)'), + NoExplicitNull, ] = None status: Annotated[ Status, @@ -98,11 +107,13 @@ class ValidationResult(AdCPBaseModel): Field( description='Per-feature breakdown for this record. SHOULD include all failed and warning features. MAY include passed features. For property validation the buyer authored the requirement, so the `requirement` that was not met MAY be echoed back on failures — this is contract data, not evaluator IP.' ), + NoExplicitNull, ] = None authorization: Annotated[ authorization_result.AuthorizationResult | None, Field( description='Authorization validation result (only present if sales_agent_url was provided in the delivery record)' ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/get_adcp_capabilities_request.py b/src/adcp/types/domains/protocol/get_adcp_capabilities_request.py index dbc8b70fc..f4261d065 100644 --- a/src/adcp/types/domains/protocol/get_adcp_capabilities_request.py +++ b/src/adcp/types/domains/protocol/get_adcp_capabilities_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_adcp_capabilities_request.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,6 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -34,6 +35,7 @@ class GetAdcpCapabilitiesRequest(AdcpRequest, AdcpVersionEnvelope): description='Specific protocols to query capabilities for. If omitted, returns capabilities for all supported protocols.', min_length=1, ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py b/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py index 884ea8890..ef884fd58 100644 --- a/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py +++ b/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_adcp_capabilities_response.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Dict, Literal -from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey +from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull from adcp.types._scalar import ScalarInt, ScalarStr from pydantic import ( AnyUrl, @@ -104,12 +104,14 @@ class Idempotency(AdCPBaseModel): ge=1, le=604800, ), + NoExplicitNull, ] = None account_id_is_opaque: Annotated[ StrictBool | None, Field( description="When true, the seller derives `account_id` via an HKDF-based one-way transform of the buyer's natural account key rather than echoing the natural key on the wire. Buyers MUST NOT attempt to invert the opaque id and MUST treat it as a blind handle scoped to this seller. Absent or false, callers should assume `account_id` is the natural key (or a server-assigned but non-opaque id). This flag does not change the wire shape, but it DOES change buyer behavior — buyers MUST NOT cache, log, or treat `account_id` as a natural-key analog when this flag is true. Migration note for sellers already returning an opaque id without this flag: set it to true at the next capabilities refresh so buyers stop inferring natural-key semantics; until set, new-buyer replay/retry logic will misclassify these ids as natural keys." ), + NoExplicitNull, ] = False @@ -184,6 +186,7 @@ class Notifications(AdCPBaseModel): ge=0, le=86400, ), + NoExplicitNull, ] = None @@ -210,12 +213,14 @@ class CapabilityChanges(AdCPBaseModel): max_length=255, min_length=1, ), + NoExplicitNull, ] = None last_modified: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp when the seller last changed the advertised capability document. This is the scoped replacement for legacy top-level `last_updated` when buyers need cache validation semantics.' ), + NoExplicitNull, ] = None cache_ttl_seconds: Annotated[ SchemaInt | None, @@ -224,12 +229,14 @@ class CapabilityChanges(AdCPBaseModel): ge=60, le=604800, ), + NoExplicitNull, ] = None notifications: Annotated[ Notifications | Notifications1 | None, Field( description='Whether the seller supports agent-level capability-change webhooks. When supported, interested consumers register endpoint subscribers with the declared registration_task; sync_principal is preferred when the broader connection surface is available, while sync_agent_notification_configs remains the specialized compatibility task. Each capabilities.changed fire is a small invalidation payload, and consumers repair by re-reading get_adcp_capabilities.' ), + NoExplicitNull, ] = None @@ -341,6 +348,7 @@ class GovernanceEnforcement(AdCPBaseModel): Field( description='Seller-wide advisory default for governance agents this enforcing service accepts as binding counterparties. A candidate satisfying any matcher is accepted by this declaration. The per-account sync_governance response is authoritative and may apply stricter account-specific criteria. Omission means accept any, preserving legacy behavior.' ), + NoExplicitNull, ] = None @@ -386,6 +394,7 @@ class Notifications2(AdCPBaseModel): Field( description='Whether `list_accounts` can return recent delivery attempts in `accounts[].webhook_activity[]` when the buyer requests `include_webhook_activity: true`.' ), + NoExplicitNull, ] = False @@ -479,12 +488,14 @@ class Account(AdCPBaseModel): Field( description="Whether the seller requires operator-level credentials. This declares who must authenticate; it does not by itself declare whether OAuth is used, whether list_accounts is exposed, or which sync_accounts modes are supported. When true, operators authenticate independently with the seller and account-scoped calls use seller/storefront-assigned account_id values because the seller or upstream platform owns the canonical account namespace. If a credential may access more than one account, the seller MUST expose list_accounts and buyers MUST resolve an explicit account_id before the first account-scoped request. If a credential is bound to exactly one account, the seller SHOULD expose list_accounts returning that singleton; a seller MAY omit list_accounts only when it provides the same explicit account_id through another declared path or out-of-band onboarding. When false (default, buyer-declared accounts), the seller trusts the agent's identity claims and account-scoped calls use the advertiser natural key: brand + operator + optional operator_unit, fixed currency, optional buyer-selected account timezone, and sandbox. operator_unit.id is owned by the operator and is distinct from the seller's account_id. The seller normally provisions through sync_accounts, but MAY lazily provision on the first account-scoped request when billing and other required settings are unambiguous from capabilities or onboarding defaults. A lazy-provisioning seller MUST keep accepting the natural key and MUST expose list_accounts for recovery; if buyer input is needed before use, the seller MUST expose sync_accounts." ), + NoExplicitNull, ] = False authorization_endpoint: Annotated[ AnyUrl | None, Field( description='OAuth authorization endpoint for obtaining operator-level credentials. Present when the seller supports OAuth for operator authentication. The agent directs the operator to this URL to authenticate and obtain a bearer token. If absent and require_operator_auth is true, operators obtain credentials out-of-band (e.g., seller portal, API key).' ), + NoExplicitNull, ] = None supported_billing: Annotated[ list[billing_party.BillingParty], @@ -499,48 +510,56 @@ class Account(AdCPBaseModel): description='Required for sellers implementing AdCP 3.2 advertiser-account provisioning, but optional in this shared 3.x response schema so existing 3.0 and 3.1 capability responses remain valid. Declares whether advertiser accounts are bound to one immutable currency (`fixed`), select currency independently per proposal or media buy (`per_media_buy`), or support both models. When only `fixed` is advertised, buyer-declared provisioning entries MUST include `currency`. When only `per_media_buy` is advertised, they MUST omit it. When both are advertised, presence of `currency` selects a fixed-currency account and omission selects per-media-buy currency. Buyers MUST treat absence as an older seller whose currency mode is not discoverable, not as support for either mode.', min_length=1, ), + NoExplicitNull, ] = None timezone: Annotated[ account_timezone_capability.AccountTimezoneCapability | None, Field( description='Required for sellers implementing AdCP 3.2 advertiser-account provisioning, but optional in the shared 3.x response schema for compatibility. Declares whether the account timezone is seller-wide or fixed per account and whether a buyer must select it during sync_accounts provisioning. Account timezone is the default for account-scoped calendar semantics; feature-specific capability fields explicitly declare exceptions.' ), + NoExplicitNull, ] = None required_for_products: Annotated[ StrictBool | None, Field( description='Whether an account reference is required for get_products. When true, the buyer must establish an account before browsing products. When false (default), the buyer can browse products without an account — useful for price comparison and discovery before committing to a seller. Before an account is provisioned, the buyer omits account rather than sending a natural key that does not resolve yet. See /docs/accounts/overview#account-references-before-provisioning.' ), + NoExplicitNull, ] = False account_financials: Annotated[ StrictBool | None, Field( description='Whether this seller exposes the `get_account_financials` task for querying account-level financial status (spend, credit, invoices). Acts as a **pre-call discriminator** — buyers MUST consult this field before issuing `get_account_financials`; when `false` (or absent), sellers MAY reject the call with an `UNSUPPORTED_FEATURE` / `OPERATION_NOT_SUPPORTED` error. Companion pattern to `creative.bills_through_adcp` (issue #2881) — both fields let buyers gate optional capability calls on a single declared boolean rather than probing for support. Only applicable to operator-billed accounts; sellers using buyer-billed flows omit or set to `false`.' ), + NoExplicitNull, ] = False notifications: Annotated[ Notifications2 | Notifications3 | None, Field( description='Whether the seller supports durable account-lifecycle webhooks through account-level `notification_configs[]`. This capability is specifically for account status changes such as approval, rejection, payment-required, suspension, recovery, and closure. When supported, buyers register subscribers with `sync_accounts.accounts[].notification_configs[]`; each `account.status_changed` fire is an invalidation payload, and buyers repair by re-reading `list_accounts` for the account_id.' ), + NoExplicitNull, ] = None change_feed: Annotated[ ChangeFeed | ChangeFeed1 | None, Field( description='Whether the seller exposes a durable, ordered feed of material changes to authoritative account-scoped state. This is distinct from webhook_activity transport diagnostics and from current-state reads. Sellers claiming support MUST retain changes for at least 90 days after recording and MUST produce records regardless of whether a mutation originated through AdCP, a seller surface, another authorized principal, seller automation, or a connected platform within declared coverage. Experimental in 3.2 (RFC #6810): sellers advertising supported: true MUST list account.change_feed in experimental_features.' ), + NoExplicitNull, ] = None identity_updates: Annotated[ IdentityUpdates | IdentityUpdates1 | None, Field( description='Whether the seller accepts buyer-desired operator identity reconciliation through sync_accounts settings-update entries. Sellers declaring support expose the exact identity transitions they implement, MUST return account revisions from sync_accounts and list_accounts, and MUST return identity_change_preview for dry-run identity updates.' ), + NoExplicitNull, ] = None sandbox: Annotated[ StrictBool | None, Field( description='Whether this seller supports sandbox accounts for testing. Buyer-declared accounts use sandbox: true in sync_accounts or, for an unambiguous lazy-provisioning seller, in the natural-key account reference. Sellers with account_id namespaces expose sandbox accounts as pre-existing test accounts through list_accounts or supply them out-of-band. Requests using a sandbox account perform no real platform calls or spend.' ), + NoExplicitNull, ] = False @@ -570,6 +589,7 @@ class AcceptancePolicyDiscovery(AdCPBaseModel): description='Local or registry-referenced catalog profiles that apply seller-wide unless a product adds further profiles. IDs MUST resolve uniquely across profiles and registry_profiles; all referenced profiles compose restrictively.', min_length=1, ), + NoExplicitNull, ] = None @@ -616,6 +636,7 @@ class ProposalRefinement(AdCPBaseModel): ge=2, le=10, ), + NoExplicitNull, ] = None @@ -628,6 +649,7 @@ class PerformanceFeedback(AdCPBaseModel): Field( description='Whether provide_performance_feedback responses include an honest application_status of accepted, applied, or not_applied. accepted is explicitly not an application claim. Sellers that cannot determine optimizer disposition omit this claim or set it false.' ), + NoExplicitNull, ] = False @@ -684,12 +706,14 @@ class RelationshipNotifications(AdCPBaseModel): max_length=1, min_length=1, ), + NoExplicitNull, ] = None supports_webhook_activity: Annotated[ StrictBool | None, Field( description='Whether the declared repair tasks can return recent delivery attempts for these account-level invalidations when include_webhook_activity is true.' ), + NoExplicitNull, ] = False @@ -708,19 +732,22 @@ class GeoMetros(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - nielsen_dma: StrictBool | None = None - uk_itl1: StrictBool | None = None - uk_itl2: StrictBool | None = None - eurostat_nuts2: StrictBool | None = None + nielsen_dma: Annotated[StrictBool | None, NoExplicitNull] = None + uk_itl1: Annotated[StrictBool | None, NoExplicitNull] = None + uk_itl2: Annotated[StrictBool | None, NoExplicitNull] = None + eurostat_nuts2: Annotated[StrictBool | None, NoExplicitNull] = None class AgeRestriction(AdCPBaseModel): supported: Annotated[ - StrictBool | None, Field(description='Whether seller supports age restrictions') + StrictBool | None, + Field(description='Whether seller supports age restrictions'), + NoExplicitNull, ] = None verification_methods: Annotated[ list[age_verification_method.AgeVerificationMethod] | None, Field(description='Age verification methods this seller supports'), + NoExplicitNull, ] = None @@ -795,8 +822,8 @@ class MatchingLatencyHours(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - min: Annotated[SchemaInt | None, Field(ge=0)] = None - max: Annotated[SchemaInt | None, Field(ge=0)] = None + min: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None + max: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None class SupportedOptimizationMetric(StrEnum): @@ -829,6 +856,7 @@ class VendorMetricOptimization(AdCPBaseModel): description='Target kinds this seller can support for vendor_metric optimization goals on at least one product. Values match optimization_goals[].target.kind for kind: vendor_metric. A target-less vendor_metric goal maximizes the metric within budget and does not require a target-kind declaration.', min_length=1, ), + NoExplicitNull, ] = None @@ -845,6 +873,7 @@ class AttributionWindow(AdCPBaseModel): event_type: Annotated[ event_type_1.EventType | None, Field(description='Event type this window applies to, or omit for default window'), + NoExplicitNull, ] = None post_click: Annotated[ list[duration.Duration], @@ -859,6 +888,7 @@ class AttributionWindow(AdCPBaseModel): description='Available post-view attribution windows (e.g. [{"interval": 1, "unit": "days"}])', min_length=1, ), + NoExplicitNull, ] = None @@ -873,6 +903,7 @@ class ContentStandards(AdCPBaseModel): Field( description="Whether the seller runs a local evaluation model. When false, all artifacts will have local_verdict: 'unevaluated' and the failures_only filter on get_media_buy_artifacts is not useful." ), + NoExplicitNull, ] = None supported_channels: Annotated[ list[channels.MediaChannel] | None, @@ -880,12 +911,14 @@ class ContentStandards(AdCPBaseModel): description='Channels for which the seller can provide content artifacts. Helps buyers understand which parts of a mixed-channel buy will have content standards coverage.', min_length=1, ), + NoExplicitNull, ] = None supports_webhook_delivery: Annotated[ StrictBool | None, Field( description='Whether the seller supports push-based artifact delivery via artifact_webhook configured at buy creation time.' ), + NoExplicitNull, ] = None @@ -914,18 +947,21 @@ class Portfolio(AdCPBaseModel): Field( description="Complete list of AdCP media channels for which this sales agent accepts and can meaningfully answer product-discovery briefs. When present, this is an exhaustive brief-routing allowlist: buyers MAY skip the agent when a brief's requested channels do not intersect it. Omission means channel scope is unknown and MUST NOT be interpreted as support for every channel. This is a routing pre-filter, not a promise of current product availability." ), + NoExplicitNull, ] = None primary_countries: Annotated[ list[PrimaryCountry] | None, Field( description="Complete list of ISO 3166-1 alpha-2 countries for which this sales agent accepts and can meaningfully answer product-discovery briefs. When present, this is an exhaustive brief-routing allowlist: buyers MAY skip the agent when a brief's requested countries do not intersect it. Omission means country scope is unknown and MUST NOT be interpreted as global coverage. This is a routing pre-filter, not a promise of current product availability and not an executable geo-targeting declaration; media_buy.execution.targeting geo capabilities and each product's overlay_support remain authoritative for targeting execution." ), + NoExplicitNull, ] = None description: Annotated[ str | None, Field( description='Markdown-formatted description of the inventory portfolio', max_length=5000 ), + NoExplicitNull, ] = None advertising_policies: Annotated[ str | None, @@ -933,6 +969,7 @@ class Portfolio(AdCPBaseModel): description='Advertising content policies, restrictions, and guidelines', max_length=10000, ), + NoExplicitNull, ] = None @@ -956,6 +993,7 @@ class Features(AdCPBaseModel): deprecated=True, description='DEPRECATED. Legacy wire flag for structured signal_ref references to provider-published signal definitions. New agents SHOULD omit this flag; callers MUST NOT require it before using signal_ref with the Signals protocol.', ), + NoExplicitNull, ] = None @@ -967,6 +1005,7 @@ class Signals(AdCPBaseModel): Field( description='Whether this agent accepts get_signals discovery without caller credentials in brief or wholesale mode. true means an anonymous discovery request can produce a successful response, but the response may be a public subset and may differ from results for an authenticated principal or selected account. false means get_signals requires an authenticated principal. Absence means unspecified legacy behavior, so callers probe and handle AUTH_MISSING. A valid authenticated request is never rejected merely because credentials were supplied.' ), + NoExplicitNull, ] = None data_provider_domains: Annotated[ list[DataProviderDomain] | None, @@ -974,6 +1013,7 @@ class Signals(AdCPBaseModel): description="Data provider domains this signals agent is authorized to resell. Buyers should fetch each data provider's adagents.json for published signal definitions and to verify authorization.", min_length=1, ), + NoExplicitNull, ] = None discovery_modes: Annotated[ list[DiscoveryMode] | None, @@ -981,9 +1021,10 @@ class Signals(AdCPBaseModel): description="Discovery modes this signals agent supports on get_signals. 'brief' (default — every signals agent supports this): semantic discovery driven by signal_spec or signal_refs, with deprecated signal_ids accepted for older clients. 'wholesale': raw wholesale signals feed enumeration — caller omits signal_spec, signal_refs, and signal_ids and the agent returns its full priced signals feed, paginated, scoped by filters/account/destinations/countries. Agents that do not declare 'wholesale' MAY return INVALID_REQUEST for wholesale calls. Absent declaration is treated as ['brief'].", min_length=1, ), + NoExplicitNull, ] = [DiscoveryMode.brief] features: Annotated[ - Features | None, Field(description='Optional signals features supported') + Features | None, Field(description='Optional signals features supported'), NoExplicitNull ] = None @@ -1038,19 +1079,26 @@ class PropertyFeature(AdCPBaseModel): ), ] range: Annotated[ - Range | None, Field(description='For quantitative features, the valid range') + Range | None, + Field(description='For quantitative features, the valid range'), + NoExplicitNull, ] = None categories: Annotated[ - list[str] | None, Field(description='For categorical features, the valid values') + list[str] | None, + Field(description='For categorical features, the valid values'), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Human-readable description of what this feature measures') + str | None, + Field(description='Human-readable description of what this feature measures'), + NoExplicitNull, ] = None methodology_url: Annotated[ AnyUrl | None, Field( description='URL to documentation explaining how this feature is calculated or measured. Helps buyers understand and compare methodologies across vendors.' ), + NoExplicitNull, ] = None @@ -1068,19 +1116,26 @@ class CreativeFeature(AdCPBaseModel): ), ] range: Annotated[ - Range | None, Field(description='For quantitative features, the valid range') + Range | None, + Field(description='For quantitative features, the valid range'), + NoExplicitNull, ] = None categories: Annotated[ - list[str] | None, Field(description='For categorical features, the valid values') + list[str] | None, + Field(description='For categorical features, the valid values'), + NoExplicitNull, ] = None description: Annotated[ - str | None, Field(description='Human-readable description of what this feature measures') + str | None, + Field(description='Human-readable description of what this feature measures'), + NoExplicitNull, ] = None methodology_url: Annotated[ AnyUrl | None, Field( description='URL to documentation explaining how this feature is calculated or measured.' ), + NoExplicitNull, ] = None @@ -1090,6 +1145,7 @@ class Governance(AdCPBaseModel): Field( description='Signal-activation policy for the portable attestation support declared in adcp.attestations. Presence means check_governance accepts runtime_attestations[] for purchase_type signal_activation. The claim_types list MUST be a subset of adcp.attestations.accepted_claim_types; issuer, resolver, verifier, proof-format, and delivery allowlists remain authoritative in the shared adcp.attestations block rather than being duplicated here.' ), + NoExplicitNull, ] = None aggregation_window_days: Annotated[ SchemaInt | None, @@ -1098,18 +1154,21 @@ class Governance(AdCPBaseModel): ge=1, le=365, ), + NoExplicitNull, ] = None property_features: Annotated[ list[PropertyFeature] | None, Field( description='Property features this governance agent can evaluate. Each feature describes a score, rating, or certification the agent can provide for properties.' ), + NoExplicitNull, ] = None creative_features: Annotated[ list[CreativeFeature] | None, Field( description='Creative features this governance agent can evaluate. Each feature describes a score, rating, or assessment the agent can provide for creatives (e.g., security scanning, creative quality, content categorization).' ), + NoExplicitNull, ] = None @@ -1135,7 +1194,9 @@ class Endpoint(AdCPBaseModel): ), ] preferred: Annotated[ - Type6 | None, Field(description='Preferred transport when host supports multiple') + Type6 | None, + Field(description='Preferred transport when host supports multiple'), + NoExplicitNull, ] = None @@ -1156,16 +1217,19 @@ class Multiplicity(AdCPBaseModel): Field( description='When true, build_creative accepts max_creatives (one distinct creative per catalog item).' ), + NoExplicitNull, ] = False max_creatives_limit: Annotated[ SchemaInt | None, Field(description='Ceiling on max_creatives. Omitted means no advertised ceiling.', ge=1), + NoExplicitNull, ] = None supports_signal_fanout: Annotated[ StrictBool | None, Field( description='Experimental (x-status: experimental) — agents setting this true MUST also list `creative.signal_fanout` in `experimental_features`; the surface MAY change between 3.x releases with notice (see docs/reference/experimental-status). When true, build_creative accepts signal_conditions[] (one distinct creative group per signal condition, keep-all). Mirrors supports_catalog_fanout.' ), + NoExplicitNull, ] = False max_signal_conditions_limit: Annotated[ SchemaInt | None, @@ -1173,26 +1237,31 @@ class Multiplicity(AdCPBaseModel): description='Ceiling on len(signal_conditions). Over-limit requests are CLAMPED (not rejected), like max_creatives_limit. Omitted means no advertised ceiling.', ge=1, ), + NoExplicitNull, ] = None supports_variants: Annotated[ StrictBool | None, Field( description='When true, build_creative accepts max_variants > 1 / variant_axis (alternatives per creative).' ), + NoExplicitNull, ] = False max_variants_limit: Annotated[ SchemaInt | None, Field(description='Ceiling on max_variants. Omitted means no advertised ceiling.', ge=1), + NoExplicitNull, ] = None variant_dimensions: Annotated[ list[VariantDimension] | None, Field(description='Which variant_axis.dimension values this agent supports.'), + NoExplicitNull, ] = None selection_strategies: Annotated[ list[creative_selection_strategy.CreativeSelectionStrategy] | None, Field( description='Which selection_strategy values this agent supports when sampling max_creatives < items_total. Sibling to variant_dimensions. Part of the experimental signal-fanout surface (feature id `creative.signal_fanout`).' ), + NoExplicitNull, ] = None @@ -1250,6 +1319,7 @@ class Localization(AdCPBaseModel): ge=0, le=50, ), + NoExplicitNull, ] = None locale_matching: Annotated[ Literal['rfc4647_lookup'], @@ -1316,6 +1386,7 @@ class RequestSigning(AdCPBaseModel): Field( description="Policy for content-digest coverage in request signatures. In AdCP 3.2 and later, an agent with request_signing.supported=true MUST explicitly emit 'required': every accepted signature on a request with a body covers content-digest, and a body-unbound signature is rejected with request_signature_components_incomplete. Omission retains the legacy effective default of 'either' only for a 3.0/3.1 response; 3.2 responses require this field explicitly. 'either' and 'forbidden' are deprecated legacy 3.0/3.1 postures retained only for version negotiation with pre-3.2 peers; they MUST NOT be advertised as a 3.2 signing posture and are removed in 4.0. A shared endpoint MUST select the verifier policy from trusted endpoint configuration and negotiated capabilities before dispatch, never from an unbound request-body version field." ), + NoExplicitNull, ] = None required_for: Annotated[ list[Annotated[str, Field(pattern='^[a-z][a-z0-9_]*$')]] | None, @@ -1323,6 +1394,7 @@ class RequestSigning(AdCPBaseModel): description="AdCP protocol operation names (e.g., 'create_media_buy') for which this agent rejects an unsigned request with request_signature_required unless an independently valid configured fallback authenticator succeeds. A transport-specific rename of an operation is not a valid entry (MCP tools and the AdCP A2A profile use the operation name verbatim, so those names coincide with it) — verifiers MUST NOT accept operation names that are not defined by the AdCP protocol spec. Over MCP the verifier matches the tools/call params.name; over A2A it matches the skill of the sole DataPart of the message (SendMessage, SendStreamingMessage, message/send, message/stream, and the HTTP+JSON and gRPC equivalents). The A2A profile uses the AdCP operation name verbatim as skill, so that is the same name space. A request that does not resolve to exactly one operation is rejected with request_body_malformed; see docs/building/by-layer/L1/security.mdx#operation-resolution-over-a2a. JSON-RPC protocol method names like `tasks/cancel` belong in `protocol_methods_required_for`, not here. Empty in 3.0 by default; sellers populate selectively during per-counterparty pilots. In 4.0 this list MUST include all spend-committing operations the agent supports (create_media_buy, acquire_*, etc.). Every operation listed MUST also appear in `supported_for`; see `x-adcp-validation`.", validate_default=True, ), + NoExplicitNull, ] = [] warn_for: Annotated[ list[Annotated[str, Field(pattern='^[a-z][a-z0-9_]*$')]] | None, @@ -1330,12 +1402,14 @@ class RequestSigning(AdCPBaseModel): description='AdCP protocol operation names for shadow-mode verification. The verifier records missing signatures and well-formed signatures that fail verification or body binding, but MUST NOT establish verified-signer identity from a failed signature; processing continues only when an independent bearer, API-key, or mTLS authenticator succeeds. A partial or malformed Signature / Signature-Input pair always hard-rejects. Used as a bridge between supported_for and required_for. Precedence: required_for > warn_for > supported_for. An operation MUST NOT appear in both warn_for and required_for; see x-adcp-validation.', validate_default=True, ), + NoExplicitNull, ] = [] supported_for: Annotated[ list[Annotated[str, Field(pattern='^[a-z][a-z0-9_]*$')]] | None, Field( description='AdCP protocol operation names for which this agent verifies signatures when present but does not require them. Under the 3.2 profile, a presented signature on a body-bearing request without content-digest coverage rejects even though an unsigned request may use configured fallback authentication. Typically a superset of required_for and warn_for.' ), + NoExplicitNull, ] = None protocol_methods_supported_for: Annotated[ list[ProtocolMethodsSupportedForItem] | None, @@ -1343,6 +1417,7 @@ class RequestSigning(AdCPBaseModel): description="JSON-RPC protocol method names for which this agent verifies signatures when present. Values MUST use exact, case-sensitive equality after JSON decoding: slash-path names such as 'tasks/cancel' and 'tasks/pushNotificationConfig/set' for A2A 0.3, or PascalCase names such as 'CancelTask' and 'CreateTaskPushNotificationConfig' for A2A 1.0. A dual-stack agent lists each supported wire name independently; implementations MUST NOT translate or normalize between protocol versions. The reserved MCP envelope method 'tools/call' is forbidden because its AdCP operation identity is params.name and belongs in supported_for. Under the 3.2 profile, a presented signature on a body-bearing request without content-digest coverage rejects. Disjoint from supported_for, which carries lower_snake_case AdCP operation names only.", validate_default=True, ), + NoExplicitNull, ] = [] protocol_methods_warn_for: Annotated[ list[ProtocolMethodsWarnForItem] | None, @@ -1350,6 +1425,7 @@ class RequestSigning(AdCPBaseModel): description='Exact JSON-RPC protocol method names for shadow-mode verification, mirroring warn_for in the AdCP-operation namespace. Wire-name grammar and exact, case-sensitive matching semantics are identical to protocol_methods_supported_for. Missing signatures and well-formed signatures that fail verification or body binding are recorded but MUST NOT establish verified-signer identity; processing continues only when an independent authenticator succeeds. A partial or malformed Signature / Signature-Input pair always hard-rejects. An item MUST NOT appear in both protocol_methods_warn_for and protocol_methods_required_for; see x-adcp-validation.', validate_default=True, ), + NoExplicitNull, ] = [] protocol_methods_required_for: Annotated[ list[ProtocolMethodsRequiredForItem] | None, @@ -1357,6 +1433,7 @@ class RequestSigning(AdCPBaseModel): description='Exact JSON-RPC protocol method names for which this agent rejects an unsigned request with request_signature_required unless an independently valid configured fallback authenticator succeeds. Wire-name grammar and exact, case-sensitive matching semantics are identical to protocol_methods_supported_for. Separate namespace from required_for: this bucket binds against the JSON-RPC method field, not tools/call params.name. Every listed method MUST also appear in protocol_methods_supported_for; see x-adcp-validation.', validate_default=True, ), + NoExplicitNull, ] = [] @@ -1377,6 +1454,7 @@ class WebhookSigning(AdCPBaseModel): Field( description='Identifier of the webhook-signing profile version the agent emits. Value MUST match the `tag=` parameter emitted in the RFC 9421 `Signature-Input` header (see docs/building/implementation/webhooks.mdx) so receivers can statically validate the declared profile against the on-wire tag. Closed enum; future profile revisions will extend this enum in a follow-up schema bump.' ), + NoExplicitNull, ] = None algorithms: Annotated[ list[Algorithm] | None, @@ -1384,6 +1462,7 @@ class WebhookSigning(AdCPBaseModel): description="Signature algorithms this agent uses on outbound webhooks. 3.0 profile permits 'ed25519' and 'ecdsa-p256-sha256' only; other values are reserved for future profile versions and MUST NOT be emitted under adcp/webhook-signing/v1.", min_length=1, ), + NoExplicitNull, ] = None legacy_hmac_fallback: Annotated[ StrictBool | None, @@ -1391,6 +1470,7 @@ class WebhookSigning(AdCPBaseModel): deprecated=True, description='Whether this agent will fall back to HMAC-SHA256 on the legacy push_notification_config.authentication, accounts[].notification_configs[].authentication, sync_principal.configuration.notification_configs[].authentication, or sync_agent_notification_configs.notification_configs[].authentication paths for receivers that have not adopted RFC 9421. Deprecated; removed in AdCP 4.0.', ), + NoExplicitNull, ] = False delivery_retry_horizon_seconds: Annotated[ SchemaInt | None, @@ -1399,6 +1479,7 @@ class WebhookSigning(AdCPBaseModel): ge=86400, le=604800, ), + NoExplicitNull, ] = None @@ -1409,21 +1490,26 @@ class KeyOrigins(AdCPBaseModel): governance_signing: Annotated[ AnyUrl | None, Field(description='Origin (scheme + host) serving the governance-signing JWKS.'), + NoExplicitNull, ] = None request_signing: Annotated[ - AnyUrl | None, Field(description='Origin (scheme + host) serving the request-signing JWKS.') + AnyUrl | None, + Field(description='Origin (scheme + host) serving the request-signing JWKS.'), + NoExplicitNull, ] = None webhook_signing: Annotated[ AnyUrl | None, Field( description='Origin (scheme + host) serving the JWKS used for webhook delivery. Webhooks are signed with `adcp_use: "request-signing"` keys; the deprecated `adcp_use: "webhook-signing"` value remains accepted during the backward-compatibility window.' ), + NoExplicitNull, ] = None tmp_signing: Annotated[ AnyUrl | None, Field( description='Origin (scheme + host) serving the TMP-signing JWKS, when this operator participates in TMP.' ), + NoExplicitNull, ] = None @@ -1434,12 +1520,14 @@ class CompromiseNotification(AdCPBaseModel): emits: Annotated[ StrictBool | None, Field(description='Whether this agent emits `identity.compromise_notification` events.'), + NoExplicitNull, ] = False accepts: Annotated[ StrictBool | None, Field( description='Whether this agent subscribes to `identity.compromise_notification` events from counterparties it verifies signatures from.' ), + NoExplicitNull, ] = False @@ -1452,24 +1540,28 @@ class Identity(AdCPBaseModel): Field( description="HTTPS URL of the operator's brand.json (typically `https://{operator-domain}/.well-known/brand.json`). Trust-root pointer for this agent's signing keys. See [security.mdx §Discovering an agent's signing keys via `brand_json_url`](https://adcontextprotocol.org/docs/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url) for the verifier algorithm and `x-adcp-validation` for structured constraints. Distinct from `sponsored_intelligence.brand_url`, which is a rendering pointer for SI agent visuals — verifiers MUST use this field for key discovery and MUST NOT fall back to `sponsored_intelligence.brand_url` as a trust-root pointer." ), + NoExplicitNull, ] = None per_principal_key_isolation: Annotated[ StrictBool | None, Field( description="When true, this multi-principal operator scopes signing keys per-principal so a single principal's key compromise does not silently re-scope across principals served by the same operator. `kid` values remain opaque to verifiers per RFC 7517; any operator-side naming convention (e.g., `{operator}:{principal}:{key_version}`) is internal bookkeeping and MUST NOT be parsed by verifiers. See docs/building/understanding/security-model.mdx." ), + NoExplicitNull, ] = False key_origins: Annotated[ KeyOrigins | None, Field( description='Map of signing-key surface/purpose → publishing origin, so counterparties can verify origin separation (e.g., governance keys served from a separate origin than transport/webhook keys) at onboarding. Absent means the operator has not declared a separation scheme; receivers SHOULD assume shared-origin. Every entry listed MUST have a corresponding signing posture declared elsewhere — `request_signing` requires non-empty `request_signing.supported_for`/`required_for`/`protocol_methods_supported_for`/`protocol_methods_required_for`; `webhook_signing` requires `webhook_signing.supported === true` and names the webhook delivery surface, not a required live `adcp_use: "webhook-signing"` key purpose — otherwise the consistency check at signature-verification time has nothing to anchor against. See `x-adcp-validation` and docs/building/by-layer/L1/security.mdx §Origin separation.' ), + NoExplicitNull, ] = None compromise_notification: Annotated[ CompromiseNotification | None, Field( description='Whether this agent emits the `identity.compromise_notification` webhook event on key revocation due to known or suspected compromise (as opposed to scheduled rotation). Subscribers use this to bound the window between compromise detected and verifiers converging on revocation. See docs/building/implementation/webhooks.mdx §identity.compromise_notification.' ), + NoExplicitNull, ] = None @@ -1507,18 +1599,21 @@ class Accreditation(AdCPBaseModel): Field( description="Optional identifier for the certification in the accrediting body's records (when one exists; many bodies do not issue stable IDs)." ), + NoExplicitNull, ] = None valid_until: Annotated[ date | None, Field( description="Optional ISO 8601 date when the current accreditation expires. Buyers MAY treat post-expiry data as un-accredited. Absence means the vendor does not assert an expiry — buyers SHOULD verify currency at the accrediting body's directory." ), + NoExplicitNull, ] = None evidence_url: Annotated[ AnyUrl | None, Field( description="Optional URL pointing at the accrediting body's public listing for this certification (the buyer's path to verify the claim independently)." ), + NoExplicitNull, ] = None @@ -1555,12 +1650,14 @@ class WholesaleFeedVersioning(AdCPBaseModel): Field( description="Whether the agent tracks pricing_version independently of wholesale_feed_version. When true, the agent returns both tokens and honors if_pricing_version separately — useful for rate-card sweeps that don't change product and signal metadata. When false or absent, the agent collapses both into wholesale_feed_version; callers SHOULD NOT send if_pricing_version (it will be ignored and may produce INVALID_REQUEST when sent without if_wholesale_feed_version per the dependencies rule)." ), + NoExplicitNull, ] = None cache_scope_account: Annotated[ StrictBool | None, Field( description="Whether the agent ever returns cache_scope: 'account' (i.e., publishes per-account overlays distinct from the public rate card). When true, buyers MUST be prepared to maintain account-overlay caches alongside the public layer. When false or absent, all responses are cache_scope: 'public' regardless of whether account was provided — the agent's rate card is universal. Confidentiality note: declaring true advertises that the agent runs custom-pricing deals (low-grade market-posture signal); agents preferring not to disclose this MAY omit the field and let consumers detect-on-call via cache_scope on response." ), + NoExplicitNull, ] = None @@ -1592,6 +1689,7 @@ class WholesaleFeedWebhooks(AdCPBaseModel): description='Wholesale feed webhook event types this agent can emit. Sales agents emit product.* events and MUST expose list_products or the deprecated 3.x wholesale get_products compatibility path. Signals agents emit signal.* events and MUST support wholesale get_signals. wholesale_feed.bulk_change requires at least one corresponding repair path.', min_length=1, ), + NoExplicitNull, ] = None @@ -1623,6 +1721,7 @@ class ReportingDestinationOffering(AdCPBaseModel): description='Physical file formats the seller can produce. Only meaningful for file_transfer.', min_length=1, ), + NoExplicitNull, ] = None access_modes: Annotated[ list[AccessMode] | None, @@ -1630,6 +1729,7 @@ class ReportingDestinationOffering(AdCPBaseModel): description='Dataset-share access families the seller can provision. Only meaningful for dataset_share.', min_length=1, ), + NoExplicitNull, ] = None verification_profiles: Annotated[ reporting_verification_profile_set.ReportingVerificationProfileSet, @@ -1665,6 +1765,7 @@ class Principal(AdCPBaseModel): ge=1, le=64, ), + NoExplicitNull, ] = None reporting_destination_offerings: Annotated[ list[ReportingDestinationOffering] | None, @@ -1673,6 +1774,7 @@ class Principal(AdCPBaseModel): max_length=3, min_length=1, ), + NoExplicitNull, ] = None suspension_interval_seconds: Annotated[ SchemaInt | None, @@ -1681,6 +1783,7 @@ class Principal(AdCPBaseModel): ge=1, le=86400, ), + NoExplicitNull, ] = None caller_event_types: Annotated[ list[notification_type.NotificationType] | None, @@ -1688,6 +1791,7 @@ class Principal(AdCPBaseModel): description='Notification types this seller can emit against caller-level subscribers, so buyers select from the offering instead of probing by rejection. Account-anchored entries fire with fire-time authorization scoping. When absent, only capabilities.changed is assumed. A caller-level subscriber requesting a type outside this set fails the sync request with UNSUPPORTED_FEATURE.', min_length=1, ), + NoExplicitNull, ] = None optimistic_concurrency: Annotated[ StrictBool, @@ -1701,6 +1805,7 @@ class TrustedMatch(AdCPBaseModel): surfaces: Annotated[ list[property_type.PropertyType] | None, Field(description='Surface types this seller supports via TMP.'), + NoExplicitNull, ] = None @@ -1708,12 +1813,14 @@ class Language(AdCPBaseModel): supported: Annotated[ StrictBool | None, Field(description='Whether seller supports language-preference targeting'), + NoExplicitNull, ] = None supported_languages: Annotated[ list[locale_tag.LanguageTag] | None, Field( description='Exact canonical BCP 47 language ranges accepted in targeting.language. Omission means the seller makes no exhaustive language-list declaration.' ), + NoExplicitNull, ] = None @@ -1743,18 +1850,21 @@ class GeoProximity(AdCPBaseModel): Field( description='Whether seller supports simple radius targeting (distance circle from a point)' ), + NoExplicitNull, ] = None travel_time: Annotated[ StrictBool | None, Field( description='Whether seller supports travel time isochrone targeting (requires a routing engine)' ), + NoExplicitNull, ] = None geometry: Annotated[ StrictBool | None, Field( description='Whether seller supports pre-computed GeoJSON geometry (buyer provides the polygon)' ), + NoExplicitNull, ] = None transport_modes: Annotated[ list[transport_mode.TransportMode] | None, @@ -1762,6 +1872,7 @@ class GeoProximity(AdCPBaseModel): description='Transport modes supported for travel_time isochrones. Only relevant when travel_time is true.', min_length=1, ), + NoExplicitNull, ] = None @@ -1769,30 +1880,35 @@ class Targeting(AdCPBaseModel): geo_countries: Annotated[ StrictBool | None, Field(description='Country-level targeting using ISO 3166-1 alpha-2 codes'), + NoExplicitNull, ] = None geo_regions: Annotated[ StrictBool | geo_region_support.GeographicRegionSupport | None, Field( description='ISO 3166-2 subdivision inclusion targeting. A legacy boolean is a coarse seller-wide declaration. Structured country/value entries are individually supported within the response scope, but do not promise joint composability or availability through the same execution route or account. Only Product.overlay_support supplies the binding set of executable targeting permissions for a configured Product.' ), + NoExplicitNull, ] = None geo_regions_exclude: Annotated[ StrictBool | geo_region_support.GeographicRegionSupport | None, Field( description='ISO 3166-2 subdivision exclusion targeting, declared independently from inclusion. Structured country/value entries are individually supported within the response scope, but do not promise joint composability or availability through the same execution route or account. Only Product.overlay_support supplies the binding set of executable targeting permissions for a configured Product; absence means buyers cannot infer exclusion support from geo_regions alone.' ), + NoExplicitNull, ] = None geo_metros: Annotated[ GeoMetros | None, Field( description='Metro area targeting. Properties indicate which classification systems are supported.' ), + NoExplicitNull, ] = None geo_postal_areas: Annotated[ postal_area_support.PostalAreaSupport | None, Field( description='Postal area targeting. Prefer the native country-keyed map where each ISO 3166-1 alpha-2 country lists supported country-local postal systems. Deprecated legacy country-fused postal-system boolean aliases may be emitted alongside native country keys during migration.' ), + NoExplicitNull, ] = None geo_places: Annotated[ dict[ @@ -1804,70 +1920,82 @@ class Targeting(AdCPBaseModel): description='Place targeting support keyed by collision-safe identifier system. Each system declares exact country-to-place-type combinations, accepted catalog versions, and a machine-readable resolver. Sellers MUST reject unsupported systems, country/type pairs, versions, and identifiers rather than silently dropping them.', min_length=1, ), + NoExplicitNull, ] = None age_restriction: Annotated[ AgeRestriction | None, Field(description='Age restriction capabilities for compliance (alcohol, gambling)'), + NoExplicitNull, ] = None demographics: Annotated[ Demographics | None, Field( description='Seller-wide discovery rollup for canonical demographic targeting. supported=true means at least one product implements the demographic targeting contract; it does not authorize demographic targeting on every product. Buyers MUST inspect Product.demographic_targeting for exact execution modes, bounds, intervals, and unknown-age behavior.' ), + NoExplicitNull, ] = None language: Annotated[ StrictBool | Language | None, Field( description="Language-preference targeting support. A legacy boolean is a coarse declaration. The structured form can enumerate the exact canonical BCP 47 targeting ranges the seller accepts. Buyers MUST treat supported_languages as exact selectable values: a declared 'fr' does not by itself authorize a request for 'fr-CA'. Sellers MUST reject unsupported requested values rather than silently widening or dropping them." ), + NoExplicitNull, ] = None keyword_targets: Annotated[ KeywordTargets | None, Field( description='Keyword targeting capabilities. Presence indicates support for targeting_overlay.keyword_targets and keyword_targets_add/remove in update_media_buy.' ), + NoExplicitNull, ] = None negative_keywords: Annotated[ NegativeKeywords | None, Field( description='Negative keyword capabilities. Presence indicates support for targeting_overlay.negative_keywords and negative_keywords_add/remove in update_media_buy.' ), + NoExplicitNull, ] = None placement_selection: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.placement_selection. False or absence makes no product-level promise. Product.overlay_support and Product.placements remain authoritative for a selected product.' ), + NoExplicitNull, ] = None property_list: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.property_list inclusion targeting. False or absence makes no product-level promise. Product.overlay_support is authoritative.' ), + NoExplicitNull, ] = None property_list_exclude: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.property_list_exclude targeting. False or absence makes no product-level promise. Product.overlay_support is authoritative.' ), + NoExplicitNull, ] = None collection_list: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.collection_list inclusion targeting. False or absence makes no product-level promise. Product.overlay_support is authoritative.' ), + NoExplicitNull, ] = None collection_list_exclude: Annotated[ StrictBool | None, Field( description='When true, seller-wide rollup indicating at least one product supports targeting_overlay.collection_list_exclude targeting. False or absence makes no product-level promise. Product.overlay_support is authoritative.' ), + NoExplicitNull, ] = None geo_proximity: Annotated[ GeoProximity | None, Field( description='Proximity targeting capabilities from arbitrary coordinates via targeting_overlay.geo_proximity.' ), + NoExplicitNull, ] = None @@ -1880,12 +2008,14 @@ class ConversionTracking(AdCPBaseModel): Field( description='Whether this seller can deduplicate conversion events across multiple event sources within a single goal. When true, the seller honors the deduplication semantics in optimization_goals event_sources arrays — the same event_id from multiple sources counts once. When false or absent, buyers should use a single event source per goal; multi-source arrays will be treated as first-source-wins. Most social platforms cannot deduplicate across independently-managed pixel and CAPI sources.' ), + NoExplicitNull, ] = None per_creative_attribution: Annotated[ StrictBool | None, Field( description='Whether the seller can attribute conversions to specific creatives within a package and surface that breakdown via media_buy_deliveries[].by_package[].by_creative[].conversions in get_media_buy_delivery. Defaults to false when omitted. Sellers that report conversions only at the line / package / placement / campaign granularity (retail-media, MMP-mediated mobile, CTV performance) declare false (or omit) and the per-creative scenario grades not_applicable for them. Sellers that surface ad-level conversion attribution (most social platforms) declare true and the scenario asserts the breakdown is populated end-to-end. Defaults to false to preserve backward compatibility.' ), + NoExplicitNull, ] = None supported_event_types: Annotated[ list[event_type_1.EventType] | None, @@ -1893,6 +2023,7 @@ class ConversionTracking(AdCPBaseModel): description='Event types this seller can track and attribute. If omitted, all standard event types are supported.', min_length=1, ), + NoExplicitNull, ] = None supported_targets: Annotated[ list[SupportedTarget1] | None, @@ -1900,10 +2031,12 @@ class ConversionTracking(AdCPBaseModel): description='Event-goal target kinds this seller can compute against. Buyers should only submit event-kind optimization goals whose target.kind is listed here — sellers MUST reject goals with unlisted target kinds. When omitted, only target-less event goals (maximize conversion count within budget) are guaranteed; sellers MAY accept specific target kinds but buyers should not rely on it. Named to parallel `metric_optimization.supported_targets` at the product level — same concept (which target kinds are supported), one at seller-capability granularity and one at product granularity.', min_length=1, ), + NoExplicitNull, ] = None supported_uid_types: Annotated[ list[uid_type.UidType] | None, Field(description='Universal ID types accepted for user matching', min_length=1), + NoExplicitNull, ] = None supported_hashed_identifiers: Annotated[ list[SupportedIdentifierType] | None, @@ -1911,16 +2044,19 @@ class ConversionTracking(AdCPBaseModel): description='Hashed PII types accepted for user matching. Buyers must hash before sending (SHA-256, normalized).', min_length=1, ), + NoExplicitNull, ] = None supported_action_sources: Annotated[ list[action_source.ActionSource] | None, Field(description='Action sources this seller accepts events from', min_length=1), + NoExplicitNull, ] = None attribution_windows: Annotated[ list[AttributionWindow] | None, Field( description='Attribution windows available from this seller. Single-element arrays indicate fixed windows; multi-element arrays indicate configurable options the buyer can choose from via attribution_window on optimization goals.' ), + NoExplicitNull, ] = None @@ -1934,6 +2070,7 @@ class FrequencyCapping(AdCPBaseModel): description='Entity granularities the seller can enforce caps against. Values from the reach-unit enum. Omit to indicate all reach-unit values are supported.', min_length=1, ), + NoExplicitNull, ] = None supported_window_units: Annotated[ list[str] | None, @@ -1941,6 +2078,7 @@ class FrequencyCapping(AdCPBaseModel): description="Duration units the seller supports for frequency cap windows. Values must match the duration.json unit enum (e.g., 'hours', 'days', 'campaign'). Omit to indicate all duration units are supported.", min_length=1, ), + NoExplicitNull, ] = None @@ -1974,12 +2112,14 @@ class BudgetCapping(AdCPBaseModel): description='Feature-specific default cap-day timezone used when timezone_basis is fixed. Use UTC or an IANA timezone identifier.', min_length=1, ), + NoExplicitNull, ] = None buyer_timezone_override: Annotated[ StrictBool | None, Field( description='When true, buyers MAY override the default cap-day boundary via media-buy-level budget_cap_timezone. The accepted timezone applies to aggregate and package caps alike. When false or absent, a submitted override is rejected with UNSUPPORTED_FEATURE.' ), + NoExplicitNull, ] = None @@ -1990,7 +2130,9 @@ class SponsoredIntelligence(AdCPBaseModel): Field(description='Modalities, components, and commerce capabilities'), ] brand_url: Annotated[ - AnyUrl | None, Field(description='URL to brand.json with colors, fonts, logos, tone') + AnyUrl | None, + Field(description='URL to brand.json with colors, fonts, logos, tone'), + NoExplicitNull, ] = None @@ -2003,24 +2145,29 @@ class Brand(AdCPBaseModel): Field( description='Supports get_rights and acquire_rights for rights discovery and clearance' ), + NoExplicitNull, ] = False right_types: Annotated[ list[right_type.RightType] | None, Field(description='Types of rights available through this agent'), + NoExplicitNull, ] = None available_uses: Annotated[ list[right_use.RightUse] | None, Field(description="Rights uses available across this agent's roster"), + NoExplicitNull, ] = None generation_providers: Annotated[ list[str] | None, Field(description='LLM/generation providers this agent can issue credentials for'), + NoExplicitNull, ] = None description: Annotated[ str | None, Field( description="Description of the agent's brand protocol capabilities", max_length=5000 ), + NoExplicitNull, ] = None @@ -2058,12 +2205,14 @@ class Metric(AdCPBaseModel): Field( description='Optional URI pointing at the published standard this metric IMPLEMENTS (e.g., IAB Attention Measurement Guidelines, MRC Viewable Impression Measurement, GARM emissions framework). Distinct from `accreditations[]` — `standard_reference` is what the metric is built against; `accreditations[]` is third-party certification that the implementation actually conforms. Buyer agents normalizing across vendors SHOULD apply the AdCP URL canonicalization rules before comparing — vendors implementing the same standard MAY use different URL forms for the same canonical document.' ), + NoExplicitNull, ] = None accreditations: Annotated[ list[Accreditation] | None, Field( description="Third-party accreditations this metric holds (MRC, ARF, JIC, ABC, BARB, AGOF, etc.). Distinct from `standard_reference`: a metric can implement a standard without being independently accredited. Buyers asking 'is this MRC-accredited?' SHOULD check this array, not just `standard_reference`. Each entry names the accrediting body and optionally pins a certification ID, validity date, and evidence URL." ), + NoExplicitNull, ] = None unit: Annotated[ str | None, @@ -2071,18 +2220,21 @@ class Metric(AdCPBaseModel): description='Unit of the metric value when reported via `vendor_metric_values.value` (e.g., `score`, `seconds`, `persons`, `gCO2e`, `lift_percent`, `USD`). Buyers SHOULD render the unit alongside the value rather than computing units locally; sellers populating `vendor_metric_values.unit` MUST match this declaration when present.', examples=['score', 'seconds', 'persons', 'gCO2e', 'lift_percent', 'index', 'USD'], ), + NoExplicitNull, ] = None description: Annotated[ str | None, Field( description='Human-readable description of what this metric measures and any relevant methodology notes. Surfaced in buyer-agent UX when explaining the metric to humans.' ), + NoExplicitNull, ] = None methodology_url: Annotated[ AnyUrl | None, Field( description="URL to the vendor's full methodology documentation for this metric. Buyers SHOULD link or fetch this when human review of the methodology is in scope (compliance, RFP review, accreditation audit). Field name mirrors `governance.property_features[].methodology_url`." ), + NoExplicitNull, ] = None methodology_version: Annotated[ str | None, @@ -2090,8 +2242,9 @@ class Metric(AdCPBaseModel): description='Optional version identifier (semver, ISO date, or vendor-defined version string) for the methodology this metric currently implements. When present, buyer agents pin the contracted version via `committed_metrics[].methodology_version` (vendor scope) so silent vendor methodology changes are detectable; absence means the vendor does not version their methodology and buyers MUST treat any change as untracked.', examples=['v2.1', '2026-Q1', '1.0'], ), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None class Measurement(AdCPBaseModel): @@ -2100,6 +2253,7 @@ class Measurement(AdCPBaseModel): Field( description="Whether this measurement agent produces compact provide_performance_feedback assertions for a buyer-controlled orchestrator gateway. In the first experimental tier the provider reads delivery through the gateway's get_media_buy_delivery task and returns assertions through its provide_performance_feedback task. The gateway decides what to forward to each seller." ), + NoExplicitNull, ] = False metrics: Annotated[ list[Metric], @@ -2117,6 +2271,7 @@ class CreativeSpecs(AdCPBaseModel): description='Seller-wide VAST execution ceiling. Each product format option declares its binding accepted subset in `params.vast_versions`.', min_length=1, ), + NoExplicitNull, ] = None macro_resolution_capabilities: Annotated[ list[macro_resolution_capability.MacroProcessingCapability] | None, @@ -2124,22 +2279,29 @@ class CreativeSpecs(AdCPBaseModel): description='Seller-wide ceiling for exact macro processing tuples (dialect identity/revision, semantic mapping, operation, actor, context, and encoding). It never proves a product execution path supports the same tuple and does not claim tracker firing; inspect the selected format option and, when standardized, product tracker capabilities.', min_length=1, ), + NoExplicitNull, ] = None mraid_versions: Annotated[ list[MraidVersion] | None, Field(description='MRAID versions supported for rich media mobile creatives'), + NoExplicitNull, ] = None vpaid: Annotated[ - StrictBool | None, Field(description='VPAID support for interactive video ads') + StrictBool | None, + Field(description='VPAID support for interactive video ads'), + NoExplicitNull, ] = None simid: Annotated[ - StrictBool | None, Field(description='SIMID support for interactive video ads') + StrictBool | None, + Field(description='SIMID support for interactive video ads'), + NoExplicitNull, ] = None vast_validation: Annotated[ VastValidation | None, Field( description="Level of VAST asset validation the seller performs at sync_creatives (including dry_run): 'structural' checks manifest shape and format requirements only and never inspects the VAST document; 'document' additionally parses the VAST document and can return VAST_PARSE_FAILED / VAST_VERSION_MISMATCH; 'wrapper' additionally resolves the wrapper chain and can return VAST_WRAPPER_DEPTH_EXCEEDED. Absent means 'structural'. See the VAST Validation section of the video channel documentation for the normative checks at each level." ), + NoExplicitNull, ] = VastValidation.structural @@ -2149,6 +2311,7 @@ class Execution(AdCPBaseModel): Field( description='Trusted Match Protocol (TMP) support. Presence of this object indicates the seller has TMP infrastructure deployed. Check individual products via get_products for per-product TMP capabilities.' ), + NoExplicitNull, ] = None axe_integrations: Annotated[ list[AnyUrl] | None, @@ -2156,15 +2319,17 @@ class Execution(AdCPBaseModel): deprecated=True, description='Deprecated. Legacy AXE integrations. Use trusted_match for new integrations.', ), + NoExplicitNull, ] = None creative_specs: Annotated[ - CreativeSpecs | None, Field(description='Creative specification support') + CreativeSpecs | None, Field(description='Creative specification support'), NoExplicitNull ] = None targeting: Annotated[ Targeting | None, Field( description='Targeting capabilities. If declared true/supported, buyer can use these targeting parameters and seller MUST honor them.' ), + NoExplicitNull, ] = None @@ -2178,6 +2343,7 @@ class SupportedFormat(AdCPBaseModel): description="Stable identifier for this format capability within the creative agent (e.g., 'audio_narration_30s'). The value MUST be unique across this agent's supported_formats catalog. Route the advertised operation with this ID: build_creative.target_capability_id(s) for build, preview_creative.target_capability_id for preview, or validate_input targets[] kind capability for validate. Capability IDs are agent-local routes and do not appear on portable manifests or products.", pattern='^[a-zA-Z0-9_-]+$', ), + NoExplicitNull, ] = None format: Annotated[ creative_operation_format_declaration.CreativeOperationFormatDeclaration, @@ -2191,6 +2357,7 @@ class SupportedFormat(AdCPBaseModel): description='Creative operations this capability supports. `build` means the agent can produce a conforming manifest via build_creative; `validate` means it can evaluate inputs against the declaration; `preview` means it can render a preview. New 3.2 producers MUST emit this field so buyers and registries can distinguish producers, validators, and renderers without probing tasks. Consumers interpret omission from a legacy 3.x entry as `["build"]`.', min_length=1, ), + NoExplicitNull, ] = [Operation.build] @@ -2203,60 +2370,70 @@ class Creative(AdCPBaseModel): Field( description='When true, this creative agent can process briefs with compliance requirements (required_disclosures, prohibited_claims) and will validate that disclosures can be satisfied by the target format.' ), + NoExplicitNull, ] = None has_creative_library: Annotated[ StrictBool | None, Field( description='When true, this agent hosts a creative library and supports list_creatives and creative_id references in build_creative. Creative agents with a library should also implement the accounts protocol (sync_accounts / list_accounts) so buyers can establish access.' ), + NoExplicitNull, ] = False supports_revisions: Annotated[ StrictBool | None, Field( description='When true, this agent accepts buyer-assigned revision_id on sync_creatives, enforces immutable revision content, echoes accepted revision identity, returns it from list_creatives, and attributes delivered executions to it. Revision support does not imply revision history, rollback, or staged activation.' ), + NoExplicitNull, ] = False supports_generation: Annotated[ StrictBool | None, Field( description='When true, this agent can generate creatives from natural language briefs via build_creative. The buyer provides a message with creative direction, and the agent produces a manifest with generated assets. When false, build_creative only supports transformation or library retrieval.' ), + NoExplicitNull, ] = False supports_transformation: Annotated[ StrictBool | None, Field( description='When true, this agent can transform or resize existing canonical manifests via build_creative. The buyer supplies a creative_manifest and an advertised target_capability_id.' ), + NoExplicitNull, ] = False representation_resolution: Annotated[ RepresentationResolution | None, Field( description='Explicit opt-in for deterministic seller-bound selection from `build_creative.creative_representation_set`. Only the destination sales agent may advertise and exercise this capability because resolution requires its current product, placement/publisher narrowings, and seller-wide execution ceilings. A standalone creative agent may help a buyer select locally but MUST NOT advertise this capability or claim seller deliverability. Absence means the caller selects a representation before sending a seller-bound manifest; the agent MUST NOT guess silently.' ), + NoExplicitNull, ] = None supports_transformers: Annotated[ StrictBool | None, Field( description='When true, this agent exposes account-scoped creative transformers via list_transformers (the creative analog of media-buy products) and accepts transformer_id + config on build_creative. Buyers SHOULD call list_transformers to discover available transformers, their typed config params (and account-scoped enumerable option values via expand_params), and pricing. When false or absent, the agent does not offer the transformer surface.' ), + NoExplicitNull, ] = False supports_refinement: Annotated[ StrictBool | None, Field( description="When true, this agent retains produced build_variant leaves for an agent-defined retention window and can re-build from one via build_creative's refine_from_build_variant_id — applying a natural-language instruction in message plus an optional config delta, returning new lineage-linked variants. A build-time agent capability independent of generation/transformation. When false or absent, refine_from_build_variant_id is rejected with UNSUPPORTED_FEATURE; buyers refine instead via the transform path (creative_manifest + message)." ), + NoExplicitNull, ] = False supports_spend_controls: Annotated[ StrictBool | None, Field( description='When true, build_creative honors a per-call `max_spend` ceiling (producing partial paid results and returning budget_status:"capped" + a BUDGET_CAP_REACHED advisory rather than overspending) AND supports mode:"estimate" dry-runs (a projected cost band, producing/billing nothing). When false or absent, max_spend / mode:estimate are rejected with UNSUPPORTED_FEATURE. Out-of-band billers (bills_through_adcp:false) have no AdCP cost truth to cap against, so this is meaningful only alongside bills_through_adcp:true.' ), + NoExplicitNull, ] = False supports_evaluator: Annotated[ StrictBool | None, Field( description="Experimental (x-status: experimental) — agents setting this true MUST also list `creative.evaluator` in `experimental_features`; the surface MAY change between 3.x releases with notice (see docs/reference/experimental-status). When true, build_creative accepts an advisory `evaluator` input (exemplars / account-arranged evaluator_id / agent_url, plus an optional `feature_requirement[]` gate, a `rank_by` ordering, and an allowlisted `feature_agent` pointer). Feature discovery uses this response's governance.creative_features catalog: rank_by, feature_requirement, and eval.features[] all share the same creative-feature vocabulary as get_creative_features. evaluator_id is not discovered from this catalog; it is a pre-provisioned account preset whose emitted feature_ids still come from it. The evaluator populates a per-leaf `eval` block of creative-feature values (creative-feature-result[], the same shape get_creative_features returns) on BuildCreativeVariantSuccess leaves, which is what the recommended/rank it sets on the best_of_n axis are computed over. The agent runs a gate-then-rank pipeline over its best_of_n exploration: it evaluates each leaf, DROPS leaves failing `feature_requirement[]` from its recommended survivors, then orders survivors by `rank_by`. The gate is internal pruning of which leaves the agent recommends/returns from its own exploration — it never blocks an already-produced billable leaf: what is produced and billed is governed by max_variants/max_creatives/max_spend, not the evaluator. When the evaluator names an external agent, it MUST appear in `creative_policy.accepted_verifiers[]` (off-list → EVALUATOR_AGENT_NOT_ACCEPTED), and the producing agent authenticates the outbound evaluator call on the transport. Evaluator credentials and caller-supplied trust material MUST NOT be passed in the build_creative payload; credential- or trust-material payload keys should be rejected with CREDENTIAL_IN_ARGS. When false or absent, the `evaluator` input is ignored and no `eval` block is emitted." ), + NoExplicitNull, ] = False refinable_retention_seconds: Annotated[ SchemaInt | None, @@ -2264,36 +2441,42 @@ class Creative(AdCPBaseModel): description='When supports_refinement is true, the GUARANTEED-MINIMUM window (a floor, not a ceiling) during which a produced build_variant_id remains refinable via refine_from_build_variant_id: a ref within this window from production SHOULD resolve; the agent MAY retain longer. Omit when the retention window is agent-defined and not advertised — buyers then treat refinability as best-effort and handle REFERENCE_NOT_FOUND.', ge=0, ), + NoExplicitNull, ] = None multiplicity: Annotated[ Multiplicity | None, Field( description="Pre-call discriminators for build_creative fan-out, so a buyer knows BEFORE sending max_creatives / max_variants whether this agent supports them and the ceilings. Over-limit requests are CLAMPED to these ceilings (the agent produces up to the limit and signals the shortfall via items_returned < items_total on BuildCreativeVariantSuccess), not rejected — consistent with item_limit's 'use the lesser' rule. Absent means no fan-out: build_creative produces a single creative and max_creatives/max_variants>1 are not supported." ), + NoExplicitNull, ] = None supported_formats: Annotated[ list[SupportedFormat] | None, Field( description='Canonical-format capability catalog for this creative agent. This is the 3.2 source of truth for discovering which format contracts the agent can build, validate, or preview; it replaces the deprecated `list_creative_formats` task. Each entry uses the authority-free `CreativeOperationFormatDeclaration` projection of a product format declaration: canonical shape and creative-route macro processing are preserved, while seller production commitments are excluded. New 3.2 producers MUST publish a stable agent-local `capability_id` and explicit `operations` for task routing. Every emitted `capability_id` MUST be unique within this catalog so a route selects exactly one entry. During the 3.x compatibility window, consumers MUST also accept legacy entries that omit either field; absent `operations` means `["build"]`, while an absent `capability_id` means the entry is discoverable by canonical contract but cannot be selected through a capability-ID route.\n\n**Publisher-specific support.** To claim exact support for a publisher declaration, `format` carries the declaration\'s `{publisher_domain, format_option_id}` pair plus its canonical `format_kind` and narrowed `params`. Generic creative agents MAY instead advertise a canonical parameter envelope without publisher identity. A generic capability matches a target declaration only when the capability can satisfy every target constraint; matching canonical names alone is insufficient. Registries MAY reverse-index these entries by `format.format_kind`, `format.publisher_domain`, and `format.format_option_id`.\n\nThis catalog describes creative operations, not sales-agent inventory deliverability. Sales agents publish the purchasable closed set on each `Product.format_options[]`; publisher acceptance lives in `adagents.json.formats[]`.' ), + NoExplicitNull, ] = None preview: Annotated[ Preview | None, Field( description='Per-route preview_creative capability metadata. New 3.2 producers whose supported_formats[] explicitly advertises a routable preview operation MUST emit this block. rendering_origin describes how each route is implemented but is informational and never grants presentation authority: only a matching publisher-origin placement preview_provider delegation can do that. routes[].capability_id MUST equal the set of capability IDs on supported_formats[] entries whose operations contains preview.' ), + NoExplicitNull, ] = None localization: Annotated[ Localization | None, Field( description='Materialized creative-localization support for sync_creatives/list_creatives, including source-only monolingual topology. Presence opts the agent into exact locale-variant round-trip, strict RFC 4647 Lookup, optional buyer-declared language-family fallback rules, explicit final default/unmatched behavior, creative-wide review, transactional replacement, seller product-format locale-policy enforcement, and delivery attribution. This is a coarse structural capability, not a promise that every locale/format/account combination is accepted; sellers publish accepted ranges on product format declarations and validate each write before mutation. Omit this object when localization is unsupported.' ), + NoExplicitNull, ] = None bills_through_adcp: Annotated[ StrictBool | None, Field( description='When true, this creative agent bills through the AdCP rate-card surface: list_creatives returns pricing_options when include_pricing=true with an authenticated account, build_creative populates pricing_option_id and vendor_cost on the response, and report_usage accepts records against the rate card. When false or absent, the agent bills out of band (flat license, SaaS contract, bundled enterprise agreement) and buyers should skip pricing fields and tolerate report_usage returning accepted: 0 with errors carrying BILLING_OUT_OF_BAND. A pre-call discriminator so buyer agents can route across many creative agents without first establishing an account to probe pricing.' ), + NoExplicitNull, ] = False canonical_catalog_version: Annotated[ str | None, @@ -2301,6 +2484,7 @@ class Creative(AdCPBaseModel): description="Optional. The AdCP canonical-formats catalog version this agent's runtime is built against (e.g., `3.1`, `3.2.0`). Lets buyer SDKs detect canonical-catalog skew between their generated types and the seller's actual support. SDKs MAY declare the version they were generated against (typically the AdCP version they ship for); when seller and SDK versions disagree, SDKs SHOULD soft-warn rather than fail (the open-enum semantics on `canonical-format-kind.json` make unknown canonicals safe to retain, so skew is not a hard error — it just means the older side might not understand newer canonical values). Omitted by sellers who haven't yet generated against a versioned catalog; absence is interpreted as the AdCP version advertised by the broader capabilities response.", pattern='^\\d+\\.\\d+(\\.\\d+)?$', ), + NoExplicitNull, ] = None @@ -2320,6 +2504,7 @@ class AudienceTargeting(AdCPBaseModel): Field( description="Whether the seller accepts the buyer's CRM/loyalty ID as a matchable identifier. Only applicable when the seller operates a closed ecosystem with a shared ID namespace (e.g., a retailer matching against their loyalty program). When true, buyers can include platform_customer_id values in AudienceMember.identifiers for matching against the seller's identity graph. Reporting on matched platform_customer_ids typically requires a clean room or the seller's own reporting surface." ), + NoExplicitNull, ] = None supported_uid_types: Annotated[ list[uid_type.UidType] | None, @@ -2327,6 +2512,7 @@ class AudienceTargeting(AdCPBaseModel): description='Universal ID types accepted for audience matching (MAIDs, RampID, UID2, etc.). MAID support varies significantly by platform — check this field before sending uids with type: maid.', min_length=1, ), + NoExplicitNull, ] = None minimum_audience_size: Annotated[ SchemaInt, @@ -2341,12 +2527,14 @@ class AudienceTargeting(AdCPBaseModel): description="Union of audience_activation.methods across the seller's products. Fast-fail discovery: a buyer reads this once and skips the catalog walk when nothing overlaps its pipeline. Per-product declarations are the source of truth; sellers MUST keep this consistent with the catalog. Operational coordinates are account-scoped: the union MAY omit consumer_identities and destination_ref until bilateral account setup establishes them, and MUST NOT expose another account's coordinates. Absence of this field with media_buy.audience_activation listed in experimental_features means walk the catalog; only a present, non-overlapping union is a fast-fail signal. Experimental (x-status: experimental): sellers implementing audience activation declarations MUST list media_buy.audience_activation in experimental_features. Per docs/reference/experimental-status, this surface MAY change between 3.x releases with notice.", min_length=1, ), + NoExplicitNull, ] = None matching_latency_hours: Annotated[ MatchingLatencyHours | None, Field( description='Expected matching latency range in hours after upload. Use to calibrate polling cadence and set appropriate expectations before configuring push_notification_config.' ), + NoExplicitNull, ] = None @@ -2356,12 +2544,14 @@ class MediaBuy(AdCPBaseModel): Field( description='Whether this seller accepts product discovery without caller credentials. This applies to list_products and to get_products in brief or wholesale mode; it does not apply to proposal refinement or finalization, purchasing, account-scoped reads, or mutations. true means an anonymous discovery request can produce a successful response, but the response may be a public subset and may differ from results for an authenticated principal or selected account. false means these discovery calls require an authenticated principal. Absence means unspecified legacy behavior, so callers probe and handle AUTH_MISSING. A valid authenticated request is never rejected merely because credentials were supplied. true is inconsistent with account.required_for_products=true because an anonymous caller cannot select protected account context.' ), + NoExplicitNull, ] = None acceptance_policy_discovery: Annotated[ AcceptancePolicyDiscovery | None, Field( description='Registry-backed seller acceptance-policy discovery. Presence means the seller publishes a versioned catalog; it does not claim that the seller evaluates acceptance_context during discovery. Discovery is advisory, exact task responses remain authoritative, and absent capability means support is unknown rather than unrestricted acceptance.' ), + NoExplicitNull, ] = None supported_pricing_models: Annotated[ list[pricing_model.PricingModel] | None, @@ -2369,6 +2559,7 @@ class MediaBuy(AdCPBaseModel): description='Pricing models this seller supports across its product portfolio. Buyers can use this for pre-flight filtering before querying individual products. Individual products may support a subset of these models.', min_length=1, ), + NoExplicitNull, ] = None buying_modes: Annotated[ list[BuyingMode] | None, @@ -2376,18 +2567,21 @@ class MediaBuy(AdCPBaseModel): description="Buying modes this seller supports on get_products. 'brief' (semantic discovery driven by the brief) is universally supported and implicit. 'wholesale' (raw wholesale product feed enumeration — caller omits brief and the seller returns the full priced product feed, paginated) is opt-in and SHOULD be declared explicitly so buyers can probe before issuing wholesale calls. 'refine' lets buyers iterate on prior products/proposals and is also the vehicle for finalizing draft proposals when the seller returns them. Sellers MAY declare ['brief', 'wholesale'] to signal wholesale support; absent declaration is treated as ['brief'] for wholesale-feed probing purposes and sellers MAY return INVALID_REQUEST for wholesale calls they do not support. Symmetric with signals.discovery_modes.", min_length=1, ), + NoExplicitNull, ] = [BuyingMode.brief] measurement_terms_acceptance: Annotated[ StrictBool | None, Field( description="Whether this seller can accept the default measurement_terms it advertises on a product. A value of true means the seller can return a product carrying measurement_terms for a measurement-specific brief and accept those terms unchanged on a package for that product. This opts the seller into conformance scenarios that discover and replay the product's own terms. False or absent means acceptance is outside the seller's advertised scope, but the seller must still reject unsupported terms with TERMS_REJECTED rather than being graded on an acceptance path it did not claim." ), + NoExplicitNull, ] = False availability_horizon: Annotated[ StrictBool | None, Field( description='Whether this seller supports flexible-window availability discovery: parsing offer_filters.availability_horizon and answering with time-dimensioned forecast points that carry availability_status. Sellers declaring true MUST apply the full window contract — half-open non-overlapping windows that partition the requested horizon (or signal gaps via incomplete[]), with availability_status computed from all booking eligibility constraints, not only competing holds. false or absent means flexible-window support is unknown: buyers SHOULD use exact start_date/end_date filtering, and sellers MAY ignore the field or reject it. Conformance storyboards gate flexible-window checks on this declaration.' ), + NoExplicitNull, ] = False lifecycle_tools: Annotated[ list[LifecycleTool] | None, @@ -2395,12 +2589,14 @@ class MediaBuy(AdCPBaseModel): description='Compact product and MediaBuy lifecycle operation names this seller supports. Added in AdCP 3.2 as task-specific contracts that form the 4.0 lifecycle foundation. Sellers may advertise any supported subset while retaining the deprecated get_products/create_media_buy/update_media_buy facades throughout 3.x. Each stateful split task has its own idempotency identity; callers MUST retry with the same tool name.', min_length=1, ), + NoExplicitNull, ] = None proposal_refinement: Annotated[ ProposalRefinement | None, Field( description='Pre-flight support for typed refine_proposals revision dimensions. These declarations mean the seller can parse and mechanically validate a dimension; they never promise that the seller will commercially concede it. Absence means typed-dimension support is unknown and buyers must handle per-result partial or unable outcomes. A dimension omitted from an explicit supported_dimensions list MUST be rejected at task level with UNSUPPORTED_FEATURE before any proposal is created.' ), + NoExplicitNull, ] = None reporting_delivery_methods: Annotated[ list[CapabilityReportingDeliveryMethod] | None, @@ -2408,12 +2604,14 @@ class MediaBuy(AdCPBaseModel): description="How this seller delivers reporting data to buyers. Polling via get_media_buy_delivery is always available as a baseline regardless of this field. This array declares additional push-based delivery methods the seller supports. 'webhook': seller pushes to buyer-provided URL (configured per buy via reporting_webhook). 'offline': seller pushes batch files to a cloud storage bucket (seller-provisioned per account via reporting_bucket on the account object). When absent, only polling is available.", min_length=1, ), + NoExplicitNull, ] = None performance_feedback: Annotated[ PerformanceFeedback | None, Field( description='Structured seller performance-feedback support beyond the legacy scalar contract. Presence means the seller accepts compact baseline/metric/provenance fields from a buyer orchestrator and returns a feedback_id.' ), + NoExplicitNull, ] = None offline_delivery_protocols: Annotated[ list[cloud_storage_protocol.CloudStorageProtocol] | None, @@ -2421,30 +2619,35 @@ class MediaBuy(AdCPBaseModel): description="Cloud storage protocols this seller supports for offline file delivery. Only meaningful when reporting_delivery_methods includes 'offline'. Buyers express a protocol preference in sync_accounts; the seller provisions the account's reporting_bucket using a supported protocol.", min_length=1, ), + NoExplicitNull, ] = None reporting_delivery: Annotated[ reporting_delivery_capabilities.ReportingDeliveryCapabilities | None, Field( description='AdCP 3.2 Reliable Reporting capability. The affirmative machine answer to ‘Do you support Reliable Reporting?’ requires this block with supported: true and reliable_reporting_version: 1.0 plus media_buy.reporting_delivery in experimental_features. Core exposes seller get_reporting_status; during the published migration window, consumer_status_task separately advertises opt-in buyer-to-seller sync_reporting_status and becomes required Core only in the next eligible minor. managed_delivery and reconciled_billing identify optional tiers. This generalizes, but does not remove, the legacy reporting_delivery_methods/offline_delivery_protocols surface; those legacy fields and declarations from other protocols do not imply Reliable Reporting support.' ), + NoExplicitNull, ] = None supports_proposals: Annotated[ StrictBool | None, Field( description='Conformance declaration that this seller supports proposals through either the compact request/refine/finalize lifecycle or the legacy get_products facade. accept_proposal, or the create_media_buy compatibility facade, consumes a finalized committed proposal_id before expires_at.' ), + NoExplicitNull, ] = False outcome_target: Annotated[ StrictBool | None, Field( description="Whether this seller supports reverse-forecast planning: parsing criteria.outcome_target (a compact goal — a forecastable-metric delivery metric or an event-type conversion event — plus a desired volume, a cost_per target, or both) and planning against it, answering with total_budget_guidance and forecasts whose points carry the goal's key in metrics; see outcome-target.json for cost_per answers and rejections. This flag does not distinguish sellers that plan cost_per; buyers rely on the negotiated adcp_version together with features.bidding_policy. false or absent means support is unknown: buyers SHOULD express outcome goals in brief prose instead, and sellers reject a structured outcome_target on proposal requests with UNSUPPORTED_FEATURE rather than silently ignoring it (list_products ignores it for every seller)." ), + NoExplicitNull, ] = False governance_aware: Annotated[ StrictBool | None, Field( description='Compatibility claim used by existing media-buy conformance runners. A value of true corresponds only to online governance consultation for create_media_buy, the historically graded surface. Agents use adcp.governance_enforcement for explicit task-scoped claims, including update_media_buy and cross-role signed-context enforcement.' ), + NoExplicitNull, ] = False propagation_surfaces: Annotated[ list[PropagationSurface] | None, @@ -2452,12 +2655,14 @@ class MediaBuy(AdCPBaseModel): description='Where this seller surfaces dependency-resource impairments (creative suspended/rejected post-approval, audience suspended, catalog item withdrawn, event source insufficient, property depublished) to buyers. Non-exclusive: a seller mirroring impairments on both the buy snapshot AND firing webhooks declares `["snapshot", "webhook"]` (the common case for premium guaranteed sellers). Each value names one surface where buyers can observe an impairment:\n\n- **`snapshot`** — seller propagates resource transitions into `media_buy.health` and `media_buy.impairments[]` on the next `get_media_buys` read. The `impairment.coherence` compliance assertion grades this surface; storyboards that exercise it (`media_buy_seller/dependency_impairment`, `media_buy_seller/dependency_impairment_cardinality`) require `"snapshot"` to be declared, else they grade `not_applicable`.\n- **`webhook`** — seller fires `notification-type: impairment` webhooks (configured via `push_notification_config`). Sellers declaring `"webhook"` MUST satisfy the persistent-channel webhook contract for the impairment event type. A seller declaring `["webhook"]` without `"snapshot"` is webhook-only — buyers reconcile state from the push channel alone, and snapshot-coherence storyboards grade `not_applicable`.\n- **`out_of_band`** — seller propagates via channels outside the AdCP protocol surface entirely (email to trafficker, separate dashboard, partner-specific notification feed). Long-tail and enterprise-bundled platforms commonly use this when impairment workflows are managed in human channels. Sellers declaring only `["out_of_band"]` are not graded by snapshot or webhook compliance — their bar is the offline agreement, not a protocol assertion. If a seller has impairment data in their API under a non-AdCP field name (a mapping gap, not truly out-of-band), they SHOULD document the mapping rather than declare `out_of_band` — the spec\'s gap, not the seller\'s posture, is what `out_of_band` legitimately covers.\n\nDefault: `["snapshot"]` when absent (preserves the existing snapshot-coherence contract for sellers that don\'t declare). Empty array `[]` is invalid (`minItems: 1`) — omit the field to inherit the default rather than declaring no surfaces. Pick the surfaces that honestly describe where buyers will see impairments on this agent. Mixing is normative — `["snapshot", "webhook"]` is the documented common case; `["snapshot", "webhook", "out_of_band"]` is valid for sellers that ship all three surfaces (rare but legal). See lifecycle.mdx § Compliance for the per-surface contract.', min_length=1, ), + NoExplicitNull, ] = [PropagationSurface.snapshot] creative_approval_mode: Annotated[ CreativeApprovalMode | None, Field( description="Tenant-wide applicability signal for media-buy creative approval behavior. This is not a notification or new approval workflow. `auto_approve` means human review does not block serving eligibility after creatives are assigned and automated validation passes. `require_human` means one or more products/accounts may require manual review before creatives become eligible to serve; buyers and compliance runners MUST treat this as a worst-case ceiling across this seller's portfolio unless a future product-level override says otherwise. Compliance runners use this mainly to decide whether auto-approval-dependent storyboards apply. When absent, approval behavior is legacy-unspecified; runners SHOULD NOT treat omission as an affirmative auto-approval claim. `ai_assisted` is intentionally not part of the enum until a behavioral contract is defined." ), + NoExplicitNull, ] = None supported_indicator_types: Annotated[ list[indicator_type.IndicatorType] | None, @@ -2465,34 +2670,41 @@ class MediaBuy(AdCPBaseModel): description="Indicator types this seller can expose on get_media_buys media-buy/package/creative-assignment snapshots. Each type's meaning is defined by the negotiated AdCP release; indicator types do not carry independent sub-versions. This is availability, not complete upstream coverage. Poll-only sellers may declare this field without relationship_notifications. If relationship_notifications includes indicators.changed, this field is required so receivers know which durable indicator types can be repaired.", min_length=1, ), + NoExplicitNull, ] = None relationship_notifications: Annotated[ RelationshipNotifications | None, Field( description='Optional durable account-level invalidations for indicator, creative-assignment, and assignment-approval changes. A seller may expose indicators only through polling and omit this block. A seller without an indicator catalog may declare creative.assignment_changed alone. get_media_buys is the complete authoritative repair read. creative.assignment_changed is independent of the optional bounded list_creatives reverse projection, so inline-only sellers can advertise approval and assignment invalidations. Presence means the seller accepts the declared subscriptions through sync_accounts notification_configs. Timestamp-only reevaluation does not fire. Poll-based upstream integrations fire when they detect a change; this declaration does not promise upstream detection latency.' ), + NoExplicitNull, ] = None - features: media_buy_features.MediaBuyFeatures | None = None + features: Annotated[media_buy_features.MediaBuyFeatures | None, NoExplicitNull] = None execution: Annotated[ - Execution | None, Field(description='Technical execution capabilities for media buying') + Execution | None, + Field(description='Technical execution capabilities for media buying'), + NoExplicitNull, ] = None audience_evidence: Annotated[ AudienceEvidence | None, Field( description='Support for structured product audience evidence and buyer-authored evidence policy. Presence means the seller can publish Product.audience_evidence, preserve immutable snapshots through package readback, and evaluate the declared policy modes. It does not declare audience-targeting capability.' ), + NoExplicitNull, ] = None rights_attestations: Annotated[ RightsAttestations | None, Field( description='Seller evaluation policy for portable rights-grant attestations carried on creative rights constraints. Presence requires adcp.attestations and the rights-grant claim URI in accepted_claim_types. The seller remains verifier-of-record and never treats verification_url or a buyer-authored evaluation as authorization.' ), + NoExplicitNull, ] = None audience_targeting: Annotated[ AudienceTargeting | None, Field( description='Audience targeting capabilities. Presence of this object indicates the seller supports audience targeting, including sync_audiences and audience_include/audience_exclude in targeting overlays.' ), + NoExplicitNull, ] = None supported_optimization_metrics: Annotated[ list[SupportedOptimizationMetric] | None, @@ -2500,48 +2712,56 @@ class MediaBuy(AdCPBaseModel): description='Optimization metrics this seller can support on at least one of their products. Seller-level rollup of product-level metric_optimization.supported_metrics declarations (core/product.json). Buyers SHOULD filter their requested optimization goals against this list before submitting briefs. Sellers MUST keep this in sync with their product catalog — if no products support a metric, it must not appear here. Omitting this field means the seller declares no specific guarantees about which metrics they support; buyers should fall back to per-product inspection of metric_optimization.supported_metrics.', min_length=1, ), + NoExplicitNull, ] = None vendor_metric_optimization: Annotated[ VendorMetricOptimization | None, Field( description='Seller-level rollup of vendor-metric optimization capabilities supported by at least one product. Product-level vendor_metric_optimization.supported_metrics[] remains authoritative for the specific (vendor, metric_id) pairs and target kinds a buyer may bind on a package; this seller-level object exists so buyers and compliance runners can discover whether vendor_metric goals are in scope before walking the catalog. Sellers MUST keep this in sync with product-level vendor_metric_optimization declarations.' ), + NoExplicitNull, ] = None conversion_tracking: Annotated[ ConversionTracking | None, Field( description='Seller-level conversion tracking capabilities. Presence of this object indicates the seller supports sync_event_sources and log_event for conversion event tracking.' ), + NoExplicitNull, ] = None frequency_capping: Annotated[ FrequencyCapping | None, Field( description='Seller-wide package frequency-capping infrastructure. Presence means the seller honors targeting_overlay.frequency_cap on packages, with an independent counter per package, and MUST reject caps it cannot enforce rather than silently dropping them. Product.overlay_support is the binding per-product declaration. A cap whose single counter spans every package in a MediaBuy is advertised separately through aggregate_frequency_capping.' ), + NoExplicitNull, ] = None aggregate_frequency_capping: Annotated[ media_buy_frequency_cap_capability.MediaBuyFrequencyCapCapability | None, Field( description='Seller-wide support for a maximum-impression cap whose single counter is shared across every package in a MediaBuy. Presence is required before a buyer sends a root frequency_cap. Products additionally declare media_buy_support.frequency_cap and any narrower constraints.' ), + NoExplicitNull, ] = None budget_capping: Annotated[ BudgetCapping | None, Field( description='Hard daily budget-cap capabilities. Presence declares only the scopes listed in supported_scopes; sellers MUST reject a daily_budget_cap at an undeclared scope with UNSUPPORTED_FEATURE before mutation and MUST NOT silently drop or soften it. A cap is always a hard ceiling. Sellers that offer a best-effort pacing target must expose that under a separately named feature rather than interpreting daily_budget_cap as soft. Daily caps are orthogonal to pacing.' ), + NoExplicitNull, ] = None content_standards: Annotated[ ContentStandards | None, Field( description='Content standards implementation details. Presence of this object indicates the seller supports content_standards configuration including sampling rates and category filtering. Gives buyers pre-buy visibility into local evaluation and artifact delivery capabilities. This is a seller-side media-buy capability; governance agents providing content standards services declare `specialisms: ["content-standards"]` instead.' ), + NoExplicitNull, ] = None portfolio: Annotated[ Portfolio | None, Field( description="Information about the seller's media inventory portfolio. Media-buy sellers SHOULD publish primary_channels and primary_countries as their complete brief-routing scope. Buyers use publisher_domains to verify authorization via adagents.json. Omitted routing arrays mean unknown scope and MUST NOT be interpreted as global coverage." ), + NoExplicitNull, ] = None @@ -2561,6 +2781,7 @@ class Adcp(AdCPBaseModel): examples=[['3.0'], ['3.0', '3.1'], ['3.0', '3.1-beta']], min_length=1, ), + NoExplicitNull, ] = None build_version: Annotated[ str | None, @@ -2575,6 +2796,7 @@ class Adcp(AdCPBaseModel): ], pattern='^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.-]+)?(\\+[a-zA-Z0-9.-]+)?$', ), + NoExplicitNull, ] = None idempotency: Annotated[ Idempotency | Idempotency1, @@ -2587,24 +2809,28 @@ class Adcp(AdCPBaseModel): Field( description='Caller-scoped durable connection configuration accepted by this agent. This is the buyer-to-seller configuration half of negotiation, not a second seller capability document: the seller advertises its objective offering here, while each authenticated caller submits its desired webhooks and reusable destinations through sync_principal and reads them back through get_principal. Per-account authority and feed selection remain in account/reporting configuration. Sellers exposing this block MUST list protocol.principal in experimental_features.' ), + NoExplicitNull, ] = None capability_changes: Annotated[ CapabilityChanges | None, Field( description='Freshness metadata and optional invalidation webhooks for this `get_adcp_capabilities` document. Buyers and registries MAY cache capabilities for up to `cache_ttl_seconds` when present, SHOULD compare `capabilities_version` across refreshes when present, and SHOULD re-run `get_adcp_capabilities` after receiving a `capabilities.changed` webhook. This block describes the agent-wide capability document, not per-caller authorization or account-scoped settings. A material capability change is any externally advertised contract change that can affect routing, validation, conformance coverage, task availability, auth/account handling, sandbox support, billing support, reporting delivery methods, creative-library support, targeting support, protocol versions, or other buyer-visible feature gates. Non-contract operational changes that do not alter the response body do not require a revision or webhook fire.' ), + NoExplicitNull, ] = None governance_enforcement: Annotated[ GovernanceEnforcement | None, Field( description='Cross-role declaration that this agent enforces buyer-provided governance authorization when performing consequential tasks. This is distinct from the top-level `governance` capability block, which describes an agent that provides governance services. An agent can enforce governance without implementing the governance protocol itself. Absence means the agent makes no governance-enforcement conformance claim.' ), + NoExplicitNull, ] = None attestations: Annotated[ attestation_capabilities.AttestationCapabilities | None, Field( description='Portable-attestation trust and delivery capabilities for this evaluator. Present only when the agent accepts AttestationReference inputs on one or more domain task surfaces. This block is an allowlist: presenters cannot expand accepted issuers, resolver endpoints, verifier agents, claim types, or proof formats by supplying values in a request.' ), + NoExplicitNull, ] = None @@ -2625,123 +2851,145 @@ class GetAdcpCapabilitiesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv Field( description='Account management capabilities. Describes how accounts are established, what billing models are supported, and whether an account is required before browsing products.' ), + NoExplicitNull, ] = None media_buy: Annotated[ MediaBuy | None, Field( description='Media-buy protocol capabilities. Expected when media_buy is in supported_protocols. Sellers declaring media_buy should also include account with supported_billing.' ), + NoExplicitNull, ] = None signals: Annotated[ Signals | None, Field( description='Signals protocol capabilities. Only present if signals is in supported_protocols.' ), + NoExplicitNull, ] = None governance: Annotated[ Governance | None, Field( description='Governance protocol capabilities. Only present if governance is in supported_protocols. Governance agents provide property and creative data like compliance scores, brand safety ratings, sustainability metrics, and creative quality assessments.' ), + NoExplicitNull, ] = None sponsored_intelligence: Annotated[ SponsoredIntelligence | None, Field( description='Sponsored Intelligence protocol capabilities. Only present if sponsored_intelligence is in supported_protocols. SI agents handle conversational brand experiences.' ), + NoExplicitNull, ] = None brand: Annotated[ Brand | None, Field( description='Brand protocol capabilities. Only present if brand is in supported_protocols. Brand agents provide identity data (logos, colors, tone, assets) and optionally rights clearance for licensable content (talent, music, stock media).' ), + NoExplicitNull, ] = None creative: Annotated[ Creative | None, Field( description='Creative protocol capabilities. Only present if creative is in supported_protocols.' ), + NoExplicitNull, ] = None oauth: Annotated[ Oauth | None, Field( description='Introduced in AdCP 3.2. OAuth 2.0 protected-resource support for inbound transport authentication. This is a capability claim, not a requirement that every caller use OAuth: `supported: true` means the agent publishes RFC 9728 protected-resource metadata for its advertised endpoint and RFC 8414 metadata for every referenced authorization server, and opts into the universal `oauth_setup` conformance storyboard. Agents that use only static Bearer, Basic, mTLS, or RFC 9421 authentication omit this block or declare `supported: false`. Operator credential acquisition remains separately described by `account.authorization_endpoint` when applicable.' ), + NoExplicitNull, ] = None request_signing: Annotated[ RequestSigning | None, Field( description='RFC 9421 HTTP Signatures support for incoming requests. Signing remains optional through 3.2, but every accepted 3.2 signature on a request with a body MUST cover content-digest. Request signing becomes required for spend-committing operations in 4.0. The full profile is defined in docs/building/by-layer/L1/security.mdx (Signed Requests (Transport Layer)).' ), + NoExplicitNull, ] = None webhook_signing: Annotated[ WebhookSigning | None, Field( description='RFC 9421 webhook-signature and delivery-retry support for outbound webhook callbacks (top-level peer of request_signing). Declares which AdCP webhook-signing profile version and algorithms this agent produces on delivery, whether it supports the legacy HMAC-SHA256 fallback for receivers that have not yet adopted RFC 9421, and the maximum retry horizon receivers use to retain immutable delivery evidence. See docs/building/by-layer/L3/webhooks.mdx.' ), + NoExplicitNull, ] = None identity: Annotated[ Identity | None, Field( description='Operator identity posture — trust-root pointer (`brand_json_url`) plus key-scoping and compromise-response controls the agent operates. `brand_json_url` is **load-bearing** for signature verification: when the agent declares any signing posture (`request_signing.supported_for`/`required_for` non-empty, `webhook_signing.supported === true`, or any `key_origins` subfield), `brand_json_url` MUST be present (storyboard-enforced in 3.x; schema-required in 4.0). Verifiers use it to bootstrap from the agent URL to the operator\'s brand.json (and from there to signing keys); see [security.mdx §Discovering an agent\'s signing keys](https://adcontextprotocol.org/docs/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url). The remaining fields (`per_principal_key_isolation`, `key_origins`, `compromise_notification`) are advisory and receivers use them to reason about blast radius and revocation latency at onboarding. Empty-object semantics: `identity: {}` means "posture block present but no posture claimed" — schema-valid but advisory-neutral and receivers MUST treat it as equivalent to omitting the block, **except** that an agent declaring a signing posture elsewhere in the response with an empty `identity` MUST be rejected by storyboard runners as missing `brand_json_url`.' ), + NoExplicitNull, ] = None measurement_gateway: Annotated[ MeasurementGateway | None, Field( description='Buyer-controlled task gateway between an orchestrator and measurement providers. In the first experimental tier, providers read buyer-approved cross-seller delivery through get_media_buy_delivery and return compact assertions through provide_performance_feedback, without receiving seller credentials. Orchestrators implementing this block MUST include measurement in supported_protocols and measurement.gateway in experimental_features; this gateway role does not claim the media_buy seller protocol.' ), + NoExplicitNull, ] = None measurement: Annotated[ Measurement | None, Field( description="Experimental measurement capability block. Presence indicates this agent computes one or more quantitative metrics about ad delivery, exposure, or effect, and is willing to be discovered as a measurement vendor. Agents implementing this block MUST list `measurement.core` in experimental_features. Returns metric definitions and whether the provider produces compact performance feedback, not pricing/coverage (negotiated via `measurement_terms` on `create_media_buy`) or raw/live datasets. Per-buy vendor values remain on delivery reports; optimizer-ready projections use provide_performance_feedback. AgenticAdvertising.org crawls each measurement agent's `metrics[]` on a TTL to populate the federated cross-vendor index." ), + NoExplicitNull, ] = None compliance_testing: Annotated[ ComplianceTesting | None, Field( description="Compliance testing capabilities. The presence of this block declares that the agent supports deterministic testing via comply_test_controller for lifecycle state machine validation. Omit the block entirely if the agent does not support compliance testing. Sellers SHOULD list every canonical controller scenario they implement so buyers and runners can distinguish full deterministic coverage from partial coverage without probing each scenario one by one; the runtime source of truth remains comply_test_controller with scenario: 'list_scenarios'." ), + NoExplicitNull, ] = None specialisms: Annotated[ list[specialism.AdcpSpecialism] | None, Field( description="Optional — specialized compliance claims this agent supports. Values MUST be kebab-case enum IDs (e.g., 'creative-generative', 'sales-non-guaranteed'). An agent that implements a specialism's tools but omits its ID from this array will receive 'No applicable tracks found' from the compliance runner — tracks for that specialism are not evaluated even if every tool works. Omitting the field means the agent declares no specialism claims (it still passes the universal + domain-baseline storyboards implied by supported_protocols). Each specialism maps to a storyboard bundle at /compliance/{version}/specialisms/{id}/ that the AAO compliance runner executes to verify the claim. Each specialism rolls up to one of the protocols in supported_protocols — the runner rejects a specialism claim whose parent protocol is missing. Only list specialisms your agent actually implements — the AAO Verified badge enumerates which specialisms were demonstrably passed." ), + NoExplicitNull, ] = None extensions_supported: Annotated[ list[ExtensionsSupportedItem] | None, Field( description='Extension namespaces this agent supports. Buyers can expect meaningful data in ext.{namespace} fields on responses from this agent. Extension schemas are published in the AdCP extension registry.' ), + NoExplicitNull, ] = None experimental_features: Annotated[ list[experimental_feature_id.ExperimentalFeatureId] | None, Field( description='Experimental AdCP surfaces this agent implements. A surface is experimental when its schema carries x-status: experimental and the working group has not yet frozen it. Sellers that implement any experimental surface MUST list its feature id here. Buyers inspect this array before relying on experimental surfaces — a seller that does not list a surface is asserting it does not implement it. Experimental surfaces MAY break between any two 3.x releases with at least 6 weeks notice; the full contract is in docs/reference/experimental-status.' ), + NoExplicitNull, ] = None wholesale_feed_versioning: Annotated[ WholesaleFeedVersioning | None, Field( description="Conditional-fetch token capabilities for get_products and get_signals. Independent of wholesale feed webhooks: an agent MAY support cheap version probes via if_wholesale_feed_version without pushing change payloads (and vice versa). When supported is true, the agent returns wholesale_feed_version on every get_products / get_signals response and honors if_wholesale_feed_version on subsequent requests. When absent or supported is false, callers MAY still send if_wholesale_feed_version — pre-3.1 agents that ignore it just return the full payload (correct, just inefficient). Pre-flight declaration here lets buyers fast-path which agents to bother caching versions for. See get_products / get_signals 'Wholesale feed versioning' sections." ), + NoExplicitNull, ] = None last_updated: Annotated[ AwareDatetime | None, Field( description='ISO 8601 timestamp of when capabilities were last updated. Buyers can use this for cache invalidation.' ), + NoExplicitNull, ] = None errors: Annotated[ - list[error.Error] | None, Field(description='Task-specific errors and warnings') + list[error.Error] | None, + Field(description='Task-specific errors and warnings'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None wholesale_feed_webhooks: Annotated[ WholesaleFeedWebhooks | None, Field( description='Per-agent wholesale product-feed and wholesale signals-feed webhook capabilities. Consumers register durable sync_accounts notification subscribers and receive actual product.*, signal.*, or wholesale_feed.bulk_change payloads without polling. Product mirrors bootstrap and repair through list_products(if_feed_version); signal mirrors use get_signals(if_wholesale_feed_version). Deprecated wholesale get_products remains the 3.x product compatibility path. Webhook emission MUST apply the same caller/account authorization and cache-scope predicate as the corresponding read.' ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/protocol/get_principal_request.py b/src/adcp/types/domains/protocol/get_principal_request.py index 68750f185..455eee3cf 100644 --- a/src/adcp/types/domains/protocol/get_principal_request.py +++ b/src/adcp/types/domains/protocol/get_principal_request.py @@ -1,11 +1,14 @@ # generated by datamodel-codegen: # filename: protocol/get_principal_request.json -# timestamp: 2026-10-04T02:05:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest +from typing import Annotated + +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict from ..core import context as context_1 @@ -17,5 +20,5 @@ class GetPrincipalRequest(AdcpRequest, AdcpVersionEnvelope): model_config = ConfigDict( extra='allow', ) - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/get_principal_response.py b/src/adcp/types/domains/protocol/get_principal_response.py index 9efec1470..0cf955231 100644 --- a/src/adcp/types/domains/protocol/get_principal_response.py +++ b/src/adcp/types/domains/protocol/get_principal_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_principal_response.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -91,8 +91,8 @@ class GetPrincipalResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): extra='allow', ) result: Result2 | Result3 | Result | Result5 - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None # Stable aliases for anonymous result arms (selected by discriminator). diff --git a/src/adcp/types/domains/protocol/get_task_status_request.py b/src/adcp/types/domains/protocol/get_task_status_request.py index 4ff18133b..7680753d1 100644 --- a/src/adcp/types/domains/protocol/get_task_status_request.py +++ b/src/adcp/types/domains/protocol/get_task_status_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_task_status_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref @@ -26,18 +27,21 @@ class GetTaskStatusRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Account scope for the task lookup. Sellers MUST return REFERENCE_NOT_FOUND for a task_id that exists only under a different account or principal. When omitted, the seller MAY use the credential-bound singleton account, but multi-account credentials SHOULD require an explicit account.' ), + NoExplicitNull, ] = None include_history: Annotated[ StrictBool | None, Field( description='Include full conversation history for this task (may increase response size)' ), + NoExplicitNull, ] = False include_result: Annotated[ StrictBool | None, Field( description="Include the task's canonical terminal result payload when one exists. Defaults to false for lightweight status-only polls. When true, sellers MUST include result for completed, failed, or rejected terminal tasks when that task produced a terminal artifact; canceled tasks may have no result. The legacy singular error field remains a convenience for failed tasks but does not replace the canonical terminal result." ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/get_task_status_response.py b/src/adcp/types/domains/protocol/get_task_status_response.py index 596218463..941006d3e 100644 --- a/src/adcp/types/domains/protocol/get_task_status_response.py +++ b/src/adcp/types/domains/protocol/get_task_status_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_task_status_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat from ..core import context as context_1 @@ -25,15 +25,21 @@ class Progress(AdCPBaseModel): extra='allow', ) percentage: Annotated[ - StrictFloat | None, Field(description='Completion percentage (0-100)', ge=0.0, le=100.0) + StrictFloat | None, + Field(description='Completion percentage (0-100)', ge=0.0, le=100.0), + NoExplicitNull, ] = None current_step: Annotated[ - str | None, Field(description='Current step or phase of the operation') + str | None, Field(description='Current step or phase of the operation'), NoExplicitNull ] = None total_steps: Annotated[ - SchemaInt | None, Field(description='Total number of steps in the operation', ge=1) + SchemaInt | None, + Field(description='Total number of steps in the operation', ge=1), + NoExplicitNull, + ] = None + step_number: Annotated[ + SchemaInt | None, Field(description='Current step number', ge=1), NoExplicitNull ] = None - step_number: Annotated[SchemaInt | None, Field(description='Current step number', ge=1)] = None class Details(AdCPBaseModel): @@ -41,11 +47,15 @@ class Details(AdCPBaseModel): extra='allow', ) protocol: Annotated[ - adcp_protocol.AdcpProtocol | None, Field(description='AdCP protocol where error occurred') + adcp_protocol.AdcpProtocol | None, + Field(description='AdCP protocol where error occurred'), + NoExplicitNull, + ] = None + operation: Annotated[ + str | None, Field(description='Specific operation that failed'), NoExplicitNull ] = None - operation: Annotated[str | None, Field(description='Specific operation that failed')] = None specific_context: Annotated[ - dict[str, Any] | None, Field(description='Domain-specific error context') + dict[str, Any] | None, Field(description='Domain-specific error context'), NoExplicitNull ] = None @@ -55,7 +65,9 @@ class Error(AdCPBaseModel): ) code: Annotated[str, Field(description='Error code for programmatic handling')] message: Annotated[str, Field(description='Detailed error message')] - details: Annotated[Details | None, Field(description='Additional error context')] = None + details: Annotated[ + Details | None, Field(description='Additional error context'), NoExplicitNull + ] = None class Type(StrEnum): @@ -95,30 +107,38 @@ class GetTaskStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) Field( description='When the task completed (ISO 8601, only for completed/failed/canceled tasks)' ), + NoExplicitNull, ] = None has_webhook: Annotated[ - StrictBool | None, Field(description='Whether this task has webhook configuration') + StrictBool | None, + Field(description='Whether this task has webhook configuration'), + NoExplicitNull, ] = None progress: Annotated[ - Progress | None, Field(description='Progress information for long-running tasks') + Progress | None, + Field(description='Progress information for long-running tasks'), + NoExplicitNull, ] = None error: Annotated[ Error | None, Field( description='Convenience summary for failed tasks. When include_result was true and the canonical terminal result is also present, this error MUST agree with the canonical fatal error in result. A legacy poll carrying only this singular summary proves failure status but not equivalence to a richer terminal webhook artifact.' ), + NoExplicitNull, ] = None history: Annotated[ list[HistoryItem] | None, Field( description='Complete conversation history for this task (only included if include_history was true in request)' ), + NoExplicitNull, ] = None result: Annotated[ dict[str, Any] | None, Field( description='Canonical task-specific terminal payload. Present when include_result was true and a completed, failed, or rejected task produced a terminal artifact; canceled tasks may omit it. For failed tasks, the singular error field is a convenience summary and MUST agree with the canonical fatal error represented here. Consumers and sellers MUST resolve and validate the exact schema through manifest.task_result_resolution: use terminal_schema_overrides[task_type] when present, otherwise tools[task_type].response_schema. The polling envelope keeps this field generic so get_task_status does not embed every task response schema.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/list_tasks_request.py b/src/adcp/types/domains/protocol/list_tasks_request.py index b152c9939..7e028e47e 100644 --- a/src/adcp/types/domains/protocol/list_tasks_request.py +++ b/src/adcp/types/domains/protocol/list_tasks_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/list_tasks_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..core import account_ref @@ -34,53 +34,69 @@ class Filters(AdCPBaseModel): extra='allow', ) protocol: Annotated[ - adcp_protocol.AdcpProtocol | None, Field(description='Filter by single AdCP protocol') + adcp_protocol.AdcpProtocol | None, + Field(description='Filter by single AdCP protocol'), + NoExplicitNull, ] = None protocols: Annotated[ list[adcp_protocol.AdcpProtocol] | None, Field(description='Filter by multiple AdCP protocols', min_length=1), + NoExplicitNull, ] = None status: Annotated[ - task_status.TaskStatus | None, Field(description='Filter by single task status') + task_status.TaskStatus | None, + Field(description='Filter by single task status'), + NoExplicitNull, ] = None statuses: Annotated[ list[task_status.TaskStatus] | None, Field(description='Filter by multiple task statuses', min_length=1), + NoExplicitNull, ] = None task_type: Annotated[ - task_type_1.TaskType | None, Field(description='Filter by single task type') + task_type_1.TaskType | None, Field(description='Filter by single task type'), NoExplicitNull ] = None task_types: Annotated[ list[task_type_1.TaskType] | None, Field(description='Filter by multiple task types', min_length=1), + NoExplicitNull, ] = None created_after: Annotated[ - AwareDatetime | None, Field(description='Filter tasks created after this date (ISO 8601)') + AwareDatetime | None, + Field(description='Filter tasks created after this date (ISO 8601)'), + NoExplicitNull, ] = None created_before: Annotated[ - AwareDatetime | None, Field(description='Filter tasks created before this date (ISO 8601)') + AwareDatetime | None, + Field(description='Filter tasks created before this date (ISO 8601)'), + NoExplicitNull, ] = None updated_after: Annotated[ AwareDatetime | None, Field(description='Filter tasks last updated after this date (ISO 8601)'), + NoExplicitNull, ] = None updated_before: Annotated[ AwareDatetime | None, Field(description='Filter tasks last updated before this date (ISO 8601)'), + NoExplicitNull, ] = None task_ids: Annotated[ list[str] | None, Field(description='Filter by specific task IDs', max_length=100, min_length=1), + NoExplicitNull, ] = None context_contains: Annotated[ str | None, Field( description='Filter tasks where context contains this text (searches media_buy_id, signal_id, etc.)' ), + NoExplicitNull, ] = None has_webhook: Annotated[ StrictBool | None, Field(description='Filter tasks that have webhook configuration when true'), + NoExplicitNull, ] = None @@ -88,9 +104,11 @@ class Sort(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - field: Annotated[Field1 | None, Field(description='Field to sort by')] = Field1.created_at + field: Annotated[Field1 | None, Field(description='Field to sort by'), NoExplicitNull] = ( + Field1.created_at + ) direction: Annotated[ - sort_direction.SortDirection | None, Field(description='Sort direction') + sort_direction.SortDirection | None, Field(description='Sort direction'), NoExplicitNull ] = sort_direction.SortDirection.desc @@ -103,17 +121,19 @@ class ListTasksRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="Account scope for task reconciliation. Sellers MUST only return tasks created for the caller's authenticated account + principal pair. When omitted, the seller MAY use the credential-bound singleton account, but multi-account credentials SHOULD require an explicit account." ), + NoExplicitNull, ] = None - filters: Annotated[Filters | None, Field(description='Filter criteria for querying tasks')] = ( - None - ) - sort: Annotated[Sort | None, Field(description='Sorting parameters')] = None - pagination: pagination_request.PaginationRequest | None = None + filters: Annotated[ + Filters | None, Field(description='Filter criteria for querying tasks'), NoExplicitNull + ] = None + sort: Annotated[Sort | None, Field(description='Sorting parameters'), NoExplicitNull] = None + pagination: Annotated[pagination_request.PaginationRequest | None, NoExplicitNull] = None include_history: Annotated[ StrictBool | None, Field( description='Include full conversation history for each task (may significantly increase response size)' ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/list_tasks_response.py b/src/adcp/types/domains/protocol/list_tasks_response.py index 53bcf2b51..483a0bd97 100644 --- a/src/adcp/types/domains/protocol/list_tasks_response.py +++ b/src/adcp/types/domains/protocol/list_tasks_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/list_tasks_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -28,12 +28,17 @@ class DomainBreakdown(AdCPBaseModel): media_buy: Annotated[ SchemaInt | None, Field(alias='media-buy', description='Number of media-buy tasks in results', ge=0), + NoExplicitNull, ] = None signals: Annotated[ - SchemaInt | None, Field(description='Number of signals tasks in results', ge=0) + SchemaInt | None, + Field(description='Number of signals tasks in results', ge=0), + NoExplicitNull, ] = None creative: Annotated[ - SchemaInt | None, Field(description='Number of creative tasks in results', ge=0) + SchemaInt | None, + Field(description='Number of creative tasks in results', ge=0), + NoExplicitNull, ] = None @@ -57,21 +62,26 @@ class QuerySummary(AdCPBaseModel): total_matching: Annotated[ SchemaInt | None, Field(description='Total number of tasks matching filters (across all pages)', ge=0), + NoExplicitNull, ] = None returned: Annotated[ - SchemaInt | None, Field(description='Number of tasks returned in this response', ge=0) + SchemaInt | None, + Field(description='Number of tasks returned in this response', ge=0), + NoExplicitNull, ] = None domain_breakdown: Annotated[ - DomainBreakdown | None, Field(description='Count of tasks by domain') + DomainBreakdown | None, Field(description='Count of tasks by domain'), NoExplicitNull ] = None status_breakdown: Annotated[ - dict[str, SchemaInt] | None, Field(description='Count of tasks by status') + dict[str, SchemaInt] | None, Field(description='Count of tasks by status'), NoExplicitNull ] = None filters_applied: Annotated[ - list[str] | None, Field(description='List of filters that were applied to the query') + list[str] | None, + Field(description='List of filters that were applied to the query'), + NoExplicitNull, ] = None sort_applied: Annotated[ - SortApplied | None, Field(description='Sort order that was applied') + SortApplied | None, Field(description='Sort order that was applied'), NoExplicitNull ] = None @@ -100,9 +110,12 @@ class Task(AdCPBaseModel): Field( description='When the task completed (ISO 8601, only for completed/failed/canceled tasks)' ), + NoExplicitNull, ] = None has_webhook: Annotated[ - StrictBool | None, Field(description='Whether this task has webhook configuration') + StrictBool | None, + Field(description='Whether this task has webhook configuration'), + NoExplicitNull, ] = None @@ -115,5 +128,5 @@ class ListTasksResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): ] tasks: Annotated[list[Task], Field(description='Array of tasks matching the query criteria')] pagination: pagination_response.PaginationResponse - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/sync_agent_notification_configs_request.py b/src/adcp/types/domains/protocol/sync_agent_notification_configs_request.py index cd9186358..8085fcec4 100644 --- a/src/adcp/types/domains/protocol/sync_agent_notification_configs_request.py +++ b/src/adcp/types/domains/protocol/sync_agent_notification_configs_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/sync_agent_notification_configs_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import agent_notification_config @@ -41,6 +42,7 @@ class SyncAgentNotificationConfigsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='When true, validate the replacement and report what would be persisted without applying it or sending endpoint proof-of-control challenges.' ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/sync_agent_notification_configs_response.py b/src/adcp/types/domains/protocol/sync_agent_notification_configs_response.py index f29354949..67fbcac0f 100644 --- a/src/adcp/types/domains/protocol/sync_agent_notification_configs_response.py +++ b/src/adcp/types/domains/protocol/sync_agent_notification_configs_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/sync_agent_notification_configs_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,6 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import agent_notification_config @@ -33,6 +34,7 @@ class SyncAgentNotificationConfigsResponse(AdcpResponse, AdcpVersionEnvelope, Pr dry_run: Annotated[ StrictBool | None, Field(description='Whether this was a dry run and no persisted subscriber set changed.'), + NoExplicitNull, ] = None action: Annotated[ Action, @@ -46,12 +48,14 @@ class SyncAgentNotificationConfigsResponse(AdcpResponse, AdcpVersionEnvelope, Pr description='Current persisted agent-level notification subscribers owned by the authenticated caller after the request. Entries are keyed by `subscriber_id`; `authentication.credentials` is omitted on every entry because credentials are write-only. Present on successful actions and MAY be present on `failed` responses to show the unchanged prior caller-scoped set.', max_length=16, ), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Operation-level errors and warnings. Present when action is `failed`, and MAY include non-fatal warnings on successful responses.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/sync_principal_request.py b/src/adcp/types/domains/protocol/sync_principal_request.py index dd4d850c0..b6d09c524 100644 --- a/src/adcp/types/domains/protocol/sync_principal_request.py +++ b/src/adcp/types/domains/protocol/sync_principal_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/sync_principal_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import agent_notification_config, agent_reporting_destination @@ -29,6 +29,7 @@ class Configuration(AdCPBaseModel): description='Complete desired agent-level subscriber set. The same caller-scoping, proof-of-control, secret handling, and replacement rules as sync_agent_notification_configs apply.', max_length=16, ), + NoExplicitNull, ] = None reporting_destinations: Annotated[ list[agent_reporting_destination.AgentReportingDestination] | None, @@ -36,12 +37,14 @@ class Configuration(AdCPBaseModel): description='Complete desired reusable reporting destination set. Omitting a previously present destination_id revokes it, and [] revokes every destination: the seller halts new deliveries to all of its generations within the advertised suspension_interval_seconds and retains it as a retired generation for reporting history. Revocation does not delete caller-owned data already delivered. destination_id values MUST be unique.', max_length=64, ), + NoExplicitNull, ] = None declarations: Annotated[ principal_declarations.AgentDeclarations | None, Field( description='Complete declared consumption facts for this principal record. A present object replaces the declared set wholesale; {} clears it; omission leaves it unchanged. The seller computes and returns the accepted intersection in state.' ), + NoExplicitNull, ] = None @@ -65,12 +68,14 @@ class SyncPrincipalRequest(AdcpRequest, AdcpVersionEnvelope): max_length=255, min_length=1, ), + NoExplicitNull, ] = None expected_principal_kind: Annotated[ principal_kind.PrincipalKind | None, Field( description='Optional assertion fence, not identity input: the caller states which party kind it believes it is authenticating as. When present and different from the seller-resolved principal_kind, the seller rejects the whole request with CONFLICT before mutation. The field never influences resolution.' ), + NoExplicitNull, ] = None configuration: Annotated[ Configuration, @@ -83,6 +88,7 @@ class SyncPrincipalRequest(AdcpRequest, AdcpVersionEnvelope): Field( description='Validate the proposed replacements and report the would-be action without persisting them, issuing durable identifiers or grants, or sending endpoint proof challenges.' ), + NoExplicitNull, ] = False - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/protocol/sync_principal_response.py b/src/adcp/types/domains/protocol/sync_principal_response.py index 126ac711e..44bdb696b 100644 --- a/src/adcp/types/domains/protocol/sync_principal_response.py +++ b/src/adcp/types/domains/protocol/sync_principal_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/sync_principal_response.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -40,7 +40,7 @@ class Result(AdCPBaseModel): kind: Literal['validated'] = 'validated' action: Action33 dry_run: Literal[True] - warnings: Annotated[list[error.Error] | None, Field(max_length=16)] = None + warnings: Annotated[list[error.Error] | None, Field(max_length=16), NoExplicitNull] = None class Result15(AdCPBaseModel): @@ -86,7 +86,7 @@ class Result13(AdCPBaseModel): ), ] configuration: principal_state.PrincipalState - warnings: Annotated[list[error.Error] | None, Field(max_length=16)] = None + warnings: Annotated[list[error.Error] | None, Field(max_length=16), NoExplicitNull] = None class SyncPrincipalResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): @@ -94,8 +94,8 @@ class SyncPrincipalResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) extra='allow', ) result: Result13 | Result | Result15 - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None # Stable aliases for anonymous result arms (selected by discriminator). diff --git a/src/adcp/types/domains/registries/v1_canonical_mapping.py b/src/adcp/types/domains/registries/v1_canonical_mapping.py index 3aeeb020f..e6917f58a 100644 --- a/src/adcp/types/domains/registries/v1_canonical_mapping.py +++ b/src/adcp/types/domains/registries/v1_canonical_mapping.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: registries/v1_canonical_mapping.json -# timestamp: 2026-10-05T13:49:50+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -22,8 +22,8 @@ class V1Pattern(AdCPBaseModel): class Dimensions(AdCPBaseModel): - width: SchemaInt | None = None - height: SchemaInt | None = None + width: Annotated[SchemaInt | None, NoExplicitNull] = None + height: Annotated[SchemaInt | None, NoExplicitNull] = None class Structural(AdCPBaseModel): @@ -35,13 +35,15 @@ class Structural(AdCPBaseModel): Field( description="Set of asset_type values that must appear in the format's slots (in any order, any count)." ), + NoExplicitNull, ] = None vast_versions: Annotated[ list[str] | None, Field(description="VAST version constraints. Strings like '>=4.0', '4.x', '4.2'."), + NoExplicitNull, ] = None - daast_versions: list[str] | None = None - dimensions: Dimensions | None = None + daast_versions: Annotated[list[str] | None, NoExplicitNull] = None + dimensions: Annotated[Dimensions | None, NoExplicitNull] = None class V1Pattern1(AdCPBaseModel): @@ -73,6 +75,7 @@ class ParameterMapping(AdCPBaseModel): Field( description='Value transform applied during forwarding. identity copies the value; singleton_array wraps it in a one-element array.' ), + NoExplicitNull, ] = Transform.identity @@ -83,6 +86,7 @@ class V2(AdCPBaseModel): Field( description='Optional parameters that narrow the canonical (e.g., width/height, vast_version). When present, become the params on the projected v2 ProductFormatDeclaration. The shape MUST be valid params for the named canonical.' ), + NoExplicitNull, ] = None parameter_mappings: Annotated[ list[ParameterMapping] | None, @@ -90,6 +94,7 @@ class V2(AdCPBaseModel): description='Machine-readable forwarding rules for parameters carried by a structured v1 format_id. SDKs copy each present source_field to the target_parameter after applying transform; absent source fields leave the target parameter omitted. Static v2.parameters are applied before these forwarded values.', min_length=1, ), + NoExplicitNull, ] = None @@ -104,10 +109,12 @@ class Mapping(AdCPBaseModel): Field( description='When true, this mapping is retained for backward-compatibility but should not be used for new mappings. SDKs SHOULD emit lint warnings when matching a deprecated entry.' ), + NoExplicitNull, ] = False notes: Annotated[ str | None, Field(description='Optional human-readable explanation, examples, or rationale.'), + NoExplicitNull, ] = None @@ -116,7 +123,7 @@ class V1V2CanonicalFormatMappingRegistry(AdCPBaseModel): str, Field(description='Semver of this registry. Bumped on every published change.') ] last_updated: Annotated[ - date | None, Field(description='ISO date of the last published change.') + date | None, Field(description='ISO date of the last published change.'), NoExplicitNull ] = None mappings: Annotated[ list[Mapping], diff --git a/src/adcp/types/domains/signals/activate_signal_request.py b/src/adcp/types/domains/signals/activate_signal_request.py index b8478e04e..5b162b9e7 100644 --- a/src/adcp/types/domains/signals/activate_signal_request.py +++ b/src/adcp/types/domains/signals/activate_signal_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: signals/activate_signal_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,6 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import account_ref @@ -32,6 +33,7 @@ class ActivateSignalRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="Whether to activate or deactivate the signal. Deactivating removes the segment from downstream platforms, required when campaigns end to comply with data governance policies (GDPR, CCPA). Defaults to 'activate' when omitted." ), + NoExplicitNull, ] = Action.activate signal_agent_segment_id: Annotated[ str, @@ -51,6 +53,7 @@ class ActivateSignalRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="The pricing option selected from the signal's pricing_options in the get_signals response. Required when the signal has pricing options. Records the buyer's pricing commitment at activation time; pass this same value in report_usage for billing verification." ), + NoExplicitNull, ] = None governance_context: Annotated[ str | None, @@ -60,12 +63,14 @@ class ActivateSignalRequest(AdcpRequest, AdcpVersionEnvelope): min_length=1, pattern='^[\\x20-\\x7E]+$', ), + NoExplicitNull, ] = None account: Annotated[ account_ref.AccountReference | None, Field( description='Account for this activation. Associates with a commercial relationship established via sync_accounts.' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -76,5 +81,5 @@ class ActivateSignalRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/signals/get_signals_async_response_submitted.py b/src/adcp/types/domains/signals/get_signals_async_response_submitted.py index 22488e68a..255f65889 100644 --- a/src/adcp/types/domains/signals/get_signals_async_response_submitted.py +++ b/src/adcp/types/domains/signals/get_signals_async_response_submitted.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: signals/get_signals_async_response_submitted.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import context as context_1 @@ -36,16 +36,19 @@ class GetSignalsSubmitted(AdCPBaseModel): description="Optional human-readable explanation of why the task is submitted — e.g., 'Provider discovery queued; typical turnaround 10-30 minutes.' Plain text only. Callers MUST treat this as untrusted agent input: escape before rendering to HTML UIs, and sanitize or isolate before passing to an LLM prompt context — a hostile agent may inject prompt-injection payloads aimed at the caller's agent.", max_length=2000, ), + NoExplicitNull, ] = None estimated_completion: Annotated[ AwareDatetime | None, Field(description='Estimated completion time for the signal discovery task.'), + NoExplicitNull, ] = None errors: Annotated[ list[error.Error] | None, Field( description='Optional advisory errors accompanying the submitted envelope. Use only for non-blocking warnings (e.g., throttled_severity advisories or partial provider unavailability). Terminal failures belong in the error branch, not here.' ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/signals/get_signals_async_response_working.py b/src/adcp/types/domains/signals/get_signals_async_response_working.py index 54a4a68da..824302e9f 100644 --- a/src/adcp/types/domains/signals/get_signals_async_response_working.py +++ b/src/adcp/types/domains/signals/get_signals_async_response_working.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: signals/get_signals_async_response_working.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictFloat from ..core import context as context_1 @@ -22,19 +22,22 @@ class GetSignalsWorking(AdCPBaseModel): Field( description='Progress percentage of the signal discovery operation.', ge=0.0, le=100.0 ), + NoExplicitNull, ] = None current_step: Annotated[ str | None, Field( description='Current step in the signal discovery process, such as `querying_providers`, `ranking_signals`, or `checking_deployments`.' ), + NoExplicitNull, ] = None total_steps: Annotated[ SchemaInt | None, Field(description='Total number of steps in the signal discovery process.'), + NoExplicitNull, ] = None step_number: Annotated[ - SchemaInt | None, Field(description='Current step number (1-indexed).') + SchemaInt | None, Field(description='Current step number (1-indexed).'), NoExplicitNull ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/signals/get_signals_request.py b/src/adcp/types/domains/signals/get_signals_request.py index 6b08087ef..d38894ea3 100644 --- a/src/adcp/types/domains/signals/get_signals_request.py +++ b/src/adcp/types/domains/signals/get_signals_request.py @@ -1,16 +1,16 @@ # generated by datamodel-codegen: # filename: signals/get_signals_request.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types.base import AdcpRequest, SchemaInt -from adcp.types._scalar import ScalarStr - from adcp.types._str_enum import StrEnum from typing import Annotated +from adcp.types.base import NoExplicitNull +from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field from ..core import account_ref @@ -79,18 +79,21 @@ class GetSignalsRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="Declares caller intent for this request. 'brief' (default): semantic discovery — signal_spec, signal_refs, or legacy signal_ids is required and the agent performs inference/RAG. 'wholesale': raw wholesale signals feed enumeration — signal_spec, signal_refs, and signal_ids MUST NOT be provided and the agent returns its full priced signals feed, paginated, scoped by filters/account/destinations/countries when present. Sellers receiving requests from pre-v3.1 clients without discovery_mode MUST default to 'brief'. Timing semantics: 'wholesale' is a wholesale signals feed read — agents SHOULD respond synchronously and MUST NOT route a 'wholesale' request through the async/Submitted arm; partial completion is signalled via the response's incomplete[] field, not via a task-handoff envelope. Agents that do not implement wholesale enumeration MAY return INVALID_REQUEST for wholesale calls; callers SHOULD probe via get_adcp_capabilities (signals.discovery_modes) first." ), + NoExplicitNull, ] = DiscoveryMode.brief account: Annotated[ account_ref.AccountReference | None, Field( description="Account for this request. When provided, the signals agent returns per-account pricing options if configured. In 'wholesale' mode, this is the rate-card scope: when omitted in wholesale mode, agents return their default rate-card pricing or omit pricing_options entirely. A reference that does not resolve returns ACCOUNT_NOT_FOUND rather than default pricing, unless the seller provisions lazily. See /docs/accounts/overview#account-references-before-provisioning." ), + NoExplicitNull, ] = None signal_spec: Annotated[ str | None, Field( description="Natural language description of the desired signals. When used alone, enables semantic discovery. When combined with signal_refs, provides context for the agent but signal_ref matches are returned first. MUST NOT be provided when discovery_mode is 'wholesale'." ), + NoExplicitNull, ] = None signal_refs: Annotated[ list[signal_ref_1.SignalRef] | None, @@ -98,6 +101,7 @@ class GetSignalsRequest(AdcpRequest, AdcpVersionEnvelope): description="Specific signals to look up by reference. Returns exact matches for the requested SignalRef values. When combined with signal_spec, these signals anchor the starting set and signal_spec guides adjustments. MUST NOT be provided when discovery_mode is 'wholesale'.", min_length=1, ), + NoExplicitNull, ] = None signal_ids: Annotated[ list[signal_id_1.SignalId] | None, @@ -106,6 +110,7 @@ class GetSignalsRequest(AdcpRequest, AdcpVersionEnvelope): description="DEPRECATED. Use signal_refs instead. Legacy exact lookup field using SignalId objects. MUST NOT be provided when discovery_mode is 'wholesale'.", min_length=1, ), + NoExplicitNull, ] = None destinations: Annotated[ list[destination.Destination] | None, @@ -113,6 +118,7 @@ class GetSignalsRequest(AdcpRequest, AdcpVersionEnvelope): description='Filter signals to those activatable on specific agents/platforms. When omitted, returns all signals available on the current agent. If the authenticated caller matches one of these destinations, activation keys will be included in the response.', min_length=1, ), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -120,14 +126,16 @@ class GetSignalsRequest(AdcpRequest, AdcpVersionEnvelope): description='Countries where signals will be used (ISO 3166-1 alpha-2 codes). When omitted, no geographic filter is applied.', min_length=1, ), + NoExplicitNull, ] = None - filters: signal_filters.SignalFilters | None = None + filters: Annotated[signal_filters.SignalFilters | None, NoExplicitNull] = None fields: Annotated[ list[Field1] | None, Field( description="Specific signal fields to include in the response, aligned with get_products.fields. Required identity and activation fields such as signal_ref or signal_id, signal_agent_segment_id, name, description, signal_type, coverage_percentage, and deployments are always included when required by the response schema. Use for progressive disclosure of rich signal-definition metadata: request fields such as demographic_predicate, taxonomy, data_sources, methodology, segmentation_criteria, criteria_url, refresh_cadence, lookback_window, onboarder, modeling, audience_expansion, device_expansion, countries, consent_basis, restricted_attributes, policy_categories, art9_basis, data_subject_rights, and last_updated when the buyer needs them inline. Omit for the agent's default discovery projection. Agents SHOULD honor requested fields for exact lookup, refinement, small custom-signal result sets, and private/source-native signals when available. fields is a projection request, not an entitlement grant; agents MAY redact requested definition fields unless the caller is authorized for the underlying lineage, methodology, and rights-routing metadata. When demographic_predicate, consent_basis, or art9_basis is projected for another provider's signal, the value remains provider-declared signal-definition posture; sellers and federating agents MUST NOT substitute their own semantics or processing basis. For broad discovery and wholesale pages, agents MAY return compact pointers instead of inlining large resources, especially when provider-published definitions can be resolved from signal_ref, taxonomy.ref, criteria_url, disclosure_url, and validators such as resolved URL plus catalog_etag, HTTP ETag/Last-Modified, or taxonomy.etag.", min_length=1, ), + NoExplicitNull, ] = None max_results: Annotated[ SchemaInt | None, @@ -136,30 +144,35 @@ class GetSignalsRequest(AdcpRequest, AdcpVersionEnvelope): description='DEPRECATED: Use pagination.max_results instead. When both fields are present, agents MUST honor pagination.max_results. When only this field is present without a pagination envelope, agents SHOULD treat it as the page size subject to a maximum of 100 results. This field will be removed in AdCP 4.0.', ge=1, ), + NoExplicitNull, ] = None pagination: Annotated[ pagination_request.PaginationRequest | None, Field( description='Pagination parameters. Use pagination.max_results (max: 100, default: 50) and pagination.cursor for cursor-based page walks. When the deprecated top-level max_results field is also present, pagination.max_results takes precedence.' ), + NoExplicitNull, ] = None push_notification_config: Annotated[ push_notification_config_1.PushNotificationConfig | None, Field( description='Optional webhook configuration for async terminal completion/failure notifications on semantic signal discovery. Meaningful only for `discovery_mode: "brief"` requests that enter the async lifecycle. Submitted envelopes with `task_id` remain pollable through `get_task_status` (legacy `tasks/get`) whether or not this field is present. If a brief request includes this field and the agent returns a Submitted envelope, the agent MUST deliver at least the terminal completion/failure notification to the configured URL; intermediate progress notifications are MAY. If the agent cannot honor the webhook channel, it MUST reject the request with a structured error instead of silently accepting. This field does not change wholesale timing semantics: agents MUST NOT route `discovery_mode: "wholesale"` requests through the async/Submitted arm or emit async delivery solely because `push_notification_config` is present; partial wholesale completion is reported via `incomplete[]`.' ), + NoExplicitNull, ] = None if_wholesale_feed_version: Annotated[ str | None, Field( description="Opaque wholesale_feed_version token returned by a prior wholesale-mode get_signals response from this agent. Only valid when discovery_mode is wholesale. When provided, the agent compares against its current wholesale signals feed version for the caller's cache_scope and MAY return an unchanged: true response (with signals omitted) if nothing has changed. The token is scope-keyed: callers cache `(cache_scope, wholesale_feed_version)` pairs. Scoping dimensions: (agent, discovery_mode, filters, destinations, countries) for cache_scope: 'public'; that tuple plus account_id for cache_scope: 'account'. pagination.cursor is NOT part of the scoping tuple. See specs/wholesale-feed-webhooks.md for the full sync pattern." ), + NoExplicitNull, ] = None if_pricing_version: Annotated[ str | None, Field( description="Opaque pricing_version token from a prior get_signals response. MUST only be sent together with if_wholesale_feed_version — pricing version has no structural baseline to compare against on its own. Evaluation order: (1) if_wholesale_feed_version mismatch → agent returns the full payload; (2) if_wholesale_feed_version matches but if_pricing_version mismatches → agent returns the full payload so the caller sees updated pricing_options; (3) both match → agent MAY return unchanged: true. Agents that don't track pricing separately ignore this and fall back to if_wholesale_feed_version semantics." ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/signals/get_signals_response.py b/src/adcp/types/domains/signals/get_signals_response.py index 9b8e1ff2d..0186bd4e7 100644 --- a/src/adcp/types/domains/signals/get_signals_response.py +++ b/src/adcp/types/domains/signals/get_signals_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: signals/get_signals_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:25:44+00:00 from __future__ import annotations @@ -10,7 +10,7 @@ from collections.abc import Sequence from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, StrictFloat @@ -42,8 +42,8 @@ class Value(AdCPBaseModel): extra='forbid', ) id: Annotated[str, Field(min_length=1)] - path: str | None = None - modifiers: list[str] | None = None + path: Annotated[str | None, NoExplicitNull] = None + modifiers: Annotated[list[str] | None, NoExplicitNull] = None class ValueMapping(AdCPBaseModel): @@ -52,8 +52,8 @@ class ValueMapping(AdCPBaseModel): ) value: str taxonomy_value_id: str - path: str | None = None - modifiers: list[str] | None = None + path: Annotated[str | None, NoExplicitNull] = None + modifiers: Annotated[list[str] | None, NoExplicitNull] = None class ParentMatchBehavior(StrEnum): @@ -67,12 +67,12 @@ class Taxonomy(AdCPBaseModel): extra='forbid', ) ref: AnyUrl - version: str | None = None - segtax: Annotated[SchemaInt | None, Field(ge=1)] = None - etag: str | None = None + version: Annotated[str | None, NoExplicitNull] = None + segtax: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None + etag: Annotated[str | None, NoExplicitNull] = None values: Annotated[list[Value], Field(min_length=1)] - value_mappings: Annotated[list[ValueMapping] | None, Field(min_length=1)] = None - parent_match_behavior: ParentMatchBehavior | None = None + value_mappings: Annotated[list[ValueMapping] | None, Field(min_length=1), NoExplicitNull] = None + parent_match_behavior: Annotated[ParentMatchBehavior | None, NoExplicitNull] = None class DataSource(StrEnum): @@ -139,9 +139,11 @@ class Onboarder(AdCPBaseModel): extra='forbid', ) match_keys: Annotated[list[MatchKey], Field(min_length=1)] - pre_onboarding_audience_expansion: StrictBool | None = None - pre_onboarding_device_expansion: StrictBool | None = None - pre_onboarding_precision_level: PreOnboardingPrecisionLevel | None = None + pre_onboarding_audience_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + pre_onboarding_device_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + pre_onboarding_precision_level: Annotated[ + PreOnboardingPrecisionLevel | None, NoExplicitNull + ] = None class Country(ScalarStr): @@ -207,10 +209,10 @@ class Channel(AdCPBaseModel): extra='forbid', ) rights: Annotated[list[Right], Field(min_length=1)] - url: AnyUrl | None = None - email: EmailStr | None = None - languages: list[str] | None = None - countries: list[Country] | None = None + url: Annotated[AnyUrl | None, NoExplicitNull] = None + email: Annotated[EmailStr | None, NoExplicitNull] = None + languages: Annotated[list[str] | None, NoExplicitNull] = None + countries: Annotated[list[Country] | None, NoExplicitNull] = None class DataSubjectRights(AdCPBaseModel): @@ -223,10 +225,11 @@ class DataSubjectRights(AdCPBaseModel): max_length=253, pattern='^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\\-]{0,61}[a-zA-Z0-9])\\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\\-]{0,61}[A-Za-z0-9])$', ), + NoExplicitNull, ] = None channels: Annotated[list[Channel], Field(min_length=1)] - response_sla_days: Annotated[SchemaInt | None, Field(ge=1, le=90)] = None - ccpa_opt_out_url: AnyUrl | None = None + response_sla_days: Annotated[SchemaInt | None, Field(ge=1, le=90), NoExplicitNull] = None + ccpa_opt_out_url: Annotated[AnyUrl | None, NoExplicitNull] = None class Scope(StrEnum): @@ -253,6 +256,7 @@ class IncompleteItem(AdCPBaseModel): Field( description='How much additional time would resolve this scope. Allows the caller to decide whether to retry with a larger time_budget.' ), + NoExplicitNull, ] = None @@ -269,7 +273,9 @@ class Modeling(AdCPBaseModel): seed_source: SeedSource training_data_jurisdictions: Annotated[list[TrainingDataJurisdiction], Field(min_length=1)] ai_act_risk_class: AiActRiskClass - disclosure: signal_modeling_disclosure.SignalModelingDisclosure | None = None + disclosure: Annotated[ + signal_modeling_disclosure.SignalModelingDisclosure | None, NoExplicitNull + ] = None class Signal(AdCPBaseModel): @@ -282,12 +288,14 @@ class Signal(AdCPBaseModel): deprecated=True, description='DEPRECATED. Use signal_ref instead. Legacy SignalId retained for compatibility with older Signals Protocol clients.', ), + NoExplicitNull, ] = None signal_ref: Annotated[ signal_ref_1.SignalRef | None, Field( description="Canonical signal reference. Use scope 'product' for a product-local signal defined by this listing; use scope 'data_provider' with data_provider_domain for a signal defined in a data provider's published adagents.json signals[]; use scope 'signal_source' with signal_source_url for a source-native signal." ), + NoExplicitNull, ] = None signal_agent_segment_id: Annotated[ str, @@ -312,6 +320,7 @@ class Signal(AdCPBaseModel): Field( description="The data type of this signal's values. Required when signal_ref_1.scope is 'product'." ), + NoExplicitNull, ] = None categories: Annotated[ list[str] | None, @@ -319,10 +328,12 @@ class Signal(AdCPBaseModel): description="Valid values for categorical signals. Present when value_type is 'categorical'.", min_length=1, ), + NoExplicitNull, ] = None range: Annotated[ Range | None, Field(description="Valid range for numeric signals. Present when value_type is 'numeric'."), + NoExplicitNull, ] = None signal_type: Annotated[ signal_catalog_type.SignalAvailabilityType, @@ -333,6 +344,7 @@ class Signal(AdCPBaseModel): Field( description='Human-readable source name for the signal, when applicable. For data_provider-scoped signals this is the data provider name; for signal_source-scoped signals it may identify the signal source or proprietary origin.' ), + NoExplicitNull, ] = None coverage_percentage: Annotated[ StrictFloat | None, @@ -342,12 +354,14 @@ class Signal(AdCPBaseModel): ge=0.0, le=100.0, ), + NoExplicitNull, ] = None coverage_forecast: Annotated[ signal_coverage_forecast.SignalCoverageForecast | None, Field( description='Optional forecast-shaped signal availability guidance. When present, this is authoritative for signal-level discovery coverage. Use this to disclose the denominator, bucket semantics, not-present bucket, aggregate present bucket, and per-value coverage distribution for the signal.' ), + NoExplicitNull, ] = None deployments: Annotated[ Sequence[deployment.Deployment], Field(description='Array of deployment targets') @@ -358,82 +372,95 @@ class Signal(AdCPBaseModel): description='Pricing options available for this signal when it has an incremental price. The buyer selects one and passes its pricing_option_id in report_usage or package-level signal_targeting_groups for billing verification. Omit when pricing is unavailable to the caller, bundled into the destination product, or has no incremental cost.', min_length=1, ), + NoExplicitNull, ] = None methodology_url: Annotated[ AnyUrl | None, Field( description='Optional link to published methodology, media-kit, or data documentation. For data_provider and signal_source refs, this SHOULD match or supplement the referenced definition.' ), + NoExplicitNull, ] = None last_updated: Annotated[ AwareDatetime | None, Field( description='When this definition record was last updated. This indicates freshness of the definition record, not an attestation that the underlying data or model was refreshed at that time.' ), + NoExplicitNull, ] = None restricted_attributes: Annotated[ list[restricted_attribute.RestrictedAttribute] | None, Field(description='Restricted attribute categories this signal touches.', min_length=1), + NoExplicitNull, ] = None demographic_predicate: Annotated[ demographic_predicate_1.DemographicPredicate | None, Field( description="Projected authoritative demographic meaning for the signal. When projected from another provider, this MUST match the provider's definition exactly. Signal names alone never establish demographic semantics." ), + NoExplicitNull, ] = None policy_categories: Annotated[ list[str] | None, Field(description='Policy categories this signal is sensitive for.', min_length=1), + NoExplicitNull, ] = None taxonomy: Annotated[ Taxonomy | None, Field( description='Optional taxonomy metadata describing what this signal means in an external audience, content, retail-media, or provider-owned taxonomy.' ), + NoExplicitNull, ] = None - segmentation_criteria: Annotated[str | None, Field(max_length=500)] = None - criteria_url: AnyUrl | None = None - data_sources: Annotated[list[DataSource] | None, Field(min_length=1)] = None - methodology: Methodology | None = None - audience_expansion: StrictBool | None = None - device_expansion: StrictBool | None = None - refresh_cadence: RefreshCadence | None = None - lookback_window: RefreshCadence | None = None - onboarder: Onboarder | None = None - countries: Annotated[list[Country] | None, Field(min_length=1)] = None + segmentation_criteria: Annotated[str | None, Field(max_length=500), NoExplicitNull] = None + criteria_url: Annotated[AnyUrl | None, NoExplicitNull] = None + data_sources: Annotated[list[DataSource] | None, Field(min_length=1), NoExplicitNull] = None + methodology: Annotated[Methodology | None, NoExplicitNull] = None + audience_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + device_expansion: Annotated[StrictBool | None, NoExplicitNull] = None + refresh_cadence: Annotated[RefreshCadence | None, NoExplicitNull] = None + lookback_window: Annotated[RefreshCadence | None, NoExplicitNull] = None + onboarder: Annotated[Onboarder | None, NoExplicitNull] = None + countries: Annotated[list[Country] | None, Field(min_length=1), NoExplicitNull] = None consent_basis: Annotated[ list[consent_basis_1.ConsentBasis] | None, Field( description="Data provider's declared GDPR Article 6 lawful basis or consent basis for the underlying signal definition, projected into this get_signals response row when requested. Sellers and federating agents that pass through another provider's signal MUST NOT substitute their own processing basis for the provider-declared basis.", min_length=1, ), + NoExplicitNull, ] = None art9_basis: Annotated[ Art9Basis | None, Field( description="Data provider's declared GDPR Article 9 basis for the underlying signal definition when special-category data is involved and Article 9 applies, projected into this get_signals response row when requested. Sellers and federating agents that pass through another provider's signal MUST NOT substitute their own Article 9 basis for the provider-declared basis." ), + NoExplicitNull, ] = None - modeling: Modeling | None = None + modeling: Annotated[Modeling | None, NoExplicitNull] = None data_subject_rights: Annotated[ DataSubjectRights | None, Field( description='Per-signal data-subject-rights routing. This is a contact/routing reference, not a machine-callable AdCP API.' ), + NoExplicitNull, ] = None - dts_compliant_version: str | None = None + dts_compliant_version: Annotated[str | None, NoExplicitNull] = None class GetSignalsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): model_config = ConfigDict( extra='allow', ) - signals: Annotated[Sequence[Signal] | None, Field(description='Array of matching signals')] = None + signals: Annotated[ + Sequence[Signal] | None, Field(description='Array of matching signals'), NoExplicitNull + ] = None errors: Annotated[ list[error.Error] | None, Field( description='Task-specific errors and warnings (e.g., signal discovery or pricing issues)' ), + NoExplicitNull, ] = None incomplete: Annotated[ list[IncompleteItem] | None, @@ -441,35 +468,41 @@ class GetSignalsResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): description="Declares what the agent could not finish within the caller's time_budget or due to internal limits. Each entry identifies a scope that is missing or partial. Absent when the response is fully complete.", min_length=1, ), + NoExplicitNull, ] = None wholesale_feed_version: Annotated[ str | None, Field( description="Opaque token representing the version of the wholesale signals feed state used to compose this response. Agents that implement conditional-fetch (if_wholesale_feed_version) MUST return this on every wholesale-mode response so callers can cache and probe later. Callers MUST treat the value as opaque — no format, no ordering, no inspection. The token is scope-keyed: it describes a version for the cache_scope declared on this response, NOT a global agent version. A caller caches `(cache_scope, wholesale_feed_version)` pairs and presents the matching token on the next request. Scoping dimensions: (agent, discovery_mode, filters, destinations, countries) for cache_scope: 'public'; that tuple plus account_id for cache_scope: 'account'. pagination.cursor is NOT part of the scoping tuple. See specs/wholesale-feed-webhooks.md for the full cache layering model." ), + NoExplicitNull, ] = None pricing_version: Annotated[ str | None, Field( description='Opaque token representing the version of the pricing layer. When the agent supports independent pricing versioning, pricing_version changes when prices move but wholesale_feed_version changes only when structure/metadata moves. Same cache_scope keying as wholesale_feed_version. Agents not separating these MAY omit pricing_version and use wholesale_feed_version for both.' ), + NoExplicitNull, ] = None cache_scope: Annotated[ CacheScope | None, Field( description="Declares whether the wholesale_feed_version and pricing_version on this response describe a universal layer or an account-specific overlay. REQUIRED on every 3.1+ response (the 3.1 schema enforces this — the safety property of the two-layer cache model depends on it). 'public': this response describes the agent's published rate card; the caller MAY dedupe under (agent, discovery_mode, filters, destinations, countries) without scoping by account. 'account': this response includes account-specific overrides; the caller MUST cache the version under that tuple plus account_id. When the request did NOT include `account`, the agent MUST return `cache_scope: 'public'`. When the request included `account`, the agent MUST return either 'public' (this account prices off the public rate card — caller dedupes) or 'account' (account-specific overrides exist — caller caches under the account key). Agents MAY return 'public' on an account-scoped request that previously had overrides — callers SHOULD interpret this as a downgrade. Without schema-required cache_scope, an agent silently omitting the field on an account-scoped response would cause callers to mis-key the cache and serve account-overlay payloads to other accounts — the canonical safety invariant of the entire cache layering model. **Backward-compatibility note for 3.1 validators:** SDKs validating strictly against the 3.1 schema MUST select the validator based on the server-declared `adcp_version`. For responses with `adcp_version` starting `3.0`, the 3.1 cache_scope-required constraint MUST be relaxed — pre-3.1 agents correctly emit no cache_scope and remain conformant to their declared version. This is a tightening within 3.1, not a 3.0 break." ), + NoExplicitNull, ] = CacheScope.public unchanged: Annotated[ Literal[True] | None, Field( description="Present and `true` ONLY on wholesale-mode responses when the request carried if_wholesale_feed_version (and/or if_pricing_version) matching the agent's current version for the caller's cache_scope, in which case signals[] MUST be omitted; wholesale_feed_version (echoed), cache_scope (echoed), and pricing_version (echoed when used) MUST still be present. Callers receiving unchanged: true MUST NOT mutate their local wholesale signals mirror. **One shape per state:** agents MUST NOT emit `unchanged: false` — the absence of the field IS the signal that the response carries signals. **Cross-scope isolation:** the comparator that decides `unchanged` MUST be keyed on `(cache_scope, wholesale_feed_version)`, not on the token value alone. An agent MUST NOT emit `unchanged: true` when it resolves the request to a different `cache_scope` than the one whose token the caller echoed in `if_wholesale_feed_version` (and/or `if_pricing_version`): because the token is scope-keyed, a value minted for `cache_scope: 'public'` cannot match the agent's current token for `cache_scope: 'account'` (or vice-versa), so such a request MUST return the full feed for the resolved scope with that scope's own token." ), + NoExplicitNull, ] = None - pagination: pagination_response.PaginationResponse | None = None + pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None sandbox: Annotated[ StrictBool | None, Field(description='When true, this response contains simulated data from sandbox mode.'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_capabilities.py b/src/adcp/types/domains/sponsored_intelligence/si_capabilities.py index bf86e4c49..43b704b4c 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_capabilities.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_capabilities.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_capabilities.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StrictBool @@ -16,9 +16,11 @@ class Voice(AdCPBaseModel): extra='allow', ) provider: Annotated[ - str | None, Field(description='TTS provider (elevenlabs, openai, etc.)') + str | None, Field(description='TTS provider (elevenlabs, openai, etc.)'), NoExplicitNull ] = None - voice_id: Annotated[str | None, Field(description='Brand voice identifier')] = None + voice_id: Annotated[str | None, Field(description='Brand voice identifier'), NoExplicitNull] = ( + None + ) class Video(AdCPBaseModel): @@ -26,10 +28,12 @@ class Video(AdCPBaseModel): extra='allow', ) formats: Annotated[ - list[str] | None, Field(description='Supported video formats (mp4, webm, etc.)') + list[str] | None, + Field(description='Supported video formats (mp4, webm, etc.)'), + NoExplicitNull, ] = None max_duration_seconds: Annotated[ - SchemaInt | None, Field(description='Maximum video duration') + SchemaInt | None, Field(description='Maximum video duration'), NoExplicitNull ] = None @@ -38,9 +42,13 @@ class Avatar(AdCPBaseModel): extra='allow', ) provider: Annotated[ - str | None, Field(description='Avatar provider (d-id, heygen, synthesia, etc.)') + str | None, + Field(description='Avatar provider (d-id, heygen, synthesia, etc.)'), + NoExplicitNull, + ] = None + avatar_id: Annotated[ + str | None, Field(description='Brand avatar identifier'), NoExplicitNull ] = None - avatar_id: Annotated[str | None, Field(description='Brand avatar identifier')] = None class Modalities(AdCPBaseModel): @@ -48,16 +56,22 @@ class Modalities(AdCPBaseModel): extra='allow', ) conversational: Annotated[ - StrictBool | None, Field(description='Pure text exchange - the baseline modality') + StrictBool | None, + Field(description='Pure text exchange - the baseline modality'), + NoExplicitNull, ] = True voice: Annotated[ - StrictBool | Voice | None, Field(description='Audio-based interaction using brand voice') + StrictBool | Voice | None, + Field(description='Audio-based interaction using brand voice'), + NoExplicitNull, ] = None video: Annotated[ - StrictBool | Video | None, Field(description='Brand video content playback') + StrictBool | Video | None, Field(description='Brand video content playback'), NoExplicitNull ] = None avatar: Annotated[ - StrictBool | Avatar | None, Field(description='Animated video presence with brand avatar') + StrictBool | Avatar | None, + Field(description='Animated video presence with brand avatar'), + NoExplicitNull, ] = None @@ -77,10 +91,12 @@ class Components(AdCPBaseModel): standard: Annotated[ list[StandardEnum] | None, Field(description='Standard components that all SI hosts must render'), + NoExplicitNull, ] = None extensions: Annotated[ dict[str, Any] | None, Field(description='Platform-specific extensions (chatgpt_apps_sdk, maps, forms, etc.)'), + NoExplicitNull, ] = None @@ -91,6 +107,7 @@ class Commerce(AdCPBaseModel): acp_checkout: Annotated[ StrictBool | None, Field(description='Supports ACP (Agentic Commerce Protocol) checkout handoff'), + NoExplicitNull, ] = None @@ -99,11 +116,12 @@ class A2ui(AdCPBaseModel): extra='allow', ) supported: Annotated[ - StrictBool | None, Field(description='Supports A2UI surface rendering') + StrictBool | None, Field(description='Supports A2UI surface rendering'), NoExplicitNull ] = False catalogs: Annotated[ list[str] | None, Field(description="Supported A2UI component catalogs (e.g., 'si-standard', 'standard')"), + NoExplicitNull, ] = None @@ -112,14 +130,19 @@ class SiCapabilities(AdCPBaseModel): extra='allow', ) modalities: Annotated[ - Modalities | None, Field(description='Interaction modalities supported') + Modalities | None, Field(description='Interaction modalities supported'), NoExplicitNull + ] = None + components: Annotated[ + Components | None, Field(description='Visual components supported'), NoExplicitNull + ] = None + commerce: Annotated[ + Commerce | None, Field(description='Commerce capabilities'), NoExplicitNull + ] = None + a2ui: Annotated[ + A2ui | None, Field(description='A2UI (Agent-to-UI) capabilities'), NoExplicitNull ] = None - components: Annotated[Components | None, Field(description='Visual components supported')] = ( - None - ) - commerce: Annotated[Commerce | None, Field(description='Commerce capabilities')] = None - a2ui: Annotated[A2ui | None, Field(description='A2UI (Agent-to-UI) capabilities')] = None mcp_apps: Annotated[ StrictBool | None, Field(description='Supports MCP Apps for rendering A2UI surfaces in iframes'), + NoExplicitNull, ] = False diff --git a/src/adcp/types/domains/sponsored_intelligence/si_get_offering_request.py b/src/adcp/types/domains/sponsored_intelligence/si_get_offering_request.py index d7728d5fc..34faf3140 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_get_offering_request.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_get_offering_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_get_offering_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -27,13 +28,17 @@ class SiGetOfferingRequest(AdcpRequest, AdcpVersionEnvelope): Field( description="Optional natural language description of user intent for personalized results (e.g., 'mens size 14 near Cincinnati'). Must be anonymous - no PII." ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None include_products: Annotated[ - StrictBool | None, Field(description='Whether to include matching products in the response') + StrictBool | None, + Field(description='Whether to include matching products in the response'), + NoExplicitNull, ] = False product_limit: Annotated[ SchemaInt | None, Field(description='Maximum number of matching products to return', ge=1, le=50), + NoExplicitNull, ] = 5 - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_get_offering_response.py b/src/adcp/types/domains/sponsored_intelligence/si_get_offering_response.py index 991dbce26..e09183a07 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_get_offering_response.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_get_offering_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_get_offering_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -24,24 +24,37 @@ class Offering(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - offering_id: Annotated[str | None, Field(description='Offering identifier')] = None - title: Annotated[str | None, Field(description='Offering title')] = None - summary: Annotated[str | None, Field(description='Brief summary of the offering')] = None - tagline: Annotated[str | None, Field(description='Short promotional tagline')] = None - expires_at: Annotated[AwareDatetime | None, Field(description='When this offering expires')] = ( + offering_id: Annotated[str | None, Field(description='Offering identifier'), NoExplicitNull] = ( None ) + title: Annotated[str | None, Field(description='Offering title'), NoExplicitNull] = None + summary: Annotated[ + str | None, Field(description='Brief summary of the offering'), NoExplicitNull + ] = None + tagline: Annotated[ + str | None, Field(description='Short promotional tagline'), NoExplicitNull + ] = None + expires_at: Annotated[ + AwareDatetime | None, Field(description='When this offering expires'), NoExplicitNull + ] = None availability_status: Annotated[ offering_availability_status.OfferingAvailabilityStatus | None, Field( description="Machine-readable availability state for the offering. Optional; when omitted, derive availability from the top-level 'available' boolean and 'unavailable_reason'." ), + NoExplicitNull, ] = None price_hint: Annotated[ - str | None, Field(description="Price indication (e.g., 'from $199', '50% off')") + str | None, + Field(description="Price indication (e.g., 'from $199', '50% off')"), + NoExplicitNull, ] = None - image_url: Annotated[AnyUrl | None, Field(description='Hero image for the offering')] = None - landing_url: Annotated[AnyUrl | None, Field(description='Landing page URL')] = None + image_url: Annotated[ + AnyUrl | None, Field(description='Hero image for the offering'), NoExplicitNull + ] = None + landing_url: Annotated[AnyUrl | None, Field(description='Landing page URL'), NoExplicitNull] = ( + None + ) class MatchingProduct(AdCPBaseModel): @@ -50,22 +63,30 @@ class MatchingProduct(AdCPBaseModel): ) product_id: Annotated[str, Field(description='Product identifier')] name: Annotated[str, Field(description='Product name')] - price: Annotated[str | None, Field(description="Display price (e.g., '$129', '$89.99')")] = None - original_price: Annotated[str | None, Field(description='Original price if on sale')] = None - image_url: Annotated[AnyUrl | None, Field(description='Product image')] = None + price: Annotated[ + str | None, Field(description="Display price (e.g., '$129', '$89.99')"), NoExplicitNull + ] = None + original_price: Annotated[ + str | None, Field(description='Original price if on sale'), NoExplicitNull + ] = None + image_url: Annotated[AnyUrl | None, Field(description='Product image'), NoExplicitNull] = None availability_summary: Annotated[ str | None, Field( description="Brief availability info (e.g., 'In stock', 'Size 14 available', '3 left')" ), + NoExplicitNull, ] = None availability_status: Annotated[ offering_availability_status.OfferingAvailabilityStatus | None, Field( description='Machine-readable availability state for this product. Structured counterpart to availability_summary.' ), + NoExplicitNull, ] = None - url: Annotated[AnyUrl | None, Field(description='Product detail page URL')] = None + url: Annotated[AnyUrl | None, Field(description='Product detail page URL'), NoExplicitNull] = ( + None + ) class SiGetOfferingResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): @@ -80,6 +101,7 @@ class SiGetOfferingResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) Field( description="Token to pass to si_initiate_session for session continuity. Brand stores the full query context server-side (products shown, order, context) so they can resolve references like 'the second one' when the session starts." ), + NoExplicitNull, ] = None ttl_seconds: Annotated[ SchemaInt | None, @@ -87,22 +109,29 @@ class SiGetOfferingResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) description='How long this offering information is valid (seconds). Host should re-fetch after TTL expires.', ge=0, ), + NoExplicitNull, ] = None checked_at: Annotated[ - AwareDatetime | None, Field(description='When this offering information was retrieved') + AwareDatetime | None, + Field(description='When this offering information was retrieved'), + NoExplicitNull, ] = None - offering: Annotated[Offering | None, Field(description='Offering details')] = None + offering: Annotated[Offering | None, Field(description='Offering details'), NoExplicitNull] = ( + None + ) matching_products: Annotated[ list[MatchingProduct] | None, Field( description='Products matching the request context. Only included if include_products was true.' ), + NoExplicitNull, ] = None sponsored_context: Annotated[ si_sponsored_context.SiSponsoredContext | None, Field( description='Declaration for the sponsored context carried by this offering response. When present, it applies to the returned offering and matching_products package as a whole unless a future extension narrows the declaration to individual items. Hosts MUST either honor the declared context_use and disclosure_obligation or reject the context before using it.' ), + NoExplicitNull, ] = None total_matching: Annotated[ SchemaInt | None, @@ -110,19 +139,22 @@ class SiGetOfferingResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) description='Total number of products matching the context (may be more than returned in matching_products)', ge=0, ), + NoExplicitNull, ] = None unavailable_reason: Annotated[ str | None, Field( description="If not available, why (e.g., 'expired', 'sold_out', 'region_restricted')" ), + NoExplicitNull, ] = None alternative_offering_ids: Annotated[ list[str] | None, Field(description='Alternative offerings to consider if this one is unavailable'), + NoExplicitNull, ] = None errors: Annotated[ - list[error.Error] | None, Field(description='Errors during offering lookup') + list[error.Error] | None, Field(description='Errors during offering lookup'), NoExplicitNull ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_identity.py b/src/adcp/types/domains/sponsored_intelligence/si_identity.py index 0dc750eac..a9ca5f49d 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_identity.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_identity.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_identity.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool @@ -24,10 +24,10 @@ class PrivacyPolicyAcknowledged(AdCPBaseModel): extra='allow', ) brand_policy_url: Annotated[ - AnyUrl | None, Field(description="URL to brand's privacy policy") + AnyUrl | None, Field(description="URL to brand's privacy policy"), NoExplicitNull ] = None brand_policy_version: Annotated[ - str | None, Field(description='Version of policy acknowledged') + str | None, Field(description='Version of policy acknowledged'), NoExplicitNull ] = None @@ -35,23 +35,29 @@ class ShippingAddress(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - street: str | None = None - city: str | None = None - state: str | None = None - postal_code: str | None = None - country: str | None = None + street: Annotated[str | None, NoExplicitNull] = None + city: Annotated[str | None, NoExplicitNull] = None + state: Annotated[str | None, NoExplicitNull] = None + postal_code: Annotated[str | None, NoExplicitNull] = None + country: Annotated[str | None, NoExplicitNull] = None class User(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - email: Annotated[EmailStr | None, Field(description="User's email address")] = None - name: Annotated[str | None, Field(description="User's display name")] = None - locale: Annotated[str | None, Field(description="User's locale (e.g., en-US)")] = None - phone: Annotated[str | None, Field(description="User's phone number")] = None + email: Annotated[EmailStr | None, Field(description="User's email address"), NoExplicitNull] = ( + None + ) + name: Annotated[str | None, Field(description="User's display name"), NoExplicitNull] = None + locale: Annotated[ + str | None, Field(description="User's locale (e.g., en-US)"), NoExplicitNull + ] = None + phone: Annotated[str | None, Field(description="User's phone number"), NoExplicitNull] = None shipping_address: Annotated[ - ShippingAddress | None, Field(description="User's shipping address for accurate pricing") + ShippingAddress | None, + Field(description="User's shipping address for accurate pricing"), + NoExplicitNull, ] = None @@ -63,18 +69,27 @@ class SiIdentity(AdCPBaseModel): StrictBool, Field(description='Whether user consented to share identity') ] consent_timestamp: Annotated[ - AwareDatetime | None, Field(description='When consent was granted (ISO 8601)') + AwareDatetime | None, + Field(description='When consent was granted (ISO 8601)'), + NoExplicitNull, ] = None consent_scope: Annotated[ - list[ConsentScopeEnum] | None, Field(description='What data was consented to share') + list[ConsentScopeEnum] | None, + Field(description='What data was consented to share'), + NoExplicitNull, ] = None privacy_policy_acknowledged: Annotated[ - PrivacyPolicyAcknowledged | None, Field(description='Brand privacy policy acknowledgment') + PrivacyPolicyAcknowledged | None, + Field(description='Brand privacy policy acknowledgment'), + NoExplicitNull, ] = None user: Annotated[ - User | None, Field(description='User data (only present if consent_granted is true)') + User | None, + Field(description='User data (only present if consent_granted is true)'), + NoExplicitNull, ] = None anonymous_session_id: Annotated[ str | None, Field(description='Session ID for anonymous users (when consent_granted is false)'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_initiate_session_request.py b/src/adcp/types/domains/sponsored_intelligence/si_initiate_session_request.py index 4a42ae51f..adb9dbbbe 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_initiate_session_request.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_initiate_session_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_initiate_session_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,6 +8,7 @@ from typing import Annotated +from adcp.types.base import NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -26,35 +27,41 @@ class SiInitiateSessionRequest(AdcpRequest, AdcpVersionEnvelope): description='Natural language description of user intent — the conversation handoff from the host describing what the user needs from the brand agent' ), ] - context: context_1.ContextObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None identity: si_identity.SiIdentity media_buy_id: Annotated[ - str | None, Field(description='AdCP media buy ID if session was triggered by advertising') + str | None, + Field(description='AdCP media buy ID if session was triggered by advertising'), + NoExplicitNull, ] = None placement: Annotated[ str | None, Field( description="Where this session was triggered (e.g., 'chatgpt_search', 'claude_chat')" ), + NoExplicitNull, ] = None offering_id: Annotated[ - str | None, Field(description='Brand-specific offering identifier to apply') + str | None, Field(description='Brand-specific offering identifier to apply'), NoExplicitNull ] = None supported_capabilities: Annotated[ si_capabilities.SiCapabilities | None, Field(description='What capabilities the host supports'), + NoExplicitNull, ] = None offering_token: Annotated[ str | None, Field( description="Token from si_get_offering response for session continuity. Brand uses this to recall what products were shown to the user, enabling natural references like 'the second one' or 'that blue shoe'." ), + NoExplicitNull, ] = None sponsored_context_receipt: Annotated[ si_sponsored_context_receipt.SiSponsoredContextReceipt | None, Field( description='Host receipt for sponsored context accepted from a prior si_get_offering response or other pre-session context package. This records the accepted context_use, disclosure commitment, paying_principal, and host receipt for audit.' ), + NoExplicitNull, ] = None idempotency_key: Annotated[ str, @@ -65,4 +72,4 @@ class SiInitiateSessionRequest(AdcpRequest, AdcpVersionEnvelope): pattern='^[A-Za-z0-9_.:-]{16,255}$', ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_initiate_session_response.py b/src/adcp/types/domains/sponsored_intelligence/si_initiate_session_response.py index aca8ffc4b..2663817a1 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_initiate_session_response.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_initiate_session_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_initiate_session_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -24,11 +24,13 @@ class Response(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - message: Annotated[str | None, Field(description='Conversational message from brand agent')] = ( - None - ) + message: Annotated[ + str | None, Field(description='Conversational message from brand agent'), NoExplicitNull + ] = None ui_elements: Annotated[ - list[si_ui_element.SiUiElement] | None, Field(description='Visual components to render') + list[si_ui_element.SiUiElement] | None, + Field(description='Visual components to render'), + NoExplicitNull, ] = None @@ -39,16 +41,20 @@ class SiInitiateSessionResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvel session_id: Annotated[ str, Field(description='Unique session identifier for subsequent messages') ] - response: Annotated[Response | None, Field(description="Brand agent's initial response")] = None + response: Annotated[ + Response | None, Field(description="Brand agent's initial response"), NoExplicitNull + ] = None negotiated_capabilities: Annotated[ si_capabilities.SiCapabilities | None, Field(description='Intersection of brand and host capabilities for this session'), + NoExplicitNull, ] = None sponsored_context: Annotated[ si_sponsored_context.SiSponsoredContext | None, Field( description='Declaration for sponsored context carried by the initial brand-agent response. Hosts MUST either honor the declared context_use and disclosure_obligation or reject the context before presenting, comparing, or otherwise using it.' ), + NoExplicitNull, ] = None session_status: Annotated[ si_session_status.SiSessionStatus, @@ -62,9 +68,12 @@ class SiInitiateSessionResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvel description='Session inactivity timeout in seconds. After this duration without a message, the brand agent may terminate the session. Hosts SHOULD warn users before timeout when possible.', ge=1, ), + NoExplicitNull, ] = None errors: Annotated[ - list[error.Error] | None, Field(description='Errors during session initiation') + list[error.Error] | None, + Field(description='Errors during session initiation'), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_send_message_request.py b/src/adcp/types/domains/sponsored_intelligence/si_send_message_request.py index 2dc9b8eba..947c3302a 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_send_message_request.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_send_message_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_send_message_request.json -# timestamp: 2026-10-07T15:06:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, model_validator from ..core import context as context_1 @@ -24,9 +24,11 @@ class ActionResponse(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - action: Annotated[str | None, Field(description='The action that was triggered')] = None + action: Annotated[ + str | None, Field(description='The action that was triggered'), NoExplicitNull + ] = None payload: Annotated[ - dict[str, Any] | None, Field(description='Action-specific response data') + dict[str, Any] | None, Field(description='Action-specific response data'), NoExplicitNull ] = None @@ -44,19 +46,23 @@ class SiSendMessageRequest(AdcpRequest, AdcpVersionEnvelope): ), ] session_id: Annotated[str, Field(description='Active session identifier')] - message: Annotated[str | None, Field(description="User's message to the brand agent")] = None + message: Annotated[ + str | None, Field(description="User's message to the brand agent"), NoExplicitNull + ] = None action_response: Annotated[ ActionResponse | None, Field(description='Response to a previous action_button (e.g., user clicked checkout)'), + NoExplicitNull, ] = None sponsored_context_receipt: Annotated[ si_sponsored_context_receipt.SiSponsoredContextReceipt | None, Field( description="Host receipt for sponsored context accepted from a prior SI response in this session. This gives the brand/seller an audit-visible record of the host's accepted use mode and disclosure commitment for that context." ), + NoExplicitNull, ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @model_validator(mode='after') def _require_schema_required_group(self) -> SiSendMessageRequest: diff --git a/src/adcp/types/domains/sponsored_intelligence/si_send_message_response.py b/src/adcp/types/domains/sponsored_intelligence/si_send_message_response.py index 809393763..8da333d59 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_send_message_response.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_send_message_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_send_message_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat from ..a2ui import surface as surface_1 @@ -28,21 +28,21 @@ class Type(StrEnum): class Price(AdCPBaseModel): - amount: StrictFloat | None = None - currency: str | None = None + amount: Annotated[StrictFloat | None, NoExplicitNull] = None + currency: Annotated[str | None, NoExplicitNull] = None class Intent(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - action: Annotated[str | None, Field(description="The commerce action (e.g., 'purchase')")] = ( - None - ) - product: Annotated[dict[str, Any] | None, Field(description='Product details for checkout')] = ( - None - ) - price: Annotated[Price | None, Field(description='Price information')] = None + action: Annotated[ + str | None, Field(description="The commerce action (e.g., 'purchase')"), NoExplicitNull + ] = None + product: Annotated[ + dict[str, Any] | None, Field(description='Product details for checkout'), NoExplicitNull + ] = None + price: Annotated[Price | None, Field(description='Price information'), NoExplicitNull] = None class ContextForCheckout(AdCPBaseModel): @@ -50,10 +50,14 @@ class ContextForCheckout(AdCPBaseModel): extra='allow', ) conversation_summary: Annotated[ - str | None, Field(description='Summary of the conversation leading to purchase') + str | None, + Field(description='Summary of the conversation leading to purchase'), + NoExplicitNull, ] = None applied_offers: Annotated[ - list[str] | None, Field(description='Offer IDs that were applied during the conversation') + list[str] | None, + Field(description='Offer IDs that were applied during the conversation'), + NoExplicitNull, ] = None @@ -66,13 +70,17 @@ class Handoff(AdCPBaseModel): Field( description='Type of handoff: transaction (ready for ACP checkout) or complete (conversation done)' ), + NoExplicitNull, ] = None intent: Annotated[ Intent | None, Field(description='For transaction handoffs: what the user wants to purchase'), + NoExplicitNull, ] = None context_for_checkout: Annotated[ - ContextForCheckout | None, Field(description='Context to pass to ACP for seamless checkout') + ContextForCheckout | None, + Field(description='Context to pass to ACP for seamless checkout'), + NoExplicitNull, ] = None @@ -80,11 +88,13 @@ class Response(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - message: Annotated[str | None, Field(description='Conversational message from brand agent')] = ( - None - ) + message: Annotated[ + str | None, Field(description='Conversational message from brand agent'), NoExplicitNull + ] = None surface: Annotated[ - surface_1.A2UiSurface | None, Field(description='A2UI surface with interactive components') + surface_1.A2UiSurface | None, + Field(description='A2UI surface with interactive components'), + NoExplicitNull, ] = None ui_elements: Annotated[ list[si_ui_element.SiUiElement] | None, @@ -92,6 +102,7 @@ class Response(AdCPBaseModel): deprecated=True, description='Visual components to render (DEPRECATED: use surface instead)', ), + NoExplicitNull, ] = None @@ -100,18 +111,22 @@ class SiSendMessageResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) extra='allow', ) session_id: Annotated[str, Field(description='Session identifier')] - response: Annotated[Response | None, Field(description="Brand agent's response")] = None + response: Annotated[ + Response | None, Field(description="Brand agent's response"), NoExplicitNull + ] = None mcp_resource_uri: Annotated[ str | None, Field( description='MCP resource URI for hosts with MCP Apps support (e.g., ui://si/session-abc123)' ), + NoExplicitNull, ] = None sponsored_context: Annotated[ si_sponsored_context.SiSponsoredContext | None, Field( description='Declaration for sponsored context carried by this brand-agent response. Hosts MUST either honor the declared context_use and disclosure_obligation or reject the context before presenting, comparing, or otherwise using it.' ), + NoExplicitNull, ] = None session_status: Annotated[ si_session_status.SiSessionStatus, @@ -120,8 +135,10 @@ class SiSendMessageResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) ), ] handoff: Annotated[ - Handoff | None, Field(description='Handoff request when session_status is pending_handoff') + Handoff | None, + Field(description='Handoff request when session_status is pending_handoff'), + NoExplicitNull, ] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context.py b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context.py index 7264f9df4..724a90e8e 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_sponsored_context.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool from ..core import brand_ref @@ -41,7 +41,9 @@ class Jurisdiction(AdCPBaseModel): extra='allow', ) country: Annotated[str, Field(description='ISO 3166-1 alpha-2 country code.')] - region: Annotated[str | None, Field(description='Optional sub-national region code.')] = None + region: Annotated[ + str | None, Field(description='Optional sub-national region code.'), NoExplicitNull + ] = None regulation: Annotated[str, Field(description='Regulation or policy identifier.')] @@ -60,16 +62,19 @@ class DisclosureObligation(AdCPBaseModel): Field( description='Disclosure label text the host should render when disclosure is required.' ), + NoExplicitNull, ] = None timing: Annotated[ Timing | None, Field( description="When the disclosure must be presented relative to the sponsored context's influence." ), + NoExplicitNull, ] = None proximity: Annotated[ Proximity | None, Field(description='Where the disclosure should appear relative to the affected output.'), + NoExplicitNull, ] = None jurisdictions: Annotated[ list[Jurisdiction] | None, @@ -77,6 +82,7 @@ class DisclosureObligation(AdCPBaseModel): description='Jurisdictions where this declared disclosure obligation applies.', min_length=1, ), + NoExplicitNull, ] = None @@ -92,7 +98,9 @@ class DeclaredBy(AdCPBaseModel): extra='allow', ) agent_url: Annotated[ - AnyUrl | None, Field(description='HTTPS URL of the declaring agent or service.') + AnyUrl | None, + Field(description='HTTPS URL of the declaring agent or service.'), + NoExplicitNull, ] = None role: Annotated[Role, Field(description='Role of the declaring party.')] @@ -110,6 +118,7 @@ class PayingPrincipal(AdCPBaseModel): Field( description='Optional seller-assigned account context for the paying principal. This intentionally carries only an account_id so the canonical economic principal remains paying_principal.brand.' ), + NoExplicitNull, ] = None operator: Annotated[ str | None, @@ -117,12 +126,14 @@ class PayingPrincipal(AdCPBaseModel): description='Domain of the operator acting for the paying principal, when different from the brand domain.', pattern='^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$', ), + NoExplicitNull, ] = None display_name: Annotated[ str | None, Field( description='Human-readable label for disclosure rendering. The canonical identity remains the brand/account reference.' ), + NoExplicitNull, ] = None @@ -147,9 +158,13 @@ class SiSponsoredContext(AdCPBaseModel): ), ] declared_at: Annotated[ - AwareDatetime | None, Field(description='When this sponsored-context declaration was made.') + AwareDatetime | None, + Field(description='When this sponsored-context declaration was made.'), + NoExplicitNull, ] = None declared_by: Annotated[ - DeclaredBy | None, Field(description='Agent or service that attached the declaration.') + DeclaredBy | None, + Field(description='Agent or service that attached the declaration.'), + NoExplicitNull, ] = None - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py index 4246a0e5f..10b6bc467 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_sponsored_context_receipt.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AwareDatetime, ConfigDict, Field from ..core import ext as ext_1 @@ -39,9 +39,12 @@ class DisclosureCommitment(AdCPBaseModel): Field( description='Disclosure label text the host committed to render, if different from or copied from the declaration.' ), + NoExplicitNull, ] = None notes: Annotated[ - str | None, Field(description='Optional host explanation for the disclosure commitment.') + str | None, + Field(description='Optional host explanation for the disclosure commitment.'), + NoExplicitNull, ] = None @@ -58,6 +61,7 @@ class HostReceipt(AdCPBaseModel): Field( description="Use mode the host accepted. When host_receipt.status is accepted, this MUST match the declaration's context_use; a host that cannot honor the declared use mode rejects the sponsored context instead of down-scoping it." ), + NoExplicitNull, ] = None received_at: Annotated[ AwareDatetime, Field(description='When the host received the sponsored context.') @@ -67,18 +71,21 @@ class HostReceipt(AdCPBaseModel): Field( description='Host-defined surface or placement where the context was accepted, such as an assistant session, search result page, or comparison module.' ), + NoExplicitNull, ] = None disclosure_commitment: Annotated[ DisclosureCommitment | None, Field( description='How the host committed to handle the declared disclosure obligation. Required when host_receipt.status is accepted.' ), + NoExplicitNull, ] = None rejection_reason: Annotated[ str | None, Field( description='Optional explanation when status is rejected, for example unsupported context_use or inability to satisfy the disclosure obligation.' ), + NoExplicitNull, ] = None @@ -98,4 +105,4 @@ class SiSponsoredContextReceipt(AdCPBaseModel): description='Receiving-surface accountability fact: the use mode the host accepted and committed to honor for this sponsored context.' ), ] - ext: ext_1.ExtensionObject | None = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_request.py b/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_request.py index 42e4630d1..20b5b5c6a 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_request.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_terminate_session_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import context as context_1 @@ -34,21 +34,28 @@ class TransactionIntent(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - action: Action | None = None - product: Annotated[dict[str, Any] | None, Field(description='Product/service details')] = None + action: Annotated[Action | None, NoExplicitNull] = None + product: Annotated[ + dict[str, Any] | None, Field(description='Product/service details'), NoExplicitNull + ] = None class TerminationContext(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - summary: Annotated[str | None, Field(description='Summary of the conversation')] = None + summary: Annotated[ + str | None, Field(description='Summary of the conversation'), NoExplicitNull + ] = None transaction_intent: Annotated[ TransactionIntent | None, Field(description='For handoff_transaction - what user wants to buy'), + NoExplicitNull, ] = None cause: Annotated[ - str | None, Field(description='For host_terminated - why host ended session') + str | None, + Field(description='For host_terminated - why host ended session'), + NoExplicitNull, ] = None @@ -59,7 +66,7 @@ class SiTerminateSessionRequest(AdcpRequest, AdcpVersionEnvelope): session_id: Annotated[str, Field(description='Session identifier to terminate')] reason: Annotated[Reason, Field(description='Reason for termination')] termination_context: Annotated[ - TerminationContext | None, Field(description='Context for the termination') + TerminationContext | None, Field(description='Context for the termination'), NoExplicitNull ] = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_response.py b/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_response.py index 5c79336e8..e05c15c20 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_response.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_terminate_session_response.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -9,7 +9,7 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool from ..core import context as context_1 @@ -29,24 +29,28 @@ class AcpHandoff(AdCPBaseModel): Field( description="Brand's ACP checkout endpoint. Hosts MUST validate this is HTTPS before opening." ), + NoExplicitNull, ] = None checkout_token: Annotated[ str | None, Field( description='Opaque token for the checkout flow. The host passes this to the checkout endpoint to correlate the SI session with the transaction.' ), + NoExplicitNull, ] = None payload: Annotated[ dict[str, Any] | None, Field( description='Rich checkout context to pass to the ACP endpoint (product details, applied offers, pricing). Alternative to checkout_token for integrations that need structured data.' ), + NoExplicitNull, ] = None expires_at: Annotated[ AwareDatetime | None, Field( description='When this handoff data expires. Hosts should initiate checkout before this time.' ), + NoExplicitNull, ] = None @@ -61,8 +65,10 @@ class FollowUp(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - action: Action | None = None - data: Annotated[dict[str, Any] | None, Field(description='Data for follow-up action')] = None + action: Annotated[Action | None, NoExplicitNull] = None + data: Annotated[ + dict[str, Any] | None, Field(description='Data for follow-up action'), NoExplicitNull + ] = None class SiTerminateSessionResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope): @@ -78,12 +84,16 @@ class SiTerminateSessionResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve Field( description="Resulting session state. 'complete' for handoff_transaction/handoff_complete, 'terminated' for user_exit/session_timeout/host_terminated." ), + NoExplicitNull, ] = None acp_handoff: Annotated[ AcpHandoff | None, Field(description='ACP checkout handoff data. Present when reason is handoff_transaction.'), + NoExplicitNull, + ] = None + follow_up: Annotated[ + FollowUp | None, Field(description='Suggested follow-up actions'), NoExplicitNull ] = None - follow_up: Annotated[FollowUp | None, Field(description='Suggested follow-up actions')] = None - errors: list[error.Error] | None = None - context: context_1.ContextObject | None = None - ext: ext_1.ExtensionObject | None = None + errors: Annotated[list[error.Error] | None, NoExplicitNull] = None + context: Annotated[context_1.ContextObject | None, NoExplicitNull] = None + ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_ui_element.py b/src/adcp/types/domains/sponsored_intelligence/si_ui_element.py index 92556de6d..e9efbdadd 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_ui_element.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_ui_element.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_ui_element.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -27,4 +27,6 @@ class SiUiElement(AdCPBaseModel): extra='allow', ) type: Annotated[Type, Field(description='Component type')] - data: Annotated[dict[str, Any] | None, Field(description='Component-specific data')] = None + data: Annotated[ + dict[str, Any] | None, Field(description='Component-specific data'), NoExplicitNull + ] = None diff --git a/src/adcp/types/domains/trusted_match/available_package.py b/src/adcp/types/domains/trusted_match/available_package.py index a7c3c7ab9..e14d46eba 100644 --- a/src/adcp/types/domains/trusted_match/available_package.py +++ b/src/adcp/types/domains/trusted_match/available_package.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: trusted_match/available_package.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import catalog, format_id, package_format_snapshot, seller_agent_ref @@ -30,6 +30,7 @@ class AvailablePackage(AdCPBaseModel): deprecated=True, description='Deprecated in AdCP 3.2; removed in AdCP 4.0. Legacy named-format identifiers eligible for this package. Use canonical `format_options`.', ), + NoExplicitNull, ] = None format_options: Annotated[ list[package_format_snapshot.PackageFormatSnapshot] | None, @@ -37,10 +38,12 @@ class AvailablePackage(AdCPBaseModel): description='Immutable seller-resolved PackageFormatSnapshots eligible for this package. A sender projects this field only when the Trusted Match transport profile supports the highest AdCP release version of every field in every snapshot. It MUST NOT strip an unsupported selector or digest field from a snapshot; for an older or version-unknown provider, omit the entire contract-bearing snapshot and do not claim production-path evidence from a legacy projection.', min_length=1, ), + NoExplicitNull, ] = None catalogs: Annotated[ list[catalog.Catalog] | None, Field( description="The buyer's catalogs attached to this package, with selectors (ids, gtins, tags, category, query) scoping which items are in play. References synced catalogs by catalog_id. The provider resolves items from its cached copy." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/trusted_match/context_match_request.py b/src/adcp/types/domains/trusted_match/context_match_request.py index 64205bfca..adadb8079 100644 --- a/src/adcp/types/domains/trusted_match/context_match_request.py +++ b/src/adcp/types/domains/trusted_match/context_match_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: trusted_match/context_match_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from uuid import UUID -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, ConfigDict, Field @@ -71,21 +71,24 @@ class ContextSignals(AdCPBaseModel): description="Content topic identifiers. Use IAB Content Taxonomy 3.0 IDs (e.g., '632' for Food & Drink) when taxonomy_id is 7, or bounded human-readable category labels (e.g., 'cooking.pasta') for custom taxonomies. For non-public content attributable to a single user or session, publishers MUST use standardized taxonomy identifiers or bounded custom category labels; custom topic strings MUST NOT reproduce distinctive verbatim phrasing and MUST NOT include PII or uniquely identifying details.", max_length=50, ), + NoExplicitNull, ] = None taxonomy_source: Annotated[ str | None, Field( description="Organization that defines the topic taxonomy. Use 'iab' for IAB Content Taxonomy. Publishers may use other values for custom taxonomies." ), + NoExplicitNull, ] = 'iab' taxonomy_id: Annotated[ SchemaInt | None, Field( description='Taxonomy version within the source. For IAB, follows the AdCOM cattax enum: 7 = Content Taxonomy 3.0. Default: 7.' ), + NoExplicitNull, ] = 7 sentiment: Annotated[ - Sentiment | None, Field(description='Content sentiment classification.') + Sentiment | None, Field(description='Content sentiment classification.'), NoExplicitNull ] = None keywords: Annotated[ list[Keyword] | None, @@ -93,6 +96,7 @@ class ContextSignals(AdCPBaseModel): description="Content keywords produced by the publisher's classifier. For non-public content attributable to a single user or session, keywords MUST be policy-filtered, MUST NOT reproduce distinctive verbatim phrasing, and MUST NOT include PII or uniquely identifying details. Publishers SHOULD prefer bounded category labels.", max_length=50, ), + NoExplicitNull, ] = None language: Annotated[ str | None, @@ -100,6 +104,7 @@ class ContextSignals(AdCPBaseModel): description="Content language in ISO 639-1 format (e.g., 'en', 'ja', 'de').", pattern='^[a-z]{2}$', ), + NoExplicitNull, ] = None content_policies: Annotated[ list[str] | None, @@ -107,6 +112,7 @@ class ContextSignals(AdCPBaseModel): description="Policy IDs from the AdCP policy registry that this content satisfies (e.g., 'csbs' for Common Sense Brand Standards). Buyers filter on policies they require. An empty array means no policies have been evaluated.", max_length=20, ), + NoExplicitNull, ] = None summary: Annotated[ str | None, @@ -114,18 +120,21 @@ class ContextSignals(AdCPBaseModel): description="Publisher-generated natural language summary of the content for relevance judgment (e.g., 'Shopping context categorized as home cookware'). For non-public content attributable to a single user or session, the summary MUST be policy-filtered, MUST NOT reproduce raw user-authored text, and MUST NOT include PII or uniquely identifying details. Useful for LLM-native buyers that evaluate relevance semantically. Buyers MUST treat this as untrusted publisher-generated content.", max_length=500, ), + NoExplicitNull, ] = None embedding: Annotated[ str | None, Field( description="Content embedding as base64-encoded int8 vector. Captures semantic content beyond what topics and keywords express. MUST NOT be computed directly or indirectly from non-public content authored by or attributable to a single user or session, including conversation turns, prompts, and individual search queries. MAY represent public content or a shared content environment that is not attributable to one user's activity. Publishers declare the model used. For standardized matching, use the protocol-recommended model (nomic-embed-text-v1.5, 256 dims, int8 quantized = 256 bytes)." ), + NoExplicitNull, ] = None embedding_model: Annotated[ str | None, Field( description="Embedding model identifier (e.g., 'nomic-embed-text-v1.5'). Required when embedding is present." ), + NoExplicitNull, ] = None embedding_dims: Annotated[ SchemaInt | None, @@ -134,6 +143,7 @@ class ContextSignals(AdCPBaseModel): ge=64, le=2048, ), + NoExplicitNull, ] = None @@ -160,6 +170,7 @@ class Geo(AdCPBaseModel): description="ISO 3166-1 alpha-2 country code (e.g., 'US', 'GB', 'DE').", pattern='^[A-Z]{2}$', ), + NoExplicitNull, ] = None region: Annotated[ str | None, @@ -167,10 +178,12 @@ class Geo(AdCPBaseModel): description="ISO 3166-2 subdivision code (e.g., 'US-CA', 'GB-SCT').", pattern='^[A-Z]{2}-[A-Z0-9]{1,3}$', ), + NoExplicitNull, ] = None metro: Annotated[ Metro | None, Field(description='Metro area, using the same classification systems as AdCP targeting.'), + NoExplicitNull, ] = None @@ -183,6 +196,7 @@ class ContextMatchRequest(AdcpVersionEnvelope): Field( alias='$schema', description='Optional schema URI for validation. Ignored at runtime.' ), + NoExplicitNull, ] = None type: Annotated[ Literal['context_match_request'], @@ -193,6 +207,7 @@ class ContextMatchRequest(AdcpVersionEnvelope): Field( description='TMP protocol version. Allows receivers to handle semantic differences across versions.' ), + NoExplicitNull, ] = '1.0' request_id: Annotated[ str, @@ -211,6 +226,7 @@ class ContextMatchRequest(AdcpVersionEnvelope): Field( description="Publisher's human-readable property slug (e.g., 'cnn_homepage'). Optional when property_rid is present. Useful for logging and debugging." ), + NoExplicitNull, ] = None property_type: Annotated[ property_type_1.PropertyType, Field(description='Type of the publisher property') @@ -232,6 +248,7 @@ class ContextMatchRequest(AdcpVersionEnvelope): Field( description='Full content artifact adjacent to this ad opportunity. Same schema used for content standards evaluation. The publisher sends the artifact when they want the buyer to evaluate the full content. Contractual protections govern buyer use. TEE deployment upgrades contractual trust to cryptographic verification. Because the router fans out to multiple buyer agents, publishers MUST NOT include bearer tokens, service-account credentials, or signed URLs in this artifact. Routers MUST remove every asset `access` object and remove or replace every credential-bearing asset `url` before forwarding; only public asset URLs that recipients can resolve independently may remain.' ), + NoExplicitNull, ] = None artifact_refs: Annotated[ list[ArtifactRef] | None, @@ -240,18 +257,21 @@ class ContextMatchRequest(AdcpVersionEnvelope): max_length=20, min_length=1, ), + NoExplicitNull, ] = None geo: Annotated[ Geo | None, Field( description='Coarse geographic location of the viewer. Publisher controls granularity — country is sufficient for regulatory compliance and volume filtering, region or metro helps with campaign targeting and valuation. Coarsened to prevent user identification: no postcode, no coordinates. All fields optional.' ), + NoExplicitNull, ] = None context_signals: Annotated[ ContextSignals | None, Field( description="Pre-computed classifier outputs for the content environment. Use when the publisher wants to provide privacy-reduced context without sharing content or public references. Can supplement artifact_refs or replace them entirely. Ephemeral content that many users encounter (a trending query, a syndicated segment) is shared content; one user's turn or query is not. For non-public content attributable to a single user or session, only the field-specific privacy-reduced outputs permitted below may be sent. Raw content MUST NOT be included. The publisher is the classifier and privacy boundary." ), + NoExplicitNull, ] = None package_ids: Annotated[ list[str] | None, @@ -260,4 +280,5 @@ class ContextMatchRequest(AdcpVersionEnvelope): max_length=500, min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/trusted_match/context_match_response.py b/src/adcp/types/domains/trusted_match/context_match_response.py index f9e878915..73f7a2253 100644 --- a/src/adcp/types/domains/trusted_match/context_match_response.py +++ b/src/adcp/types/domains/trusted_match/context_match_response.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: trusted_match/context_match_response.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, RootModel, StringConstraints from ..core.protocol_envelope import ProtocolEnvelope @@ -21,6 +21,7 @@ class Signals(AdCPBaseModel): segments: Annotated[ list[str] | None, Field(description='Contextual segment identifiers combined across provider responses.'), + NoExplicitNull, ] = None @@ -75,6 +76,7 @@ class ContextMatchResponseRouterPublisher(AdcpVersionEnvelope, ProtocolEnvelope) Field( description='Merged non-keyed response-level signals. Provider-local targeting pairs do not pass through this object; the router emits them only in signals_by_provider.' ), + NoExplicitNull, ] = None signals_by_provider: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[A-Za-z0-9_]+$', min_length=1, max_length=64)], SignalsByProvider] @@ -83,4 +85,5 @@ class ContextMatchResponseRouterPublisher(AdcpVersionEnvelope, ProtocolEnvelope) description="Router-authored map of provider targeting pairs, keyed by the publisher-assigned provider_id from provider registration. For every provider response containing a non-empty signals.targeting_kvs list, the router copies the complete list unchanged into that provider's bucket. The router derives the map key from its registration and MUST ignore or reject provider-supplied signals_by_provider data. A provider with no targeting pairs is omitted. Publishers resolve each (provider_id, key) tuple to a local ad-server destination and drop tuples that have no local mapping.", min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/trusted_match/error.py b/src/adcp/types/domains/trusted_match/error.py index 987255417..7ea7b9d53 100644 --- a/src/adcp/types/domains/trusted_match/error.py +++ b/src/adcp/types/domains/trusted_match/error.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: trusted_match/error.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field @@ -38,5 +38,7 @@ class TmpError(AdCPBaseModel): ), ] message: Annotated[ - str | None, Field(description='Human-readable error description for debugging') + str | None, + Field(description='Human-readable error description for debugging'), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/trusted_match/identity_match_request.py b/src/adcp/types/domains/trusted_match/identity_match_request.py index 7b6c559e1..ed7540657 100644 --- a/src/adcp/types/domains/trusted_match/identity_match_request.py +++ b/src/adcp/types/domains/trusted_match/identity_match_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: trusted_match/identity_match_request.json -# timestamp: 2026-10-04T15:01:24+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool from ..core import brand_ref @@ -27,18 +27,25 @@ class Consent(AdCPBaseModel): extra='forbid', ) gdpr: Annotated[ - StrictBool | None, Field(description='Whether GDPR applies to this request.') + StrictBool | None, + Field(description='Whether GDPR applies to this request.'), + NoExplicitNull, ] = None tcf_consent: Annotated[ - str | None, Field(description='IAB TCF v2.2 consent string. Present when gdpr is true.') + str | None, + Field(description='IAB TCF v2.2 consent string. Present when gdpr is true.'), + NoExplicitNull, + ] = None + gpp: Annotated[ + str | None, Field(description='IAB Global Privacy Platform string.'), NoExplicitNull ] = None - gpp: Annotated[str | None, Field(description='IAB Global Privacy Platform string.')] = None us_privacy: Annotated[ str | None, Field( deprecated=True, description='US Privacy string (CCPA). Deprecated in favor of GPP but still widely used.', ), + NoExplicitNull, ] = None @@ -83,12 +90,14 @@ class Attestation(AdCPBaseModel): Field( description="Relying-party id the proof was minted for. The receiver checks this against the relying_party_id's published owner (brand.json `identity_relying_parties[]`) so a forwarded proof cannot be replayed under a different owner." ), + NoExplicitNull, ] = None action: Annotated[ str | None, Field( description='Issuer action/scope the proof was bound to (e.g. "humanity-check-for-ads").' ), + NoExplicitNull, ] = None claims: Annotated[ list[attestation_claim.AttestationClaim], @@ -103,12 +112,14 @@ class Attestation(AdCPBaseModel): Field( description='Credential strength (e.g. World ID Orb = biometric unique-human; Device = weaker; Document = passport/NFC).' ), + NoExplicitNull, ] = None signal_binding: Annotated[ str | None, Field( description='Hash of the signal the proof commits to. The receiver MUST verify it matches the context the receiver expects (e.g. a buyer-issued nonce or the request_id) and that the attestation is within its freshness window; this, plus nullifier-reuse tracking, is the replay defense.' ), + NoExplicitNull, ] = None proof: Annotated[ dict[str, Any], @@ -119,6 +130,7 @@ class Attestation(AdCPBaseModel): expires_at: Annotated[ AwareDatetime | None, Field(description='Attestation validity horizon. Receivers MUST reject when past.'), + NoExplicitNull, ] = None @@ -143,6 +155,7 @@ class Identity(AdCPBaseModel): Field( description="Optional verifiable proof ABOUT this identity (e.g. World ID proof-of-personhood and/or age). The receiver MUST verify it (see conformance) and MUST treat an absent-or-unverifiable attestation as 'no attestation' — never as an asserted-true claim. Issuer-agnostic: World ID is the first scheme; mDL / VC-style issuers use the same shape. Receivers MUST bound attestation size to prevent DoS." ), + NoExplicitNull, ] = None @@ -155,6 +168,7 @@ class IdentityMatchRequest(AdcpVersionEnvelope): Field( alias='$schema', description='Optional schema URI for validation. Ignored at runtime.' ), + NoExplicitNull, ] = None type: Annotated[ Literal['identity_match_request'], @@ -165,6 +179,7 @@ class IdentityMatchRequest(AdcpVersionEnvelope): Field( description='TMP protocol version. Allows receivers to handle semantic differences across versions.' ), + NoExplicitNull, ] = '1.0' request_id: Annotated[ str, @@ -191,6 +206,7 @@ class IdentityMatchRequest(AdcpVersionEnvelope): Field( description='Privacy consent signals. Buyers in regulated jurisdictions MUST NOT process the user token without consent information.' ), + NoExplicitNull, ] = None package_ids: Annotated[ list[str] | None, @@ -198,6 +214,7 @@ class IdentityMatchRequest(AdcpVersionEnvelope): description="Optional. When omitted, the buyer evaluates eligibility against the full set of active packages it has registered for `seller_agent_url`. When provided, the composition of `package_ids` MUST be statistically independent of the current placement — sending only the page-specific subset would let the buyer correlate Identity Match with Context Match by comparing package sets. Two acceptable modes: (a) **all-active** — include every active package this buyer has at this publisher; (b) **fuzzed** — include a random sample of active packages, optionally padded with synthetic non-existent IDs, drawn from a distribution that does not depend on the current placement. The buyer's silent-drop behavior on unknown IDs (specified below) is what makes synthetic-ID padding safe — they do not affect the response shape and cannot leak registry membership. When both `seller_agent_url` and `package_ids` are present, the buyer evaluates against the intersection of its registered active set and `package_ids`; IDs in `package_ids` that the buyer has not registered for this seller MUST be silently ignored (not surfaced as errors) to avoid leaking registry membership back to the publisher.", min_length=1, ), + NoExplicitNull, ] = None country: Annotated[ str | None, @@ -205,6 +222,7 @@ class IdentityMatchRequest(AdcpVersionEnvelope): description='ISO 3166-1 alpha-2 country code. Routing directive for the TMP Router — used to select the correct regional provider. The router MUST strip this field before forwarding the request to the buyer agent. Not an identity signal.', pattern='^[A-Z]{2}$', ), + NoExplicitNull, ] = None sealed_credentials: Annotated[ list[SealedCredential] | None, @@ -212,4 +230,5 @@ class IdentityMatchRequest(AdcpVersionEnvelope): description='Optional HPKE-sealed credentials addressed to specific audiences — the network-as-RP ("issuer-as-RP"/Mechanism B) carrier. Each payload is opaque to the publisher, who relays it untouched; the inner plaintext is an `attestation` (see identities[].attestation) scoped to the audience\'s relying party. Reuses the TMPX envelope format. Router handling (normative — see docs/trusted-match/specification.mdx): the router forwards each entry only to the provider that owns its `audience_kid` (not broadcast), folds `sealed_credentials` into the per-provider re-signature canonical bytes so an injected/swapped blob breaks the signature, and includes a `sealed_credentials_hash` in the dedup cache key. Receivers decrypt only entries whose `audience_kid` they hold a key for and ignore the rest. Receivers MUST bound count and size to prevent DoS amplification.', max_length=8, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/trusted_match/identity_match_response.py b/src/adcp/types/domains/trusted_match/identity_match_response.py index bd5eb5e1a..3d6c5610e 100644 --- a/src/adcp/types/domains/trusted_match/identity_match_response.py +++ b/src/adcp/types/domains/trusted_match/identity_match_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: trusted_match/identity_match_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, StringConstraints, model_validator from ..core.protocol_envelope import ProtocolEnvelope @@ -77,12 +77,14 @@ class IdentityMatchResponseRouterPublisher(AdcpVersionEnvelope, ProtocolEnvelope deprecated=True, description='DEPRECATED in favor of tmpx_providers. Routers MAY continue to populate this field for back-compat with consumers that only know the single-token shape; when both fields are present, tmpx_providers is authoritative. Single HPKE-encrypted exposure token containing the resolved user identity tokens. Wire format: kid.base64url_nopad(ciphertext) — unpadded base64url per RFC 4648 section 5 (no = characters). Publishers MUST treat this value as opaque pass-through data. Removed in 4.0.', ), + NoExplicitNull, ] = None tmpx_providers: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[A-Za-z0-9_]+$', min_length=1, max_length=64)], TmpxProviders] | None, Field( description="Router-populated: ordered TMPX chunk/value pairs grouped by the originating identity provider's `provider_id`. Each entry's `chunks[]` is a copy of the provider's emitted `tmpx_chunks` list, in the same order. Each chunk carries a provider-local `slot_id` (from the provider's registered `tmpx_slots`) and an opaque URL-safe `value`; the publisher's deployment configuration (see publisher-tmpx-config.json) resolves each `(provider_id, slot_id)` pair to the ad-server macro name, targeting key, VAST substitution, or play-log field for that surface. The protocol carries values and attribution only. Required by router conformance when any identity provider emitted TMPX in this request; collapsing per-provider tokens into a single string loses attribution and breaks per-provider impression accounting. Map keys MUST match the provider_id charset registered in provider-registration.json (enforced by `propertyNames`). Publishers MUST NOT parse, decode, or transform any chunk's `value` — each is an opaque URL-safe wire string substituted verbatim into the mapped destination." ), + NoExplicitNull, ] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/trusted_match/offer.py b/src/adcp/types/domains/trusted_match/offer.py index 0cd6257cb..499d17f6e 100644 --- a/src/adcp/types/domains/trusted_match/offer.py +++ b/src/adcp/types/domains/trusted_match/offer.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: trusted_match/offer.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field from ..core import brand_ref @@ -25,34 +25,40 @@ class Offer(AdCPBaseModel): Field( description="Optional echo of the package's seller agent from sync-time metadata. Provided for publisher-side observability so log pipelines can attribute offers to sellers without round-tripping to the media-buy store. Non-authoritative: the binding on the cached AvailablePackage is source of truth. When omitted, the router MAY stamp this field from its cached package→seller map." ), + NoExplicitNull, ] = None brand: Annotated[ brand_ref.BrandReference | None, Field( description='Brand for this offer. Required when the product allows dynamic brands (brand selected at match time rather than fixed on the package). For single-brand packages, the brand is already known from the media buy.' ), + NoExplicitNull, ] = None price: Annotated[ offer_price.OfferPrice | None, Field( description='Price for this offer. Only present when the product supports variable pricing. For fixed-price packages, price is already set on the media buy.' ), + NoExplicitNull, ] = None summary: Annotated[ str | None, Field( description="Buyer-generated description of the offer, for the publisher to judge relevance. E.g., '50% off Goldenfield mayo — recipe integration'. The publisher (or their AI assistant) uses this to decide whether the offer fits the context." ), + NoExplicitNull, ] = None creative_manifest: Annotated[ creative_manifest_1.CreativeManifest | None, Field( description='Full creative details, inline. When present, the publisher has everything needed to render. Inline for small creatives (markdown, product card). For large creatives (VAST, video), the manifest references external assets via URLs.' ), + NoExplicitNull, ] = None creative_data: Annotated[ dict[str, str] | None, Field( description="Optional free-form string enhancements for dynamic creative rendering, such as sponsor labels and promotion codes. The map has no protocol-level key registry or required keys: meanings are package-local, receivers MUST ignore unknown keys, and a missing key MUST NOT make an otherwise renderable offer fail. Required renderable content belongs in creative_manifest assets, so creative_data may complement but never replace a manifest's required assets. This is not ad-server macro substitution or attribution tracking; tracker URLs belong in creative_manifest assets and per-user exposure tracking uses TMPX." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/trusted_match/offer_price.py b/src/adcp/types/domains/trusted_match/offer_price.py index 174fc0e17..3496bb89f 100644 --- a/src/adcp/types/domains/trusted_match/offer_price.py +++ b/src/adcp/types/domains/trusted_match/offer_price.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: trusted_match/offer_price.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from pydantic import ConfigDict, Field, StrictFloat @@ -27,6 +27,8 @@ class OfferPrice(AdCPBaseModel): StrictFloat, Field(description='Price amount in the specified currency', ge=0.0) ] currency: Annotated[ - str | None, Field(description='ISO 4217 currency code', pattern='^[A-Z]{3}$') + str | None, + Field(description='ISO 4217 currency code', pattern='^[A-Z]{3}$'), + NoExplicitNull, ] = 'USD' model: Annotated[Model, Field(description='Pricing model for this offer')] diff --git a/src/adcp/types/domains/trusted_match/provider_context_match_response.py b/src/adcp/types/domains/trusted_match/provider_context_match_response.py index b120d6b25..c15bae21e 100644 --- a/src/adcp/types/domains/trusted_match/provider_context_match_response.py +++ b/src/adcp/types/domains/trusted_match/provider_context_match_response.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: trusted_match/provider_context_match_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field, RootModel from ..core.protocol_envelope import ProtocolEnvelope @@ -40,10 +40,10 @@ class Signals(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - segments: Annotated[list[str] | None, Field(description='Contextual segment identifiers.')] = ( - None - ) - targeting_kvs: TargetingKvs | None = None + segments: Annotated[ + list[str] | None, Field(description='Contextual segment identifiers.'), NoExplicitNull + ] = None + targeting_kvs: Annotated[TargetingKvs | None, NoExplicitNull] = None class ContextMatchResponseProviderRouter(AdcpVersionEnvelope, ProtocolEnvelope): @@ -72,10 +72,12 @@ class ContextMatchResponseProviderRouter(AdcpVersionEnvelope, ProtocolEnvelope): ge=0, le=86400, ), + NoExplicitNull, ] = None signals: Annotated[ Signals | None, Field( description="Provider-origin response-level signals. Targeting pairs use the provider's local key vocabulary; the router preserves and attributes them rather than passing them through as flattened publisher targeting." ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/trusted_match/provider_identity_match_response.py b/src/adcp/types/domains/trusted_match/provider_identity_match_response.py index 91d88bb28..9e5e06ea7 100644 --- a/src/adcp/types/domains/trusted_match/provider_identity_match_response.py +++ b/src/adcp/types/domains/trusted_match/provider_identity_match_response.py @@ -1,12 +1,12 @@ # generated by datamodel-codegen: # filename: trusted_match/provider_identity_match_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import SchemaInt +from adcp.types.base import NoExplicitNull, SchemaInt from pydantic import ConfigDict, Field from ..core.protocol_envelope import ProtocolEnvelope @@ -48,4 +48,5 @@ class IdentityMatchResponseProviderRouter(AdcpVersionEnvelope, ProtocolEnvelope) max_length=2, min_length=1, ), + NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/trusted_match/provider_registration.py b/src/adcp/types/domains/trusted_match/provider_registration.py index 706732e31..bc1fad6be 100644 --- a/src/adcp/types/domains/trusted_match/provider_registration.py +++ b/src/adcp/types/domains/trusted_match/provider_registration.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: trusted_match/provider_registration.json -# timestamp: 2026-10-11T15:55:26+00:00 +# timestamp: 2026-10-21T11:05:54+00:00 from __future__ import annotations @@ -8,7 +8,7 @@ from typing import Annotated, Literal from uuid import UUID -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt from adcp.types._scalar import ScalarStr from pydantic import AnyUrl, ConfigDict, Field, StrictBool, field_validator, model_validator @@ -64,6 +64,7 @@ class TmpProviderRegistration1(AdCPBaseModel): identity_match: Annotated[ StrictBool | None, Field(description='Provider handles Identity Match requests (POST /identity).'), + NoExplicitNull, ] = None countries: Annotated[ list[Country] | None, @@ -71,6 +72,7 @@ class TmpProviderRegistration1(AdCPBaseModel): description="ISO 3166-1 alpha-2 country codes this provider serves. The router filters Identity Match providers by the request's country field. MUST be present and non-empty when identity_match is true.", min_length=1, ), + NoExplicitNull, ] = None uid_types: Annotated[ list[uid_type.UidType] | None, @@ -78,6 +80,7 @@ class TmpProviderRegistration1(AdCPBaseModel): description="Identity types this provider can resolve. The router selects Identity Match providers whose uid_types overlaps with any uid_type in the request's identities array. MUST be present and non-empty when identity_match is true.", min_length=1, ), + NoExplicitNull, ] = None properties: Annotated[ list[UUID] | None, @@ -85,6 +88,7 @@ class TmpProviderRegistration1(AdCPBaseModel): description='Property RIDs (UUID v7) this provider serves. When present, the router only sends requests from these properties to this provider. When absent, the provider serves all properties.', min_length=1, ), + NoExplicitNull, ] = None timeout_ms: Annotated[ SchemaInt | None, @@ -93,6 +97,7 @@ class TmpProviderRegistration1(AdCPBaseModel): ge=5, le=5000, ), + NoExplicitNull, ] = 50 priority: Annotated[ SchemaInt | None, @@ -100,6 +105,7 @@ class TmpProviderRegistration1(AdCPBaseModel): description='Provider ordering for Context Match offer conflict resolution. Lower values have higher priority. When two providers return offers for the same package_id (a configuration error), the router keeps the offer from the higher-priority provider; equal priorities are broken by first response received. Identity Match eligibility remains a responder-scoped union because silent omission is not a negative vote. Also used for adaptive timeout allocation — higher-priority providers receive a larger share of the latency budget.', ge=0, ), + NoExplicitNull, ] = 0 tmpx_slots: Annotated[ list[TmpxSlot] | None, @@ -108,12 +114,14 @@ class TmpProviderRegistration1(AdCPBaseModel): max_length=2, min_length=1, ), + NoExplicitNull, ] = None status: Annotated[ Status | None, Field( description='Provider lifecycle status. Active providers receive requests. Inactive providers are skipped entirely. Draining providers stop receiving new requests but in-flight requests complete normally.' ), + NoExplicitNull, ] = Status.active @@ -156,6 +164,7 @@ class TmpProviderRegistration2(AdCPBaseModel): context_match: Annotated[ StrictBool | None, Field(description='Provider handles Context Match requests (POST /context).'), + NoExplicitNull, ] = None identity_match: Annotated[ Literal[True], @@ -167,6 +176,7 @@ class TmpProviderRegistration2(AdCPBaseModel): description="ISO 3166-1 alpha-2 country codes this provider serves. The router filters Identity Match providers by the request's country field. MUST be present and non-empty when identity_match is true.", min_length=1, ), + NoExplicitNull, ] = None uid_types: Annotated[ list[uid_type.UidType] | None, @@ -174,6 +184,7 @@ class TmpProviderRegistration2(AdCPBaseModel): description="Identity types this provider can resolve. The router selects Identity Match providers whose uid_types overlaps with any uid_type in the request's identities array. MUST be present and non-empty when identity_match is true.", min_length=1, ), + NoExplicitNull, ] = None properties: Annotated[ list[UUID] | None, @@ -181,6 +192,7 @@ class TmpProviderRegistration2(AdCPBaseModel): description='Property RIDs (UUID v7) this provider serves. When present, the router only sends requests from these properties to this provider. When absent, the provider serves all properties.', min_length=1, ), + NoExplicitNull, ] = None timeout_ms: Annotated[ SchemaInt | None, @@ -189,6 +201,7 @@ class TmpProviderRegistration2(AdCPBaseModel): ge=5, le=5000, ), + NoExplicitNull, ] = 50 priority: Annotated[ SchemaInt | None, @@ -196,6 +209,7 @@ class TmpProviderRegistration2(AdCPBaseModel): description='Provider ordering for Context Match offer conflict resolution. Lower values have higher priority. When two providers return offers for the same package_id (a configuration error), the router keeps the offer from the higher-priority provider; equal priorities are broken by first response received. Identity Match eligibility remains a responder-scoped union because silent omission is not a negative vote. Also used for adaptive timeout allocation — higher-priority providers receive a larger share of the latency budget.', ge=0, ), + NoExplicitNull, ] = 0 tmpx_slots: Annotated[ list[TmpxSlot] | None, @@ -204,12 +218,14 @@ class TmpProviderRegistration2(AdCPBaseModel): max_length=2, min_length=1, ), + NoExplicitNull, ] = None status: Annotated[ Status | None, Field( description='Provider lifecycle status. Active providers receive requests. Inactive providers are skipped entirely. Draining providers stop receiving new requests but in-flight requests complete normally.' ), + NoExplicitNull, ] = Status.active diff --git a/tests/conformance/_schema_parity.py b/tests/conformance/_schema_parity.py index 02f142c26..8b5a09bd4 100644 --- a/tests/conformance/_schema_parity.py +++ b/tests/conformance/_schema_parity.py @@ -565,15 +565,6 @@ def _is_numeric_string(value: Any) -> bool: #: on it. A predicate that matches nothing has been fixed, and the suite says #: to delete it — this set only shrinks. DECLARED: dict[str, Callable[[Divergence], bool]] = { - # The generator renders an optional property as ``X | None``, which - # conflates "may be absent" with "may be null". JSON Schema separates - # them: a property outside ``required`` may be absent, and ``null`` is - # valid only when the declared type admits it. Closing this needs a - # decision about whether AdCP intends optional properties to accept an - # explicit null; if it does, the schemas declare ``["string", "null"]``. - "optional_field_accepts_explicit_null": lambda d: ( - d.direction == MODEL_ACCEPTS and d.mutation is None - ), # ``format: date-time`` and ``format: date`` become ``AwareDatetime`` and # ``date``, whose parsers read a number as a Unix timestamp and a numeric # string as one too. The schema requires an RFC 3339 string. diff --git a/tests/conformance/test_schema_model_parity.py b/tests/conformance/test_schema_model_parity.py index b68847f0f..5bb3facf3 100644 --- a/tests/conformance/test_schema_model_parity.py +++ b/tests/conformance/test_schema_model_parity.py @@ -18,6 +18,7 @@ from __future__ import annotations import copy +import json from functools import cache from typing import Any @@ -76,8 +77,11 @@ def _model_for(tool: str) -> tuple[str, Any] | tuple[None, None]: def _model_verdict(model: Any, document: Any) -> tuple[bool, frozenset[str]]: + # The document under test is a JSON document, so the model grades it in + # JSON mode: that is where a ``null`` is a value rather than the SDK's + # Python spelling of absence (``adcp.types.base.NoExplicitNull``). try: - model.model_validate(document) + model.model_validate_json(json.dumps(document)) except ValidationError as exc: return False, frozenset(error["type"] for error in exc.errors()) return True, frozenset() diff --git a/tests/test_canceled_literal_default.py b/tests/test_canceled_literal_default.py index b1b851d3d..6617f11a8 100644 --- a/tests/test_canceled_literal_default.py +++ b/tests/test_canceled_literal_default.py @@ -101,7 +101,7 @@ class TestFixIdempotency: """The post-gen fix must be idempotent — running it twice produces the same output.""" def test_fix_is_idempotent_on_synthesized_source(self) -> None: - from scripts.post_generate_fixes import _CANCELED_FIELD_RE + from scripts.post_generate_fixes import _CANCELED_FIELD_RE, _WIDENED_LITERAL_TRUE already_fixed = ( " canceled: Annotated[\n" @@ -115,14 +115,14 @@ def test_fix_is_idempotent_on_synthesized_source(self) -> None: ) result, count = _CANCELED_FIELD_RE.subn( - r"\1Literal[True] | None\2 = None", + _WIDENED_LITERAL_TRUE, already_fixed, ) assert count == 0, "fix must not re-apply to already-widened source" assert result == already_fixed def test_fix_matches_destructive_source(self) -> None: - from scripts.post_generate_fixes import _CANCELED_FIELD_RE + from scripts.post_generate_fixes import _CANCELED_FIELD_RE, _WIDENED_LITERAL_TRUE destructive = ( " canceled: Annotated[\n" @@ -136,7 +136,7 @@ def test_fix_matches_destructive_source(self) -> None: ) result, count = _CANCELED_FIELD_RE.subn( - r"\1Literal[True] | None\2 = None", + _WIDENED_LITERAL_TRUE, destructive, ) assert count == 1 diff --git a/tests/test_codegen_invariants.py b/tests/test_codegen_invariants.py index 36fe98aee..bae0673f7 100644 --- a/tests/test_codegen_invariants.py +++ b/tests/test_codegen_invariants.py @@ -176,17 +176,34 @@ def test_proposal_refinement_does_not_remove_frequency_cap_by_default() -> None: @pytest.mark.parametrize("prefix", ["", "bundled"]) @pytest.mark.parametrize( - "annotation,default", + "annotation,default,widened", [ - ("Literal[True]", "True"), - ("Annotated[Literal[True], Field(deprecated=True)]", "True"), - ("Annotated[Literal[True], Field(description='Explicit choice — True')]", "True"), + ("Literal[True]", "True", "Annotated[Literal[True] | None, NoExplicitNull]"), + ( + "Annotated[Literal[True], Field(deprecated=True)]", + "True", + "Annotated[Literal[True] | None, Field(deprecated=True), NoExplicitNull]", + ), + ( + "Annotated[Literal[True], Field(description='Explicit choice — True')]", + "True", + "Annotated[Literal[True] | None, Field(description='Explicit choice — True'), " + "NoExplicitNull]", + ), ( "Annotated[\n Literal[True],\n" " Field(description='Explicit choice — preserve metadata'),\n ]", "True", + "Annotated[\n Literal[True] | None,\n" + " Field(description='Explicit choice — preserve metadata'),\n" + " NoExplicitNull,\n ]", + ), + ( + "Annotated[Literal[False], Field(description='False-only flag') ]", + "False", + "Annotated[Literal[False] | None, Field(description='False-only flag') , " + "NoExplicitNull]", ), - ("Annotated[Literal[False], Field(description='False-only flag') ]", "False"), ], ) def test_optional_const_fixes_are_idempotent( @@ -195,7 +212,10 @@ def test_optional_const_fixes_are_idempotent( prefix: str, annotation: str, default: str, + widened: str, ) -> None: + """Widening keeps the constant's metadata and adds ``NoExplicitNull`` (#1547): + the schema admits absence, never ``null``.""" from scripts import post_generate_fixes target = tmp_path / prefix / "core/model.py" @@ -212,10 +232,13 @@ def test_optional_const_fixes_are_idempotent( fix = post_generate_fixes.fix_optional_boolean_literal_defaults fix() updated = target.read_text() - optional_annotation = annotation.replace(f"Literal[{default}]", f"Literal[{default}] | None") - assert updated == original.replace( - f"optional_flag: {annotation} = {default}", - f"optional_flag: {optional_annotation} = None", + assert updated == ( + "from typing import Annotated\n" + "from adcp.types.base import NoExplicitNull\n" + + original.replace( + f"optional_flag: {annotation} = {default}", + f"optional_flag: {widened} = None", + ) ) ast.parse(updated) fix() diff --git a/tests/test_manifest_response_readback.py b/tests/test_manifest_response_readback.py index 8947987dd..366ad4b3d 100644 --- a/tests/test_manifest_response_readback.py +++ b/tests/test_manifest_response_readback.py @@ -44,7 +44,8 @@ def response_case(case, kind=FUTURE_KIND): manifest = { - "format_kind": kind, + # ``None`` means the property is absent: the schema admits no null. + **({"format_kind": kind} if kind is not None else {}), "assets": {}, "vendor_annotation": {"notes": ["preserved"]}, } @@ -175,7 +176,9 @@ def test_async_response_union_and_webhook_round_trip(case): result = adapter.validate_json(json.dumps(payload)) assert isinstance(result, model) assert read_manifest(result, case).format_kind == FUTURE_KIND - reparsed = adapter.validate_json(adapter.dump_json(result)) + # A ``TypeAdapter`` dump has no ``exclude_none`` default of its own, and + # the schema admits no ``null`` for an omitted property. + reparsed = adapter.validate_json(adapter.dump_json(result, exclude_none=True)) assert isinstance(reparsed, model) assert read_manifest(reparsed, case).format_kind == FUTURE_KIND @@ -209,7 +212,12 @@ async def test_public_completed_task_parser_keeps_manifest_readback(case): # A successful untyped fallback would silently hide strict-union rejection. assert isinstance(result.data, model) assert read_manifest(result.data, case).format_kind == FUTURE_KIND - reparsed = TaskResult[AdcpAsyncResponseData].model_validate_json(result.model_dump_json()) + # ``TaskResult`` is a plain pydantic model: its dump carries every ``None`` + # as ``null`` unless told otherwise, and a JSON ``null`` is not an AdCP + # document (#1547). Dump the way the CLI does, with ``exclude_none``. + reparsed = TaskResult[AdcpAsyncResponseData].model_validate_json( + result.model_dump_json(exclude_none=True) + ) assert isinstance(reparsed.data, model) assert read_manifest(reparsed.data, case).format_kind == FUTURE_KIND diff --git a/tests/test_no_explicit_null.py b/tests/test_no_explicit_null.py new file mode 100644 index 000000000..63ca04fef --- /dev/null +++ b/tests/test_no_explicit_null.py @@ -0,0 +1,289 @@ +"""An optional property refuses an explicit ``null`` unless its schema admits one. + +The generator renders every optional property as ``T | None = None``. That is +the right Python surface — omitted reads as ``None`` and dumps as absent — but +it also admitted an explicit JSON ``null`` that the schema, and so the SDK's +own JSON-Schema arm, rejects (#1547). The generator now marks each optional +property the bundled validator says cannot be ``null`` and the post-generation +pass points the mark at :data:`adcp.types.base.NoExplicitNull`. + +The rule is about the wire: a JSON document carrying ``null`` is refused, and +the published JSON Schema has no null arm. A Python ``None`` — keyword +construction, ``model_validate`` on a dict — stays the SDK's spelling of +"not supplied", symmetric with ``model_dump``'s ``exclude_none`` default, unless +``ADCP_STRICT_VALIDATION`` asks for it to be refused too. + +``tests/conformance/test_schema_model_parity.py`` is the sweep: it mutates every +leaf of every graded tool's request to ``None`` and requires the bundled schema +and the model (in JSON mode) to agree, with the +``optional_field_accepts_explicit_null`` divergence no longer declared. These +tests pin the rule's shape on the reported case, a genuinely nullable property, +and a sample of generated models. +""" + +from __future__ import annotations + +import json +import random +from pathlib import Path +from typing import Annotated, Any + +import pytest +from pydantic import BaseModel, ValidationError + +import adcp.types as public_types +from adcp.types import base as types_base +from adcp.types.base import NoExplicitNull +from adcp.types.domains.creative.sync_creatives_request import SyncCreativesRequest +from adcp.types.domains.media_buy.update_media_buy_request import UpdateMediaBuyRequest +from adcp.validation import get_schema, validate_request + +#: The #1547 reproduction: a legal assignment-only sync, plus ``creatives: null``. +_SYNC_WITH_NULL_CREATIVES: dict[str, Any] = { + "adcp_version": "3.2", + "account": {"account_id": "acct_1"}, + "idempotency_key": "repro-null-creatives-0001", + "creatives": None, + "assignment_operations": [ + {"operation": "unassign", "creative_id": "cr_1", "package_id": "pkg_1"}, + ], +} + + +def _null_errors(exc: ValidationError) -> list[tuple[tuple[Any, ...], str]]: + return [(error["loc"], error["type"]) for error in exc.errors()] + + +def test_explicit_null_creatives_is_refused_like_the_bundled_schema() -> None: + """The reported case: both arms now reject the JSON document at the same place.""" + schema_node = get_schema("sync_creatives", "request", version="3.2")["properties"]["creatives"] + assert schema_node["type"] == "array" + + outcome = validate_request("sync_creatives", _SYNC_WITH_NULL_CREATIVES, version="3.2") + assert [(issue.pointer, issue.keyword) for issue in outcome.issues] == [("/creatives", "type")] + + with pytest.raises(ValidationError) as excinfo: + SyncCreativesRequest.model_validate_json(json.dumps(_SYNC_WITH_NULL_CREATIVES)) + # The type's own error, exactly as a required array given null would report. + assert _null_errors(excinfo.value) == [(("creatives",), "list_type")] + + +def test_a_python_none_still_spells_absence() -> None: + """``model_validate`` on a dict and keyword construction keep the SDK's convention. + + ``None`` on the Python side means "not supplied": it reads back as ``None`` + and ``model_dump`` leaves it off the wire, exactly as an omitted property + would. Refusing it here would break every caller who passes an optional + value through as ``field=maybe_none``. + """ + parsed = SyncCreativesRequest.model_validate(_SYNC_WITH_NULL_CREATIVES) + assert parsed.creatives is None + assert "creatives" not in parsed.model_dump() + + base = {k: v for k, v in _SYNC_WITH_NULL_CREATIVES.items() if k != "creatives"} + constructed = SyncCreativesRequest(**base, creatives=None) + assert constructed.creatives is None + assert constructed.model_dump() == SyncCreativesRequest(**base).model_dump() + + +def test_strict_validation_refuses_a_python_none_too(monkeypatch: pytest.MonkeyPatch) -> None: + """``ADCP_STRICT_VALIDATION`` extends the refusal to Python-mode input. + + The policy is read when a model's core schema is built, like the + ``extra`` policy it travels with, so the check uses a model built under it. + """ + monkeypatch.setattr(types_base, "_EXTRA_POLICY", "forbid") + + class Strict(BaseModel): + items: Annotated[list[str] | None, NoExplicitNull] = None + + assert Strict().items is None + with pytest.raises(ValidationError) as excinfo: + Strict.model_validate({"items": None}) + assert _null_errors(excinfo.value) == [(("items",), "list_type")] + with pytest.raises(ValidationError): + Strict(items=None) + assert Strict.model_validate_json('{"items": ["a"]}').items == ["a"] + + +def test_canonical_subclass_inherits_the_rule_through_inherit() -> None: + """The canonical redeclaration (``_inherit``, #1548) keeps the generated marker.""" + field = public_types.SyncCreativesRequest.model_fields["creatives"] + assert NoExplicitNull in field.metadata + canonical = public_types.SyncCreativesRequest + with pytest.raises(ValidationError) as excinfo: + canonical.model_validate_json(json.dumps(_SYNC_WITH_NULL_CREATIVES)) + assert (("creatives",), "list_type") in _null_errors(excinfo.value) + assert canonical.model_validate(_SYNC_WITH_NULL_CREATIVES).creatives is None + + +def test_a_redeclaration_that_keeps_the_field_info_keeps_the_marker() -> None: + """What ``_inherit`` relies on: assigning the parent's ``FieldInfo`` carries the rule.""" + parent_field = SyncCreativesRequest.model_fields["creatives"] + + class Narrowed(SyncCreativesRequest): + creatives: list[dict[str, object]] | None = parent_field # type: ignore[assignment] + + assert NoExplicitNull in Narrowed.model_fields["creatives"].metadata + with pytest.raises(ValidationError) as excinfo: + Narrowed.model_validate_json(json.dumps(_SYNC_WITH_NULL_CREATIVES)) + assert _null_errors(excinfo.value) == [(("creatives",), "list_type")] + + +def test_omitted_creatives_still_reads_as_none() -> None: + """Absence is the one permitted way of not supplying the property, and reads as ``None``.""" + payload = {k: v for k, v in _SYNC_WITH_NULL_CREATIVES.items() if k != "creatives"} + assert validate_request("sync_creatives", payload, version="3.2").valid + + request = SyncCreativesRequest.model_validate(payload) + assert request.creatives is None + assert "creatives" not in request.model_fields_set + assert "creatives" not in request.model_dump() + + # Keyword construction without the property behaves the same way. + constructed = SyncCreativesRequest(**payload) + assert constructed.creatives is None + assert constructed.model_dump() == request.model_dump() + + +def test_constraints_and_description_survive_beside_the_marker() -> None: + """The marker is appended to the field's metadata, not substituted for it.""" + field = SyncCreativesRequest.model_fields["creatives"] + assert NoExplicitNull in field.metadata + assert field.description == "Array of creative assets to sync (create or update)" + too_many = { + **{k: v for k, v in _SYNC_WITH_NULL_CREATIVES.items() if k != "creatives"}, + "creatives": [ + {"creative_id": f"cr_{i}", "name": f"n{i}", "format_kind": "image"} for i in range(101) + ], + } + with pytest.raises(ValidationError) as excinfo: + SyncCreativesRequest.model_validate(too_many) + assert any(error["type"] == "too_long" for error in excinfo.value.errors()) + + +def test_published_schema_states_what_the_bundle_states() -> None: + """``model_json_schema`` no longer advertises a null arm for a non-nullable property.""" + node = SyncCreativesRequest.model_json_schema()["properties"]["creatives"] + assert "anyOf" not in node + assert node["type"] == "array" + assert node["minItems"] == 1 + assert node["maxItems"] == 100 + assert node["description"] == "Array of creative assets to sync (create or update)" + assert "creatives" not in SyncCreativesRequest.model_json_schema().get("required", []) + + +def test_genuinely_nullable_property_keeps_accepting_null() -> None: + """A property whose schema lists ``null`` is generated without the marker.""" + schema = get_schema("update_media_buy", "request", version="3.2") + assert "null" in schema["properties"]["budget_cap_timezone"]["type"] + assert "null" in schema["properties"]["daily_budget_cap"]["type"] + + payload = { + "account": {"account_id": "acct_1"}, + "media_buy_id": "mb_1", + "idempotency_key": "nullable-timezone-0001", + "budget_cap_timezone": None, + "daily_budget_cap": None, + } + assert validate_request("update_media_buy", payload, version="3.2").valid + + request = UpdateMediaBuyRequest.model_validate_json(json.dumps(payload)) + assert request.budget_cap_timezone is None + assert "budget_cap_timezone" in request.model_fields_set + for name in ("budget_cap_timezone", "daily_budget_cap"): + assert NoExplicitNull not in UpdateMediaBuyRequest.model_fields[name].metadata + node = UpdateMediaBuyRequest.model_json_schema()["properties"]["budget_cap_timezone"] + assert {"type": "null"} in node["anyOf"] + + +def test_json_round_trip_of_an_omitted_property_stays_omitted() -> None: + """Dump then parse as JSON: an omitted property never becomes an explicit null.""" + base = {k: v for k, v in _SYNC_WITH_NULL_CREATIVES.items() if k != "creatives"} + request = SyncCreativesRequest(**base, creatives=None) + wire = request.model_dump_json() + assert '"creatives"' not in wire + assert SyncCreativesRequest.model_validate_json(wire).creatives is None + + +# --- a sample of generated models, graded against their own schema ----------------- + + +def _generated_models() -> list[tuple[str, type[BaseModel]]]: + names = sorted( + name + for name in dir(public_types) + if not name.startswith("_") and not name.startswith("Legacy") + ) + models = [] + for name in names: + obj = getattr(public_types, name, None) + if isinstance(obj, type) and issubclass(obj, BaseModel) and obj.model_fields: + models.append((name, obj)) + return models + + +@pytest.mark.parametrize( + "name,model", + random.Random(1547).sample(_generated_models(), 60), + ids=lambda value: value if isinstance(value, str) else "", +) +def test_every_optional_field_either_carries_the_marker_or_admits_null( + name: str, model: type[BaseModel] +) -> None: + """For a sample of models: the marker is the only thing that refuses ``None``, and + pydantic's verdict on ``null`` follows the marker. + + Each optional field is probed in isolation with ``model_construct``-free input: + a document carrying every required field's current default-less sentinel would + need per-model synthesis, so the probe reads the field's own validator through + the published schema instead — a marked field has no null arm, an unmarked + optional field has one (or is a bare ``Any``). + """ + schema = model.model_json_schema() + required = set(schema.get("required", [])) + for field_name, field in model.model_fields.items(): + if field.is_required(): + continue + wire_name = field.alias or field_name + node = schema["properties"].get(wire_name) + if node is None: + continue + marked = NoExplicitNull in field.metadata + annotation = repr(field.annotation) + if marked: + assert wire_name not in required + assert node.get("type") != "null" + assert {"type": "null"} not in node.get("anyOf", []), (name, field_name, node) + elif "None" in annotation and "Any" not in annotation and node.get("anyOf") is None: + # An unmarked optional ``T | None`` must publish a null arm, unless the + # type itself is a reference the schema expresses without ``anyOf``. + assert "$ref" in node or node.get("type") == "null" or "allOf" in node, ( + name, + field_name, + node, + ) + + +def test_no_generated_module_still_carries_the_transient_mark() -> None: + """The ``x-adcp-no-explicit-null`` key is a generator-internal carrier and never ships.""" + domains = Path(public_types.__file__).parent / "domains" + offenders = [ + path.relative_to(domains) + for path in domains.rglob("*.py") + if "x-adcp-no-explicit-null" in path.read_text() + ] + assert offenders == [] + bundled = Path(public_types.__file__).parent.parent / "_schemas" + if bundled.exists(): + for path in bundled.rglob("*.json"): + assert "x-adcp-no-explicit-null" not in path.read_text(), path + + +def test_marker_is_widespread_in_the_generated_tree() -> None: + """The rule is generator-level: it reaches thousands of fields across hundreds of modules.""" + domains = Path(public_types.__file__).parent / "domains" + modules = [path for path in domains.rglob("*.py") if "NoExplicitNull" in path.read_text()] + fields = sum(path.read_text().count("NoExplicitNull") - 1 for path in modules) + assert len(modules) > 500 + assert fields > 8000 diff --git a/tests/test_post_generate_no_explicit_null.py b/tests/test_post_generate_no_explicit_null.py new file mode 100644 index 000000000..e10c8d014 --- /dev/null +++ b/tests/test_post_generate_no_explicit_null.py @@ -0,0 +1,179 @@ +"""The ``NoExplicitNull`` post-generation rewrite reproduces the generator's layout. + +``point_omittable_fields_at_no_explicit_null`` removes the +``json_schema_extra={'x-adcp-no-explicit-null': True}`` keyword the generator +emitted for a marked property and appends ``NoExplicitNull`` to the field's +``Annotated`` metadata, re-rendering the statement the way black laid it out. +Every later fix reads that text, so the layout has to be the generator's own: +with the marker suppressed the rewrite must give back exactly what the +generator writes for the same schema without the mark. +""" + +from __future__ import annotations + +import ast +import textwrap + +import pytest + +from scripts import post_generate_fixes as fixes + +MARK = fixes._NO_EXPLICIT_NULL_EXTRA + + +def _rewrite(source: str, marker: str | None = fixes._NO_EXPLICIT_NULL_MARKER) -> tuple[str, int]: + rewritten, count = fixes._rewrite_no_explicit_null_marks(textwrap.dedent(source), marker) + ast.parse(rewritten) + return rewritten, count + + +def test_width_follows_the_repository_black_configuration() -> None: + assert fixes._GENERATED_LINE_WIDTH == 100 + + +def test_mark_alone_becomes_the_marker_on_one_line() -> None: + source = f"""\ + class X(AdCPBaseModel): + ref: Annotated[R | None, Field({MARK})] = None + """ + rewritten, count = _rewrite(source) + assert count == 1 + assert " ref: Annotated[R | None, NoExplicitNull] = None\n" in rewritten + + +def test_mark_beside_other_arguments_is_removed_and_the_marker_appended() -> None: + source = f"""\ + class X(AdCPBaseModel): + creatives: Annotated[ + list[Creative] | None, + Field( + description='Array of creative assets to sync (create or update)', + {MARK}, + max_length=100, + min_length=1, + ), + ] = None + """ + rewritten, count = _rewrite(source) + assert count == 1 + assert rewritten == textwrap.dedent("""\ + class X(AdCPBaseModel): + creatives: Annotated[ + list[Creative] | None, + Field( + description='Array of creative assets to sync (create or update)', + max_length=100, + min_length=1, + ), + NoExplicitNull, + ] = None + """) + + +def test_an_annotation_that_already_refuses_none_only_loses_the_keyword() -> None: + description = "Set to true to cancel the media buy. " * 3 + source = f"""\ + class X(AdCPBaseModel): + status: Annotated[Literal['removed'], Field({MARK})] = 'removed' + canceled: Annotated[ + Literal[True], + Field( + description='{description}', + {MARK}, + ), + ] = True + """ + rewritten, count = _rewrite(source) + assert count == 0 + assert "NoExplicitNull" not in rewritten + assert " status: Literal['removed'] = 'removed'\n" in rewritten + # A single long argument is kept on one inner line with no trailing comma, + # which is the form ``_CANCELED_FIELD_RE`` matches. + assert fixes._CANCELED_FIELD_RE.search(rewritten) is not None + + +def test_a_parenthesised_default_is_read_back_and_re_split_only_when_needed() -> None: + source = f"""\ + class X(AdCPBaseModel): + layout: Annotated[Layout | None, Field({MARK})] = ( + Layout.vertical + ) + """ + rewritten, _count = _rewrite(source, marker=None) + assert rewritten == textwrap.dedent("""\ + class X(AdCPBaseModel): + layout: Layout | None = Layout.vertical + """) + + +def test_black_forms_are_chosen_by_width() -> None: + long_description = "x" * 30 + source = f"""\ + class X(AdCPBaseModel): + short: Annotated[StrictBool | None, Field(description='Short one.', {MARK})] = False + longer_name_here: Annotated[ + StrictBool | None, Field(description='{long_description}', {MARK}) + ] = False + """ + rewritten, count = _rewrite(source) + assert count == 2 + lines = rewritten.splitlines() + short_field = "Annotated[StrictBool | None, Field(description='Short one.'), NoExplicitNull]" + assert f" short: {short_field} = False" in lines + # Too wide for one line and the head does not fit either: the subscript is + # split, with its content hugged on one inner line. + hugged = f"StrictBool | None, Field(description='{long_description}'), NoExplicitNull" + assert " longer_name_here: Annotated[" in lines + assert f" {hugged}" in lines + assert " ] = False" in lines + assert all(len(line) <= fixes._GENERATED_LINE_WIDTH for line in lines) + + +def test_marker_suppressed_gives_back_the_unmarked_rendering() -> None: + source = f"""\ + class X(AdCPBaseModel): + a: Annotated[str | None, Field({MARK})] = None + b: Annotated[str | None, Field(description='kept', {MARK})] = None + c: Annotated[str | None, Field({MARK}, max_length=3)] = None + """ + rewritten, count = _rewrite(source, marker=None) + assert count == 0 + assert rewritten == textwrap.dedent("""\ + class X(AdCPBaseModel): + a: str | None = None + b: Annotated[str | None, Field(description='kept')] = None + c: Annotated[str | None, Field(max_length=3)] = None + """) + + +@pytest.mark.parametrize( + "form", ["Field(json_schema_extra={'x-adcp-no-explicit-null': True, 'other': 1})"] +) +def test_a_json_schema_extra_that_is_not_only_the_mark_is_left_alone(form: str) -> None: + source = f"""\ + class X(AdCPBaseModel): + a: Annotated[str | None, {form}] = None + """ + rewritten, count = _rewrite(source) + assert count == 0 + assert form in rewritten + + +def test_non_ascii_text_before_the_mark_does_not_shift_the_edit() -> None: + """``ast`` columns are UTF-8 byte offsets; an em dash earlier on the line must not + corrupt the rewrite (a whole-tree run caught this once).""" + source = f"""\ + class X(AdCPBaseModel): + a: Annotated[str | None, Field(description='Explicit choice — True', {MARK})] = None + b: Annotated[ + str | None, + Field(description='Irreversible — canceled buys stop. Ünïcode.', {MARK}), + ] = None + """ + rewritten, count = _rewrite(source) + assert count == 2 + assert "Field(description='Explicit choice — True'), NoExplicitNull" in rewritten + assert ( + "Ünïcode.'), NoExplicitNull" in rewritten + or "Ünïcode.'),\n NoExplicitNull" in rewritten + ) diff --git a/tests/test_targeting_overlay_compat.py b/tests/test_targeting_overlay_compat.py index 2e6e668c2..5aa892c65 100644 --- a/tests/test_targeting_overlay_compat.py +++ b/tests/test_targeting_overlay_compat.py @@ -315,28 +315,28 @@ def test_bridge_serialization_keeps_objects_on_the_wire( @pytest.mark.parametrize("container,required", _CONTAINERS) -def test_container_json_schema_keeps_only_mutation_input_and_null( +def test_container_json_schema_keeps_only_mutation_input( container: type[BaseModel], required: dict[str, Any] ) -> None: - schema = container.model_json_schema() - assert schema["properties"]["targeting_overlay"]["anyOf"] == [ - {"$ref": "#/$defs/TargetingOverlayInput"}, - {"type": "null"}, - ] + # The bundled schema declares the overlay as the input shape with no null + # arm (the field is optional, not nullable), and the published schema + # states the same since ``NoExplicitNull`` (#1547). + node = container.model_json_schema()["properties"]["targeting_overlay"] + assert node["$ref"] == "#/$defs/TargetingOverlayInput" + assert "anyOf" not in node @pytest.mark.parametrize("container,required", _CONTAINERS) -def test_field_serialization_schema_keeps_only_mutation_input_and_null( +def test_field_serialization_schema_keeps_only_mutation_input( container: type[BaseModel], required: dict[str, Any] ) -> None: # Canonical parents have their own opaque wrap serializer. Check the field # contract directly rather than expecting properties in that parent schema. annotation = container.model_fields["targeting_overlay"].rebuild_annotation() schema = TypeAdapter(annotation).json_schema(mode="serialization") - assert schema["anyOf"] == [ - {"$ref": "#/$defs/TargetingOverlayInput"}, - {"type": "null"}, - ] + assert "anyOf" not in schema + assert schema["title"] == "TargetingOverlayInput" + assert schema["type"] == "object" @pytest.mark.parametrize( diff --git a/tests/test_targeting_overlay_schema.py b/tests/test_targeting_overlay_schema.py index d3495a3b9..e735ca680 100644 --- a/tests/test_targeting_overlay_schema.py +++ b/tests/test_targeting_overlay_schema.py @@ -23,7 +23,10 @@ _PACKAGE_PATH, ("properties", "new_packages", "anyOf", 0, "items", "properties", "targeting_overlay"), }, - "control_media_buy": {_PACKAGE_PATH}, + # ``packages`` is optional but not nullable on this request, so its + # advertised node is the array itself rather than an ``anyOf`` with a null + # arm (#1547). + "control_media_buy": {("properties", "packages", "items", "properties", "targeting_overlay")}, "buy_products": {("properties", "purchases", "items", "properties", "targeting_overlay")}, } @@ -52,21 +55,31 @@ def test_advertised_targeting_mutations_keep_input_only_schema(tool: str) -> Non # Independent mutation-only reference. Canonical create/update schemas # intentionally sanitize nested creative identities across their graph. - expected = _inline_refs(TypeAdapter(TargetingOverlayInput | None).json_schema()) + # The overlay property is optional, not nullable: the bundled schema + # declares the input shape with no null arm, and the advertised node now + # states the same (#1547), so the reference is the input shape itself. + expected = _inline_refs(TypeAdapter(TargetingOverlayInput).json_schema()) if tool in {"create_media_buy", "update_media_buy"}: expected = sanitize_canonical_schema(expected) - assert len(expected["anyOf"]) == 2 - assert expected["anyOf"][0]["title"] == "TargetingOverlayInput" - assert expected["anyOf"][1] == {"type": "null"} + assert expected["title"] == "TargetingOverlayInput" + assert expected["type"] == "object" for path, node in nodes.items(): - assert set(node) == {"anyOf", "default", "description", "title"}, (tool, path) - # The runtime bridge makes this field non-ref-shaped, so Pydantic adds - # this annotation (30 bytes/site). Pin it rather than permitting an - # arbitrary amount of metadata outside the mutation-only anyOf. + assert set(node) == { + "additionalProperties", + "default", + "description", + "properties", + "title", + "type", + }, (tool, path) + # Inlining the reference merges the input object into the field node; + # the field keeps its own title and description. Pin the keys rather + # than permitting an arbitrary amount of metadata around the object. assert node["title"] == "Targeting Overlay", (tool, path) - assert len(node["anyOf"]) == 2, (tool, path) - assert node["anyOf"] == expected["anyOf"], (tool, path) + assert node["type"] == "object", (tool, path) + assert node["properties"] == expected["properties"], (tool, path) + assert node["additionalProperties"] == expected["additionalProperties"], (tool, path) assert node["default"] is None assert isinstance(node["description"], str) actual_bytes = json.dumps(node, sort_keys=True).encode() @@ -96,7 +109,7 @@ def test_advertised_targeting_fields_carry_the_description_the_schema_declares() t["inputSchema"] for t in ADCP_TOOL_DEFINITIONS if t["name"] == "create_media_buy" ) overlay = next(iter(dict(_targeting_nodes(_inline_refs(advertised))).values())) - fields = next(arm for arm in overlay["anyOf"] if "properties" in arm)["properties"] + fields = overlay["properties"] assert set(fields) == set( TargetingOverlayInput.model_json_schema()["properties"] From 564e49f07b2d13b89fb2770726c52bd01f069378 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 23 Oct 2026 20:33:53 -0400 Subject: [PATCH 12/54] docs(review): require a pathway back for every minor-release wire change (#1563) * docs(review): require a pathway back for every 3.x wire change Adds the adcontextprotocol/adcp DR-0033 rule (and DR-0032's lossless-or-refuse translation bar) to PR review. Co-Authored-By: Claude Opus 5.5 (1M context) * docs(review): apply the pathway rule to every major's minor releases Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- LADON.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/LADON.md b/LADON.md index ac2439b59..a7a59db0b 100644 --- a/LADON.md +++ b/LADON.md @@ -87,6 +87,30 @@ reason, or silencing mypy with a blanket `# type: ignore` is a `high` finding. Specific codes (`# type: ignore[no-any-return]`) are fine — the blanket form is the problem. +### Mandatory: every minor-release wire change has a pathway back + +Per adcontextprotocol/adcp DR-0032 and DR-0033, the SDK lets application code +speak one AdCP release and translates for older counterparts. Minor releases +are non-breaking and backward compatible: on 3.x now, and in each later +major's own minors from 4.1 onward. Every PR that adds or changes support for a +wire field, enum value, task, union variant or semantic rule in a minor release +must also handle the previous minor of the same major, in one of four ways: + +- **Droppable:** older counterparts never see it. On a mutating request this + is allowed only if dropping it does not change spend, targeting, caps or + account identity. +- **Moves:** it maps to an exact equivalent in the older release, implemented + in the SDK's version-translation layer for both directions that exist. +- **Refused:** the call fails before sending, with a typed unsupported-feature + error. +- **Identity-bearing:** it changes an account or resource key, and follows the + one-to-one reduction rule (never merge two buyer accounts into one seller + account). + +The PR includes a test for its pathway. Missing pathway handling or a missing test is `high`. A +mutation-side field that is dropped when it should be refused is `high`. +Translation that drops or rewrites a field without reporting it is `high`. + ### Docs here are adopter-facing behavior `skills/*/SKILL.md`, `AGENTS.md`, `llms.txt`, and `README.md` document the build From 9e238f34f75870d4d27b07156e26093a11b30151 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 23 Oct 2026 23:09:31 -0400 Subject: [PATCH 13/54] fix(types): refuse a JSON null on reporting_webhook.operation_id too (#1570) #1555 added NoExplicitNull to every generated optional, non-nullable property, including push_notification_config.operation_id. The 3.2 forward-compat reporting_webhook.operation_id from #1552 is pinned to that field's constraints and kept the old metadata, so the parity test failed on main. Add the marker to the extension, compare the published schema against the push field, and cover the explicit-null refusal. Also raise the Postgres core conformance lane timeout from 30 to 45 minutes; it runs 25-29 minutes on main and slow runners cancelled it. Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/ci.yml | 3 ++- src/adcp/types/_forward_compat.py | 5 ++++- tests/test_reporting_webhook_operation_id.py | 19 ++++++++++++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7ccf64763..689855d76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -199,8 +199,9 @@ jobs: matrix: include: # Core also builds and installs the reporting wheel/sdist fixtures. + # It runs 25-29 minutes on main, so 30 cancelled it on slow runners. - lane: core - timeout_minutes: 30 + timeout_minutes: 45 - lane: process timeout_minutes: 15 services: diff --git a/src/adcp/types/_forward_compat.py b/src/adcp/types/_forward_compat.py index a3e8a4f92..42851a573 100644 --- a/src/adcp/types/_forward_compat.py +++ b/src/adcp/types/_forward_compat.py @@ -58,7 +58,7 @@ from pydantic_core import CoreSchema, InitErrorDetails, PydanticCustomError, core_schema from pydantic_core.core_schema import ErrorType -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types.canonical_creative import CreateMediaBuyRequest as _PublicCreateMediaBuyRequest from adcp.types.canonical_creative import PackageRequest as PublicPackageRequest from adcp.types.canonical_creative import PackageUpdate as PublicPackageUpdate @@ -156,6 +156,9 @@ "identical wire shape, so confirm seller support before relying on it." ), ), + # Optional, not nullable: a JSON null is refused, as on the generated + # push_notification_config.operation_id this field mirrors (#1547). + NoExplicitNull, ] if TYPE_CHECKING: diff --git a/tests/test_reporting_webhook_operation_id.py b/tests/test_reporting_webhook_operation_id.py index 6727ce410..07747ae79 100644 --- a/tests/test_reporting_webhook_operation_id.py +++ b/tests/test_reporting_webhook_operation_id.py @@ -220,7 +220,12 @@ def test_reporting_operation_id_constraints_match_push_notification_config() -> assert reporting.annotation == push.annotation assert [repr(item) for item in reporting.metadata] == [repr(item) for item in push.metadata] schema = ReportingWebhook.model_json_schema()["properties"]["operation_id"] - assert schema["anyOf"][0] == { + push_schema = PushNotificationConfig.model_json_schema()["properties"]["operation_id"] + wire_keys = ("type", "minLength", "maxLength", "pattern", "anyOf") + assert {key: schema.get(key) for key in wire_keys} == { + key: push_schema.get(key) for key in wire_keys + } + assert {key: schema[key] for key in ("type", "minLength", "maxLength", "pattern")} == { "type": "string", "minLength": 1, "maxLength": 255, @@ -229,6 +234,18 @@ def test_reporting_operation_id_constraints_match_push_notification_config() -> assert "MUST NOT emit `media_buy_delivery`" in schema["description"] +def test_reporting_operation_id_refuses_an_explicit_json_null() -> None: + """Omitted is fine; a JSON null is not, matching the generated push field.""" + base = { + "url": "https://buyer.example/reporting", + "authentication": _AUTHENTICATION, + "reporting_frequency": "daily", + } + assert ReportingWebhook.model_validate_json(json.dumps(base)).operation_id is None + with pytest.raises(ValidationError): + ReportingWebhook.model_validate_json(json.dumps({**base, "operation_id": None})) + + @pytest.mark.asyncio async def test_stored_registration_correlates_delivery_and_retry(tmp_path: Path) -> None: registration = ReportingWebhook( From d59eee9da4d246557bfacf42119729e1d68e78a7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 00:35:59 -0400 Subject: [PATCH 14/54] feat(reporting)!: ship the Reliable Reporting storage as adcp-reporting-storage (#1557) Publish the PostgreSQL ledger store, the chunked row storage with its filesystem, GCS, S3 and Azure providers, the host change feed, period retirement, row migration, the caller-ownership migration helpers and the PostgreSQL service factory as adcp-reporting-storage, a second distribution in this repository with its own Release Please component. adcp keeps the store protocols, in-memory stores, producer, handlers, row encoding and digests, and never imports the new package. The root component excludes packages/adcp-reporting-storage so storage-only commits do not release adcp. Fixes #1510. Refs #1490, #1488, #1489, #1491. BREAKING CHANGE: the PostgreSQL reporting stores, the row storage providers, the change feed, the ownership-migration helpers and `ReliableReportingService.postgres` leave `adcp`; install `adcp-reporting-storage[pg]` (plus `gcs`/`s3`/`azure`) and import them from `adcp_reporting_storage`. The `adcp[reporting-gcs]`, `[reporting-s3]` and `[reporting-azure]` extras are removed. See docs/reporting-storage-package-migration.md. --- .github/workflows/ci.yml | 58 +- .github/workflows/release-please.yml | 12 +- .github/workflows/release-publish.yml | 25 +- .gitignore | 1 + .release-please-manifest.json | 3 +- CHANGELOG.md | 26 + MIGRATION_v8_to_v9.md | 1 + Makefile | 21 +- README.md | 2 +- docs/releasing.md | 34 +- docs/reliable-reporting-service.md | 17 +- docs/reporting-adapter-testing.md | 4 +- docs/reporting-caller-ownership-migration.md | 6 +- docs/reporting-ledger-migration.md | 12 +- docs/reporting-notification-outbox.md | 8 +- docs/reporting-row-storage.md | 33 +- docs/reporting-status-notifications.md | 4 +- docs/reporting-storage-package-migration.md | 103 + docs/reporting-webhook-activity.md | 2 +- examples/reporting_durable_materializer.py | 3 +- examples/reporting_production.py | 4 +- examples/reporting_receipt_ingress.py | 8 +- examples/reporting_service_production.py | 4 +- examples/reporting_status_notifications.py | 5 +- examples/reporting_webhook_activity.py | 5 +- packages/adcp-reporting-storage/CHANGELOG.md | 12 + packages/adcp-reporting-storage/LICENSE | 17 + packages/adcp-reporting-storage/README.md | 71 + .../adcp-reporting-storage/pyproject.toml | 87 + .../src/adcp_reporting_storage/__init__.py | 353 + .../_inline_storage_schema.py | 0 .../adcp_reporting_storage/feed/__init__.py | 1 + .../src/adcp_reporting_storage}/feed/pg.py | 12 +- .../feed/required_schema.json | 0 .../adcp_reporting_storage}/feed/schema.py | 6 +- .../adcp_reporting_storage/inline_storage.py | 304 + .../adcp_reporting_storage/ledger/__init__.py | 1 + .../ledger/change_feed.py | 2 +- .../ledger/delivery_pg.py | 24 +- .../ledger/period_retirement.py | 12 +- .../src/adcp_reporting_storage}/ledger/pg.py | 524 +- .../ledger/reporting_buyer_submissions.sql | 0 .../ledger/reporting_caller_ownership.sql | 0 .../ledger/reporting_changes.sql | 0 .../ledger/reporting_feed.sql | 2 +- .../ledger/reporting_inline_storage.sql | 0 .../ledger/reporting_ledger.sql | 0 .../reporting_ledger_account_generations.sql | 0 .../reporting_ledger_obligation_currency.sql | 0 .../reporting_ledger_reconciliation.sql | 0 .../ledger/reporting_materializer.sql | 0 .../ledger/reporting_notification_outbox.sql | 0 .../ledger/reporting_period_retirement.sql | 0 .../ledger/reporting_production.sql | 0 .../ledger/reporting_projection.sql | 0 .../ledger/reporting_projection_feed.sql | 0 .../reporting_projection_notifications.sql | 0 .../reporting_provisional_observations.sql | 0 .../ledger/reporting_receipt_ingestion.sql | 0 .../ledger/reporting_row_migration.sql | 0 .../ledger/reporting_row_storage.sql | 0 .../ledger/reporting_status_notifications.sql | 0 .../reporting_status_selector_version.sql | 0 .../ledger/reporting_webhook_activity.sql | 0 .../ledger/required_provisional_schema.json | 0 .../ledger/required_row_storage_schema.json | 0 .../ledger/row_migration.py | 9 +- .../ledger/row_retention.py | 4 +- .../ledger/row_storage.py | 8 +- .../ledger/row_storage_azure.py | 6 +- .../ledger/row_storage_gcs.py | 6 +- .../ledger/row_storage_object.py | 0 .../ledger/row_storage_provider_io.py | 2 +- .../ledger/row_storage_s3.py | 6 +- .../ledger/row_write.py | 10 +- .../ledger/status_snapshot.py | 206 + .../materializer/__init__.py | 1 + .../materializer/pg.py | 49 +- .../materializer/required_schema.json | 0 .../materializer/schema.py | 4 +- .../src/adcp_reporting_storage}/migration.py | 4 +- .../adcp_reporting_storage/outbox/__init__.py | 1 + .../outbox/_activity_pg.py | 8 +- .../adcp_reporting_storage}/outbox/_schema.py | 4 +- .../src/adcp_reporting_storage}/outbox/pg.py | 32 +- .../outbox/required_schema.json | 0 .../outbox/required_status_schema.json | 0 .../required_status_selector_schema.json | 0 .../outbox/status_activity_pg.py | 13 +- .../outbox/status_pg.py | 65 +- .../outbox/status_schema.py | 8 +- .../production/__init__.py | 1 + .../adcp_reporting_storage}/production/pg.py | 184 +- .../production/required_schema.json | 0 .../production/schema.py | 8 +- .../production/service_context.sql | 0 .../production/service_context_schema.json | 0 .../projection/__init__.py | 1 + .../projection/notifications.py | 4 +- .../adcp_reporting_storage}/projection/pg.py | 28 +- .../projection/required_schema.json | 0 .../projection/schema.py | 8 +- .../src/adcp_reporting_storage/py.typed | 0 .../receipts/__init__.py | 1 + .../adcp_reporting_storage}/receipts/pg.py | 22 +- .../receipts/required_schema.json | 0 .../receipts/schema.py | 6 +- .../src/adcp_reporting_storage/service.py | 58 + .../submissions/__init__.py | 1 + .../adcp_reporting_storage}/submissions/pg.py | 4 +- pyproject.toml | 40 +- release-please-config.json | 45 +- scripts/ci/ci_scope.py | 1 + .../reporting_interop/python_core_server.py | 8 +- scripts/export_resolution.py | 7 +- scripts/generate_row_storage_catalog.py | 8 +- scripts/normalize_pyproject_prerelease.py | 29 +- ...regenerate_row_storage_interop_fixtures.py | 79 + src/adcp/reporting/feed/__init__.py | 16 +- src/adcp/reporting/inline_storage.py | 273 +- src/adcp/reporting/ledger/__init__.py | 54 +- src/adcp/reporting/ledger/_record_rows.py | 314 + src/adcp/reporting/ledger/producer.py | 24 +- src/adcp/reporting/ledger/status_snapshot.py | 182 +- src/adcp/reporting/ledger/store.py | 43 +- src/adcp/reporting/materializer/__init__.py | 15 +- src/adcp/reporting/materializer/capture.py | 20 - src/adcp/reporting/outbox/__init__.py | 42 +- src/adcp/reporting/outbox/_capabilities.py | 36 +- src/adcp/reporting/outbox/durable.py | 81 + src/adcp/reporting/outbox/memory.py | 12 + src/adcp/reporting/outbox/status_support.py | 41 +- src/adcp/reporting/outbox/support.py | 111 +- src/adcp/reporting/production/__init__.py | 25 +- .../reporting/production/delivery_window.py | 157 +- src/adcp/reporting/production/memory.py | 132 +- .../reporting/production/notifications.py | 96 +- src/adcp/reporting/production/service.py | 119 +- src/adcp/reporting/production/store.py | 182 + src/adcp/reporting/projection/__init__.py | 25 +- src/adcp/reporting/projection/memory.py | 10 +- src/adcp/reporting/projection/wire.py | 4 +- src/adcp/reporting/receipts/__init__.py | 16 +- src/adcp/reporting/service.py | 62 +- src/adcp/reporting/service_production.py | 37 +- src/adcp/reporting/submissions/__init__.py | 7 +- .../reporting/_buyer_submission_benchmark.py | 18 +- .../reporting/_buyer_submission_installed.py | 30 +- .../reporting/_buyer_submission_process.py | 24 +- .../reporting/_buyer_submission_support.py | 18 +- .../_durable_materializer_support.py | 17 +- .../reporting/_empty_ledger_support.py | 21 +- .../reporting/_feed_installed_base.py | 26 +- tests/conformance/reporting/_feed_process.py | 55 +- tests/conformance/reporting/_feed_support.py | 22 +- .../conformance/reporting/_frozen_status_c.py | 25 +- .../reporting/_hardening_installed.py | 24 +- .../reporting/_hardening_packaging.py | 96 +- .../reporting/_inline_storage_worker.py | 17 +- .../reporting/_late_account_server.py | 19 +- .../reporting/_legacy_row_first_lease.py | 18 +- .../reporting/_materializer_frozen.py | 54 +- .../reporting/_materializer_installed.py | 35 +- .../reporting/_materializer_process.py | 17 +- .../reporting/_ownership_archive_restart.py | 17 +- .../reporting/_ownership_legacy_seed.py | 58 +- .../reporting/_production_delivery_process.py | 23 +- .../reporting/_production_installed.py | 26 +- .../_production_installed_process.py | 27 +- .../reporting/_production_legacy_process.py | 33 +- .../reporting/_production_packaging.py | 71 +- .../reporting/_production_support.py | 22 +- .../reporting/_projection_support.py | 26 +- .../reporting/_provisional_catalog.py | 19 +- .../reporting/_rc6_continuation_process.py | 28 +- .../conformance/reporting/_receipt_frozen.py | 65 +- .../reporting/_receipt_installed.py | 18 +- .../conformance/reporting/_receipt_process.py | 17 +- .../conformance/reporting/_receipt_support.py | 17 +- .../reporting/_reconciliation_support.py | 18 +- .../reporting/_reliable_process.py | 24 +- .../reporting/_reliable_support.py | 20 +- .../reporting/_row_storage_interop.py | 85 +- .../reporting/_status_old_process.py | 24 +- .../conformance/reporting/_status_process.py | 25 +- .../reporting/test_pg_reporting_ledger.py | 3 +- .../test_reliable_reporting_core_recovery.py | 3 +- ...reliable_reporting_production_admission.py | 2 +- ...st_reliable_reporting_service_lifecycle.py | 5 +- .../test_reporting_activity_migration.py | 18 +- .../test_reporting_activity_schema_proof.py | 34 +- .../test_reporting_buyer_submission_cas.py | 4 +- ...st_reporting_buyer_submission_migration.py | 2 +- ...st_reporting_buyer_submission_packaging.py | 34 +- ...test_reporting_buyer_submission_process.py | 3 +- .../test_reporting_buyer_submission_seller.py | 2 +- .../test_reporting_buyer_submissions.py | 2 +- .../test_reporting_caller_ownership.py | 25 +- ...test_reporting_change_feed_consumers_pg.py | 5 +- .../test_reporting_change_feed_horizon_pg.py | 5 +- .../test_reporting_change_feed_pg.py | 7 +- .../test_reporting_conditional_metrics.py | 3 +- .../test_reporting_core_lifecycle.py | 5 +- .../reporting/test_reporting_currency.py | 2 +- .../test_reporting_currency_migration.py | 15 +- ...test_reporting_empty_legacy_replacement.py | 5 +- ...reporting_empty_legacy_replacement_sync.py | 3 +- .../test_reporting_empty_owned_replacement.py | 5 +- ...reporting_evidence_currency_integration.py | 8 +- ...test_reporting_feed_hardening_installed.py | 2 + .../reporting/test_reporting_feed_history.py | 2 +- .../test_reporting_feed_installed_pg.py | 10 +- .../test_reporting_feed_migration.py | 22 +- .../test_reporting_feed_packaging.py | 18 +- .../test_reporting_feed_transactions.py | 9 +- .../test_reporting_feed_transports.py | 3 +- .../test_reporting_generation_identity.py | 4 +- .../test_reporting_generation_migration.py | 12 +- .../reporting/test_reporting_host_reads_pg.py | 3 +- .../test_reporting_inline_storage.py | 15 +- .../test_reporting_materializer_durable.py | 8 +- ...est_reporting_materializer_installed_pg.py | 15 +- .../test_reporting_materializer_migration.py | 15 +- .../test_reporting_materializer_packaging.py | 29 +- .../test_reporting_materializer_progress.py | 7 +- .../test_reporting_materializer_service.py | 2 +- ...est_reporting_materializer_transactions.py | 4 +- .../test_reporting_notification_migration.py | 20 +- .../test_reporting_notification_packaging.py | 146 +- ...t_reporting_notification_process_matrix.py | 4 +- ...est_reporting_notification_transactions.py | 3 +- .../test_reporting_ownership_packaging.py | 39 +- .../test_reporting_period_retirement_pg.py | 9 +- ..._reporting_production_lease_generations.py | 7 +- .../test_reporting_production_lease_order.py | 13 +- .../test_reporting_production_lock_retry.py | 9 +- .../test_reporting_production_migration.py | 18 +- ...test_reporting_production_notifications.py | 2 +- .../test_reporting_production_packaging.py | 8 +- .../test_reporting_production_readiness.py | 10 +- .../test_reporting_production_rolling.py | 4 +- ...st_reporting_production_service_factory.py | 12 +- .../test_reporting_production_testing_kit.py | 7 +- .../test_reporting_production_transactions.py | 2 +- .../test_reporting_projection_history.py | 2 +- ...test_reporting_projection_notifications.py | 2 +- .../test_reporting_projection_rc6.py | 4 +- .../test_reporting_provisional_catalog.py | 14 +- ...est_reporting_provisional_catalog_scope.py | 14 +- .../test_reporting_provisional_readiness.py | 6 +- .../test_reporting_publication_time.py | 2 +- .../test_reporting_receipt_diagnostics.py | 17 +- .../reporting/test_reporting_receipt_graph.py | 6 +- .../test_reporting_receipt_installed_pg.py | 7 +- .../test_reporting_receipt_migration.py | 14 +- .../test_reporting_receipt_transports.py | 2 +- ...est_reporting_reconciliation_boundaries.py | 2 +- .../test_reporting_reconciliation_changes.py | 2 +- ...test_reporting_reconciliation_migration.py | 18 +- .../test_reporting_reconciliation_sql.py | 6 +- .../test_reporting_row_migration_pg.py | 5 +- .../test_reporting_row_retention_pg.py | 9 +- ...test_reporting_row_storage_alignment_pg.py | 5 +- .../test_reporting_row_storage_interop_pg.py | 29 +- .../test_reporting_row_storage_object_pg.py | 28 +- .../test_reporting_row_storage_pg.py | 20 +- ...test_reporting_row_storage_readiness_pg.py | 5 +- .../test_reporting_source_authorization.py | 20 +- .../test_reporting_status_boundaries.py | 15 +- .../test_reporting_status_capabilities.py | 2 +- .../test_reporting_status_delivery.py | 2 +- .../test_reporting_status_identity.py | 7 +- .../test_reporting_status_migration.py | 29 +- .../test_reporting_status_process_matrix.py | 13 +- ...st_reporting_status_projection_contract.py | 2 +- ...porting_status_restatement_transactions.py | 4 +- .../test_reporting_status_selector_epoch.py | 12 +- .../test_reporting_status_selector_pg.py | 7 +- .../test_reporting_status_transactions.py | 22 +- .../test_reporting_status_waiver_rc6.py | 7 +- tests/fixtures/public_api_snapshot.json | 78 +- .../js-written.json | 12957 +--------------- .../python-written.json | 12957 +--------------- tests/test_normalize_pyproject_prerelease.py | 37 + tests/test_release_configuration.py | 38 + tests/test_reporting_ledger.py | 3 +- tests/test_reporting_period_date.py | 6 +- .../test_reporting_production_maintenance.py | 38 +- tests/test_reporting_production_public.py | 36 +- ...test_reporting_provisional_observations.py | 2 +- tests/test_reporting_row_storage_azure.py | 7 +- .../test_reporting_row_storage_filesystem.py | 16 +- tests/test_reporting_row_storage_gcs.py | 15 +- tests/test_reporting_row_storage_s3.py | 7 +- tests/test_reporting_startup_recovery.py | 3 +- tests/test_reporting_storage_boundary.py | 135 + tests/test_reporting_typed_control_totals.py | 2 +- .../reliable_reporting_production_context.py | 4 +- .../type_checks/reporting_caller_ownership.py | 20 +- .../reporting_durable_materializer.py | 2 +- tests/type_checks/reporting_frozen_feed.py | 2 +- tests/type_checks/reporting_inline_storage.py | 2 +- .../reporting_notification_outbox.py | 3 +- .../type_checks/reporting_receipt_ingress.py | 2 +- .../reporting_reconciliation_records.py | 2 +- .../reporting_status_notifications.py | 8 +- .../type_checks/reporting_webhook_activity.py | 3 +- 307 files changed, 5347 insertions(+), 28245 deletions(-) create mode 100644 docs/reporting-storage-package-migration.md create mode 100644 packages/adcp-reporting-storage/CHANGELOG.md create mode 100644 packages/adcp-reporting-storage/LICENSE create mode 100644 packages/adcp-reporting-storage/README.md create mode 100644 packages/adcp-reporting-storage/pyproject.toml create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/_inline_storage_schema.py (100%) create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/__init__.py rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/feed/pg.py (96%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/feed/required_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/feed/schema.py (76%) create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/inline_storage.py create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/__init__.py rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/change_feed.py (99%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/delivery_pg.py (99%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/period_retirement.py (97%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/pg.py (91%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_buyer_submissions.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_caller_ownership.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_changes.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_feed.sql (96%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_inline_storage.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_ledger.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_ledger_account_generations.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_ledger_obligation_currency.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_ledger_reconciliation.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_materializer.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_notification_outbox.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_period_retirement.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_production.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_projection.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_projection_feed.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_projection_notifications.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_provisional_observations.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_receipt_ingestion.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_row_migration.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_row_storage.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_status_notifications.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_status_selector_version.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/reporting_webhook_activity.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/required_provisional_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/required_row_storage_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/row_migration.py (97%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/row_retention.py (99%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/row_storage.py (99%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/row_storage_azure.py (98%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/row_storage_gcs.py (98%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/row_storage_object.py (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/row_storage_provider_io.py (97%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/row_storage_s3.py (98%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/ledger/row_write.py (98%) create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/status_snapshot.py create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/__init__.py rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/materializer/pg.py (96%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/materializer/required_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/materializer/schema.py (93%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/migration.py (99%) create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/__init__.py rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/outbox/_activity_pg.py (97%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/outbox/_schema.py (98%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/outbox/pg.py (93%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/outbox/required_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/outbox/required_status_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/outbox/required_status_selector_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/outbox/status_activity_pg.py (86%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/outbox/status_pg.py (92%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/outbox/status_schema.py (89%) create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/production/__init__.py rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/production/pg.py (83%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/production/required_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/production/schema.py (76%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/production/service_context.sql (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/production/service_context_schema.json (100%) create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/__init__.py rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/projection/notifications.py (89%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/projection/pg.py (96%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/projection/required_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/projection/schema.py (72%) create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/py.typed create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/__init__.py rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/receipts/pg.py (97%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/receipts/required_schema.json (100%) rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/receipts/schema.py (76%) create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/service.py create mode 100644 packages/adcp-reporting-storage/src/adcp_reporting_storage/submissions/__init__.py rename {src/adcp/reporting => packages/adcp-reporting-storage/src/adcp_reporting_storage}/submissions/pg.py (99%) create mode 100644 scripts/regenerate_row_storage_interop_fixtures.py create mode 100644 src/adcp/reporting/ledger/_record_rows.py create mode 100644 src/adcp/reporting/outbox/durable.py create mode 100644 src/adcp/reporting/production/store.py create mode 100644 tests/test_reporting_storage_boundary.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 689855d76..61b81df6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -131,15 +131,15 @@ jobs: - name: Install dependencies run: | - uv pip install --system -e ".[dev]" + uv pip install --system -e ".[dev]" -e "./packages/adcp-reporting-storage" - name: Run linter if: matrix.python-version == '3.12' - run: ruff check src/ + run: ruff check src/ packages/adcp-reporting-storage/src/ - name: Run type checker if: matrix.python-version == '3.12' - run: mypy src/adcp/ + run: mypy src/adcp/ packages/adcp-reporting-storage/src/adcp_reporting_storage/ - name: Run adopter type-check suite if: matrix.python-version == '3.12' @@ -245,7 +245,7 @@ jobs: - name: Install dependencies (with [pg] extra) run: | - uv pip install --system -e ".[dev,pg]" + uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Run Postgres conformance tests env: @@ -356,8 +356,12 @@ jobs: npm ci npm run build test -f dist/lib/reporting/ledger/index.js + # SDK 16 moved the row storage into the @adcp/reporting-storage workspace + # package, which the root build does not compile; the interop driver reads it. + npm run build --workspace packages/reporting-storage + test -f node_modules/@adcp/reporting-storage/dist/index.js - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Run the interop test in both directions env: ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_interop_test @@ -445,7 +449,7 @@ jobs: - name: Install dependencies (with [pg] extra) run: | - uv pip install --system -e ".[dev,pg]" + uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Run status semantics, transaction, clock/process and maintenance gates env: @@ -508,7 +512,7 @@ jobs: prune-cache: true - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Run installed frozen binaries and materializer migrations # Propagate pytest failures through tee while retaining evidence for upload. shell: bash @@ -574,7 +578,7 @@ jobs: prune-cache: true - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Run receipt transactions, real mounts, crash and installed gates shell: bash timeout-minutes: 30 @@ -642,7 +646,7 @@ jobs: prune-cache: true - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Require stopped-worker retention, ownership backfill and receipt isolation env: ADCP_PG_TEST_URL: postgresql://postgres@localhost:5432/adcp_receipt_maintenance_test @@ -690,7 +694,7 @@ jobs: prune-cache: true - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Run frozen feeds, checkpoints, mounts, transaction faults and migrations shell: bash timeout-minutes: 20 @@ -773,7 +777,7 @@ jobs: prune-cache: true - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Require private feeds and legacy cursor invalidation after maintenance shell: bash timeout-minutes: 45 @@ -836,7 +840,7 @@ jobs: prune-cache: true - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Run installed base and PostgreSQL restart cells shell: bash timeout-minutes: 40 @@ -894,7 +898,7 @@ jobs: prune-cache: true - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Run complete production and projection conformance shell: bash # A passing suite plus harness cleanup can exceed 30 minutes. @@ -977,7 +981,7 @@ jobs: prune-cache: true - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Run installed source-equivalent production contract shell: bash timeout-minutes: 45 @@ -1045,7 +1049,7 @@ jobs: prune-cache: true - name: Install test dependencies - run: uv pip install --system -e ".[dev,pg]" + run: uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" - name: Require owned production admission and installed maintenance contracts shell: bash timeout-minutes: 42 @@ -1141,6 +1145,10 @@ jobs: python -m build --outdir dist/ python scripts/ci/check_distribution_size.py dist/ pip install dist/*.whl + # The storage distribution is built from its own tree and must + # install over the freshly built adcp wheel, never pull adcp from PyPI. + python -m build --outdir dist-storage/ packages/adcp-reporting-storage + pip install --no-deps dist-storage/*.whl - name: Verify prerelease dependency resolution imports server run: | @@ -1262,7 +1270,15 @@ jobs: ) assert adcp.get_adcp_version(), "ADCP_VERSION file is empty" - print(f"OK — adcp=={adcp.__version__}, spec={adcp.get_adcp_version()}") + # The storage distribution's facade is lazy and its stores work without a driver installed. + import sys + import adcp_reporting_storage + assert not [m for m in sys.modules if m.startswith("adcp_reporting_storage.")] + from adcp_reporting_storage import PgReportingLedgerStore, ReportingRowStorageOptionsV1 + assert "adcp.reporting.ledger.pg" not in sys.modules, "the ledger store moved; adcp must not ship it" + + print(f"OK — adcp=={adcp.__version__}, spec={adcp.get_adcp_version()}, " + f"adcp-reporting-storage=={adcp_reporting_storage.__version__}") PY schema-check: @@ -1291,7 +1307,7 @@ jobs: - name: Install dependencies run: | - uv pip install --system -e ".[dev]" + uv pip install --system -e ".[dev]" -e "./packages/adcp-reporting-storage" # Blocking. Grades the COMMITTED generated tree against a regeneration # from the COMMITTED schemas, so it runs before the sync steps below @@ -1665,7 +1681,7 @@ jobs: - name: Install dependencies run: | - uv pip install --system -e ".[dev,pg]" + uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" # Example-local deps: the v3 reference seller imports # sqlalchemy + asyncpg + httpx-respx but those aren't in the # SDK's [dev] extras. Install them inline rather than adding @@ -1740,7 +1756,7 @@ jobs: - name: Install dependencies run: | - uv pip install --system -e ".[dev,pg]" + uv pip install --system -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]" # Example-local deps: the v3 reference seller imports # sqlalchemy + asyncpg (async Postgres driver) which aren't # in the SDK's [dev,pg] extras. Install inline rather than @@ -2011,7 +2027,7 @@ jobs: - name: Install dependencies run: | - uv pip install --system -e ".[dev]" + uv pip install --system -e ".[dev]" -e "./packages/adcp-reporting-storage" - name: Map tenant subdomains to localhost run: | @@ -2148,7 +2164,7 @@ jobs: - name: Install dependencies run: | - uv pip install --system -e ".[dev]" + uv pip install --system -e ".[dev]" -e "./packages/adcp-reporting-storage" - name: Boot sales-proposal-mode seller run: | diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 5edaca795..5e2a4f2eb 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -45,10 +45,18 @@ jobs: env: GH_TOKEN: ${{ steps.app-token.outputs.token }} PROPOSAL_PRS: ${{ steps.release.outputs.prs }} - - name: Queue publication for the created release + - name: Queue publication for the created adcp release if: steps.release.outputs.release_created == 'true' env: GH_TOKEN: ${{ github.token }} RELEASE_SHA: ${{ steps.release.outputs.sha }} RELEASE_TAG: ${{ steps.release.outputs.tag_name }} - run: gh workflow run release-publish.yml --ref main -f release_sha="$RELEASE_SHA" -f tag_name="$RELEASE_TAG" + run: gh workflow run release-publish.yml --ref main -f package=adcp -f release_sha="$RELEASE_SHA" -f tag_name="$RELEASE_TAG" + - name: Queue publication for the created adcp-reporting-storage release + # Manifest mode prefixes every non-root package's outputs with its path. + if: steps.release.outputs['packages/adcp-reporting-storage--release_created'] == 'true' + env: + GH_TOKEN: ${{ github.token }} + RELEASE_SHA: ${{ steps.release.outputs['packages/adcp-reporting-storage--sha'] }} + RELEASE_TAG: ${{ steps.release.outputs['packages/adcp-reporting-storage--tag_name'] }} + run: gh workflow run release-publish.yml --ref main -f package=adcp-reporting-storage -f release_sha="$RELEASE_SHA" -f tag_name="$RELEASE_TAG" diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 51fd4a685..22105b728 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -3,6 +3,14 @@ name: Release publish on: workflow_dispatch: inputs: + package: + description: Distribution to publish (each Release Please component publishes on its own) + required: true + type: choice + default: adcp + options: + - adcp + - adcp-reporting-storage release_sha: description: Exact commit of the Release Please release required: true @@ -15,7 +23,7 @@ on: permissions: {} concurrency: - group: release-publish-${{ inputs.tag_name }} + group: release-publish-${{ inputs.package }}-${{ inputs.tag_name }} cancel-in-progress: false jobs: @@ -39,8 +47,15 @@ jobs: GH_TOKEN: ${{ github.token }} RELEASE_SHA: ${{ inputs.release_sha }} RELEASE_TAG: ${{ inputs.tag_name }} + PACKAGE: ${{ inputs.package }} run: | [[ "$RELEASE_SHA" =~ ^[0-9a-f]{40}$ ]] + # adcp tags are vX.Y.Z; the storage component tags carry its name. + case "$PACKAGE" in + adcp) [[ "$RELEASE_TAG" =~ ^v[0-9] ]] ;; + adcp-reporting-storage) [[ "$RELEASE_TAG" =~ ^adcp-reporting-storage-v[0-9] ]] ;; + *) echo "unknown package $PACKAGE" >&2; exit 1 ;; + esac test "$(git rev-parse "$RELEASE_TAG^{commit}")" = "$RELEASE_SHA" git merge-base --is-ancestor "$RELEASE_SHA" HEAD for attempt in {1..220}; do @@ -57,9 +72,15 @@ jobs: with: python-version: '3.12' - name: Build and check distributions + env: + PACKAGE: ${{ inputs.package }} run: | python -m pip install build twine - python -m build + if [ "$PACKAGE" = adcp ]; then + python -m build + else + python -m build --outdir dist/ packages/adcp-reporting-storage + fi python -m twine check dist/* - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: diff --git a/.gitignore b/.gitignore index d913805df..9c13fb12a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ build/ develop-eggs/ dist/ +dist-storage/ # Bundled schema copy — populated by scripts/bundle_schemas.py before # wheel build, mirrors schemas/cache/ so setuptools package-data ships # them. Committed copies would duplicate ~13MB across both trees. diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 30e6f9c78..4ce00a003 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,4 @@ { - ".": "9.0.0-beta.4" + ".": "9.0.0-beta.4", + "packages/adcp-reporting-storage": "0.1.0-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index f894071c5..354aed72e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ ### ⚠ BREAKING CHANGES +* **reporting:** the Reliable Reporting storage implementations ship as a + separate distribution, `adcp-reporting-storage`, maintained in this + repository and versioned on its own line (`packages/adcp-reporting-storage`). + The PostgreSQL ledger, reconciliation, materializer, receipt, feed, + projection, production, outbox, status, inline-storage and buyer-submission + stores, the chunked row storage with its filesystem, GCS, S3 and Azure + providers, the host change feed, period retirement, row migration, the + caller-ownership migration helpers and `ReliableReportingService.postgres` + moved out of `adcp.reporting`; the old import paths are gone, with no shims. + `adcp` keeps the store protocols, the in-memory stores, the producer, the + handlers, the row encoding and the store-contract errors, and never imports + the new package. The `adcp[reporting-gcs]`, `[reporting-s3]` and + `[reporting-azure]` extras are removed; `adcp[pg]` remains for the signing + and decisioning stores. Install `adcp-reporting-storage[pg]` (plus `gcs`, + `s3`, `azure`) and import from `adcp_reporting_storage`; the full old-to-new + table is in [docs/reporting-storage-package-migration.md](docs/reporting-storage-package-migration.md). + The composition now depends on store protocols + (`adcp.reporting.production.store.ReportingProductionStore`, + `adcp.reporting.outbox.durable.ReportingDurableNotificationLedger`, + `ReportingDurableStatusStore`, `adcp.reporting.ledger.ReportingRowStagingStore`, + `ReportingSourcePublicationStore`, `ReportingWorkerRetryPolicy`) instead of + recognizing the PostgreSQL classes by type; `ReliableReportingService` takes + `staging=`, `seals=` and `initialize_source_storage=`. The public-API + snapshot now covers `adcp.reporting`, `adcp.reporting.ledger` and + `adcp_reporting_storage`. Refs #1510, #1490, #1488, #1489. + * **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, diff --git a/MIGRATION_v8_to_v9.md b/MIGRATION_v8_to_v9.md index 85fd16530..b8b304869 100644 --- a/MIGRATION_v8_to_v9.md +++ b/MIGRATION_v8_to_v9.md @@ -15,6 +15,7 @@ changes below before upgrading each client, server and reporting worker. | `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. `Format` stays the lenient path for reading and publishing stored declarations; `ProductFormatDeclaration` is the authoring check. See [product declarations](docs/types-9-migration.md#product-declarations-are-graded-against-their-root-schema) and [strict authoring and lenient publishing](docs/types-9-migration.md#strict-authoring-lenient-reading-and-publishing). | | 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). | +| The PostgreSQL reporting stores and row storage providers ship as `adcp-reporting-storage` | Install `adcp-reporting-storage[pg]` (and `gcs`/`s3`/`azure`) and import from `adcp_reporting_storage`; `ReliableReportingService.postgres` is `adcp_reporting_storage.postgres_reliable_reporting_service`. `adcp` keeps the protocols, the in-memory stores and the digest code. See [the storage package migration](docs/reporting-storage-package-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). | | New source requests and sealed artifacts include their owning consumer | Read `request.identity.consumer_id` when implementing adapters. Historical ownerless artifacts retain their original hashes, but active owned generations refuse ownerless or differently owned frozen acquisitions; reconcile retained state and admit a new generation instead of relabelling evidence. See [reporting ownership migration](docs/reporting-caller-ownership-migration.md). | | URL canonicalization gives an empty path its `/` whatever the query does | The `@target-uri` signature base changes for bare-authority URLs such as `https://seller.example.com`. Dial signed clients at the explicit root path, which canonicalizes identically on both versions, or move an unrespellable agent URL's signer and verifier in one window. Identifier matching on `adagents.json`, `brand.json`, TMP and the governance issuer only becomes more permissive. See [the empty-path canonicalization fix](docs/request-signing-migration.md#6-upgrading-across-the-empty-path-canonicalization-fix). | diff --git a/Makefile b/Makefile index 64bf08d2f..9a8234b35 100644 --- a/Makefile +++ b/Makefile @@ -15,8 +15,11 @@ help: ## Show this help message @echo 'Available targets:' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' +STORAGE := packages/adcp-reporting-storage +STORAGE_SRC := $(STORAGE)/src/adcp_reporting_storage + install-dev: ## Legacy pip-only install; bootstrap is preferred for contributors - $(PIP) install -e ".[dev]" + $(PIP) install -e ".[dev]" -e "./$(STORAGE)[pg,gcs,s3,azure]" check-uv: @command -v $(UV) >/dev/null || { \ @@ -29,19 +32,19 @@ bootstrap: check-uv ## Recommended contributor setup: uv-managed deps and git ho $(UV) run --extra dev --group dev pre-commit install --hook-type commit-msg format: ## Format code with black (excludes generated files) - $(BLACK) src/ tests/ scripts/ + $(BLACK) src/ tests/ scripts/ $(STORAGE)/src/ @echo "✓ Code formatted successfully (_generated.py excluded via pyproject.toml)" -lint: ## Run linter (ruff) on source code - $(RUFF) check src/ +lint: ## Run linter (ruff) on source code (both distributions) + $(RUFF) check src/ $(STORAGE)/src/ @echo "✓ Linting passed" lint-all: ## Run linter (ruff) on source and tests - $(RUFF) check src/ tests/ + $(RUFF) check src/ $(STORAGE)/src/ tests/ @echo "✓ Source and test linting passed" -typecheck: ## Run type checker (mypy) on source code - $(MYPY) src/adcp/ +typecheck: ## Run type checker (mypy) on source code (both distributions) + $(MYPY) src/adcp/ $(STORAGE_SRC)/ @echo "✓ Type checking passed" typecheck-all: typecheck test-type-checks test-type-checks-pyright check-type-ignore-contract ## Run all type-check contracts @@ -117,6 +120,7 @@ ci-local: lint typecheck-all test validate-generated ## Run core CI checks local clean: ## Clean generated files and caches rm -rf build/ rm -rf dist/ + rm -rf dist-storage/ $(STORAGE)/build/ $(STORAGE)/src/*.egg-info rm -rf *.egg-info rm -rf .pytest_cache/ rm -rf .mypy_cache/ @@ -127,9 +131,10 @@ clean: ## Clean generated files and caches find . -type f -name "*.pyc" -delete @echo "✓ Cleaned all generated files and caches" -build: ## Build distribution packages +build: ## Build distribution packages (adcp into dist/, adcp-reporting-storage into dist-storage/) $(PYTHON) scripts/bundle_schemas.py python -m build + python -m build --outdir dist-storage/ $(STORAGE) @echo "✓ Distribution packages built" # Development workflow commands diff --git a/README.md b/README.md index b6a3a675c..351669a66 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ This README serves both sides of an AdCP integration. Jump to what you're doing: - **Connect as a buyer** → [Quick Start: Test Helpers](#quick-start-test-helpers) and [Quick Start: Distributed Operations](#quick-start-distributed-operations). Entry point: `from adcp import ADCPClient, AgentConfig`; start with the `client.simple.*` API. - **Build a seller / agent** → [Building an AdCP Agent](#building-an-adcp-agent). Entry point: `from adcp.server import ADCPHandler, serve`; use the [production seller path](docs/production-seller.md) when adding tenants, durable tasks, and webhooks. -- **Run Reliable Reporting** → [Account currencies](docs/reporting-currency.md), [ledger migrations](docs/reporting-ledger-migration.md), [reconciliation storage](docs/reporting-reconciliation-storage.md), and [durable status notifications](docs/reporting-status-notifications.md). +- **Run Reliable Reporting** → [Account currencies](docs/reporting-currency.md), [ledger migrations](docs/reporting-ledger-migration.md), [reconciliation storage](docs/reporting-reconciliation-storage.md), and [durable status notifications](docs/reporting-status-notifications.md). The PostgreSQL and object-storage stores are the separate [`adcp-reporting-storage`](packages/adcp-reporting-storage/README.md) distribution. - **Understand the type system & imports** → [Type Safety](#type-safety) (import surface, partial modules, cold-start note). - **Test against reference agents** → [Quick Start: Test Helpers](#quick-start-test-helpers) and [Test Helpers](#test-helpers). Entry point: `from adcp.testing import test_agent, creative_agent`. diff --git a/docs/releasing.md b/docs/releasing.md index f3a8ce8d1..5b1a930f1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -52,11 +52,43 @@ One-time setup: required reviewer and `main` as its deployment branch. - In PyPI → adcp → Publishing, add a Trusted Publisher for owner `adcontextprotocol`, repository `adcp-client-python`, workflow - `release-publish.yml`, and environment `release-publish`. + `release-publish.yml`, and environment `release-publish`. The + `adcp-reporting-storage` project needs the same (see below). - Keep the existing `IPR_APP_ID` and `IPR_APP_PRIVATE_KEY` secrets available to `release-please.yml`. The App needs repository contents and pull-request write access. +## adcp-reporting-storage + +`packages/adcp-reporting-storage` is a second Release Please component +(`separate-pull-requests` is on, so it gets its own release PR) on the same +beta channel. Its tags carry the component name +(`adcp-reporting-storage-v0.1.0-beta.1`); the root keeps bare `vX.Y.Z` tags. +Only commits that touch `packages/adcp-reporting-storage/` bump it, and the +root package lists that path in `exclude-paths` (Release Please otherwise hands +the root every commit), so a protocol fix in `adcp` does not release the storage +package and a storage fix does not release `adcp`. A commit touching both trees +counts for both. Keep storage changes out of a root `Release-As:` commit, or the +storage package takes that version too. Conventional-commit scopes are free text, but +`feat(reporting-storage):` keeps the two changelogs readable. + +On a merge, `release-please.yml` queues `release-publish.yml` with +`package=adcp-reporting-storage`; the workflow builds that package's sdist and +wheel from `packages/adcp-reporting-storage` and publishes them through the same +`release-publish` environment approval. The storage wheel pins +`adcp>=9.0.0bN,<10`; publish the `adcp` release it depends on first. + +One-time setup, before the first storage release: + +- In PyPI, add a **pending** Trusted Publisher for a new project named + `adcp-reporting-storage` (owner `adcontextprotocol`, repository + `adcp-client-python`, workflow `release-publish.yml`, environment + `release-publish`). A pending publisher creates the project on first upload; + without it the publish job fails and the `adcp` release is unaffected. +- The first release PR bumps `0.1.0-beta.0` (the manifest's bootstrap value, + never published) to `0.1.0-beta.1`. Check the PR's `pyproject.toml` version + was normalized to `0.1.0b1` the same way the root is. + If publication fails, fix the cause and rerun `release-publish.yml` for the same release SHA and tag. PyPI will reject files it has already accepted; check its published file list before rerunning a partial upload. Do not create another diff --git a/docs/reliable-reporting-service.md b/docs/reliable-reporting-service.md index a68c9927a..dffee6fd7 100644 --- a/docs/reliable-reporting-service.md +++ b/docs/reliable-reporting-service.md @@ -189,7 +189,7 @@ The PostgreSQL factory installs ledger, source staging, and replay-seal schemas in the caller's pool. Ordinary adapter registrations use those durable stores; restart replay reads the sealed bytes without refetching the source. Open the pool before starting the service and close it after service shutdown settles. -This is a change for existing `postgres()` callers, including those that do not +This is a change for existing PostgreSQL factory callers, including those that do not enable `production`: `initialize()` now bootstraps `reporting_inline_objects` and `reporting_inline_seals` in addition to the ledger schema. Before upgrading, ensure the pool's startup role can run that source-schema DDL. A DDL-restricted @@ -251,16 +251,13 @@ The SDK constructs the component graph: ```python from psycopg_pool import AsyncConnectionPool -from adcp.reporting import ( - ReliableReportingService, - ReportingProductionOptions, - ReportingServiceOffering, -) +from adcp.reporting import ReportingProductionOptions, ReportingServiceOffering +from adcp_reporting_storage import postgres_reliable_reporting_service pool = AsyncConnectionPool(DATABASE_URL, open=False) await pool.open() -reporting = ReliableReportingService.postgres( +reporting = postgres_reliable_reporting_service( pool=pool, account_context=resolve_reporting_context, consumer_status_enabled=True, @@ -323,7 +320,8 @@ contracts, and [the wiring example](../examples/reporting_service_production.py) ## Advanced composition -`postgres()` without production options remains the Core factory. Advanced +`postgres_reliable_reporting_service()` (from `adcp_reporting_storage`) without +production options remains the Core factory. Advanced adopters can still inject workers and receipt handlers, pass explicit `staging=`/`seals=` to `sources.register`, use `constituent_of=` for a custom row identity, or register a complete executor and object reader. Explicit stores are @@ -424,7 +422,8 @@ when its official close becomes due. Every automatic read still waits for its ac offering to be ready. For Core service composition, set `read_jitter_window=timedelta(...)` on -`ReliableReportingService.memory(...)`, `.postgres(...)`, or the constructor. +`ReliableReportingService.memory(...)`, `postgres_reliable_reporting_service(...)`, +or the constructor. For managed production, set the same option on `ReportingProductionOptions`. Direct `ReportingProducer` users can set it on the producer. A nonnegative duration is required; `timedelta(0)` opts out. Managed production owns this diff --git a/docs/reporting-adapter-testing.md b/docs/reporting-adapter-testing.md index 235dfd701..f8714faa5 100644 --- a/docs/reporting-adapter-testing.md +++ b/docs/reporting-adapter-testing.md @@ -31,12 +31,12 @@ Use a request/capability fixture that matches the adapter's declared offering, scope, currency, metrics, and source-local period. The SDK's `redacted_*` fixtures describe a sample provider, not every provider's contract. -To test source replay through PostgreSQL, install `adcp[pg]`, use an isolated +To test source replay through PostgreSQL, install `adcp-reporting-storage[pg]`, use an isolated test database/schema, and inject the public stores. Initialize the schema before wrapping a store; failure wrappers borrow it and do not own DDL or the pool. ```python -from adcp.reporting.inline_storage import PgReportingSealStore, PgReportingStagingStore +from adcp_reporting_storage.inline_storage import PgReportingSealStore, PgReportingStagingStore from adcp.reporting.testing import run_reporting_adapter_conformance diff --git a/docs/reporting-caller-ownership-migration.md b/docs/reporting-caller-ownership-migration.md index 09f2f4e37..7f41c7487 100644 --- a/docs/reporting-caller-ownership-migration.md +++ b/docs/reporting-caller-ownership-migration.md @@ -76,7 +76,7 @@ If reporting was enabled but no configuration or other reporting state was ever admitted, use the explicit operator API without creating a quarantine archive: ```python -from adcp.reporting.migration import replace_empty_legacy_reporting +from adcp_reporting_storage.migration import replace_empty_legacy_reporting await replace_empty_legacy_reporting(operator_connection) await store.create_schema() @@ -116,7 +116,7 @@ itself called from async code. import sqlalchemy as sa from alembic import op -from adcp.reporting.migration import ( +from adcp_reporting_storage.migration import ( replace_empty_legacy_reporting_sync, replace_empty_owned_reporting_sync, ) @@ -245,7 +245,7 @@ immutable record identifiers it has already consumed. ## Operator example ```python -from adcp.reporting.migration import ( +from adcp_reporting_storage.migration import ( ReportingOwnershipBackfill, backfill_legacy_reporting, legacy_generation_digest, migrate_legacy_reporting, ) diff --git a/docs/reporting-ledger-migration.md b/docs/reporting-ledger-migration.md index 5bb3c7577..38ca19b7b 100644 --- a/docs/reporting-ledger-migration.md +++ b/docs/reporting-ledger-migration.md @@ -78,17 +78,17 @@ Custom ledger stores must implement `RetryScheduleStore`, or provide a durable store with no shared retry state to avoid silently retrying on every replica. For deployments managed by a migration tool, the standalone migration is -[`reporting_ledger_account_generations.sql`](../src/adcp/reporting/ledger/reporting_ledger_account_generations.sql). +[`reporting_ledger_account_generations.sql`](../packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_account_generations.sql). It upgrades an existing beta.15 ledger by itself, including in autocommit mode. For a combined bootstrap and upgrade, run all five bundled files in one transaction: ```sh psql "$REPORTING_DATABASE_URL" --set=ON_ERROR_STOP=1 --single-transaction \ - -f src/adcp/reporting/ledger/reporting_ledger.sql \ - -f src/adcp/reporting/ledger/reporting_ledger_account_generations.sql \ - -f src/adcp/reporting/ledger/reporting_ledger_obligation_currency.sql \ - -f src/adcp/reporting/ledger/reporting_ledger_reconciliation.sql \ - -f src/adcp/reporting/ledger/reporting_notification_outbox.sql + -f packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger.sql \ + -f packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_account_generations.sql \ + -f packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_obligation_currency.sql \ + -f packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_reconciliation.sql \ + -f packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_notification_outbox.sql ``` Use the ledger's existing `search_path` and a role that owns its tables. All diff --git a/docs/reporting-notification-outbox.md b/docs/reporting-notification-outbox.md index d89581553..17fb3f47f 100644 --- a/docs/reporting-notification-outbox.md +++ b/docs/reporting-notification-outbox.md @@ -30,12 +30,8 @@ existing behavior. A memory outbox shares its ledger's rollback boundary but has no process-crash durability. ```python -from adcp.reporting.ledger import PgReportingReconciliationStore -from adcp.reporting.outbox import ( - PgReportingOutbox, - ReportingEnvelopeCipher, - ReportingNotificationWorker, -) +from adcp.reporting.outbox import ReportingEnvelopeCipher, ReportingNotificationWorker +from adcp_reporting_storage import PgReportingOutbox, PgReportingReconciliationStore ledger = PgReportingReconciliationStore(pool=pool, notifications=True) await ledger.create_schema() diff --git a/docs/reporting-row-storage.md b/docs/reporting-row-storage.md index 6725454c3..7c8d3ec48 100644 --- a/docs/reporting-row-storage.md +++ b/docs/reporting-row-storage.md @@ -12,7 +12,7 @@ writes the same tables and the same bytes, so chunks written by either SDK verif ## Enable it ```python -from adcp.reporting.ledger import PgReportingLedgerStore, ReportingRowStorageOptionsV1 +from adcp_reporting_storage import PgReportingLedgerStore, ReportingRowStorageOptionsV1 store = PgReportingLedgerStore( pool=pool, @@ -123,13 +123,13 @@ configuration the allowlist: the database records which binding each revision us can never point reads or writes at a destination the host did not configure. ```python -from adcp.reporting.ledger import ( +from adcp_reporting_storage import ( PgReportingLedgerStore, ReportingObjectRowBindingV1, ReportingPostgresRowBindingV1, ReportingRowStorageOptionsV1, + create_filesystem_reporting_row_object_provider_v1, ) -from adcp.reporting.ledger.row_storage_object import create_filesystem_reporting_row_object_provider_v1 store = PgReportingLedgerStore( pool=pool, @@ -206,19 +206,19 @@ await store.create_schema() ### Cloud providers Cloud providers wrap official clients that the host constructs with its own credentials, so the extras only need to -be installed where those clients are created: `pip install 'adcp[reporting-gcs]'`, `'adcp[reporting-s3]'` or -`'adcp[reporting-azure]'`. A binding's `location` holds only non-secret coordinates, and its optional +be installed where those clients are created: `pip install 'adcp-reporting-storage[gcs]'`, `'adcp-reporting-storage[s3]'` or +`'adcp-reporting-storage[azure]'`. A binding's `location` holds only non-secret coordinates, and its optional `credential_ref` names one of the clients you injected; an unknown reference fails `UNSAFE_BINDING`. Provider errors surface only as stable `ReportingRowStoreError` codes. Row buckets are separate from Managed Delivery buckets. A native version always identifies exactly one write, and every read and delete is pinned to it. An ETag is never enough, because an identical upload re-created at the same key has the same ETag and a delayed delete would remove the replacement. -**Google Cloud Storage** (`adcp.reporting.ledger.row_storage_gcs`): +**Google Cloud Storage** (`adcp_reporting_storage.ledger.row_storage_gcs`): ```python from google.cloud import storage -from adcp.reporting.ledger.row_storage_gcs import create_gcs_reporting_row_object_provider_v1 +from adcp_reporting_storage.ledger.row_storage_gcs import create_gcs_reporting_row_object_provider_v1 gcs = create_gcs_reporting_row_object_provider_v1( storage=storage.Client(), # keyless credentials: workload identity or ADC @@ -236,11 +236,11 @@ gcs = create_gcs_reporting_row_object_provider_v1( retention-driven deletion in the ledger, not in bucket lifecycle. - The client needs `storage.buckets.get` for the probe plus object create, get and delete. -**Amazon S3 and S3-compatible stores** (`adcp.reporting.ledger.row_storage_s3`): +**Amazon S3 and S3-compatible stores** (`adcp_reporting_storage.ledger.row_storage_s3`): ```python import boto3 -from adcp.reporting.ledger.row_storage_s3 import create_s3_reporting_row_object_provider_v1 +from adcp_reporting_storage.ledger.row_storage_s3 import create_s3_reporting_row_object_provider_v1 s3 = create_s3_reporting_row_object_provider_v1(client=boto3.client("s3", region_name="us-east-1")) # ReportingObjectRowBindingV1(provider="s3", location={"bucket": "acme-adcp-rows", "region": "us-east-1"}, ...) @@ -263,12 +263,12 @@ s3 = create_s3_reporting_row_object_provider_v1(client=boto3.client("s3", region - The client needs `s3:ListBucket`, `s3:GetLifecycleConfiguration`, `s3:GetBucketVersioning`, object put and get, `s3:GetObjectVersion` and `s3:DeleteObjectVersion`. `s3:GetBucketPolicyStatus` is optional. -**Azure Blob Storage** (`adcp.reporting.ledger.row_storage_azure`): +**Azure Blob Storage** (`adcp_reporting_storage.ledger.row_storage_azure`): ```python from azure.identity import DefaultAzureCredential from azure.storage.blob import BlobServiceClient -from adcp.reporting.ledger.row_storage_azure import create_azure_blob_reporting_row_object_provider_v1 +from adcp_reporting_storage.ledger.row_storage_azure import create_azure_blob_reporting_row_object_provider_v1 azure = create_azure_blob_reporting_row_object_provider_v1( client=BlobServiceClient("https://acmerows.blob.core.windows.net", credential=DefaultAzureCredential()) @@ -374,10 +374,13 @@ ADCP_JS_SDK_DIR=/path/to/adcp-client ADCP_PG_TEST_URL=postgresql://... \ pytest tests/conformance/reporting/test_reporting_row_storage_interop_pg.py ``` -It needs Node and a built `adcp-client` checkout (`npm ci && npm run build`); without them it skips. The rows each SDK -wrote are committed under `tests/fixtures/reporting-row-storage-interop-v1/` (refresh them with -`ADCP_UPDATE_INTEROP_FIXTURES=1`), and the replay tests load them into a fresh database and bucket and verify them -here, so Python CI does not need Node. The JavaScript SDK can replay `python-written.json` the same way. +It needs Node and a built `adcp-client` checkout (`npm ci && npm run build && npm run build --workspace +packages/reporting-storage`, since SDK 16 ships the row storage as `@adcp/reporting-storage`). Locally it skips without +them; CI runs it with `ADCP_REQUIRE_JS_INTEROP=1`, so a missing build fails the lane. The rows each SDK wrote are +committed under `tests/fixtures/reporting-row-storage-interop-v1/` (refresh them with +`scripts/regenerate_row_storage_interop_fixtures.py`), and the replay tests load them into a fresh database and bucket +and verify them here, so Python CI does not need Node. A replay test fails, rather than skips, when a fixture is +missing. The JavaScript SDK has no replay of `python-written.json`; the live test is what proves that direction. ## Host change feed and consumers diff --git a/docs/reporting-status-notifications.md b/docs/reporting-status-notifications.md index cdb6555e3..974727c87 100644 --- a/docs/reporting-status-notifications.md +++ b/docs/reporting-status-notifications.md @@ -2,7 +2,7 @@ This caller-ownership release requires all reporting readers, admission and workers to stop during upgrade. Earlier additive or rolling procedures below apply only to pre-ownership releases; use the [maintenance and recovery contract](reporting-caller-ownership-migration.md) for retained state. -Install `adcp[pg]` and mount the optional status lifecycle to publish +Install `adcp-reporting-storage[pg]` and mount the optional status lifecycle to publish `reporting.status_changed`. `get_reporting_status` remains authoritative. C supports Core status; Managed/Reconciled materialization and receipt expiry clocks remain in D. An account with destination bindings cannot advertise C @@ -104,7 +104,7 @@ wire flags. `ReportingLedgerStore` and `ReportingNotificationOutbox` implementations retain their structural contracts. Pure types and memory implementations import without PostgreSQL. PostgreSQL exports load lazily and construction without the extra -raises an actionable `adcp[pg]` installation hint. +raises an actionable `adcp-reporting-storage[pg]` installation hint. `PgStatusNotificationStore.create_schema()` executes the six existing ledger/A/B steps plus `reporting_status_notifications.sql` and diff --git a/docs/reporting-storage-package-migration.md b/docs/reporting-storage-package-migration.md new file mode 100644 index 000000000..a7b55ec06 --- /dev/null +++ b/docs/reporting-storage-package-migration.md @@ -0,0 +1,103 @@ +# Reporting storage moved to `adcp-reporting-storage` + +SDK 9.0.0 ships the Reliable Reporting *storage implementations* as a separate +distribution, `adcp-reporting-storage`, maintained in this repository and +versioned on its own line ([#1510](https://github.com/adcontextprotocol/adcp-client-python/issues/1510)). +`adcp` keeps the store protocols, the in-memory stores, the producer, the +handlers, the row encoding and the digests. The PostgreSQL stores, the chunked +row storage with its filesystem, GCS, S3 and Azure providers, the host change +feed, period retirement, the ownership-migration helpers and the PostgreSQL +service factory moved. + +This is a clean cut, not a deprecation: nothing is on PyPI stable, the 9.0 +betas are the only adopters, and the JavaScript SDK made the same cut during +its 16.0 beta (`@adcp/reporting-storage`). The old import paths no longer +exist, and `adcp` never imports the new package (a test enforces it). + +## Install + +| Before | After | +| --- | --- | +| `pip install "adcp-reporting-storage[pg]"` for the reporting stores | `pip install "adcp-reporting-storage[pg]"` (installs `adcp` too) | +| `adcp-reporting-storage[gcs]`, `adcp-reporting-storage[s3]`, `adcp-reporting-storage[azure]` | `adcp-reporting-storage[gcs]`, `[s3]`, `[azure]` | +| `adcp-reporting-storage[pg]` for `adcp.signing.PgReplayStore` and the decisioning stores | unchanged | + +Pin both: `adcp-reporting-storage` depends on `adcp>=9.0.0bN,<10` and tracks +one `adcp` major. + +## Imports + +Every moved name is importable from the top-level `adcp_reporting_storage` +package, lazily (no driver is imported until a store is constructed). The deep +paths mirror `adcp.reporting`: `adcp.reporting..pg` became +`adcp_reporting_storage..pg`. + +| Old | New | +| --- | --- | +| `adcp.reporting.ledger.PgReportingLedgerStore`, `.ledger.pg` | `adcp_reporting_storage.PgReportingLedgerStore` (`adcp_reporting_storage.ledger.pg`) | +| `adcp.reporting.ledger.PgReportingReconciliationStore`, `.ledger.delivery_pg` | `adcp_reporting_storage.PgReportingReconciliationStore` (`.ledger.delivery_pg`) | +| `adcp.reporting.materializer.PgReportingMaterializerStore` | `adcp_reporting_storage.PgReportingMaterializerStore` (`.materializer.pg`) | +| `adcp.reporting.receipts.PgReportingReceiptStore` | `adcp_reporting_storage.PgReportingReceiptStore` (`.receipts.pg`) | +| `adcp.reporting.feed.PgReportingFeedStore` | `adcp_reporting_storage.PgReportingFeedStore` (`.feed.pg`) | +| `adcp.reporting.projection.PgReportingProjectionStore`, `PgReportingStatusProjection`, `PgReportingProjectionOutbox` | `adcp_reporting_storage.*` (`.projection.pg`, `.projection.notifications`) | +| `adcp.reporting.production.PgReportingProductionStore`, `PgReportingProductionOutbox` | `adcp_reporting_storage.*` (`.production.pg`) | +| `adcp.reporting.outbox.PgReportingOutbox`, `PgReportingStatusOutbox`, `PgStatusNotificationStore`, `PgReportingActivityUnionStore` | `adcp_reporting_storage.*` (`.outbox.pg`, `.outbox.status_pg`, `.outbox.status_activity_pg`) | +| `adcp.reporting.submissions.PgReportingSubmissionIntentStore` | `adcp_reporting_storage.PgReportingSubmissionIntentStore` (`.submissions.pg`) | +| `adcp.reporting.inline_storage.PgReportingStagingStore`, `PgReportingSealStore` | `adcp_reporting_storage.inline_storage.*` (also top-level) | +| `adcp.reporting.migration` (`migrate_legacy_reporting`, `backfill_legacy_reporting`, `replace_empty_legacy_reporting`, `replace_empty_legacy_reporting_sync`, `replace_empty_owned_reporting`, `replace_empty_owned_reporting_sync`, `ReportingOwnershipBackfill`, `ReportingOwnershipMigrationError`, …) | `adcp_reporting_storage.migration` (also top-level) | +| `adcp.reporting.ledger.row_storage*`, `row_write`, `row_migration`, `row_retention`, `period_retirement`, `change_feed` | `adcp_reporting_storage.ledger.` | +| `adcp.reporting.ledger.ReportingRowStorageOptionsV1`, `ReportingObjectRowBindingV1`, `ReportingPostgresRowBindingV1`, `ReportingRowBindingSelectionInputV1`, `ReportingRowIntentSweepV1`, `ReportingRowPruneV1`, `ReportingPeriodRetirementV1`, `ReportingChange*V1`, `ReportingChangeCursorExpiredError` | `adcp_reporting_storage.` | +| `adcp.reporting.ledger.row_storage_object.ReportingRowObjectProviderV1`, `create_filesystem_reporting_row_object_provider_v1`, `run_reporting_row_object_provider_conformance_v1`, `reporting_period_date_v1` | `adcp_reporting_storage.` (`.ledger.row_storage_object`) | +| `adcp.reporting.ledger.row_storage_gcs.create_gcs_reporting_row_object_provider_v1`, `..._s3...`, `..._azure...` | `adcp_reporting_storage.` | +| `adcp.reporting.ledger.status_snapshot.read_snapshot_on`, `settle_snapshot_on`, `persist_replay_lifecycles_on`, `apply_intents_on` | `adcp_reporting_storage.ledger.status_snapshot.` | +| `adcp.reporting.outbox._schema`, `status_schema`; `adcp.reporting..schema` validators and `required_*.json` manifests | `adcp_reporting_storage.outbox._schema`, `.status_schema`; `adcp_reporting_storage..schema` | +| `postgres_reliable_reporting_service(pool=..., ...)` | `adcp_reporting_storage.postgres_reliable_reporting_service(pool=..., ...)` (same keywords) | +| DDL at `adcp/reporting/ledger/*.sql` | `adcp_reporting_storage/ledger/*.sql`; `importlib.resources.files("adcp_reporting_storage.ledger")` | + +What stays in `adcp.reporting.ledger`: `ReportingLedgerStore` and every other +store protocol, `InMemoryReportingLedgerStore`, `ReportingProducer`, +`ReportingStatusHandler`, `ReportingRowStoreError` and +`ReportingRowStoreErrorCode` (store-contract errors every implementation +raises), `ReportingRowPage`, `row_encoding` (the canonical row bytes and +digests), and the `delivery_changes` reconciliation feed port. + +## New protocols in `adcp` + +The protocol layer used to recognize the PostgreSQL classes by `type(...)`. +It now asks the store. If you implement a store, these are additive and +optional; the in-memory stores implement them, and `adcp-reporting-storage` +implements them for PostgreSQL: + +- `adcp.reporting.production.store.ReportingProductionStore` and + `ReportingProductionProjection`: what `ReportingProductionSupport` and + `ReportingProductionOptions` compose over (queues, projection, schema + readiness, delivery windows, maintenance passes). +- `adcp.reporting.outbox.durable.ReportingDurableNotificationLedger` and + `ReportingDurableStatusStore`: how the capability checks prove a durable + notification, activity and status chain. +- `adcp.reporting.ledger.ReportingRowStagingStore`, + `ReportingSourcePublicationStore`, `ReportingWorkerRetryPolicy`: the + optional hooks the producer used to reach through `getattr` + ([#1489](https://github.com/adcontextprotocol/adcp-client-python/issues/1489), + minimal form; the staged-handle variant is a follow-up). +- `ReliableReportingService(...)` takes `staging=`, `seals=` and + `initialize_source_storage=` so a storage distribution can supply durable + source stores without reaching into the service. + +Public attributes replaced private ones where the composition reads them: +`store.production_support` (was `_production_support`), +`store.projection_read_policy` / `projection_read_escalation`, +`store.activate_production(...)` (was `_activate_production`), +`store.source_publication(...)` (was `_source_publication`), +`store.worker_lock_errors()` (was `_worker_lock_errors`), and +`outbox.next_due_account(delivery=..., now=...)`. + +## Tests and tooling + +The conformance tests keep living under `tests/` and import +`adcp_reporting_storage`; CI installs both distributions in every lane +(`uv pip install -e ".[dev,pg]" -e "./packages/adcp-reporting-storage[pg,gcs,s3,azure]"`). +`make lint`, `make typecheck` and `make build` cover both. The public-API +snapshot (`tests/fixtures/public_api_snapshot.json`) now records +`adcp.reporting`, `adcp.reporting.ledger` and `adcp_reporting_storage` +([#1490](https://github.com/adcontextprotocol/adcp-client-python/issues/1490)). diff --git a/docs/reporting-webhook-activity.md b/docs/reporting-webhook-activity.md index 36c93e188..e66a619d9 100644 --- a/docs/reporting-webhook-activity.md +++ b/docs/reporting-webhook-activity.md @@ -13,7 +13,7 @@ are mounted. ## Mounting and lifecycle -Install `adcp[pg]` and use PostgreSQL 16. The +Install `adcp-reporting-storage[pg]` and use PostgreSQL 16. The [fully typed example](../examples/reporting_webhook_activity.py) shows trusted identity resolution, registration persistence, migration, capability validation, both worker phases, HTTP serving, bounded draining, and pool shutdown. diff --git a/examples/reporting_durable_materializer.py b/examples/reporting_durable_materializer.py index ebbf1e0bf..83dd75c17 100644 --- a/examples/reporting_durable_materializer.py +++ b/examples/reporting_durable_materializer.py @@ -10,8 +10,9 @@ import asyncio +from adcp_reporting_storage import PgReportingMaterializerStore + from adcp.reporting.materializer import ( - PgReportingMaterializerStore, ReportingDestinationIO, ReportingDestinationResolver, ReportingDestinationWriter, diff --git a/examples/reporting_production.py b/examples/reporting_production.py index c5ea501a8..5c4728f38 100644 --- a/examples/reporting_production.py +++ b/examples/reporting_production.py @@ -8,6 +8,8 @@ from __future__ import annotations +from adcp_reporting_storage import PgReportingProductionStore, PgReportingStatusProjection + from adcp.decisioning.registry import BuyerAgentRegistry from adcp.reporting.materializer import ( ReportingDestinationIO, @@ -20,7 +22,6 @@ ReportingSubscriptionResolver, ) from adcp.reporting.production import ( - PgReportingProductionStore, ReportingProductionConfigurationTask, ReportingProductionDestination, ReportingProductionOffering, @@ -28,7 +29,6 @@ ReportingProductionSupport, production_notification_workers, ) -from adcp.reporting.projection import PgReportingStatusProjection from adcp.reporting.receipts import ReceiptAccountResolver from adcp.server import serve from adcp.server.auth import BearerTokenAuth, auth_context_factory diff --git a/examples/reporting_receipt_ingress.py b/examples/reporting_receipt_ingress.py index 6320124ce..3e7ff6566 100644 --- a/examples/reporting_receipt_ingress.py +++ b/examples/reporting_receipt_ingress.py @@ -7,13 +7,11 @@ from __future__ import annotations +from adcp_reporting_storage import PgReportingReceiptStore + from adcp.decisioning.registry import BuyerAgentRegistry from adcp.reporting.feed import ReportingFeedStore -from adcp.reporting.receipts import ( - PgReportingReceiptStore, - ReceiptAccountResolver, - ReportingReceiptHandler, -) +from adcp.reporting.receipts import ReceiptAccountResolver, ReportingReceiptHandler from adcp.server import serve from adcp.server.auth import BearerTokenAuth, auth_context_factory diff --git a/examples/reporting_service_production.py b/examples/reporting_service_production.py index 0afde0869..ba8357941 100644 --- a/examples/reporting_service_production.py +++ b/examples/reporting_service_production.py @@ -1,3 +1,5 @@ +from adcp_reporting_storage import postgres_reliable_reporting_service + """Compose PostgreSQL reporting from registered adapters and trusted providers. See docs/reliable-reporting-service.md for ReportingProductionOptions inputs. @@ -33,7 +35,7 @@ def compose_service( settles; configure owned pools with ReportingServiceResource when needed. Reporting admission comes from the typed sync_accounts task, never configure. """ - service = ReliableReportingService.postgres( + service = postgres_reliable_reporting_service( pool=pool, account_context=account_context, production=production ) for name, adapter in adapters.items(): diff --git a/examples/reporting_status_notifications.py b/examples/reporting_status_notifications.py index e5efe2db8..a11b7ec1b 100644 --- a/examples/reporting_status_notifications.py +++ b/examples/reporting_status_notifications.py @@ -1,4 +1,4 @@ -"""Strictly typed #1168C Core mounting; install ``adcp[pg]``. +"""Strictly typed #1168C Core mounting; install ``adcp-reporting-storage[pg]``. The adopter supplies real account configuration and immutable revision-content handlers, trusted caller resolution and a durable subscription/key registry. @@ -13,19 +13,18 @@ from copy import deepcopy from typing import Any, Protocol +from adcp_reporting_storage import PgReportingLedgerStore, PgStatusNotificationStore from psycopg_pool import AsyncConnectionPool from pydantic import BaseModel from adcp.reporting.ledger import ( ConsumerStatusIngest, - PgReportingLedgerStore, ReportingDeliveryEscalation, ReportingStatusCallerResolver, ReportingStatusHandler, ReportingStatusNotificationHandler, ) from adcp.reporting.outbox import ( - PgStatusNotificationStore, ReportingEnvelopeCipher, ReportingNotificationError, ReportingNotificationSubscription, diff --git a/examples/reporting_webhook_activity.py b/examples/reporting_webhook_activity.py index efdaecaa2..49392b77e 100644 --- a/examples/reporting_webhook_activity.py +++ b/examples/reporting_webhook_activity.py @@ -1,4 +1,4 @@ -"""Typed #1168B mounting/lifecycle example; install ``adcp[pg]``. +"""Typed #1168B mounting/lifecycle example; install ``adcp-reporting-storage[pg]``. Call ``register_approved_subscription`` after account authorization and endpoint proof verification. ``run_reporting_service`` accepts the adopter's existing @@ -12,6 +12,7 @@ from collections.abc import Awaitable, Callable, Sequence from typing import Protocol +from adcp_reporting_storage import PgReportingLedgerStore, PgReportingOutbox from psycopg_pool import AsyncConnectionPool from adcp.decisioning import ( @@ -21,9 +22,7 @@ ) from adcp.decisioning.accounts import ResolveContext from adcp.decisioning.handler import PlatformHandler -from adcp.reporting.ledger import PgReportingLedgerStore from adcp.reporting.outbox import ( - PgReportingOutbox, ReportingActivityProjector, ReportingActivitySupport, ReportingEnvelopeCipher, diff --git a/packages/adcp-reporting-storage/CHANGELOG.md b/packages/adcp-reporting-storage/CHANGELOG.md new file mode 100644 index 000000000..15f6f65ba --- /dev/null +++ b/packages/adcp-reporting-storage/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## Unreleased + +### Features + +* **reporting-storage:** first release. The PostgreSQL ledger, reconciliation, + materializer, receipt, feed, projection, production, outbox, status, inline + storage and buyer submission stores, the chunked row storage with its + filesystem, Google Cloud Storage, Amazon S3 and Azure Blob providers, the + host change feed, period retirement, row migration and the caller-ownership + migration helpers, moved here from `adcp.reporting` (adcp-client-python#1510). diff --git a/packages/adcp-reporting-storage/LICENSE b/packages/adcp-reporting-storage/LICENSE new file mode 100644 index 000000000..4aa20f8c0 --- /dev/null +++ b/packages/adcp-reporting-storage/LICENSE @@ -0,0 +1,17 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +Copyright 2025 AdCP Community + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/packages/adcp-reporting-storage/README.md b/packages/adcp-reporting-storage/README.md new file mode 100644 index 000000000..63ede42ae --- /dev/null +++ b/packages/adcp-reporting-storage/README.md @@ -0,0 +1,71 @@ +# adcp-reporting-storage + +PostgreSQL and object-storage implementations of the AdCP Reliable Reporting +stores, maintained in the [adcp-client-python](https://github.com/adcontextprotocol/adcp-client-python) +repository and versioned on its own line. + +[`adcp`](https://pypi.org/project/adcp/) defines the store protocols, the +in-memory stores, the producer, the `get_reporting_status` handlers, the +reconciliation logic and the row encoding. This distribution implements those +protocols durably: + +| What | Where | +| --- | --- | +| Core ledger, reconciliation, materializer, receipt, feed, projection and production stores over PostgreSQL | `adcp_reporting_storage.ledger.pg`, `.ledger.delivery_pg`, `.materializer.pg`, `.receipts.pg`, `.feed.pg`, `.projection.pg`, `.production.pg` | +| Notification, status and activity queues over PostgreSQL | `adcp_reporting_storage.outbox.pg`, `.outbox.status_pg`, `.outbox.status_activity_pg` | +| Chunked revision row storage, with filesystem, Google Cloud Storage, Amazon S3 and Azure Blob providers | `adcp_reporting_storage.ledger.row_storage*` | +| The host change feed, period retirement, row migration and retention | `adcp_reporting_storage.ledger.change_feed`, `.period_retirement`, `.row_migration`, `.row_retention` | +| Durable inline source staging and seals, buyer submission intents | `adcp_reporting_storage.inline_storage`, `.submissions.pg` | +| Caller-ownership migration helpers | `adcp_reporting_storage.migration` | +| The adapter-first `ReliableReportingService` over a borrowed pool | `adcp_reporting_storage.service` | + +Every public name is also importable from the top-level package, lazily: + +```python +from psycopg_pool import AsyncConnectionPool + +from adcp.reporting.ledger import ProducerOfferings, ReportingProducer +from adcp_reporting_storage import PgReportingLedgerStore, ReportingRowStorageOptionsV1 + +async with AsyncConnectionPool(dsn, min_size=2, max_size=10) as pool: + store = PgReportingLedgerStore( + pool=pool, + row_storage=ReportingRowStorageOptionsV1(deployment_namespace="prod-us"), + ) + await store.create_schema() + producer = ReportingProducer(source=..., offerings=ProducerOfferings(...), store=store) + await producer.run_worker() +``` + +## Install + +```bash +pip install "adcp-reporting-storage[pg]" # PostgreSQL stores (psycopg) +pip install "adcp-reporting-storage[pg,gcs]" # + rows on Google Cloud Storage +pip install "adcp-reporting-storage[pg,s3]" # + rows on Amazon S3 +pip install "adcp-reporting-storage[pg,azure]" # + rows on Azure Blob Storage +``` + +`adcp` is a dependency and is installed with it. The cloud providers take a +client you build; the extras only pin the client libraries. + +## Compatibility + +This package implements the protocols of one `adcp` major and imports a few +private helpers of `adcp.reporting` to do so (`adcp.reporting.ledger._record_rows`, +`_delivery_state`, `status_snapshot`). Its dependency range is therefore one +major (`adcp>=9.0.0b4,<10`), and a new `adcp` major means a new release here. +`adcp` never imports this package; a test in the main repository enforces that. + +## Versioning + +0.x: breaking changes may land in a minor release, each with a migration note in +[CHANGELOG.md](CHANGELOG.md). The package reaches 1.0 when the storage layout +shared with the JavaScript SDK (`@adcp/reporting-storage`) is declared stable. + +## Documentation + +The guides live in the repository: [Reliable Reporting service](../../docs/reliable-reporting-service.md), +[row storage](../../docs/reporting-row-storage.md), [ledger migrations](../../docs/reporting-ledger-migration.md), +[caller-ownership migration](../../docs/reporting-caller-ownership-migration.md), +and the [migration note for the 9.0 split](../../docs/reporting-storage-package-migration.md). diff --git a/packages/adcp-reporting-storage/pyproject.toml b/packages/adcp-reporting-storage/pyproject.toml new file mode 100644 index 000000000..a650f5126 --- /dev/null +++ b/packages/adcp-reporting-storage/pyproject.toml @@ -0,0 +1,87 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "adcp-reporting-storage" +version = "0.1.0b0" +description = "PostgreSQL and object-storage implementations of the AdCP Reliable Reporting stores" +authors = [ + {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} +] +readme = "README.md" +requires-python = ">=3.10" +license = {text = "Apache-2.0"} +keywords = ["adcp", "reporting", "postgresql", "advertising"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Libraries :: Python Modules", + "Typing :: Typed", +] + +dependencies = [ + # The store protocols, in-memory stores, producer, handlers, row encoding + # and record codecs this distribution implements against. The stores in + # this package subclass nothing in ``adcp`` but import its private + # ``adcp.reporting`` helpers, so the range is one major: a new ``adcp`` + # major means a new release here. + "adcp>=9.0.0b4,<10", + # RFC 8785 canonical JSON for row bodies and object keys; the same pin as adcp. + "rfc8785>=0.1.4", +] + +[project.optional-dependencies] +pg = [ + # Every store in this package is PostgreSQL-backed. psycopg3 ships both + # sync and async pool interfaces; the stores take an AsyncConnectionPool. + "psycopg[binary]>=3.1.0", + "psycopg-pool>=3.2.0", +] +gcs = [ + # Row storage on Google Cloud Storage. The provider takes a client you build, + # with keyless credentials; it never imports the library itself. + "google-cloud-storage>=2.18", +] +s3 = [ + # Row storage on Amazon S3 or an S3-compatible store (needs bucket versioning). + "boto3>=1.35", +] +azure = [ + # Row storage on Azure Blob Storage (needs blob versioning on the account). + "azure-storage-blob>=12.20", +] + +[project.urls] +Homepage = "https://github.com/adcontextprotocol/adcp-client-python" +Documentation = "https://github.com/adcontextprotocol/adcp-client-python/blob/main/packages/adcp-reporting-storage/README.md" +Repository = "https://github.com/adcontextprotocol/adcp-client-python" +Issues = "https://github.com/adcontextprotocol/adcp-client-python/issues" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +adcp_reporting_storage = [ + "py.typed", + "ledger/*.sql", + "ledger/*.json", + "outbox/*.json", + "materializer/*.json", + "receipts/*.json", + "feed/*.json", + "projection/*.json", + "production/*.json", + "production/*.sql", +] + +# Inside this repository the dependency is the checkout, not PyPI. uv ignores +# this when the package is installed from a built distribution. +[tool.uv.sources] +adcp = { workspace = true } diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py new file mode 100644 index 000000000..8979c0afc --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/__init__.py @@ -0,0 +1,353 @@ +"""PostgreSQL and object-storage implementations of the AdCP Reliable Reporting stores. + +``adcp`` defines the store protocols, the in-memory stores, the producer, the +handlers and the row encoding. This distribution implements those protocols +over PostgreSQL and, for revision rows, over a filesystem or an object store +(Google Cloud Storage, Amazon S3, Azure Blob Storage). It is versioned on its +own line so a storage fix never has to ship as a protocol-client release. + +What you assemble:: + + from psycopg_pool import AsyncConnectionPool + from adcp.reporting.ledger import ProducerOfferings, ReportingProducer + from adcp_reporting_storage import PgReportingLedgerStore + + store = PgReportingLedgerStore(pool=pool) + await store.create_schema() + producer = ReportingProducer(source=..., offerings=..., store=store) + +or, for the adapter-first service:: + + from adcp_reporting_storage import postgres_reliable_reporting_service + + reporting = postgres_reliable_reporting_service(pool=pool, account_context=...) + +Names are resolved lazily (:pep:`562`): importing this package loads no driver +and no cloud SDK until the first store or provider is touched. The layout +mirrors ``adcp.reporting`` (``adcp_reporting_storage.ledger.pg`` is where +``PgReportingLedgerStore`` is defined), so a deep import is a supported +address too. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + __version__: str + from adcp_reporting_storage.feed.pg import PgReportingFeedStore as PgReportingFeedStore + from adcp_reporting_storage.inline_storage import ( + PgReportingSealStore as PgReportingSealStore, + ) + from adcp_reporting_storage.inline_storage import ( + PgReportingStagingStore as PgReportingStagingStore, + ) + from adcp_reporting_storage.ledger.change_feed import ( + ReportingChangeCursorExpiredError as ReportingChangeCursorExpiredError, + ) + from adcp_reporting_storage.ledger.change_feed import ( + ReportingChangeKindV1 as ReportingChangeKindV1, + ) + from adcp_reporting_storage.ledger.change_feed import ( + ReportingChangeRecordV1 as ReportingChangeRecordV1, + ) + from adcp_reporting_storage.ledger.change_feed import ( + ReportingChangesPageV1 as ReportingChangesPageV1, + ) + from adcp_reporting_storage.ledger.delivery_pg import ( + PgReportingReconciliationStore as PgReportingReconciliationStore, + ) + from adcp_reporting_storage.ledger.period_retirement import ( + ReportingPeriodRetirementV1 as ReportingPeriodRetirementV1, + ) + from adcp_reporting_storage.ledger.pg import ( + PgReportingLedgerStore as PgReportingLedgerStore, + ) + from adcp_reporting_storage.ledger.row_migration import ( + ReportingRowMigrationBatchV1 as ReportingRowMigrationBatchV1, + ) + from adcp_reporting_storage.ledger.row_migration import ( + ReportingRowQuarantineReason as ReportingRowQuarantineReason, + ) + from adcp_reporting_storage.ledger.row_retention import ( + ReportingRowIntentSweepV1 as ReportingRowIntentSweepV1, + ) + from adcp_reporting_storage.ledger.row_retention import ( + ReportingRowPruneV1 as ReportingRowPruneV1, + ) + from adcp_reporting_storage.ledger.row_storage import ( + ReportingObjectRowBindingV1 as ReportingObjectRowBindingV1, + ) + from adcp_reporting_storage.ledger.row_storage import ( + ReportingPostgresRowBindingV1 as ReportingPostgresRowBindingV1, + ) + from adcp_reporting_storage.ledger.row_storage import ( + ReportingRowBindingSelectionInputV1 as ReportingRowBindingSelectionInputV1, + ) + from adcp_reporting_storage.ledger.row_storage import ( + ReportingRowStorageOptionsV1 as ReportingRowStorageOptionsV1, + ) + from adcp_reporting_storage.ledger.row_storage import ( + ReportingRowStorageV1 as ReportingRowStorageV1, + ) + from adcp_reporting_storage.ledger.row_storage_azure import ( + create_azure_blob_reporting_row_object_provider_v1 as create_azure_blob_reporting_row_object_provider_v1, # noqa: E501 + ) + from adcp_reporting_storage.ledger.row_storage_gcs import ( + assert_gcs_reporting_row_bucket_policy_v1 as assert_gcs_reporting_row_bucket_policy_v1, + ) + from adcp_reporting_storage.ledger.row_storage_gcs import ( + create_gcs_reporting_row_object_provider_v1 as create_gcs_reporting_row_object_provider_v1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectContextV1 as ReportingRowObjectContextV1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectDeleteInputV1 as ReportingRowObjectDeleteInputV1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectGetInputV1 as ReportingRowObjectGetInputV1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectProviderV1 as ReportingRowObjectProviderV1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectPutInputV1 as ReportingRowObjectPutInputV1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectPutResultV1 as ReportingRowObjectPutResultV1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectStatInputV1 as ReportingRowObjectStatInputV1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectStatResultV1 as ReportingRowObjectStatResultV1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + create_filesystem_reporting_row_object_provider_v1 as create_filesystem_reporting_row_object_provider_v1, # noqa: E501 + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + render_reporting_row_object_key_v1 as render_reporting_row_object_key_v1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + reporting_period_date_v1 as reporting_period_date_v1, + ) + from adcp_reporting_storage.ledger.row_storage_object import ( + run_reporting_row_object_provider_conformance_v1 as run_reporting_row_object_provider_conformance_v1, # noqa: E501 + ) + from adcp_reporting_storage.ledger.row_storage_s3 import ( + create_s3_reporting_row_object_provider_v1 as create_s3_reporting_row_object_provider_v1, + ) + from adcp_reporting_storage.materializer.pg import ( + PgReportingMaterializerStore as PgReportingMaterializerStore, + ) + from adcp_reporting_storage.migration import ( + ReportingOwnershipBackfill as ReportingOwnershipBackfill, + ) + from adcp_reporting_storage.migration import ( + ReportingOwnershipMigrationError as ReportingOwnershipMigrationError, + ) + from adcp_reporting_storage.migration import ( + backfill_legacy_reporting as backfill_legacy_reporting, + ) + from adcp_reporting_storage.migration import ( + legacy_generation_digest as legacy_generation_digest, + ) + from adcp_reporting_storage.migration import ( + migrate_legacy_reporting as migrate_legacy_reporting, + ) + from adcp_reporting_storage.migration import ( + replace_empty_legacy_reporting as replace_empty_legacy_reporting, + ) + from adcp_reporting_storage.migration import ( + replace_empty_legacy_reporting_sync as replace_empty_legacy_reporting_sync, + ) + from adcp_reporting_storage.migration import ( + replace_empty_owned_reporting as replace_empty_owned_reporting, + ) + from adcp_reporting_storage.migration import ( + replace_empty_owned_reporting_sync as replace_empty_owned_reporting_sync, + ) + from adcp_reporting_storage.migration import ( + require_owned_schema_or_empty as require_owned_schema_or_empty, + ) + from adcp_reporting_storage.outbox.pg import PgReportingOutbox as PgReportingOutbox + from adcp_reporting_storage.outbox.status_activity_pg import ( + PgReportingActivityUnionStore as PgReportingActivityUnionStore, + ) + from adcp_reporting_storage.outbox.status_pg import ( + PgReportingStatusOutbox as PgReportingStatusOutbox, + ) + from adcp_reporting_storage.outbox.status_pg import ( + PgStatusNotificationStore as PgStatusNotificationStore, + ) + from adcp_reporting_storage.production.pg import ( + PgReportingProductionOutbox as PgReportingProductionOutbox, + ) + from adcp_reporting_storage.production.pg import ( + PgReportingProductionStore as PgReportingProductionStore, + ) + from adcp_reporting_storage.projection.notifications import ( + PgReportingProjectionOutbox as PgReportingProjectionOutbox, + ) + from adcp_reporting_storage.projection.pg import ( + PgReportingProjectionStore as PgReportingProjectionStore, + ) + from adcp_reporting_storage.projection.pg import ( + PgReportingStatusProjection as PgReportingStatusProjection, + ) + from adcp_reporting_storage.receipts.pg import ( + PgReportingReceiptStore as PgReportingReceiptStore, + ) + from adcp_reporting_storage.service import ( + postgres_reliable_reporting_service as postgres_reliable_reporting_service, + ) + from adcp_reporting_storage.submissions.pg import ( + PgReportingSubmissionIntentStore as PgReportingSubmissionIntentStore, + ) + +#: Public name -> (module under this package, attribute). Resolved on first use. +_LAZY_EXPORTS: dict[str, tuple[str, str]] = { + # Stores + "PgReportingLedgerStore": ("ledger.pg", "PgReportingLedgerStore"), + "PgReportingReconciliationStore": ("ledger.delivery_pg", "PgReportingReconciliationStore"), + "PgReportingMaterializerStore": ("materializer.pg", "PgReportingMaterializerStore"), + "PgReportingReceiptStore": ("receipts.pg", "PgReportingReceiptStore"), + "PgReportingFeedStore": ("feed.pg", "PgReportingFeedStore"), + "PgReportingProjectionStore": ("projection.pg", "PgReportingProjectionStore"), + "PgReportingStatusProjection": ("projection.pg", "PgReportingStatusProjection"), + "PgReportingProjectionOutbox": ("projection.notifications", "PgReportingProjectionOutbox"), + "PgReportingProductionStore": ("production.pg", "PgReportingProductionStore"), + "PgReportingProductionOutbox": ("production.pg", "PgReportingProductionOutbox"), + "PgReportingOutbox": ("outbox.pg", "PgReportingOutbox"), + "PgReportingStatusOutbox": ("outbox.status_pg", "PgReportingStatusOutbox"), + "PgStatusNotificationStore": ("outbox.status_pg", "PgStatusNotificationStore"), + "PgReportingActivityUnionStore": ("outbox.status_activity_pg", "PgReportingActivityUnionStore"), + "PgReportingSubmissionIntentStore": ("submissions.pg", "PgReportingSubmissionIntentStore"), + "PgReportingStagingStore": ("inline_storage", "PgReportingStagingStore"), + "PgReportingSealStore": ("inline_storage", "PgReportingSealStore"), + # The adapter-first service over PostgreSQL + "postgres_reliable_reporting_service": ("service", "postgres_reliable_reporting_service"), + # Caller-ownership migration + "ReportingOwnershipBackfill": ("migration", "ReportingOwnershipBackfill"), + "ReportingOwnershipMigrationError": ("migration", "ReportingOwnershipMigrationError"), + "backfill_legacy_reporting": ("migration", "backfill_legacy_reporting"), + "legacy_generation_digest": ("migration", "legacy_generation_digest"), + "migrate_legacy_reporting": ("migration", "migrate_legacy_reporting"), + "replace_empty_legacy_reporting": ("migration", "replace_empty_legacy_reporting"), + "replace_empty_legacy_reporting_sync": ("migration", "replace_empty_legacy_reporting_sync"), + "replace_empty_owned_reporting": ("migration", "replace_empty_owned_reporting"), + "replace_empty_owned_reporting_sync": ("migration", "replace_empty_owned_reporting_sync"), + "require_owned_schema_or_empty": ("migration", "require_owned_schema_or_empty"), + # Host change feed + "ReportingChangeCursorExpiredError": ( + "ledger.change_feed", + "ReportingChangeCursorExpiredError", + ), + "ReportingChangeKindV1": ("ledger.change_feed", "ReportingChangeKindV1"), + "ReportingChangeRecordV1": ("ledger.change_feed", "ReportingChangeRecordV1"), + "ReportingChangesPageV1": ("ledger.change_feed", "ReportingChangesPageV1"), + # Row storage: options, bindings, maintenance results + "ReportingRowStorageV1": ("ledger.row_storage", "ReportingRowStorageV1"), + "ReportingRowStorageOptionsV1": ("ledger.row_storage", "ReportingRowStorageOptionsV1"), + "ReportingObjectRowBindingV1": ("ledger.row_storage", "ReportingObjectRowBindingV1"), + "ReportingPostgresRowBindingV1": ("ledger.row_storage", "ReportingPostgresRowBindingV1"), + "ReportingRowBindingSelectionInputV1": ( + "ledger.row_storage", + "ReportingRowBindingSelectionInputV1", + ), + "ReportingRowIntentSweepV1": ("ledger.row_retention", "ReportingRowIntentSweepV1"), + "ReportingRowPruneV1": ("ledger.row_retention", "ReportingRowPruneV1"), + "ReportingPeriodRetirementV1": ("ledger.period_retirement", "ReportingPeriodRetirementV1"), + "ReportingRowMigrationBatchV1": ("ledger.row_migration", "ReportingRowMigrationBatchV1"), + "ReportingRowQuarantineReason": ("ledger.row_migration", "ReportingRowQuarantineReason"), + # Row object providers: the contract, the filesystem reference, the cloud factories + "ReportingRowObjectProviderV1": ("ledger.row_storage_object", "ReportingRowObjectProviderV1"), + "ReportingRowObjectContextV1": ("ledger.row_storage_object", "ReportingRowObjectContextV1"), + "ReportingRowObjectPutInputV1": ("ledger.row_storage_object", "ReportingRowObjectPutInputV1"), + "ReportingRowObjectPutResultV1": ("ledger.row_storage_object", "ReportingRowObjectPutResultV1"), + "ReportingRowObjectGetInputV1": ("ledger.row_storage_object", "ReportingRowObjectGetInputV1"), + "ReportingRowObjectDeleteInputV1": ( + "ledger.row_storage_object", + "ReportingRowObjectDeleteInputV1", + ), + "ReportingRowObjectStatInputV1": ("ledger.row_storage_object", "ReportingRowObjectStatInputV1"), + "ReportingRowObjectStatResultV1": ( + "ledger.row_storage_object", + "ReportingRowObjectStatResultV1", + ), + "render_reporting_row_object_key_v1": ( + "ledger.row_storage_object", + "render_reporting_row_object_key_v1", + ), + "reporting_period_date_v1": ("ledger.row_storage_object", "reporting_period_date_v1"), + "create_filesystem_reporting_row_object_provider_v1": ( + "ledger.row_storage_object", + "create_filesystem_reporting_row_object_provider_v1", + ), + "run_reporting_row_object_provider_conformance_v1": ( + "ledger.row_storage_object", + "run_reporting_row_object_provider_conformance_v1", + ), + "create_gcs_reporting_row_object_provider_v1": ( + "ledger.row_storage_gcs", + "create_gcs_reporting_row_object_provider_v1", + ), + "assert_gcs_reporting_row_bucket_policy_v1": ( + "ledger.row_storage_gcs", + "assert_gcs_reporting_row_bucket_policy_v1", + ), + "create_s3_reporting_row_object_provider_v1": ( + "ledger.row_storage_s3", + "create_s3_reporting_row_object_provider_v1", + ), + "create_azure_blob_reporting_row_object_provider_v1": ( + "ledger.row_storage_azure", + "create_azure_blob_reporting_row_object_provider_v1", + ), +} + +_LAZY_SUBMODULES = frozenset( + { + "feed", + "inline_storage", + "ledger", + "materializer", + "migration", + "outbox", + "production", + "projection", + "receipts", + "service", + "submissions", + } +) + +__all__ = sorted(_LAZY_EXPORTS) + + +def __getattr__(name: str) -> Any: + if name == "__version__": + # Read the installed distribution's PEP 440 version rather than a + # literal Release Please would rewrite in its own -beta.N spelling. + from importlib.metadata import PackageNotFoundError, version + + try: + return version("adcp-reporting-storage") + except PackageNotFoundError: + return "0.0.0" + lazy_export = _LAZY_EXPORTS.get(name) + if lazy_export is not None: + import importlib + + module_name, attribute = lazy_export + return getattr(importlib.import_module(f"{__name__}.{module_name}"), attribute) + if name in _LAZY_SUBMODULES: + import importlib + + return importlib.import_module(f"{__name__}.{name}") + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__() -> list[str]: + return sorted(set(__all__) | _LAZY_SUBMODULES | {"__version__"}) diff --git a/src/adcp/reporting/_inline_storage_schema.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/_inline_storage_schema.py similarity index 100% rename from src/adcp/reporting/_inline_storage_schema.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/_inline_storage_schema.py diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/__init__.py new file mode 100644 index 000000000..6c6b9c1c1 --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/__init__.py @@ -0,0 +1 @@ +"""The PostgreSQL frozen feed store.""" diff --git a/src/adcp/reporting/feed/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/pg.py similarity index 96% rename from src/adcp/reporting/feed/pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/pg.py index 867246426..9a99288bc 100644 --- a/src/adcp/reporting/feed/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/pg.py @@ -16,7 +16,6 @@ from adcp.reporting.feed.errors import ReportingFeedError from adcp.reporting.feed.projection import capture_feed from adcp.reporting.feed.request import FeedRequest -from adcp.reporting.feed.schema import validate_feed_schema from adcp.reporting.feed.snapshot import ( ReportingFeedSnapshot, StoredFeedSnapshot, @@ -25,14 +24,15 @@ ) from adcp.reporting.ledger.delivery_changes import ReportingReconciliationChange from adcp.reporting.ledger.delivery_models import ReportingDeliveryPrincipal -from adcp.reporting.ledger.pg import _BOUND_CONNECTION from adcp.reporting.ledger.status_projection import ReportingStatusSnapshot -from adcp.reporting.ledger.status_snapshot import read_snapshot_on from adcp.reporting.materializer.capture import decode_materializer_boundary -from adcp.reporting.materializer.pg import _now from adcp.reporting.receipts.capture import decode_receipt_boundary -from adcp.reporting.receipts.pg import PgReportingReceiptStore from adcp.server.helpers import inject_context +from adcp_reporting_storage.feed.schema import validate_feed_schema +from adcp_reporting_storage.ledger.pg import _BOUND_CONNECTION +from adcp_reporting_storage.ledger.status_snapshot import read_snapshot_on +from adcp_reporting_storage.materializer.pg import _now +from adcp_reporting_storage.receipts.pg import PgReportingReceiptStore @dataclass(frozen=True, repr=False) @@ -95,7 +95,7 @@ class PgReportingFeedStore(PgReportingReceiptStore): async def create_schema(self) -> None: async with self._connection() as connection, connection.transaction(): await self._create_schema_on(connection) - root = files("adcp.reporting.ledger") + root = files("adcp_reporting_storage.ledger") for name in ( "reporting_materializer.sql", "reporting_receipt_ingestion.sql", diff --git a/src/adcp/reporting/feed/required_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/required_schema.json similarity index 100% rename from src/adcp/reporting/feed/required_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/required_schema.json diff --git a/src/adcp/reporting/feed/schema.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/schema.py similarity index 76% rename from src/adcp/reporting/feed/schema.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/schema.py index 0a0e74823..cddf4eb20 100644 --- a/src/adcp/reporting/feed/schema.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/feed/schema.py @@ -7,15 +7,15 @@ from typing import Any from adcp.reporting.feed.errors import ReportingFeedError -from adcp.reporting.outbox._schema import schema_objects -from adcp.reporting.receipts.schema import validate_receipt_schema +from adcp_reporting_storage.outbox._schema import schema_objects +from adcp_reporting_storage.receipts.schema import validate_receipt_schema async def validate_feed_schema(connection: Any, *, notifications: bool = False) -> None: ready = False try: required = json.loads( - files("adcp.reporting.feed").joinpath("required_schema.json").read_text() + files("adcp_reporting_storage.feed").joinpath("required_schema.json").read_text() ) actual = await schema_objects(connection) if required and all(actual.get(k) == v for k, v in required.items()): diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/inline_storage.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/inline_storage.py new file mode 100644 index 000000000..539b28106 --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/inline_storage.py @@ -0,0 +1,304 @@ +"""PostgreSQL inline source storage: immutable staged objects and seals. + +The staging and seal stores keep the producer's sealed source slices in the +same database as the ledger, under the ledger's account lock when the +PostgreSQL ledger store publishes a seal. The contract, the prepared-object +shapes and the error policy are :mod:`adcp.reporting.inline_storage`; this +module is the durable backend. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator, Mapping +from contextlib import asynccontextmanager +from importlib.resources import files +from typing import TYPE_CHECKING, Any, ClassVar + +from adcp.reporting.inline_source import SealedSlice +from adcp.reporting.inline_storage import ( + _DIGEST, + INLINE_STAGING_MAX_BYTES, + InlineStorageError, + PreparedBackendObjectV1, + PreparedBackendSealV1, + _account, + _identity, + _object_inputs, + _payload_digest, + _prepared_identity, + _redact, + _reference, + _seal, +) +from adcp.reporting.source import SOURCE_BATCH_MANIFEST_MAX_BYTES_V1, SourceBatchManifestReferenceV1 +from adcp_reporting_storage._inline_storage_schema import REQUIRED_OBJECTS +from adcp_reporting_storage.outbox._schema import schema_objects + +if TYPE_CHECKING: + from psycopg_pool import AsyncConnectionPool + +__all__ = ["PgReportingSealStore", "PgReportingStagingStore"] + + +class _PgStorage: + is_durable: ClassVar[bool] = True + + def __init__(self, *, pool: AsyncConnectionPool) -> None: + try: + from psycopg_pool import AsyncConnectionPool as Pool + except ImportError: + pass + else: + if not isinstance(pool, Pool): + raise InlineStorageError("INVALID_INPUT") + self._pool = pool + return + raise ImportError("PostgreSQL inline storage requires adcp-reporting-storage[pg]") + + def __repr__(self) -> str: + return f"{type(self).__name__}()" + + async def _ready_on(self, connection: Any) -> None: + installed = await schema_objects(connection) + if not REQUIRED_OBJECTS or any( + installed.get(key, {}).get(field) != value[field] + for key, value in REQUIRED_OBJECTS.items() + for field in ("fingerprint", "enabled") + ): + raise InlineStorageError("SCHEMA_UNREADY") + + @asynccontextmanager + async def _transaction(self) -> AsyncIterator[Any]: + from psycopg.errors import UndefinedTable + + async with self._pool.connection() as connection, connection.transaction(): + # DO NOTHING's conflict winner must be visible to the next SELECT, + # regardless of a pool's default isolation level. + await connection.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED") + try: + await connection.execute( + "LOCK TABLE reporting_inline_objects, reporting_inline_seals " + "IN ACCESS SHARE MODE" + ) + except UndefinedTable: + raise InlineStorageError("SCHEMA_UNREADY") from None + await self._ready_on(connection) + yield connection + + @_redact + async def create_schema(self) -> None: + """Install the standalone additive migration and verify it atomically.""" + async with self._pool.connection() as connection, connection.transaction(): + await connection.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED") + await connection.execute( + files("adcp_reporting_storage.ledger") + .joinpath("reporting_inline_storage.sql") + .read_text() + ) + await self._ready_on(connection) + + @_redact + async def check_ready(self) -> None: + """Verify required schema objects, including constraints and write guards.""" + async with self._pool.connection() as connection, connection.transaction(): + await self._ready_on(connection) + + +class PgReportingStagingStore(_PgStorage): + """Immutable account-qualified content storage using a borrowed async pool.""" + + def __init__( + self, *, pool: AsyncConnectionPool, max_payload_bytes: int = INLINE_STAGING_MAX_BYTES + ) -> None: + if ( + type(max_payload_bytes) is not int + or not 1 <= max_payload_bytes <= INLINE_STAGING_MAX_BYTES + ): + raise InlineStorageError("INVALID_INPUT") + super().__init__(pool=pool) + self._max_payload_bytes = max_payload_bytes + + async def _read_on(self, connection: Any, account_id: str, digest: str) -> bytes: + row = await ( + await connection.execute( + "SELECT CASE WHEN octet_length(payload) <= %s THEN payload END" + " FROM reporting_inline_objects WHERE account_id = %s AND payload_sha256 = %s", + (self._max_payload_bytes, account_id, digest), + ) + ).fetchone() + if row is None: + raise InlineStorageError("NOT_FOUND") + payload = row[0] + if type(payload) is not bytes or await _payload_digest(payload) != digest: + raise InlineStorageError("INTEGRITY_FAILED") + return payload + + async def _prepare_object( + self, *, account_id: str, source_execution_key: str, ordinal: int, payload: bytes + ) -> PreparedBackendObjectV1: + _object_inputs(account_id, source_execution_key, ordinal, payload, self._max_payload_bytes) + digest = await _payload_digest(payload) + return PreparedBackendObjectV1(account_id, source_execution_key, ordinal, payload, digest) + + async def _stage_on( + self, connection: Any, prepared: PreparedBackendObjectV1 + ) -> tuple[str, str]: + """Stage on the caller's audited transaction; the returned identity is tentative. + + Caller owns account-lock ordering and the full transaction/error boundary. + No checkout, task, transaction, commit, drain or fence is owned here. + """ + if type(prepared) is not PreparedBackendObjectV1: + raise InlineStorageError("INVALID_INPUT") + prepared._validate(self._max_payload_bytes) + await connection.execute( + "INSERT INTO reporting_inline_objects (account_id, payload_sha256, payload)" + " VALUES (%s, %s, %s) ON CONFLICT (account_id, payload_sha256) DO NOTHING", + (prepared.account_id, prepared.payload_sha256, prepared.payload), + ) + if ( + await self._read_on(connection, prepared.account_id, prepared.payload_sha256) + != prepared.payload + ): + raise InlineStorageError("INTEGRITY_FAILED") + return _reference(prepared.account_id, prepared.payload_sha256), prepared.payload_sha256 + + @_redact + async def stage( + self, *, account_id: str, source_execution_key: str, ordinal: int, payload: bytes + ) -> tuple[str, str]: + prepared = await self._prepare_object( + account_id=account_id, + source_execution_key=source_execution_key, + ordinal=ordinal, + payload=payload, + ) + async with self._transaction() as connection: + return await self._stage_on(connection, prepared) + + @_redact + async def read( + self, + *, + object_ref: str, + object_generation: str, + account_id: str, + source_scope: Mapping[str, Any], + cancel: asyncio.Event, + ) -> bytes: + _account(account_id) + if type(object_generation) is not str or _DIGEST.fullmatch(object_generation) is None: + raise InlineStorageError("INVALID_INPUT") + if type(object_ref) is not str or object_ref != _reference(account_id, object_generation): + raise InlineStorageError("NOT_FOUND") + if cancel.is_set(): + raise asyncio.CancelledError + async with self._transaction() as connection: + payload = await self._read_on(connection, account_id, object_generation) + if cancel.is_set(): + raise asyncio.CancelledError + return payload + + +class PgReportingSealStore(_PgStorage): + """First committed seal wins for each trusted account and execution key.""" + + async def _get_on(self, connection: Any, account_id: str, key: str) -> SealedSlice | None: + row = await ( + await connection.execute( + "SELECT staged_commit_ref, manifest_sha256, byte_count," + " CASE WHEN octet_length(manifest) <= %s THEN manifest END" + " FROM reporting_inline_seals WHERE account_id = %s AND source_execution_key = %s", + (SOURCE_BATCH_MANIFEST_MAX_BYTES_V1, account_id, key), + ) + ).fetchone() + if row is None: + return None + reference: SourceBatchManifestReferenceV1 | None + try: + reference = SourceBatchManifestReferenceV1( + staged_commit_ref=row[0], manifest_sha256=row[1], byte_count=row[2] + ) + except Exception: + reference = None + if reference is None: + raise InlineStorageError("INTEGRITY_FAILED") + return _seal( + account_id, + key, + SealedSlice(reference=reference, manifest_bytes=row[3]), + "INTEGRITY_FAILED", + ) + + @_redact + async def get(self, *, account_id: str, source_execution_key: str) -> SealedSlice | None: + _identity(account_id, source_execution_key) + async with self._transaction() as connection: + return await self._get_on(connection, account_id, source_execution_key) + + def _prepare_seal( + self, *, account_id: str, source_execution_key: str, sealed: SealedSlice + ) -> PreparedBackendSealV1: + _prepared_identity(account_id, source_execution_key) + candidate = _seal(account_id, source_execution_key, sealed, "INVALID_INPUT") + reference = candidate.reference + return PreparedBackendSealV1( + account_id, + source_execution_key, + reference.staged_commit_ref, + reference.manifest_sha256, + reference.byte_count, + candidate.manifest_bytes, + ) + + async def _put_on(self, connection: Any, prepared: PreparedBackendSealV1) -> SealedSlice: + """Return the verified stored winner on the caller's audited transaction. + + The neutral value proves no admission, referenced-object existence or + commit. Caller owns account locks, cancellation, settlement and any fence. + """ + if type(prepared) is not PreparedBackendSealV1: + raise InlineStorageError("INVALID_INPUT") + prepared._validate() + # Build a fresh reference, then revalidate it inside the existing closed + # candidate validator. No mutable caller model is retained or trusted. + reference = SourceBatchManifestReferenceV1.model_construct( + staged_commit_ref=prepared.staged_commit_ref, + manifest_sha256=prepared.manifest_sha256, + byte_count=prepared.byte_count, + ) + candidate = _seal( + prepared.account_id, + prepared.source_execution_key, + SealedSlice(reference=reference, manifest_bytes=prepared.manifest_bytes), + "INVALID_INPUT", + ) + await connection.execute( + "INSERT INTO reporting_inline_seals (account_id, source_execution_key," + " staged_commit_ref, manifest_sha256, byte_count, manifest)" + " VALUES (%s, %s, %s, %s, %s, %s)" + " ON CONFLICT (account_id, source_execution_key) DO NOTHING", + ( + prepared.account_id, + prepared.source_execution_key, + candidate.reference.staged_commit_ref, + candidate.reference.manifest_sha256, + candidate.reference.byte_count, + candidate.manifest_bytes, + ), + ) + winner = await self._get_on(connection, prepared.account_id, prepared.source_execution_key) + if winner is None: + raise InlineStorageError("INTEGRITY_FAILED") + return winner + + @_redact + async def put( + self, *, account_id: str, source_execution_key: str, sealed: SealedSlice + ) -> SealedSlice: + prepared = self._prepare_seal( + account_id=account_id, source_execution_key=source_execution_key, sealed=sealed + ) + async with self._transaction() as connection: + return await self._put_on(connection, prepared) diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/__init__.py new file mode 100644 index 000000000..76496e31d --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/__init__.py @@ -0,0 +1 @@ +"""The PostgreSQL ledger, reconciliation store, row storage and change feed.""" diff --git a/src/adcp/reporting/ledger/change_feed.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/change_feed.py similarity index 99% rename from src/adcp/reporting/ledger/change_feed.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/change_feed.py index 5dc38735a..ac1e7b6ca 100644 --- a/src/adcp/reporting/ledger/change_feed.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/change_feed.py @@ -38,7 +38,7 @@ from typing import TYPE_CHECKING, Any, Literal if TYPE_CHECKING: - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore __all__ = [ "REPORTING_CHANGES_DDL_PATH", diff --git a/src/adcp/reporting/ledger/delivery_pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/delivery_pg.py similarity index 99% rename from src/adcp/reporting/ledger/delivery_pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/delivery_pg.py index 5397ac60b..3fe35ad19 100644 --- a/src/adcp/reporting/ledger/delivery_pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/delivery_pg.py @@ -20,6 +20,15 @@ unavailable, validate_transition, ) +from adcp.reporting.ledger._record_rows import ( + _ADJUSTMENT_COLUMNS, + _OBLIGATION_COLUMNS, + _REVISION_COLUMNS, + _adjustment_from_row, + _configuration_from_row, + _obligation_from_row, + _revision_from_row, +) from adcp.reporting.ledger.delivery import ( ReportingReconciliationSnapshot, _boundary_unavailable, @@ -43,18 +52,11 @@ ReportingDeliveryRecord, ReportingDestinationBinding, ) -from adcp.reporting.ledger.pg import ( - _ADJUSTMENT_COLUMNS, - _OBLIGATION_COLUMNS, - _REVISION_COLUMNS, +from adcp.reporting.ledger.store import LedgerConflictError +from adcp_reporting_storage.ledger.pg import ( PgReportingLedgerStore, - _adjustment_from_row, - _configuration_from_row, _json, - _obligation_from_row, - _revision_from_row, ) -from adcp.reporting.ledger.store import LedgerConflictError _IDENTITY_COLUMNS = ( "account_id", @@ -92,6 +94,10 @@ def _filter_params(filters: ReportingReconciliationFilter) -> tuple[Any, ...]: class PgReportingReconciliationStore(PgReportingLedgerStore, _ReconciliationOperations): + @property + def durable_reconciliation(self) -> bool: + return True + """Evidence and receipt heads commit with the feed, including autocommit pools. All state decisions run under the same per-account transaction lock as Core diff --git a/src/adcp/reporting/ledger/period_retirement.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/period_retirement.py similarity index 97% rename from src/adcp/reporting/ledger/period_retirement.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/period_retirement.py index 2ab4e0a7f..20354f3ac 100644 --- a/src/adcp/reporting/ledger/period_retirement.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/period_retirement.py @@ -44,11 +44,11 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from adcp.reporting.ledger.row_retention import NoLongerEligibleError -from adcp.reporting.ledger.row_storage import ReportingRowStoreError +from adcp_reporting_storage.ledger.row_retention import NoLongerEligibleError +from adcp_reporting_storage.ledger.row_storage import ReportingRowStoreError if TYPE_CHECKING: - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore __all__ = ["ReportingPeriodRetirementV1", "retire_expired_periods"] @@ -404,19 +404,19 @@ async def _retire_period( raise NoLongerEligibleError await connection.execute(_tombstone_insert(has_rows), (obligation_id, account_id)) if has_rows: - from adcp.reporting.ledger.row_retention import mark_period_pruning + from adcp_reporting_storage.ledger.row_retention import mark_period_pruning row_set_ids = await mark_period_pruning(connection, obligation_id, account_id) # Phase 2: external bytes, at their recorded versions. if has_rows and store._row_storage is not None: - from adcp.reporting.ledger.row_retention import delete_period_objects + from adcp_reporting_storage.ledger.row_retention import delete_period_objects await delete_period_objects(store, obligation_id, row_set_ids) # Phase 3: the records, together. async with store._pool.connection() as connection, connection.transaction(): await store._lock_account(connection, account_id) if has_rows: - from adcp.reporting.ledger.row_retention import _finish_period + from adcp_reporting_storage.ledger.row_retention import _finish_period await _finish_period(connection, obligation_id, account_id, row_set_ids) try: diff --git a/src/adcp/reporting/ledger/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py similarity index 91% rename from src/adcp/reporting/ledger/pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py index ef440bbed..8ab1f7ee2 100644 --- a/src/adcp/reporting/ledger/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/pg.py @@ -76,7 +76,7 @@ import base64 import hashlib import json -from collections.abc import AsyncIterator, Callable, Sequence +from collections.abc import AsyncIterator, Awaitable, Callable, Sequence from contextlib import asynccontextmanager from contextvars import ContextVar from copy import deepcopy @@ -92,7 +92,25 @@ from adcp.reporting.canonical_json import canonical_json_utf8_v1 from adcp.reporting.currency import require_frozen_currency -from adcp.reporting.evidence import ReportingCanonicalDigest, ReportingControlTotalRecord +from adcp.reporting.ledger._record_rows import ( + _ADJUSTMENT_COLUMNS, + _ISSUE_COLUMNS, + _OBLIGATION_COLUMNS, + _REVISION_COLUMNS, + _STATUS_COLUMNS, + _STATUS_COLUMNS_BARE, + _adjustment_from_row, + _configuration_from_row, + _configuration_payload, + _consumer_status_payload, + _definition_payload, + _issue_from_row, + _obligation_from_row, + _revision_from_row, + _revision_payload, + _schedule_payload, + _status_from_row, +) from adcp.reporting.ledger.health import issue_id_for_occurrence from adcp.reporting.ledger.host_reads import ReportingCurrentRevisionsPageV1 from adcp.reporting.ledger.models import ( @@ -103,12 +121,9 @@ ReportingCaller, ReportingConfiguration, ReportingConfigurationGenerationKey, - ReportingDefinitionBinding, ReportingIssueLifecycle, ReportingObligationRecord, - ReportingPeriodBoundary, ReportingRevisionRecord, - ReportingScheduleSpec, ) from adcp.reporting.ledger.notification_models import ( DirtyReason, @@ -122,12 +137,6 @@ validate_scope_refinement, ) from adcp.reporting.ledger.provisional import ProvisionalAcquisition, ProvisionalObservation -from adcp.reporting.ledger.row_storage import ( - REPORTING_ROW_STORAGE_DDL_PATH, - ReportingRowStorageOptionsV1, - ReportingRowStorageV1, - ReportingRowStoreError, -) from adcp.reporting.ledger.store import ( LeasedConfiguration, LedgerConflictError, @@ -139,23 +148,34 @@ configuration_lifecycle, encode_cursor, issue_is_retirable, - managed_revision_metadata, reject_reserved_authoritative_party, validate_adjustment_currency, validate_managed_revision_rows, validate_revision_currency, ) +from adcp_reporting_storage.ledger.row_storage import ( + REPORTING_ROW_STORAGE_DDL_PATH, + ReportingRowStorageOptionsV1, + ReportingRowStorageV1, + ReportingRowStoreError, +) if TYPE_CHECKING: from psycopg_pool import AsyncConnectionPool from adcp.reporting._source_authorization import InlineSealPublisher from adcp.reporting.inline_source import ReportingSealStore, SealedSlice - from adcp.reporting.ledger.change_feed import ReportingChangeKindV1, ReportingChangesPageV1 - from adcp.reporting.ledger.period_retirement import ReportingPeriodRetirementV1 - from adcp.reporting.ledger.row_migration import ReportingRowMigrationBatchV1 - from adcp.reporting.ledger.row_retention import ReportingRowIntentSweepV1, ReportingRowPruneV1 from adcp.reporting.ledger.status_projection import ReportingStatusSnapshot + from adcp_reporting_storage.ledger.change_feed import ( + ReportingChangeKindV1, + ReportingChangesPageV1, + ) + from adcp_reporting_storage.ledger.period_retirement import ReportingPeriodRetirementV1 + from adcp_reporting_storage.ledger.row_migration import ReportingRowMigrationBatchV1 + from adcp_reporting_storage.ledger.row_retention import ( + ReportingRowIntentSweepV1, + ReportingRowPruneV1, + ) try: from psycopg_pool import AsyncConnectionPool as _AsyncConnectionPool # noqa: F401 @@ -166,7 +186,7 @@ _INSTALL_HINT = ( "PgReportingLedgerStore requires psycopg3 and psycopg-pool. " - "Install the 'pg' extra: `pip install 'adcp[pg]'`." + "Install the 'pg' extra: `pip install 'adcp-reporting-storage[pg]'`." ) _DDL_PATH = Path(__file__).parent / "reporting_ledger.sql" @@ -282,13 +302,139 @@ async def transaction(self) -> AsyncIterator[PgReportingLedgerStore]: finally: _BOUND_CONNECTION.reset(token) + # -- what the protocol layer may ask of a durable store ------------------ + # + # ``adcp`` composes queues, projections and capability checks over this + # store without importing it; see :mod:`adcp.reporting.outbox.durable` and + # :mod:`adcp.reporting.production.store`. Exact-class checks live here so + # a subclass is refused exactly where it was refused before. + + @property + def clock(self) -> Callable[[], datetime] | None: + return self._clock + + @property + def storage_identity(self) -> object: + return self._pool + + @property + def storage_open(self) -> bool: + return self._pool.closed is False + + @property + def notifications_enabled(self) -> bool: + return self._notifications_enabled + + @property + def durable_reconciliation(self) -> bool: + return False + + def _is_core_ledger(self) -> bool: + from adcp_reporting_storage.ledger.delivery_pg import PgReportingReconciliationStore + + return type(self) in {PgReportingLedgerStore, PgReportingReconciliationStore} + + def _is_own_queue(self, outbox: object) -> bool: + from adcp_reporting_storage.outbox.pg import PgReportingOutbox + + return ( + type(outbox) is PgReportingOutbox + and self._is_core_ledger() + and outbox._pool is self._pool + and self._notifications_enabled + ) + + async def validate_notification_chain(self, outbox: object) -> None: + from adcp_reporting_storage.outbox._schema import validate_schema + + if not self._is_own_queue(outbox): + raise ReportingNotificationError("notification_chain_unready") + async with self._pool.connection() as connection, connection.transaction(): + await validate_schema(connection) + + def validate_activity_chain(self, outbox: object) -> None: + if not self._is_own_queue(outbox): + raise ReportingNotificationError("activity_chain_unready") + + def validate_composite_activity_chain( + self, *, outbox: object, status_store: object, reader: object + ) -> None: + from adcp_reporting_storage.outbox.pg import PgReportingOutbox + from adcp_reporting_storage.outbox.status_activity_pg import PgReportingActivityUnionStore + from adcp_reporting_storage.outbox.status_pg import ( + PgReportingStatusOutbox, + PgStatusNotificationStore, + ) + + if ( + type(status_store) is not PgStatusNotificationStore + or not self._is_core_ledger() + or type(outbox) is not PgReportingOutbox + or type(reader) is not PgReportingActivityUnionStore + or reader.b_outbox is not outbox + or reader.status_outbox is not status_store.outbox + or status_store.ledger is not self + or outbox._pool is not self._pool + or type(status_store.outbox) is not PgReportingStatusOutbox + or status_store.outbox._pool is not self._pool + or outbox._clock is not status_store.outbox._clock + or not self._notifications_enabled + ): + raise ReportingNotificationError("activity_chain_unready") + + def activity_schema_contract(self, *, composite: bool) -> str: + from adcp_reporting_storage.outbox import _schema + + contract = _schema._digest(_schema.REQUIRED_OBJECTS) + if composite: + from adcp_reporting_storage.outbox import status_schema + + contract += ":" + _schema._digest(status_schema.REQUIRED_STATUS_OBJECTS) + return contract + + async def validate_activity_schema(self, *, composite: bool) -> None: + from adcp_reporting_storage.outbox import _schema + + async with self._pool.connection() as connection: + try: + installed = await _schema.schema_objects(connection) + except Exception: + raise ReportingNotificationError( + "notification_schema_unready:catalog_unavailable" + ) from None + # One physical catalog capture, each required contract once. + retired = await _schema.legacy_rows_retired(connection, installed) + _schema._validate_schema_objects( + installed, activity=True, **({"legacy_rows_retired": True} if retired else {}) + ) + if composite: + from adcp_reporting_storage.outbox import status_schema + + status_schema._validate_status_objects(installed, activity=True, status=False) + + def maintenance_operations(self) -> tuple[tuple[str, Callable[[], Awaitable[Any]]], ...]: + """Retention passes for the features this store opted into, in run order.""" + operations: list[tuple[str, Callable[[], Awaitable[Any]]]] = [] + if self._row_storage is not None: + operations.append(("intent_sweep", lambda: self.sweep_row_write_intents(limit=100))) + # Frees row bytes of periods past retention even while their records stay + # (protected by append-only guards), so storage is reclaimed whether or not + # records can be. + operations.append(("row_pruning", lambda: self.prune_expired_row_sets(limit=20))) + if self._row_storage is not None or self._change_feed: + operations.append(("period_retirement", lambda: self.retire_expired_periods(limit=20))) + if self._change_feed: + operations.append(("change_pruning", lambda: self.prune_changes())) + return tuple(operations) + @asynccontextmanager - async def _source_publication( + async def source_publication( self, account_id: str, *, seals: ReportingSealStore | None = None ) -> AsyncIterator[InlineSealPublisher | None]: from psycopg import Error - from adcp.reporting.inline_storage import InlineStorageError, PgReportingSealStore + from adcp.reporting.inline_storage import InlineStorageError + from adcp_reporting_storage.inline_storage import PgReportingSealStore if isinstance(seals, PgReportingSealStore) and seals._pool is self._pool: # The backend's audited READ COMMITTED transaction owns both the @@ -363,7 +509,7 @@ async def migrate_inline_rows( ``ROWS_INTEGRITY_FAILED``. Pass the returned ``cursor`` back to resume; it is ``None`` once no candidates remain. Requires the ``row_storage`` option. """ - from adcp.reporting.ledger.row_migration import migrate_inline_rows + from adcp_reporting_storage.ledger.row_migration import migrate_inline_rows return await migrate_inline_rows( self, limit=limit, account_id=account_id, cursor=cursor, binding_id=binding_id @@ -387,7 +533,7 @@ async def sweep_row_write_intents( sets when an unswept intent is counted as ``overdue``. Schedule it alongside the producer. """ - from adcp.reporting.ledger.row_retention import sweep_row_write_intents + from adcp_reporting_storage.ledger.row_retention import sweep_row_write_intents return await sweep_row_write_intents( self, limit=limit, account_id=account_id, overdue_after_seconds=overdue_after_seconds @@ -405,9 +551,9 @@ async def prune_expired_row_sets( Rows stop being served first, then external objects are deleted at their recorded versions, then the manifests and bodies go. A failed delete leaves the period ``pruning`` with every locator intact and the next pass resumes. - See :mod:`adcp.reporting.ledger.row_retention` for what qualifies a period. + See :mod:`adcp_reporting_storage.ledger.row_retention` for what qualifies a period. """ - from adcp.reporting.ledger.row_retention import prune_expired_row_sets + from adcp_reporting_storage.ledger.row_retention import prune_expired_row_sets return await prune_expired_row_sets( self, @@ -424,7 +570,7 @@ async def changes_after( kinds: Sequence[ReportingChangeKindV1] | None = None, limit: int = 500, ) -> ReportingChangesPageV1: - """Read the shared host change feed; see :mod:`adcp.reporting.ledger.change_feed`. + """Read the shared host change feed; see :mod:`adcp_reporting_storage.ledger.change_feed`. Account feeds order by ``seq``; the deployment-wide feed orders by ``(xid, seq)`` below the reading snapshot's ``xmin``. A cursor older than the retained history @@ -432,7 +578,7 @@ async def changes_after( prune commits while the page is being read (the horizon is read again after the page). Requires ``change_feed=True``. """ - from adcp.reporting.ledger.change_feed import changes_after + from adcp_reporting_storage.ledger.change_feed import changes_after return await changes_after( self, cursor=cursor, account_id=account_id, kinds=kinds, limit=limit @@ -450,9 +596,9 @@ async def save_feed_consumer_cursor( Forward-only within a feed scope: a cursor behind the saved one is ignored and the call returns ``False``. Pass ``rewind=True`` to move the position back deliberately. - See :func:`adcp.reporting.ledger.change_feed.save_feed_consumer_cursor`. + See :func:`adcp_reporting_storage.ledger.change_feed.save_feed_consumer_cursor`. """ - from adcp.reporting.ledger.change_feed import save_feed_consumer_cursor + from adcp_reporting_storage.ledger.change_feed import save_feed_consumer_cursor return await save_feed_consumer_cursor( self, name, cursor, refresh_unchanged=refresh_unchanged, rewind=rewind @@ -460,7 +606,7 @@ async def save_feed_consumer_cursor( async def remove_feed_consumer(self, name: str) -> bool: """Unregister a consumer so it stops holding back pruning; ``True`` if it was registered.""" - from adcp.reporting.ledger.change_feed import remove_feed_consumer + from adcp_reporting_storage.ledger.change_feed import remove_feed_consumer return await remove_feed_consumer(self, name) @@ -472,7 +618,7 @@ async def prune_changes( limit: int = 10_000, ) -> int: """Delete change rows past retention that every live registered consumer has passed.""" - from adcp.reporting.ledger.change_feed import prune_changes + from adcp_reporting_storage.ledger.change_feed import prune_changes return await prune_changes( self, @@ -495,9 +641,9 @@ async def retire_expired_periods( records: ordered and resumable, so the records that locate row bytes are never deleted while the bytes remain. Periods referenced by a materialization, a receipt or an unread registered feed consumer are held. See - :mod:`adcp.reporting.ledger.period_retirement` for what Python can delete today. + :mod:`adcp_reporting_storage.ledger.period_retirement` for what Python can delete today. """ - from adcp.reporting.ledger.period_retirement import retire_expired_periods + from adcp_reporting_storage.ledger.period_retirement import retire_expired_periods return await retire_expired_periods( self, @@ -513,12 +659,12 @@ async def retire_legacy_inline_rows(self) -> bool: The table is renamed, never truncated, so binaries that predate chunked rows fail closed. Returns ``False`` when it was already retired. """ - from adcp.reporting.ledger.row_migration import retire_legacy_inline_rows + from adcp_reporting_storage.ledger.row_migration import retire_legacy_inline_rows return await retire_legacy_inline_rows(self) async def _create_schema_on(self, connection: Any) -> None: - from adcp.reporting.migration import require_owned_schema_or_empty + from adcp_reporting_storage.migration import require_owned_schema_or_empty await require_owned_schema_or_empty(connection) for path in ( @@ -541,17 +687,17 @@ async def _create_schema_on(self, connection: Any) -> None: await connection.execute(_ROW_MIGRATION_DDL_PATH.read_text()) await self._require_provisional_schema(connection) if self._notifications_enabled: - from adcp.reporting.outbox._schema import validate_schema + from adcp_reporting_storage.outbox._schema import validate_schema await validate_schema(connection) async def _require_provisional_schema(self, connection: Any) -> None: - from adcp.reporting.outbox._schema import validate_provisional_schema + from adcp_reporting_storage.outbox._schema import validate_provisional_schema await validate_provisional_schema(connection) async def _notification_now(self, connection: Any) -> datetime: - from adcp.reporting.outbox.pg import database_now + from adcp_reporting_storage.outbox.pg import database_now if self._clock is not None: # Preserve the explicit conformance clock across deferred boundary @@ -562,7 +708,7 @@ async def _notification_now(self, connection: Any) -> datetime: async def _record_notification(self, connection: Any, event: ReportingDomainEvent) -> None: if self._notifications_enabled: - from adcp.reporting.outbox.pg import enqueue_event + from adcp_reporting_storage.outbox.pg import enqueue_event await enqueue_event(connection, event) @@ -575,8 +721,8 @@ async def _dirty_status( after: ReportingStatusEvidence | None = None, ) -> None: if self._notifications_enabled: - from adcp.reporting.ledger.status_snapshot import settle_snapshot_on - from adcp.reporting.outbox.pg import mark_dirty + from adcp_reporting_storage.ledger.status_snapshot import settle_snapshot_on + from adcp_reporting_storage.outbox.pg import mark_dirty await settle_snapshot_on(self, connection, account_id=scope.account_id) await mark_dirty( @@ -738,7 +884,7 @@ async def _bound_lock_waits(connection: Any) -> None: " FROM pg_settings WHERE name='lock_timeout'" ) - def _worker_lock_errors(self) -> tuple[type[Exception], ...]: + def worker_lock_errors(self) -> tuple[type[Exception], ...]: from psycopg.errors import DeadlockDetected, LockNotAvailable bound = _BOUND_CONNECTION.get() @@ -1299,7 +1445,7 @@ async def commit_revision( if write_error is not None: raise write_error if self._row_storage is not None: - from adcp.reporting.ledger.row_write import persist_revision_row_set + from adcp_reporting_storage.ledger.row_write import persist_revision_row_set await persist_revision_row_set( self, @@ -1362,9 +1508,9 @@ async def _write_revision_row_set( """Prepare and store a revision's rows in this transaction (``postgres`` bindings only). Used by the inline-row migration, which has no upload phase. The commit path - goes through :func:`~adcp.reporting.ledger.row_write.persist_revision_row_set`. + goes through :func:`~adcp_reporting_storage.ledger.row_write.persist_revision_row_set`. """ - from adcp.reporting.ledger.row_write import prepare_revision_row_set + from adcp_reporting_storage.ledger.row_write import prepare_revision_row_set storage = self._row_storage assert storage is not None @@ -1388,7 +1534,7 @@ async def stage_revision_rows( the write intent atomically with the header. Returns whether anything was staged; replays and stores without row storage return ``False``. """ - from adcp.reporting.ledger.row_write import stage_revision_rows + from adcp_reporting_storage.ledger.row_write import stage_revision_rows if self._row_storage is None: return False @@ -2387,10 +2533,8 @@ async def record_consumer_status( if replay is not None: return replay, False - from adcp.reporting.ledger.status_snapshot import ( - read_snapshot_on, - validate_status_evidence, - ) + from adcp.reporting.ledger.status_snapshot import validate_status_evidence + from adcp_reporting_storage.ledger.status_snapshot import read_snapshot_on validate_status_evidence( status, @@ -2483,7 +2627,7 @@ async def record_consumer_status( status.reporting_status_id, consumer_id=status.consumer_id, ) - from adcp.reporting.ledger.status_snapshot import settle_snapshot_on + from adcp_reporting_storage.ledger.status_snapshot import settle_snapshot_on await settle_snapshot_on(self, connection, account_id=status.account_id) if self._notifications_enabled: @@ -2513,8 +2657,8 @@ async def record_consumer_status_with_lifecycle( async def read_status_snapshot(self, *, caller: ReportingCaller) -> ReportingStatusSnapshot: from adcp.reporting.ledger.delivery_models import ReportingDeliveryPrincipal - from adcp.reporting.ledger.status_snapshot import settle_snapshot_on from adcp.reporting.materializer.capture import private_snapshot + from adcp_reporting_storage.ledger.status_snapshot import settle_snapshot_on async with self._connection() as connection, connection.transaction(): await self._lock_account(connection, caller.account_id) @@ -2719,7 +2863,7 @@ async def set_issue_state( check_issue_state_transition(live.issue_state, state) if state == "waived" and live.issue_state != "waived": from adcp.reporting.ledger.status_projection import bind_mismatch_waiver - from adcp.reporting.ledger.status_snapshot import read_snapshot_on + from adcp_reporting_storage.ledger.status_snapshot import read_snapshot_on snapshot = await read_snapshot_on( connection, @@ -3290,22 +3434,6 @@ def _translate_integrity_error(error: Exception) -> LedgerConflictError: return LedgerConflictError("LEDGER_WRITE_FAILED", "the ledger write violated an integrity rule") -_OBLIGATION_COLUMNS = ( - "reporting_obligation_id, account_id, delivery_config_id, delivery_config_version," - " report_definition_id, reporting_profile, feed_purpose, period_key, period_start," - " period_end, source_timezone, expected_at, scope_resolved_at," - " automated_recovery_deadline_at, required_finality, coverage_status, media_buy_ids," - " package_ids, schedule, definition, created_at, currency, consumer_id" -) - -_REVISION_COLUMNS = ( - "reporting_revision_id, account_id, reporting_obligation_id, finality," - " revision_content_sha256, row_count, control_totals, observed_at, data_through," - " created_at, supersedes_reporting_revision_id, finality_basis, finality_policy_id," - " finalized_at, readable, readable_at_commit, source_publication_id, source_manifest_sha256," - " canonical_content_digest, managed_control_totals" -) - _OUTER_REVISION_COLUMNS = ", ".join(f"r.{name.strip()}" for name in _REVISION_COLUMNS.split(",")) # The current revision of an obligation is the official one if it exists, otherwise the # snapshot nothing supersedes yet. An official revision is terminal, so it is never superseded. @@ -3317,44 +3445,6 @@ def _translate_integrity_error(error: Exception) -> LedgerConflictError: " ORDER BY (r.finality = 'official') DESC, r.created_at DESC, r.reporting_revision_id DESC" ) -_ADJUSTMENT_COLUMNS = ( - "reporting_adjustment_id, account_id, adjusts_reporting_revision_id, reason_code," - " reason_detail, accounting_period_start, accounting_period_end, control_total_deltas," - " correction_observed_at, created_at, managed_control_total_deltas" -) - -_STATUS_COLUMNS = ( - "s.reporting_status_id, s.account_id, s.consumer_id, s.delivery_config_id," - " s.delivery_config_version, s.report_definition_id, s.period_start, s.period_end," - " s.period_source_timezone, s.consumer_status, s.status_as_of, s.recorded_at," - " s.supersedes_reporting_status_id, s.reporting_obligation_id, s.reporting_revision_id," - " s.observed_revision_content_sha256, s.failure_code, s.mismatch_code," - " s.consumer_commit_ref, s.seller_ledger_snapshot_id, s.seller_ledger_as_of, s.superseded" -) - -_STATUS_COLUMNS_BARE = _STATUS_COLUMNS.replace("s.", "") - -_ISSUE_COLUMNS = ( - "issue_key, account_id, generation, issue_id, consumer_id, opened_at, issue_state," - " external_ref, retired_at" -) - - -def _issue_from_row(row: Sequence[Any]) -> ReportingIssueLifecycle: - return ReportingIssueLifecycle( - issue_key=row[0], - account_id=row[1], - generation=row[2], - issue_id=row[3], - consumer_id=row[4], - opened_at=_utc(row[5]), - issue_state=row[6], - external_ref=row[7], - retired_at=_utc(row[8]) if row[8] else None, - waived_reporting_status_id=row[9] if len(row) > 9 else None, - waived_conflict_sha256=row[10] if len(row) > 10 else None, - ) - async def _waiver_storage_on(connection: Any) -> bool: row = await ( @@ -3410,230 +3500,6 @@ async def _save_waiver_binding(connection: Any, issue: ReportingIssueLifecycle) raise LedgerConflictError("WAIVER_BINDING_IMMUTABLE", "a recorded waiver cannot be rebound") -def _schedule_payload(schedule: ReportingScheduleSpec) -> dict[str, Any]: - return { - "period_duration": schedule.period_duration, - "delivery_sla": schedule.delivery_sla, - "alignment": schedule.alignment, - "period_timezone": schedule.period_timezone, - "period_anchor": ( - _utc(schedule.period_anchor).isoformat() if schedule.period_anchor else None - ), - } - - -def _definition_payload( - definition: ReportingDefinitionBinding | None, -) -> dict[str, Any] | None: - return definition.to_storage() if definition is not None else None - - -def _definition_from_payload(payload: dict[str, Any] | None) -> ReportingDefinitionBinding | None: - return ReportingDefinitionBinding(**payload) if payload else None - - -def _schedule_from_payload(payload: dict[str, Any]) -> ReportingScheduleSpec: - anchor = payload.get("period_anchor") - return ReportingScheduleSpec( - period_duration=payload["period_duration"], - delivery_sla=payload["delivery_sla"], - alignment=payload.get("alignment", "utc"), - period_timezone=payload.get("period_timezone"), - period_anchor=datetime.fromisoformat(anchor) if anchor else None, - ) - - -def _configuration_payload(configuration: ReportingConfiguration) -> dict[str, Any]: - return { - "account_id": configuration.account_id, - "report_definition_id": configuration.report_definition_id, - "reporting_profile": configuration.reporting_profile, - "feed_purpose": configuration.feed_purpose, - "required_finality": configuration.required_finality, - "account_timezone": configuration.account_timezone, - "media_buy_ids": sorted(configuration.media_buy_ids), - "schedule": _schedule_payload(configuration.schedule), - "definition": _definition_payload(configuration.definition), - } - - -def _configuration_from_row(row: Sequence[Any]) -> ReportingConfiguration: - return ReportingConfiguration( - delivery_config_id=row[0], - delivery_config_version=row[1], - account_id=row[2], - report_definition_id=row[3], - reporting_profile=row[4], - feed_purpose=row[5], - required_finality=row[6], - account_timezone=row[7], - schedule=_schedule_from_payload(row[8]), - media_buy_ids=tuple(row[9] or ()), - activated_at=_utc(row[10]) if row[10] else None, - deactivated_at=_utc(row[11]) if row[11] else None, - automated_recovery_window=timedelta(seconds=float(row[12])), - status_retention_days=row[13], - definition=_definition_from_payload(row[14]), - authoritative_party=row[15], - consumer_id=row[16], - quarantined=row[17], - ) - - -def _obligation_from_row(row: Sequence[Any]) -> ReportingObligationRecord: - return ReportingObligationRecord( - reporting_obligation_id=row[0], - account_id=row[1], - delivery_config_id=row[2], - delivery_config_version=row[3], - report_definition_id=row[4], - reporting_profile=row[5], - feed_purpose=row[6], - period=ReportingPeriodBoundary( - period_key=row[7], - start=_utc(row[8]), - end=_utc(row[9]), - source_timezone=row[10], - expected_at=_utc(row[11]), - ), - scope_resolved_at=_utc(row[12]), - automated_recovery_deadline_at=_utc(row[13]), - required_finality=row[14], - coverage_status=row[15], - media_buy_ids=tuple(row[16] or ()), - package_ids=tuple(row[17] or ()), - schedule=_schedule_from_payload(row[18]), - definition=_definition_from_payload(row[19]), - created_at=_utc(row[20]), - currency=row[21], - consumer_id=row[22], - ) - - -def _revision_from_row(row: Sequence[Any]) -> ReportingRevisionRecord: - return ReportingRevisionRecord( - reporting_revision_id=row[0], - account_id=row[1], - reporting_obligation_id=row[2], - finality=row[3], - revision_content_sha256=row[4], - row_count=int(row[5]), - control_totals=tuple((name, value) for name, value in (row[6] or [])), - observed_at=_utc(row[7]), - data_through=_utc(row[8]) if row[8] else None, - created_at=_utc(row[9]), - supersedes_reporting_revision_id=row[10], - finality_basis=row[11], - finality_policy_id=row[12], - finalized_at=_utc(row[13]) if row[13] else None, - readable=row[14], - readable_at_commit=row[15], - source_publication_id=row[16], - source_manifest_sha256=row[17], - canonical_content_digest=( - ReportingCanonicalDigest.from_wire(row[18]) if row[18] is not None else None - ), - managed_control_totals=( - tuple(ReportingControlTotalRecord.from_wire(item) for item in row[19]) - if row[19] is not None - else None - ), - ) - - -def _adjustment_from_row(row: Sequence[Any]) -> ReportingAdjustmentRecord: - return ReportingAdjustmentRecord( - reporting_adjustment_id=row[0], - account_id=row[1], - adjusts_reporting_revision_id=row[2], - reason_code=row[3], - reason_detail=row[4], - accounting_period_start=_utc(row[5]), - accounting_period_end=_utc(row[6]), - control_total_deltas=tuple((name, value) for name, value in (row[7] or [])), - correction_observed_at=_utc(row[8]), - created_at=_utc(row[9]), - managed_control_total_deltas=( - tuple(ReportingControlTotalRecord.from_wire(item) for item in row[10]) - if row[10] is not None - else None - ), - ) - - -def _status_from_row(row: Sequence[Any]) -> ConsumerStatusRecord: - return ConsumerStatusRecord( - reporting_status_id=row[0], - account_id=row[1], - consumer_id=row[2], - delivery_config_id=row[3], - delivery_config_version=row[4], - report_definition_id=row[5], - period_start=_utc(row[6]), - period_end=_utc(row[7]), - period_source_timezone=row[8], - consumer_status=row[9], - status_as_of=_utc(row[10]), - recorded_at=_utc(row[11]), - supersedes_reporting_status_id=row[12], - reporting_obligation_id=row[13], - reporting_revision_id=row[14], - observed_revision_content_sha256=row[15], - failure_code=row[16], - mismatch_code=row[17], - consumer_commit_ref=row[18], - seller_ledger_snapshot_id=row[19], - seller_ledger_as_of=_utc(row[20]) if row[20] else None, - superseded=row[21], - ) - - -def _consumer_status_payload(status: ConsumerStatusRecord) -> dict[str, Any]: - payload: dict[str, Any] = { - "chain": list(status.chain_key), - "consumer_status": status.consumer_status, - "status_as_of": _utc(status.status_as_of).isoformat(), - "supersedes": status.supersedes_reporting_status_id, - "obligation": status.reporting_obligation_id, - "revision": status.reporting_revision_id, - "digest": status.observed_revision_content_sha256, - "failure_code": status.failure_code, - } - # Conditional on purpose. ``canonical_json_utf8_v1`` encodes ``None`` as - # ``null``, so including the key unconditionally would change the digest of - # every statement that has no mismatch_code -- i.e. every row an rc.2 SDK - # wrote. After an in-place upgrade a buyer's exact retry would then fail - # with STATUS_IDENTITY_CONFLICT instead of replaying as ``unchanged``, - # which is the one thing an idempotent append-only surface must never do. - # Omitting the key when absent keeps rc.2 digests byte-identical while a - # code-only change is still a conflict: "the metric is missing" and "the - # currency is wrong" are different claims and must not share one immutable - # identity. - if status.mismatch_code is not None: - payload["mismatch_code"] = status.mismatch_code - return payload - - -def _revision_payload(revision: ReportingRevisionRecord) -> dict[str, Any]: - payload = { - "finality": revision.finality, - "revision_content_sha256": revision.revision_content_sha256, - "row_count": revision.row_count, - "control_totals": [list(item) for item in revision.control_totals], - "obligation": revision.reporting_obligation_id, - "supersedes": revision.supersedes_reporting_revision_id, - } - if revision.canonical_content_digest is not None: - payload["canonical_content_digest"] = revision.canonical_content_digest.to_wire() - if revision.managed_control_totals is not None: - payload["managed_control_totals"] = [ - item.to_wire() for item in revision.managed_control_totals - ] - if revision.canonical_content_digest is not None or revision.managed_control_totals is not None: - payload["managed_metadata"] = managed_revision_metadata(revision) - return payload - - _RESOLVERS: dict[str, tuple[str, str, str, Any]] = { "obligation": ( "reporting_obligations", diff --git a/src/adcp/reporting/ledger/reporting_buyer_submissions.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_buyer_submissions.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_buyer_submissions.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_buyer_submissions.sql diff --git a/src/adcp/reporting/ledger/reporting_caller_ownership.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_caller_ownership.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_caller_ownership.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_caller_ownership.sql diff --git a/src/adcp/reporting/ledger/reporting_changes.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_changes.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_changes.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_changes.sql diff --git a/src/adcp/reporting/ledger/reporting_feed.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_feed.sql similarity index 96% rename from src/adcp/reporting/ledger/reporting_feed.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_feed.sql index fd3dc69c5..ee8d79771 100644 --- a/src/adcp/reporting/ledger/reporting_feed.sql +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_feed.sql @@ -1,7 +1,7 @@ -- B2.3 isolated immutable snapshots. No old catalog object is changed. DO $migration$ BEGIN - PERFORM pg_advisory_xact_lock(hashtext('adcp.reporting.feed.schema')); + PERFORM pg_advisory_xact_lock(hashtext('adcp_reporting_storage.feed.schema')); CREATE TABLE IF NOT EXISTS reporting_feed_snapshots ( account_id TEXT COLLATE "C" NOT NULL, consumer_id TEXT COLLATE "C" NOT NULL, diff --git a/src/adcp/reporting/ledger/reporting_inline_storage.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_inline_storage.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_inline_storage.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_inline_storage.sql diff --git a/src/adcp/reporting/ledger/reporting_ledger.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_ledger.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger.sql diff --git a/src/adcp/reporting/ledger/reporting_ledger_account_generations.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_account_generations.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_ledger_account_generations.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_account_generations.sql diff --git a/src/adcp/reporting/ledger/reporting_ledger_obligation_currency.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_obligation_currency.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_ledger_obligation_currency.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_obligation_currency.sql diff --git a/src/adcp/reporting/ledger/reporting_ledger_reconciliation.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_reconciliation.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_ledger_reconciliation.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_ledger_reconciliation.sql diff --git a/src/adcp/reporting/ledger/reporting_materializer.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_materializer.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_materializer.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_materializer.sql diff --git a/src/adcp/reporting/ledger/reporting_notification_outbox.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_notification_outbox.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_notification_outbox.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_notification_outbox.sql diff --git a/src/adcp/reporting/ledger/reporting_period_retirement.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_period_retirement.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_period_retirement.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_period_retirement.sql diff --git a/src/adcp/reporting/ledger/reporting_production.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_production.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_production.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_production.sql diff --git a/src/adcp/reporting/ledger/reporting_projection.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_projection.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_projection.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_projection.sql diff --git a/src/adcp/reporting/ledger/reporting_projection_feed.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_projection_feed.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_projection_feed.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_projection_feed.sql diff --git a/src/adcp/reporting/ledger/reporting_projection_notifications.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_projection_notifications.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_projection_notifications.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_projection_notifications.sql diff --git a/src/adcp/reporting/ledger/reporting_provisional_observations.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_provisional_observations.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_provisional_observations.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_provisional_observations.sql diff --git a/src/adcp/reporting/ledger/reporting_receipt_ingestion.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_receipt_ingestion.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_receipt_ingestion.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_receipt_ingestion.sql diff --git a/src/adcp/reporting/ledger/reporting_row_migration.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_migration.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_row_migration.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_migration.sql diff --git a/src/adcp/reporting/ledger/reporting_row_storage.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_row_storage.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_row_storage.sql diff --git a/src/adcp/reporting/ledger/reporting_status_notifications.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_status_notifications.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_status_notifications.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_status_notifications.sql diff --git a/src/adcp/reporting/ledger/reporting_status_selector_version.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_status_selector_version.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_status_selector_version.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_status_selector_version.sql diff --git a/src/adcp/reporting/ledger/reporting_webhook_activity.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_webhook_activity.sql similarity index 100% rename from src/adcp/reporting/ledger/reporting_webhook_activity.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/reporting_webhook_activity.sql diff --git a/src/adcp/reporting/ledger/required_provisional_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/required_provisional_schema.json similarity index 100% rename from src/adcp/reporting/ledger/required_provisional_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/required_provisional_schema.json diff --git a/src/adcp/reporting/ledger/required_row_storage_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/required_row_storage_schema.json similarity index 100% rename from src/adcp/reporting/ledger/required_row_storage_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/required_row_storage_schema.json diff --git a/src/adcp/reporting/ledger/row_migration.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_migration.py similarity index 97% rename from src/adcp/reporting/ledger/row_migration.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_migration.py index be65d3f99..a0f0f971f 100644 --- a/src/adcp/reporting/ledger/row_migration.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_migration.py @@ -25,11 +25,14 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any, Literal -from adcp.reporting.ledger.row_storage import REPORTING_ROW_STORAGE_TABLES, ReportingRowStoreError from adcp.reporting.ledger.store import LedgerConflictError +from adcp_reporting_storage.ledger.row_storage import ( + REPORTING_ROW_STORAGE_TABLES, + ReportingRowStoreError, +) if TYPE_CHECKING: - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore __all__ = [ "LEGACY_ROW_ARCHIVE_TABLE", @@ -127,7 +130,7 @@ async def migrate_inline_rows( async def _migrate_one( store: PgReportingLedgerStore, revision_id: str, account_id: str, binding_id: str | None ) -> Literal["migrated", "skipped"] | ReportingRowQuarantineReason: - from adcp.reporting.ledger import pg as pg_module + from adcp.reporting.ledger import _record_rows as pg_module async with store._pool.connection() as connection, connection.transaction(): await store._lock_account(connection, account_id) diff --git a/src/adcp/reporting/ledger/row_retention.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_retention.py similarity index 99% rename from src/adcp/reporting/ledger/row_retention.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_retention.py index 75cb14c2d..1834aa4b2 100644 --- a/src/adcp/reporting/ledger/row_retention.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_retention.py @@ -32,10 +32,10 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from adcp.reporting.ledger.row_storage import ReportingRowStoreError +from adcp_reporting_storage.ledger.row_storage import ReportingRowStoreError if TYPE_CHECKING: - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore __all__ = [ "ReportingRowIntentSweepV1", diff --git a/src/adcp/reporting/ledger/row_storage.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py similarity index 99% rename from src/adcp/reporting/ledger/row_storage.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py index f8b23b18e..39d7ef542 100644 --- a/src/adcp/reporting/ledger/row_storage.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage.py @@ -54,7 +54,7 @@ ReportingRowStoreError, ReportingRowStoreErrorCode, ) -from adcp.reporting.ledger.row_storage_object import ( +from adcp_reporting_storage.ledger.row_storage_object import ( REPORTING_ROW_DEFAULT_KEY_TEMPLATE, ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, @@ -553,10 +553,12 @@ async def _verify_catalog(connection: Any) -> None: """ from importlib.resources import files - from adcp.reporting.outbox._schema import _catalog_objects + from adcp_reporting_storage.outbox._schema import _catalog_objects required: dict[str, dict[str, Any]] = json.loads( - files("adcp.reporting.ledger").joinpath("required_row_storage_schema.json").read_text() + files("adcp_reporting_storage.ledger") + .joinpath("required_row_storage_schema.json") + .read_text() ) installed = await _catalog_objects( connection, diff --git a/src/adcp/reporting/ledger/row_storage_azure.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_azure.py similarity index 98% rename from src/adcp/reporting/ledger/row_storage_azure.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_azure.py index 439a699fa..a2d12e811 100644 --- a/src/adcp/reporting/ledger/row_storage_azure.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_azure.py @@ -1,4 +1,4 @@ -"""Azure Blob Storage row-object provider (``adcp[reporting-azure]``). +"""Azure Blob Storage row-object provider (``adcp-reporting-storage[azure]``). Inject official ``azure.storage.blob.BlobServiceClient`` instances created by the host with managed or workload identity. Bindings use ``location={"account": ..., "container": @@ -27,7 +27,7 @@ from typing import Any, Literal from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError -from adcp.reporting.ledger.row_storage_object import ( +from adcp_reporting_storage.ledger.row_storage_object import ( ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, @@ -37,7 +37,7 @@ ReportingRowObjectStatInputV1, ReportingRowObjectStatResultV1, ) -from adcp.reporting.ledger.row_storage_provider_io import ( +from adcp_reporting_storage.ledger.row_storage_provider_io import ( assert_location_keys, assert_read_bounds, client_map, diff --git a/src/adcp/reporting/ledger/row_storage_gcs.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_gcs.py similarity index 98% rename from src/adcp/reporting/ledger/row_storage_gcs.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_gcs.py index e917384e9..782465687 100644 --- a/src/adcp/reporting/ledger/row_storage_gcs.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_gcs.py @@ -1,4 +1,4 @@ -"""Google Cloud Storage row-object provider (``adcp[reporting-gcs]``). +"""Google Cloud Storage row-object provider (``adcp-reporting-storage[gcs]``). Inject official ``google.cloud.storage.Client`` instances configured by the host with keyless credentials (workload identity, ADC). Bindings use ``location={"bucket": ...}``; @@ -20,7 +20,7 @@ from typing import Any, Literal from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError -from adcp.reporting.ledger.row_storage_object import ( +from adcp_reporting_storage.ledger.row_storage_object import ( ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, @@ -30,7 +30,7 @@ ReportingRowObjectStatInputV1, ReportingRowObjectStatResultV1, ) -from adcp.reporting.ledger.row_storage_provider_io import ( +from adcp_reporting_storage.ledger.row_storage_provider_io import ( assert_key, assert_location_keys, assert_read_bounds, diff --git a/src/adcp/reporting/ledger/row_storage_object.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py similarity index 100% rename from src/adcp/reporting/ledger/row_storage_object.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_object.py diff --git a/src/adcp/reporting/ledger/row_storage_provider_io.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_provider_io.py similarity index 97% rename from src/adcp/reporting/ledger/row_storage_provider_io.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_provider_io.py index ab460bfb6..d59b6cc46 100644 --- a/src/adcp/reporting/ledger/row_storage_provider_io.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_provider_io.py @@ -10,7 +10,7 @@ from typing import Any, TypeVar from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError -from adcp.reporting.ledger.row_storage_object import ReportingRowObjectGetInputV1 +from adcp_reporting_storage.ledger.row_storage_object import ReportingRowObjectGetInputV1 __all__ = [ "assert_key", diff --git a/src/adcp/reporting/ledger/row_storage_s3.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_s3.py similarity index 98% rename from src/adcp/reporting/ledger/row_storage_s3.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_s3.py index 7b3b53b94..d3d96e423 100644 --- a/src/adcp/reporting/ledger/row_storage_s3.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_storage_s3.py @@ -1,4 +1,4 @@ -"""Amazon S3 and S3-compatible row-object provider (``adcp[reporting-s3]``). +"""Amazon S3 and S3-compatible row-object provider (``adcp-reporting-storage[s3]``). Inject official ``boto3`` S3 clients configured by the host (region, endpoint, credentials, path style). Bindings use ``location={"bucket": ..., "region": ...}``; @@ -26,7 +26,7 @@ from typing import Any, Literal from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError -from adcp.reporting.ledger.row_storage_object import ( +from adcp_reporting_storage.ledger.row_storage_object import ( ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, @@ -36,7 +36,7 @@ ReportingRowObjectStatInputV1, ReportingRowObjectStatResultV1, ) -from adcp.reporting.ledger.row_storage_provider_io import ( +from adcp_reporting_storage.ledger.row_storage_provider_io import ( assert_key, assert_location_keys, assert_read_bounds, diff --git a/src/adcp/reporting/ledger/row_write.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_write.py similarity index 98% rename from src/adcp/reporting/ledger/row_write.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_write.py index c26531cb4..7f3624993 100644 --- a/src/adcp/reporting/ledger/row_write.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/row_write.py @@ -37,18 +37,18 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, Any -from adcp.reporting.ledger.row_storage import ( +from adcp.reporting.ledger.row_storage_errors import ReportingRowBindingMismatchError +from adcp.reporting.ledger.store import LedgerConflictError +from adcp_reporting_storage.ledger.row_storage import ( ReportingObjectRowBindingV1, ReportingPreparedRowSetV1, ReportingRowChunkLocatorV1, ReportingRowStoreError, ) -from adcp.reporting.ledger.row_storage_errors import ReportingRowBindingMismatchError -from adcp.reporting.ledger.store import LedgerConflictError if TYPE_CHECKING: from adcp.reporting.ledger.models import ReportingRevisionRecord - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore __all__ = [ "StagedRevisionRows", @@ -148,7 +148,7 @@ async def stage_revision_rows( upload. Never call this inside a store transaction: it takes the account lock briefly on its own connection and uploads without holding it. """ - from adcp.reporting.ledger.pg import _BOUND_CONNECTION + from adcp_reporting_storage.ledger.pg import _BOUND_CONNECTION storage = store._row_storage if storage is None: diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/status_snapshot.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/status_snapshot.py new file mode 100644 index 000000000..b0c9ccd36 --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/ledger/status_snapshot.py @@ -0,0 +1,206 @@ +"""Connection-bound status snapshot reads and lifecycle persistence over PostgreSQL. + +The pure projection helpers (memory snapshots, evidence validation, decoding a +stored snapshot) are :mod:`adcp.reporting.ledger.status_snapshot`; these +functions run on a caller-owned connection inside the account transaction. +""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from adcp.reporting.ledger.notification_models import ReportingNotificationError +from adcp.reporting.ledger.status_projection import ( + ReportingStatusSnapshot, + StatusLifecycleIntent, + apply_intents_to_snapshot, + lifecycle_intents, +) +from adcp.reporting.ledger.status_snapshot import snapshot_from_storage + +if TYPE_CHECKING: + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore + +__all__ = [ + "apply_intents_on", + "persist_replay_lifecycles_on", + "read_snapshot_on", + "settle_snapshot_on", +] + + +_TABLES = { + "configurations": "reporting_configurations", + "obligations": "reporting_obligations", + "revisions": "reporting_revisions", + "statuses": "reporting_consumer_statuses", + "lifecycles": "reporting_issue_lifecycle", + "waivers": "reporting_issue_waiver_bindings", + "issue_scopes": "reporting_issue_status_scopes", + "adjustments": "reporting_adjustments", + "changes": "reporting_ledger_changes", + "consumers": "reporting_reconciliation_heads", +} + + +async def read_snapshot_on( + connection: Any, + *, + account_id: str, + clock: Callable[[], datetime] | None = None, + as_of: datetime | None = None, + include_issue_scopes: bool = True, +) -> ReportingStatusSnapshot: + from adcp_reporting_storage.ledger.pg import _waiver_storage_on + from adcp_reporting_storage.outbox.pg import database_now + + at = as_of or await database_now(connection, clock) + raw: dict[str, Any] = {"account_id": account_id, "as_of": at.isoformat()} + for key, table in _TABLES.items(): + if key == "issue_scopes" and not include_issue_scopes: + continue + if key == "waivers" and not await _waiver_storage_on(connection): + continue + # Table names are the closed SDK constants above. Parameters are bound. + rows = await ( + await connection.execute( + f"SELECT to_jsonb(r) FROM {table} r WHERE account_id = %s", # nosec B608 + (account_id,), + ) + ).fetchall() + raw[key] = [row[0] for row in rows] + return snapshot_from_storage(raw) + + +async def apply_intents_on( + store: PgReportingLedgerStore, connection: Any, intents: tuple[StatusLifecycleIntent, ...] +) -> None: + """No pooled calls. The source/projector owns account and checkpoint locks.""" + for intent in intents: + issue = intent.lifecycle + if intent.action == "ensure_mismatch": + await store._ensure_issue_opened_on( + connection, + issue_key=issue.issue_key, + account_id=issue.account_id, + consumer_id=issue.consumer_id, + observed_at=issue.opened_at, + status_scope=intent.scope, + enqueue=False, + ) + elif intent.action == "retire_mismatch": + assert issue.retired_at is not None + await store._retire_issue_on( + connection, + issue_key=issue.issue_key, + account_id=issue.account_id, + at=issue.retired_at, + status_scope=intent.scope, + enqueue=False, + ) + else: + await store._dirty_issue(connection, issue, intent.scope, enqueue=False) + + +async def persist_replay_lifecycles_on( + store: PgReportingLedgerStore, connection: Any, snapshot: ReportingStatusSnapshot +) -> None: + """Persist derived historical occurrences without regressing later evidence. + + Captured source rows remain immutable. These are the same public lifecycle + rows used by polling, including repairs for an old writer's interrupted + status/issue pair. The enclosing account lock fences concurrent ingestion. + """ + from adcp_reporting_storage.ledger.pg import _save_waiver_binding + + scopes = dict(snapshot.issue_scopes) + for issue in sorted(snapshot.lifecycles, key=lambda i: (i.issue_key, i.generation)): + await connection.execute( + "INSERT INTO reporting_issue_lifecycle" + " (account_id, issue_key, generation, issue_id, consumer_id, opened_at," + " issue_state, external_ref, retired_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)" + " ON CONFLICT (account_id, issue_key, generation) DO NOTHING", + ( + issue.account_id, + issue.issue_key, + issue.generation, + issue.issue_id, + issue.consumer_id, + issue.opened_at, + issue.issue_state, + issue.external_ref, + issue.retired_at, + ), + ) + await _save_waiver_binding(connection, issue) + if issue.issue_state != "open": + await connection.execute( + "UPDATE reporting_issue_lifecycle SET issue_state=%s, external_ref=%s," + " retired_at=%s" + " WHERE account_id=%s AND issue_key=%s AND generation=%s" + " AND issue_state IN ('open','acknowledged')", + ( + issue.issue_state, + issue.external_ref, + issue.retired_at, + issue.account_id, + issue.issue_key, + issue.generation, + ), + ) + scope = scopes.get(issue.issue_id) + if scope is not None: + # Existing scope may already be more precise because a later source + # transaction attached this occurrence. Historical replay retains it. + row = await ( + await connection.execute( + "SELECT scope FROM reporting_issue_status_scopes" + " WHERE account_id=%s AND issue_id=%s", + (issue.account_id, issue.issue_id), + ) + ).fetchone() + if row is not None: + from adcp.reporting.ledger.notification_models import ( + decode_status_scope, + validate_scope_refinement, + ) + + old = decode_status_scope(row[0]) + try: + validate_scope_refinement(old, scope) + except ReportingNotificationError: + validate_scope_refinement(scope, old) + scope = old + await store._dirty_issue(connection, issue, scope, enqueue=False) + + +async def settle_snapshot_on( + store: PgReportingLedgerStore, + connection: Any, + *, + account_id: str, + as_of: datetime | None = None, +) -> ReportingStatusSnapshot: + from adcp_reporting_storage.outbox.pg import database_now + + at = as_of or await database_now(connection, store._clock) + has_scope_storage = await store._issue_scope_storage_on(connection) + for _ in range(3): + snapshot = await read_snapshot_on( + connection, account_id=account_id, as_of=at, include_issue_scopes=has_scope_storage + ) + if not has_scope_storage: + # Pre-outbox Core schemas retain lifecycle but have no scope table. + # Derive scope from authenticated status evidence for this read; + # notification-enabled participants require durable scope storage. + snapshot = apply_intents_to_snapshot( + snapshot, + tuple(i for i in lifecycle_intents(snapshot) if i.action == "refine_scope"), + ) + intents = lifecycle_intents(snapshot) + if not intents: + return snapshot + await apply_intents_on(store, connection, intents) + raise ReportingNotificationError("status_lifecycle_did_not_converge") diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/__init__.py new file mode 100644 index 000000000..de17b58d9 --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/__init__.py @@ -0,0 +1 @@ +"""The PostgreSQL materializer store.""" diff --git a/src/adcp/reporting/materializer/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/pg.py similarity index 96% rename from src/adcp/reporting/materializer/pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/pg.py index 50509342a..c983b5d9d 100644 --- a/src/adcp/reporting/materializer/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/pg.py @@ -9,6 +9,13 @@ from uuid import uuid4 from adcp.reporting.ledger._delivery_state import RecordT +from adcp.reporting.ledger._record_rows import ( + _OBLIGATION_COLUMNS, + _REVISION_COLUMNS, + _configuration_from_row, + _obligation_from_row, + _revision_from_row, +) from adcp.reporting.ledger.delivery_models import ( ReportingDeliveryPrincipal, ReportingDeliveryScope, @@ -17,15 +24,11 @@ ReportingMaterializationRecord, ReportingObligationDeliveryRecord, ) -from adcp.reporting.ledger.delivery_pg import PgReportingReconciliationStore from adcp.reporting.ledger.models import ReportingConfigurationGenerationKey from adcp.reporting.ledger.notification_events import materialization_event -from adcp.reporting.ledger.pg import ( - _OBLIGATION_COLUMNS, - _REVISION_COLUMNS, - _configuration_from_row, - _obligation_from_row, - _revision_from_row, +from adcp.reporting.ledger.notification_models import ( + ReportingDomainEvent, + ReportingNotificationError, ) from adcp.reporting.ledger.store import LedgerConflictError from adcp.reporting.materializer._errors import ( @@ -35,7 +38,6 @@ from adcp.reporting.materializer.capture import ( ReportingMaterializerBoundary, decode_materializer_boundary, - enqueue_materializer_event_on, private_snapshot, ) from adcp.reporting.materializer.contracts import ( @@ -47,7 +49,6 @@ binding_fingerprint, failure, ) -from adcp.reporting.materializer.schema import validate_materializer_schema from adcp.reporting.materializer.verification import ( ReportingVerifiedDestination, validate_materialization_target, @@ -63,6 +64,28 @@ validate_lease_seconds, verification_key_id, ) +from adcp_reporting_storage.ledger.delivery_pg import PgReportingReconciliationStore +from adcp_reporting_storage.materializer.schema import validate_materializer_schema +from adcp_reporting_storage.outbox.pg import enqueue_event + + +class _MaterializerQueueConnection: + """Closed table substitution, preserving the finish transaction's connection.""" + + def __init__(self, connection: Any) -> None: + self.connection = connection + + async def execute(self, query: str, params: Any = None) -> Any: + return await self.connection.execute( + query.replace("reporting_notification_", "reporting_materializer_notification_"), params + ) + + +async def enqueue_materializer_event_on(connection: Any, event: ReportingDomainEvent) -> None: + if event.notification_type != "reporting.delivery_ready": + raise ReportingNotificationError("materializer_event_invalid") + await enqueue_event(_MaterializerQueueConnection(connection), event) + _SCOPE_WHERE = ( "account_id=%s AND consumer_id=%s AND delivery_config_id=%s" @@ -133,7 +156,9 @@ async def create_schema(self) -> None: async with self._connection() as connection, connection.transaction(): await self._create_schema_on(connection) await connection.execute( - files("adcp.reporting.ledger").joinpath("reporting_materializer.sql").read_text() + files("adcp_reporting_storage.ledger") + .joinpath("reporting_materializer.sql") + .read_text() ) @materializer_errors @@ -831,8 +856,8 @@ async def _materializer_dirty_on( await self._dirty_status(connection, scope, reason, after=evidence) from adcp.reporting.canonical_json import canonical_json_sha256_v1 - from adcp.reporting.ledger.pg import _json - from adcp.reporting.ledger.status_snapshot import settle_snapshot_on + from adcp_reporting_storage.ledger.pg import _json + from adcp_reporting_storage.ledger.status_snapshot import settle_snapshot_on core = await settle_snapshot_on( self, connection, account_id=scope.account_id, as_of=outcome.completed_at diff --git a/src/adcp/reporting/materializer/required_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/required_schema.json similarity index 100% rename from src/adcp/reporting/materializer/required_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/required_schema.json diff --git a/src/adcp/reporting/materializer/schema.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/schema.py similarity index 93% rename from src/adcp/reporting/materializer/schema.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/schema.py index dfea0b742..bfc4fcb49 100644 --- a/src/adcp/reporting/materializer/schema.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/materializer/schema.py @@ -7,7 +7,7 @@ from typing import Any from adcp.reporting.ledger.store import LedgerConflictError -from adcp.reporting.outbox._schema import ( +from adcp_reporting_storage.outbox._schema import ( REQUIRED_OBJECTS, is_legacy_row_object, legacy_rows_retired, @@ -18,7 +18,7 @@ async def validate_materializer_schema(connection: Any, *, notifications: bool = False) -> None: required = json.loads( - files("adcp.reporting.materializer").joinpath("required_schema.json").read_text() + files("adcp_reporting_storage.materializer").joinpath("required_schema.json").read_text() ) if not required: raise LedgerConflictError("MATERIALIZER_SCHEMA_UNREADY", "install the materializer schema") diff --git a/src/adcp/reporting/migration.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py similarity index 99% rename from src/adcp/reporting/migration.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py index aeebc5d9c..226a29af2 100644 --- a/src/adcp/reporting/migration.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py @@ -554,7 +554,7 @@ async def migrate_legacy_reporting( sql.SQL(kind), sql.Identifier(archive_schema) ) ) - root = files("adcp.reporting.ledger") + root = files("adcp_reporting_storage.ledger") for name in ( "reporting_ledger.sql", "reporting_ledger_account_generations.sql", @@ -576,7 +576,7 @@ async def migrate_legacy_reporting( ): await connection.execute(root.joinpath(name).read_text()) await connection.execute( - files("adcp.reporting.production").joinpath("service_context.sql").read_text() + files("adcp_reporting_storage.production").joinpath("service_context.sql").read_text() ) await connection.execute( "INSERT INTO adcp_reporting_ownership_archives(archive_schema) VALUES (%s)", diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/__init__.py new file mode 100644 index 000000000..a650be3b8 --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/__init__.py @@ -0,0 +1 @@ +"""PostgreSQL notification, status and activity queues.""" diff --git a/src/adcp/reporting/outbox/_activity_pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/_activity_pg.py similarity index 97% rename from src/adcp/reporting/outbox/_activity_pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/_activity_pg.py index 8549b2858..79b368102 100644 --- a/src/adcp/reporting/outbox/_activity_pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/_activity_pg.py @@ -68,7 +68,7 @@ async def _activity_transaction(self) -> AsyncIterator[Any]: raise ReportingNotificationError("activity_store_unavailable") from None async def _activity_fence(self, conn: Any, lease: DeliveryLease) -> datetime | None: - from adcp.reporting.outbox.pg import _DELIVERY_COLUMNS, _delivery, database_now + from adcp_reporting_storage.outbox.pg import _DELIVERY_COLUMNS, _delivery, database_now b = lease.delivery.binding # The projection is an SDK constant; every selector is a bound value. @@ -111,7 +111,7 @@ async def reserve_attempt( async def _reserve_attempt_on( self, conn: Any, lease: DeliveryLease, *, request: ActivityRequest ) -> WebhookAttempt | None: - from adcp.reporting.outbox.pg import database_now + from adcp_reporting_storage.outbox.pg import database_now b = lease.delivery.binding consumer = canonical_consumer(b.principal_id) @@ -185,7 +185,7 @@ async def _next_attempt_on(self, conn: Any, binding: DeliveryBinding) -> int: async def complete_attempt( self, attempt: WebhookAttempt, *, outcome: ActivityOutcome, now: datetime ) -> bool: - from adcp.reporting.outbox.pg import database_now + from adcp_reporting_storage.outbox.pg import database_now ActivityOutcome.__post_init__(outcome) b = attempt.binding @@ -247,7 +247,7 @@ async def list_activity( async def purge_activity( self, *, account_id: str, consumer_id: str, now: datetime, retention_days: int = 30 ) -> int: - from adcp.reporting.outbox.pg import database_now + from adcp_reporting_storage.outbox.pg import database_now consumer_id = canonical_consumer(consumer_id) retention_cutoff(now, retention_days) diff --git a/src/adcp/reporting/outbox/_schema.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/_schema.py similarity index 98% rename from src/adcp/reporting/outbox/_schema.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/_schema.py index b2709cba3..86276fd36 100644 --- a/src/adcp/reporting/outbox/_schema.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/_schema.py @@ -17,10 +17,10 @@ from adcp.reporting.ledger.notification_models import ReportingNotificationError REQUIRED_OBJECTS: dict[str, dict[str, Any]] = json.loads( - files("adcp.reporting.outbox").joinpath("required_schema.json").read_text() + files("adcp_reporting_storage.outbox").joinpath("required_schema.json").read_text() ) PROVISIONAL_REQUIRED_OBJECTS: dict[str, dict[str, Any]] = json.loads( - files("adcp.reporting.ledger").joinpath("required_provisional_schema.json").read_text() + files("adcp_reporting_storage.ledger").joinpath("required_provisional_schema.json").read_text() ) SCHEMA_CONTRACT: dict[str, str] = { key: value["fingerprint"] for key, value in REQUIRED_OBJECTS.items() diff --git a/src/adcp/reporting/outbox/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/pg.py similarity index 93% rename from src/adcp/reporting/outbox/pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/pg.py index 7b798ccad..be74e7831 100644 --- a/src/adcp/reporting/outbox/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/pg.py @@ -24,7 +24,6 @@ dirty_storage, event_storage, ) -from adcp.reporting.outbox._activity_pg import _PgReportingActivity from adcp.reporting.outbox.memory import validate_finish from adcp.reporting.outbox.models import ( DeliveryBinding, @@ -35,6 +34,7 @@ StoredDelivery, WorkState, ) +from adcp_reporting_storage.outbox._activity_pg import _PgReportingActivity if TYPE_CHECKING: from psycopg_pool import AsyncConnectionPool @@ -114,7 +114,7 @@ async def mark_dirty( before: ReportingStatusEvidence | None = None, after: ReportingStatusEvidence | None = None, ) -> None: - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore await PgReportingLedgerStore._lock_account(connection, scope.account_id) scope_hash = hashlib.sha256(canonical_json_utf8_v1(asdict(scope))).hexdigest() @@ -183,17 +183,41 @@ def __init__( # Same actionable hint the sibling PG stores raise, so a base-install # adopter is told to add the extra instead of meeting a driver error # from inside the first query. - from adcp.reporting.ledger.pg import _INSTALL_HINT, PG_AVAILABLE + from adcp_reporting_storage.ledger.pg import _INSTALL_HINT, PG_AVAILABLE if not PG_AVAILABLE: raise ImportError(_INSTALL_HINT) self._pool, self._clock = pool, clock async def create_schema(self) -> None: - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore await PgReportingLedgerStore(pool=self._pool, notifications=True).create_schema() + async def next_due_account(self, *, delivery: bool, now: datetime) -> str | None: + """Sample one indexed due queue row by database time; never enumerate accounts. + + ``now`` is the worker's instant; a durable queue orders by database time + (or the explicit conformance clock), so it is not consulted here. + """ + # Both identifiers are closed SDK literals. The concrete queue adapter + # selects the matching isolated table on this same actual connection. + table = ( + "reporting_notification_deliveries" if delivery else "reporting_notification_expansions" + ) + async with self._connection() as connection: + at = await database_now(connection, self._clock) + row = await ( + await connection.execute( + f"SELECT account_id FROM {table}" # nosec B608 + " WHERE due_at<=%s AND (state='pending' OR" + " (state='leased' AND lease_expires_at<=%s))" + " ORDER BY due_at,account_id LIMIT 1", + (at, at), + ) + ).fetchone() + return str(row[0]) if row is not None else None + async def list_events(self, *, account_id: str) -> tuple[ReportingDomainEvent, ...]: async with self._connection() as conn: rows = await ( diff --git a/src/adcp/reporting/outbox/required_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/required_schema.json similarity index 100% rename from src/adcp/reporting/outbox/required_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/required_schema.json diff --git a/src/adcp/reporting/outbox/required_status_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/required_status_schema.json similarity index 100% rename from src/adcp/reporting/outbox/required_status_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/required_status_schema.json diff --git a/src/adcp/reporting/outbox/required_status_selector_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/required_status_selector_schema.json similarity index 100% rename from src/adcp/reporting/outbox/required_status_selector_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/required_status_selector_schema.json diff --git a/src/adcp/reporting/outbox/status_activity_pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_activity_pg.py similarity index 86% rename from src/adcp/reporting/outbox/status_activity_pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_activity_pg.py index 48808dbfb..e03413e12 100644 --- a/src/adcp/reporting/outbox/status_activity_pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_activity_pg.py @@ -5,12 +5,12 @@ from datetime import datetime from adcp.reporting.ledger.notification_models import ReportingNotificationError -from adcp.reporting.ledger.pg import PG_AVAILABLE -from adcp.reporting.outbox._activity_pg import _ACTIVITY_COLUMNS, _attempt from adcp.reporting.outbox.activity import WebhookAttempt, activity_limit, retention_cutoff from adcp.reporting.outbox.identity import canonical_consumer -from adcp.reporting.outbox.pg import PgReportingOutbox, database_now -from adcp.reporting.outbox.status_pg import PgReportingStatusOutbox +from adcp_reporting_storage.ledger.pg import PG_AVAILABLE +from adcp_reporting_storage.outbox._activity_pg import _ACTIVITY_COLUMNS, _attempt +from adcp_reporting_storage.outbox.pg import PgReportingOutbox, database_now +from adcp_reporting_storage.outbox.status_pg import PgReportingStatusOutbox class PgReportingActivityUnionStore: @@ -18,7 +18,10 @@ class PgReportingActivityUnionStore: def __init__(self, b_outbox: PgReportingOutbox, status_outbox: PgReportingStatusOutbox) -> None: if not PG_AVAILABLE: - raise ImportError("PgReportingActivityUnionStore requires PostgreSQL; install adcp[pg]") + raise ImportError( + "PgReportingActivityUnionStore requires PostgreSQL;" + " install adcp-reporting-storage[pg]" + ) if ( type(b_outbox) is not PgReportingOutbox or type(status_outbox) is not PgReportingStatusOutbox diff --git a/src/adcp/reporting/outbox/status_pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_pg.py similarity index 92% rename from src/adcp/reporting/outbox/status_pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_pg.py index b5f9077db..63f4e91f3 100644 --- a/src/adcp/reporting/outbox/status_pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_pg.py @@ -13,7 +13,7 @@ from datetime import timedelta from importlib.resources import files from secrets import token_hex -from typing import Any +from typing import TYPE_CHECKING, Any from pydantic import TypeAdapter @@ -27,7 +27,6 @@ decode_status_scope, event_storage, ) -from adcp.reporting.ledger.pg import PG_AVAILABLE, PgReportingLedgerStore from adcp.reporting.ledger.status_projection import ( ReportingStatusSnapshot, StatusProjectionInput, @@ -35,13 +34,8 @@ projection_scopes, with_replay_lifecycles, ) -from adcp.reporting.ledger.status_snapshot import ( - persist_replay_lifecycles_on, - settle_snapshot_on, - snapshot_from_storage, -) +from adcp.reporting.ledger.status_snapshot import snapshot_from_storage from adcp.reporting.outbox.models import DeliveryBinding -from adcp.reporting.outbox.pg import PgReportingOutbox, database_now from adcp.reporting.outbox.status import ( StatusCheckpoint, StatusDueLease, @@ -50,6 +44,12 @@ escalation_identity, settled_replay, ) +from adcp_reporting_storage.ledger.pg import PG_AVAILABLE, PgReportingLedgerStore +from adcp_reporting_storage.ledger.status_snapshot import ( + persist_replay_lifecycles_on, + settle_snapshot_on, +) +from adcp_reporting_storage.outbox.pg import PgReportingOutbox, database_now _KEY = ( "account_id, consumer_namespace, delivery_config_id, version, scope_kind, obligation_namespace" @@ -63,6 +63,9 @@ " lease_token, lease_expires_at, selector_semantics_version, selector_writer_floor, initialized" ) +if TYPE_CHECKING: + from adcp.reporting.outbox.worker import ReportingNotificationWorker + @deferred_adapter def _lifecycle_adapter() -> TypeAdapter[ReportingIssueLifecycle]: @@ -201,24 +204,58 @@ def __init__( escalation: ReportingDeliveryEscalation | None = None, ) -> None: if not PG_AVAILABLE: - raise ImportError("PgStatusNotificationStore requires PostgreSQL; install adcp[pg]") + raise ImportError( + "PgStatusNotificationStore requires PostgreSQL; install adcp-reporting-storage[pg]" + ) if not isinstance(ledger, PgReportingLedgerStore) or not ledger._notifications_enabled: raise ReportingNotificationError("status_chain_unready") self.ledger, self.escalation = ledger, escalation self.outbox = PgReportingStatusOutbox(pool=ledger._pool, clock=ledger._clock) + # -- adcp.reporting.outbox.durable.ReportingDurableStatusStore ------------- + + async def status_queue_durable(self, worker: ReportingNotificationWorker) -> bool: + from adcp_reporting_storage.outbox.status_schema import validate_status_schema + + if ( + type(self) is not PgStatusNotificationStore + or type(worker.outbox) is not PgReportingStatusOutbox + or worker.outbox is not self.outbox + or self.ledger._pool is not self.outbox._pool + or not self.ledger._notifications_enabled + or (worker.activity is not None and worker.activity is not worker.outbox) + ): + return False + try: + async with self.ledger._pool.connection() as connection: + await validate_status_schema(connection, activity=worker.activity is not None) + except ReportingNotificationError: + return False + return True + + async def managed_delivery_recorded(self, *, account_id: str) -> bool: + async with self.ledger._pool.connection() as connection: + managed = await ( + await connection.execute( + "SELECT EXISTS(SELECT 1 FROM reporting_reconciliation_records" + " WHERE account_id=%s AND record_kind='destination_binding')", + (account_id,), + ) + ).fetchone() + return bool(managed is not None and managed[0]) + async def create_schema(self) -> None: - from adcp.reporting.outbox.status_schema import validate_status_schema + from adcp_reporting_storage.outbox.status_schema import validate_status_schema async with self.ledger._pool.connection() as connection, connection.transaction(): await self.ledger._create_schema_on(connection) await connection.execute( - files("adcp.reporting.ledger") + files("adcp_reporting_storage.ledger") .joinpath("reporting_status_notifications.sql") .read_text() ) await connection.execute( - files("adcp.reporting.ledger") + files("adcp_reporting_storage.ledger") .joinpath("reporting_status_selector_version.sql") .read_text() ) @@ -345,7 +382,7 @@ async def _enqueue_status_on(self, connection: Any, event: ReportingDomainEvent) await _enqueue_on(connection, event) async def baseline(self, *, account_id: str) -> bool: - from adcp.reporting.outbox.status_schema import validate_status_schema + from adcp_reporting_storage.outbox.status_schema import validate_status_schema async with self._transaction(account_id) as connection: await validate_status_schema(connection) @@ -384,7 +421,7 @@ async def baseline(self, *, account_id: str) -> bool: return True async def baseline_ready(self, *, account_id: str) -> bool: - from adcp.reporting.outbox.status_schema import validate_status_schema + from adcp_reporting_storage.outbox.status_schema import validate_status_schema async with self._transaction(account_id) as connection: await validate_status_schema(connection) diff --git a/src/adcp/reporting/outbox/status_schema.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_schema.py similarity index 89% rename from src/adcp/reporting/outbox/status_schema.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_schema.py index a8c8ff010..876abad8f 100644 --- a/src/adcp/reporting/outbox/status_schema.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/outbox/status_schema.py @@ -7,13 +7,15 @@ from typing import Any from adcp.reporting.ledger.notification_models import ReportingNotificationError -from adcp.reporting.outbox._schema import schema_objects, validate_schema +from adcp_reporting_storage.outbox._schema import schema_objects, validate_schema REQUIRED_STATUS_OBJECTS: dict[str, dict[str, Any]] = json.loads( - files("adcp.reporting.outbox").joinpath("required_status_schema.json").read_text() + files("adcp_reporting_storage.outbox").joinpath("required_status_schema.json").read_text() ) REQUIRED_STATUS_SELECTOR_OBJECTS: dict[str, dict[str, Any]] = json.loads( - files("adcp.reporting.outbox").joinpath("required_status_selector_schema.json").read_text() + files("adcp_reporting_storage.outbox") + .joinpath("required_status_selector_schema.json") + .read_text() ) diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/__init__.py new file mode 100644 index 000000000..4fc3fd196 --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/__init__.py @@ -0,0 +1 @@ +"""The PostgreSQL production store and its readiness queue.""" diff --git a/src/adcp/reporting/production/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/pg.py similarity index 83% rename from src/adcp/reporting/production/pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/production/pg.py index 6d1a13e3e..aa8865eab 100644 --- a/src/adcp/reporting/production/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/pg.py @@ -5,7 +5,7 @@ import asyncio import json import weakref -from collections.abc import AsyncIterator +from collections.abc import AsyncIterator, Sequence from contextlib import asynccontextmanager from contextvars import ContextVar from datetime import datetime, timedelta @@ -13,14 +13,18 @@ from typing import TYPE_CHECKING, Any from adcp.reporting.ledger._delivery_state import decode_record +from adcp.reporting.ledger._record_rows import _configuration_from_row from adcp.reporting.ledger.delivery_models import ( ReportingDeliveryPrincipal, ReportingDeliveryScope, ReportingDestinationBinding, ) -from adcp.reporting.ledger.models import ReportingConfiguration, ReportingObligationRecord +from adcp.reporting.ledger.models import ( + ReportingConfiguration, + ReportingDeliveryEscalation, + ReportingObligationRecord, +) from adcp.reporting.ledger.notification_models import ReportingNotificationError -from adcp.reporting.ledger.pg import _configuration_from_row from adcp.reporting.ledger.producer_progress import acquisition_state, check_next_period from adcp.reporting.ledger.store import LeasedConfiguration, LedgerConflictError, _utc from adcp.reporting.materializer.capture import ReportingMaterializerBoundary @@ -36,10 +40,16 @@ ReportingMaterializerTurn, key_for, ) -from adcp.reporting.outbox.status_pg import PgReportingStatusOutbox +from adcp.reporting.outbox.activity import ActivityRequest, ReportingActivityStore, WebhookAttempt +from adcp.reporting.outbox.models import DeliveryLease from adcp.reporting.production.source_registry import ReportingProductionSourceContext -from adcp.reporting.projection.pg import PgReportingProjectionStore +from adcp.reporting.production.store import ReportingProductionProjection, SavedDeliveryWindow from adcp.reporting.source import ReportingConstituent +from adcp_reporting_storage.outbox.status_pg import PgReportingStatusOutbox +from adcp_reporting_storage.projection.pg import ( + PgReportingProjectionStore, + PgReportingStatusProjection, +) if TYPE_CHECKING: from adcp.reporting.production.service import ReportingProductionSupport @@ -142,7 +152,7 @@ class PgReportingProductionStore(PgReportingProjectionStore): permanently quarantined enqueue. Installation alone admits nothing. """ - _production_support: weakref.ReferenceType[ReportingProductionSupport] | None = None + production_support: weakref.ReferenceType[ReportingProductionSupport] | None = None _production_lease_samples: dict[tuple[str, ...], _ProducerSample] | None = None def _owner(self) -> ReportingProductionSupport: @@ -150,6 +160,160 @@ def _owner(self) -> ReportingProductionSupport: return production_owner(self) + # -- the production store contract (adcp.reporting.production.store) ------ + + @property + def durable(self) -> bool: + return True + + def is_sdk_production_store(self) -> bool: + return type(self) is PgReportingProductionStore + + def status_projection( + self, + *, + consumer_status_enabled: bool, + escalation: ReportingDeliveryEscalation | None, + revision_ownership: bool, + ) -> PgReportingStatusProjection: + return PgReportingStatusProjection( + self, + consumer_status_enabled=consumer_status_enabled, + escalation=escalation, + revision_ownership=revision_ownership, + ) + + def is_sdk_status_projection(self, projection: object) -> bool: + return type(projection) is PgReportingStatusProjection + + def projection_linked(self, projection: ReportingProductionProjection) -> bool: + from adcp_reporting_storage.projection.notifications import PgReportingProjectionOutbox + + return ( + type(projection.outbox) is PgReportingProjectionOutbox + and projection.outbox._pool is self._pool + ) + + def production_outboxes( + self, projection: ReportingProductionProjection + ) -> tuple[Any, Any, Any]: + from adcp_reporting_storage.outbox.pg import PgReportingOutbox + + if type(self) is not PgReportingProductionStore: + raise ReportingNotificationError("notification_chain_unready") + return ( + PgReportingOutbox(pool=self._pool, clock=self._clock), + projection.outbox, + PgReportingProductionOutbox(pool=self._pool, clock=self._clock), + ) + + def notification_workers_linked(self, workers: Sequence[Any]) -> bool: + from adcp_reporting_storage.outbox.pg import PgReportingOutbox + from adcp_reporting_storage.projection.notifications import PgReportingProjectionOutbox + + expected = (PgReportingOutbox, PgReportingProjectionOutbox, PgReportingProductionOutbox) + return ( + all(getattr(w.outbox, "_pool", None) is self._pool for w in workers) + and tuple(type(w.outbox) for w in workers) == expected + ) + + async def validate_production_schema( + self, *, notifications: bool, service_context: bool + ) -> None: + from adcp_reporting_storage.production.schema import validate_production_schema + + async with self._pool.connection() as connection: + await validate_production_schema( + connection, notifications=notifications, service_context=service_context + ) + + async def read_delivery_window( + self, key: tuple[str, str], *, now: datetime + ) -> tuple[SavedDeliveryWindow | None, datetime]: + from adcp_reporting_storage.outbox.pg import database_now + + try: + async with self._connection() as connection, connection.transaction(): + saved = await ( + await connection.execute( + "SELECT queue,body_sha256,started_at,expires_at" + " FROM reporting_production_delivery_windows" + " WHERE account_id=%s AND idempotency_key=%s", + key, + ) + ).fetchone() + moment = await database_now(connection, self._clock) + except ReportingNotificationError: + raise + except Exception: + raise ReportingNotificationError("notification_retry_unready") from None + return (tuple(saved) if saved is not None else None), moment + + async def reserve_delivery_attempt( + self, + activity: ReportingActivityStore, + lease: DeliveryLease, + *, + request: ActivityRequest, + now: datetime, + queue: str, + expected: tuple[str, str], + horizon_seconds: int, + ) -> tuple[WebhookAttempt | None, datetime | None, bool]: + from adcp.reporting.production.delivery_window import inspect_window + from adcp_reporting_storage.outbox.pg import PgReportingOutbox, database_now + + binding = lease.delivery.binding + key = (binding.account_id, binding.idempotency_key) + if not isinstance(activity, PgReportingOutbox) or activity._pool is not self._pool: + raise ReportingNotificationError("notification_retry_unready") + deadline: datetime | None = None + try: + # The activity participant supplies its exact queue adapter and + # connection. Take the inherited account lock before its row lock. + async with activity._activity_transaction() as connection: + await self._lock_account(connection, binding.account_id) + moment = await database_now(connection, self._clock) + saved = await ( + await connection.execute( + "SELECT queue,body_sha256,started_at,expires_at" + " FROM reporting_production_delivery_windows" + " WHERE account_id=%s AND idempotency_key=%s", + key, + ) + ).fetchone() + deadline, expired = inspect_window(saved, expected, moment) + if expired: + return None, deadline, True + attempt = await activity._reserve_attempt_on(connection, lease, request=request) + if attempt is None: + return None, deadline, False + deadline, expired = inspect_window(saved, expected, attempt.fired_at) + if expired: + # Time can cross the deadline while reserving an ordinal. + # Roll back that provisional head and attempt, too. + raise ReportingNotificationError("notification_retry_expired") + await connection.execute( + "INSERT INTO reporting_production_delivery_windows" + " (account_id,idempotency_key,queue,body_sha256,started_at,expires_at)" + " VALUES(%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING", + ( + *key, + *expected, + attempt.fired_at, + attempt.fired_at + timedelta(seconds=horizon_seconds), + ), + ) + return ( + attempt, + deadline or attempt.fired_at + timedelta(seconds=horizon_seconds), + False, + ) + except ReportingNotificationError as error: + if error.code == "notification_retry_expired": + return None, deadline, True + raise + @asynccontextmanager async def _connection(self) -> AsyncIterator[Any]: admission = _ADMISSION_CONNECTION.get() @@ -629,7 +793,7 @@ async def _lease_epoch(self, lease: ReportingMaterializerLease) -> AsyncIterator async def create_schema(self) -> None: async with self._connection() as connection, connection.transaction(): await self._create_schema_on(connection) - root = files("adcp.reporting.ledger") + root = files("adcp_reporting_storage.ledger") for name in ( "reporting_materializer.sql", "reporting_receipt_ingestion.sql", @@ -643,7 +807,9 @@ async def create_schema(self) -> None: ): await connection.execute(root.joinpath(name).read_text()) await connection.execute( - files("adcp.reporting.production").joinpath("service_context.sql").read_text() + files("adcp_reporting_storage.production") + .joinpath("service_context.sql") + .read_text() ) async def materializer_ready(self) -> bool: @@ -653,7 +819,7 @@ async def materializer_ready(self) -> bool: owner._assert_components() return True - async def _activate_production(self, *, account_id: str) -> bool: + async def activate_production(self, *, account_id: str) -> bool: owner = self._owner() await self.materializer_ready() async with self._connection() as connection, connection.transaction(): diff --git a/src/adcp/reporting/production/required_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/required_schema.json similarity index 100% rename from src/adcp/reporting/production/required_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/production/required_schema.json diff --git a/src/adcp/reporting/production/schema.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/schema.py similarity index 76% rename from src/adcp/reporting/production/schema.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/production/schema.py index 7212a3ffd..228d541d3 100644 --- a/src/adcp/reporting/production/schema.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/schema.py @@ -7,8 +7,8 @@ from typing import Any from adcp.reporting.ledger.notification_models import ReportingNotificationError -from adcp.reporting.outbox._schema import schema_objects -from adcp.reporting.projection.schema import validate_projection_schema +from adcp_reporting_storage.outbox._schema import schema_objects +from adcp_reporting_storage.projection.schema import validate_projection_schema async def validate_production_schema( @@ -16,12 +16,12 @@ async def validate_production_schema( ) -> None: try: required = json.loads( - files("adcp.reporting.production").joinpath("required_schema.json").read_text() + files("adcp_reporting_storage.production").joinpath("required_schema.json").read_text() ) if service_context: required.update( json.loads( - files("adcp.reporting.production") + files("adcp_reporting_storage.production") .joinpath("service_context_schema.json") .read_text() ) diff --git a/src/adcp/reporting/production/service_context.sql b/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/service_context.sql similarity index 100% rename from src/adcp/reporting/production/service_context.sql rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/production/service_context.sql diff --git a/src/adcp/reporting/production/service_context_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/production/service_context_schema.json similarity index 100% rename from src/adcp/reporting/production/service_context_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/production/service_context_schema.json diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/__init__.py new file mode 100644 index 000000000..92d380ffb --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/__init__.py @@ -0,0 +1 @@ +"""The PostgreSQL projection store, status projection and projection queue.""" diff --git a/src/adcp/reporting/projection/notifications.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/notifications.py similarity index 89% rename from src/adcp/reporting/projection/notifications.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/notifications.py index 8349cd2a8..ba2a695f3 100644 --- a/src/adcp/reporting/projection/notifications.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/notifications.py @@ -6,7 +6,7 @@ from contextlib import asynccontextmanager from typing import Any -from adcp.reporting.outbox.status_pg import PgReportingStatusOutbox +from adcp_reporting_storage.outbox.status_pg import PgReportingStatusOutbox class _ProjectionQueueConnection: @@ -36,6 +36,6 @@ async def _connection(self) -> AsyncIterator[Any]: yield _ProjectionQueueConnection(connection) async def create_schema(self) -> None: - from adcp.reporting.projection.pg import PgReportingProjectionStore + from adcp_reporting_storage.projection.pg import PgReportingProjectionStore await PgReportingProjectionStore(pool=self._pool, notifications=True).create_schema() diff --git a/src/adcp/reporting/projection/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/pg.py similarity index 96% rename from src/adcp/reporting/projection/pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/pg.py index b24e723eb..577af64d6 100644 --- a/src/adcp/reporting/projection/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/pg.py @@ -10,7 +10,6 @@ from importlib.resources import files from typing import Any -from adcp.reporting.feed.pg import PgReportingFeedStore, _CapturedFeed, _PreparedFeed from adcp.reporting.feed.snapshot import StoredFeedSnapshot from adcp.reporting.ledger.delivery_models import ReportingDeliveryPrincipal from adcp.reporting.ledger.models import ReportingDeliveryEscalation @@ -19,17 +18,12 @@ ReportingNotificationError, ) from adcp.reporting.ledger.status_projection import with_replay_lifecycles -from adcp.reporting.ledger.status_snapshot import persist_replay_lifecycles_on -from adcp.reporting.outbox.pg import database_now from adcp.reporting.outbox.status import ( StatusDueLease, StatusTurn, escalation_identity, settled_replay, ) -from adcp.reporting.outbox.status_pg import _CHECKPOINT as _LEGACY_CHECKPOINT -from adcp.reporting.outbox.status_pg import PgStatusNotificationStore, _enqueue_on -from adcp.reporting.outbox.status_pg import _checkpoint as _legacy_checkpoint from adcp.reporting.projection.capture import ( ReportingProjectionInput, decode_projection_input, @@ -42,11 +36,17 @@ decode_checkpoint, project_boundary, ) -from adcp.reporting.projection.notifications import ( +from adcp_reporting_storage.feed.pg import PgReportingFeedStore, _CapturedFeed, _PreparedFeed +from adcp_reporting_storage.ledger.status_snapshot import persist_replay_lifecycles_on +from adcp_reporting_storage.outbox.pg import database_now +from adcp_reporting_storage.outbox.status_pg import _CHECKPOINT as _LEGACY_CHECKPOINT +from adcp_reporting_storage.outbox.status_pg import PgStatusNotificationStore, _enqueue_on +from adcp_reporting_storage.outbox.status_pg import _checkpoint as _legacy_checkpoint +from adcp_reporting_storage.projection.notifications import ( PgReportingProjectionOutbox, _ProjectionQueueConnection, ) -from adcp.reporting.projection.schema import validate_projection_schema +from adcp_reporting_storage.projection.schema import validate_projection_schema class _ProjectionFeedConnection: @@ -64,13 +64,15 @@ async def execute(self, query: str, params: Any = None) -> Any: class PgReportingProjectionStore(PgReportingFeedStore): """Optional v2 store. Installation alone neither activates tiers nor sends readiness.""" - _projection_read_policy: dict[str, Any] - _projection_read_escalation: ReportingDeliveryEscalation | None + # Replaced (never mutated) by the status projection that reads this store; + # present from construction so the production store protocol matches. + projection_read_policy: dict[str, Any] = {} + projection_read_escalation: ReportingDeliveryEscalation | None = None async def create_schema(self) -> None: async with self._connection() as connection, connection.transaction(): await self._create_schema_on(connection) - root = files("adcp.reporting.ledger") + root = files("adcp_reporting_storage.ledger") for name in ( "reporting_materializer.sql", "reporting_receipt_ingestion.sql", @@ -196,8 +198,8 @@ def __init__( revision_ownership, ) self.outbox = PgReportingProjectionOutbox(pool=ledger._pool, clock=ledger._clock) - ledger._projection_read_policy = self.policy - ledger._projection_read_escalation = escalation + ledger.projection_read_policy = self.policy + ledger.projection_read_escalation = escalation async def _enqueue_status_on(self, connection: Any, event: ReportingDomainEvent) -> None: await _enqueue_on(_ProjectionQueueConnection(connection), event) diff --git a/src/adcp/reporting/projection/required_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/required_schema.json similarity index 100% rename from src/adcp/reporting/projection/required_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/required_schema.json diff --git a/src/adcp/reporting/projection/schema.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/schema.py similarity index 72% rename from src/adcp/reporting/projection/schema.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/schema.py index 9b46a0592..e4bb3c4a0 100644 --- a/src/adcp/reporting/projection/schema.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/projection/schema.py @@ -6,16 +6,16 @@ from importlib.resources import files from typing import Any -from adcp.reporting.feed.schema import validate_feed_schema from adcp.reporting.ledger.notification_models import ReportingNotificationError -from adcp.reporting.outbox._schema import schema_objects -from adcp.reporting.outbox.status_schema import validate_status_schema +from adcp_reporting_storage.feed.schema import validate_feed_schema +from adcp_reporting_storage.outbox._schema import schema_objects +from adcp_reporting_storage.outbox.status_schema import validate_status_schema async def validate_projection_schema(connection: Any, *, notifications: bool = False) -> None: try: required = json.loads( - files("adcp.reporting.projection").joinpath("required_schema.json").read_text() + files("adcp_reporting_storage.projection").joinpath("required_schema.json").read_text() ) actual = await schema_objects(connection) if not required or any(actual.get(k) != v for k, v in required.items()): diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/py.typed b/packages/adcp-reporting-storage/src/adcp_reporting_storage/py.typed new file mode 100644 index 000000000..e69de29bb diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/__init__.py new file mode 100644 index 000000000..c5ba36036 --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/__init__.py @@ -0,0 +1 @@ +"""The PostgreSQL receipt store.""" diff --git a/src/adcp/reporting/receipts/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/pg.py similarity index 97% rename from src/adcp/reporting/receipts/pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/pg.py index b219b3ac6..57d963002 100644 --- a/src/adcp/reporting/receipts/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/pg.py @@ -11,6 +11,12 @@ from adcp.reporting.canonical_json import canonical_json_sha256_v1 from adcp.reporting.ledger._delivery_state import RecordT +from adcp.reporting.ledger._record_rows import ( + _OBLIGATION_COLUMNS, + _REVISION_COLUMNS, + _obligation_from_row, + _revision_from_row, +) from adcp.reporting.ledger.delivery_models import ( ReportingAdjustmentReceiptRecord, ReportingDeliveryPrincipal, @@ -18,17 +24,8 @@ ReportingReceiptRecord, ReportingRevisionReceiptRecord, ) -from adcp.reporting.ledger.pg import ( - _OBLIGATION_COLUMNS, - _REVISION_COLUMNS, - _json, - _obligation_from_row, - _revision_from_row, -) -from adcp.reporting.ledger.status_snapshot import settle_snapshot_on from adcp.reporting.ledger.store import LedgerConflictError from adcp.reporting.materializer.capture import private_snapshot -from adcp.reporting.materializer.pg import PgReportingMaterializerStore, _now from adcp.reporting.receipts._diagnostics import _Boundary, _storage_failure from adcp.reporting.receipts.capture import ReportingReceiptBoundary, decode_receipt_boundary from adcp.reporting.receipts.errors import ReportingReceiptError @@ -38,7 +35,6 @@ receipt_record, success_result, ) -from adcp.reporting.receipts.schema import validate_receipt_schema from adcp.reporting.receipts.wire import ( ReceiptBatch, ReceiptKind, @@ -46,6 +42,10 @@ validate_receipt_results, ) from adcp.server.helpers import inject_context +from adcp_reporting_storage.ledger.pg import _json +from adcp_reporting_storage.ledger.status_snapshot import settle_snapshot_on +from adcp_reporting_storage.materializer.pg import PgReportingMaterializerStore, _now +from adcp_reporting_storage.receipts.schema import validate_receipt_schema _P = ParamSpec("_P") _R = TypeVar("_R") @@ -86,7 +86,7 @@ class PgReportingReceiptStore(PgReportingMaterializerStore): async def create_schema(self) -> None: async with self._connection() as connection, connection.transaction(): await self._create_schema_on(connection) - root = files("adcp.reporting.ledger") + root = files("adcp_reporting_storage.ledger") await connection.execute(root.joinpath("reporting_materializer.sql").read_text()) await connection.execute(root.joinpath("reporting_receipt_ingestion.sql").read_text()) diff --git a/src/adcp/reporting/receipts/required_schema.json b/packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/required_schema.json similarity index 100% rename from src/adcp/reporting/receipts/required_schema.json rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/required_schema.json diff --git a/src/adcp/reporting/receipts/schema.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/schema.py similarity index 76% rename from src/adcp/reporting/receipts/schema.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/schema.py index cb874c93e..7991e76b7 100644 --- a/src/adcp/reporting/receipts/schema.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/receipts/schema.py @@ -6,16 +6,16 @@ from importlib.resources import files from typing import Any -from adcp.reporting.materializer.schema import validate_materializer_schema -from adcp.reporting.outbox._schema import schema_objects from adcp.reporting.receipts.errors import ReportingReceiptError +from adcp_reporting_storage.materializer.schema import validate_materializer_schema +from adcp_reporting_storage.outbox._schema import schema_objects async def validate_receipt_schema(connection: Any, *, notifications: bool = False) -> None: ready = False try: required = json.loads( - files("adcp.reporting.receipts").joinpath("required_schema.json").read_text() + files("adcp_reporting_storage.receipts").joinpath("required_schema.json").read_text() ) installed = await schema_objects(connection) if required and all(installed.get(key) == expected for key, expected in required.items()): diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/service.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/service.py new file mode 100644 index 000000000..a8fa46d38 --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/service.py @@ -0,0 +1,58 @@ +"""The adapter-first Reliable Reporting service over a borrowed PostgreSQL pool.""" + +from __future__ import annotations + +from collections.abc import Callable +from datetime import datetime +from typing import Any + +from adcp.reporting.service import ( + ReliableReportingService, + ReportingCallerResolver, + ReportingContextResolver, +) +from adcp.reporting.service_production import ReportingProductionOptions + +__all__ = ["postgres_reliable_reporting_service"] + + +def postgres_reliable_reporting_service( + *, + pool: Any, + account_context: ReportingContextResolver, + caller_resolver: ReportingCallerResolver | None = None, + clock: Callable[[], datetime] | None = None, + production: ReportingProductionOptions | None = None, + **kwargs: Any, +) -> ReliableReportingService: + """Create durable ledger, staging and seal stores over a borrowed pool. + + ``production`` additionally composes the managed pipeline, receipts and + optional signed notifications when ``install`` freezes registered sources. + Startup installs all SDK-owned schemas. Explicit source-store overrides + remain the adopter's responsibility. No pool is opened or closed here. + + This is what ``ReliableReportingService.postgres(...)`` was before the + storage implementations moved to this distribution. + """ + from adcp_reporting_storage.inline_storage import PgReportingSealStore, PgReportingStagingStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.production.pg import PgReportingProductionStore + + store = ( + PgReportingProductionStore(pool=pool, clock=clock, notifications=production.notifications) + if production is not None + else PgReportingLedgerStore(pool=pool, clock=clock) + ) + staging = PgReportingStagingStore(pool=pool) + return ReliableReportingService( + store=store, + account_context=account_context, + caller_resolver=caller_resolver, + clock=clock, + production=production, + staging=staging, + seals=PgReportingSealStore(pool=pool), + initialize_source_storage=staging.create_schema, + **kwargs, + ) diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/submissions/__init__.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/submissions/__init__.py new file mode 100644 index 000000000..eeb4aac1d --- /dev/null +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/submissions/__init__.py @@ -0,0 +1 @@ +"""The PostgreSQL buyer submission intent store.""" diff --git a/src/adcp/reporting/submissions/pg.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/submissions/pg.py similarity index 99% rename from src/adcp/reporting/submissions/pg.py rename to packages/adcp-reporting-storage/src/adcp_reporting_storage/submissions/pg.py index cd3147f6d..05d357472 100644 --- a/src/adcp/reporting/submissions/pg.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/submissions/pg.py @@ -2,7 +2,7 @@ The application owns the pool and its lifecycle. ``create_schema`` installs only the isolated buyer tables, without modifying seller schemas or existing APIs. -Importing this module does not require psycopg; actual use requires adcp[pg]. +Importing this module does not require psycopg; actual use requires adcp-reporting-storage[pg]. """ from __future__ import annotations @@ -100,7 +100,7 @@ async def create_schema(self) -> None: async with self._pool.connection() as connection, connection.transaction(): await connection.execute("SELECT pg_advisory_xact_lock(%s)", (712071172,)) sql = ( - files("adcp.reporting.ledger") + files("adcp_reporting_storage.ledger") .joinpath("reporting_buyer_submissions.sql") .read_text() ) diff --git a/pyproject.toml b/pyproject.toml index 8317e6be6..cec53208f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -198,23 +198,16 @@ pg = [ # PostgreSQL-backed adcp.signing.PgReplayStore, # adcp.decisioning.PostgresTaskRegistry (durable HITL task state), and # future PgIdempotencyBackend. psycopg3 ships both sync + async pool - # interfaces so the single dep serves all three use cases. + # interfaces so the single dep serves all three use cases. The reporting + # stores are ``adcp-reporting-storage[pg]``. "psycopg[binary]>=3.1.0", "psycopg-pool>=3.2.0", ] -reporting-gcs = [ - # Row storage on Google Cloud Storage. The provider takes a client you build, - # with keyless credentials; it never imports the library itself. - "google-cloud-storage>=2.18", -] -reporting-s3 = [ - # Row storage on Amazon S3 or an S3-compatible store (needs bucket versioning). - "boto3>=1.35", -] -reporting-azure = [ - # Row storage on Azure Blob Storage (needs blob versioning on the account). - "azure-storage-blob>=12.20", -] +# The reporting row-storage providers (``reporting-gcs``, ``reporting-s3``, +# ``reporting-azure``) moved with the PostgreSQL reporting stores to the +# separately versioned ``adcp-reporting-storage`` distribution +# (packages/adcp-reporting-storage); install its ``[gcs]``/``[s3]``/``[azure]`` +# extras instead. observability = [ # API-only library instrumentation: without an application-configured SDK # provider/exporter, OpenTelemetry remains a non-recording no-op. @@ -244,16 +237,7 @@ adcp = [ "notification_outbox.sql", "signing/pg/*.sql", "decisioning/pg/*.sql", - "reporting/ledger/*.sql", - "reporting/ledger/*.json", - "reporting/outbox/*.json", "reporting/materializer/assets/*.json", - "reporting/materializer/*.json", - "reporting/receipts/*.json", - "reporting/feed/*.json", - "reporting/projection/*.json", - "reporting/production/*.json", - "reporting/production/*.sql", # PREVIEW: vendored sync_reporting_status schemas. They are the runtime # validator for the wire conditionals codegen cannot express, so the wheel # must carry them. Removed with the rest of _preview/ at rc.2. @@ -435,8 +419,18 @@ directory = "htmlcov" exclude_dirs = ["tests", "scripts"] skips = ["B101"] # Allow assert in code (we're not using -O optimization) +[tool.uv.workspace] +members = ["packages/adcp-reporting-storage"] + +[tool.uv.sources] +adcp-reporting-storage = { workspace = true } + [dependency-groups] dev = [ + # The storage distribution, editable, with every provider extra, so the + # shared test suite and type checks see both distributions at once. pip + # users install it explicitly: ``make install-dev``. + "adcp-reporting-storage[pg,gcs,s3,azure]", "datamodel-code-generator==0.64.0", "pre-commit>=4.4.0", "types-protobuf>=7.34.1.20260408", diff --git a/release-please-config.json b/release-please-config.json index 81a7d6407..8b69b51a2 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -9,14 +9,45 @@ "prerelease": true, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": false, - "include-component-in-tag": false + "include-component-in-tag": false, + "exclude-paths": [ + "packages/adcp-reporting-storage" + ] + }, + "packages/adcp-reporting-storage": { + "release-type": "python", + "package-name": "adcp-reporting-storage", + "component": "adcp-reporting-storage", + "changelog-path": "CHANGELOG.md", + "versioning": "prerelease", + "prerelease-type": "beta", + "prerelease": true, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": false, + "include-component-in-tag": true } }, "changelog-sections": [ - {"type": "feat", "section": "Features"}, - {"type": "fix", "section": "Bug Fixes"}, - {"type": "perf", "section": "Performance Improvements"}, - {"type": "revert", "section": "Reverts"}, - {"type": "deps", "section": "Dependencies"} - ] + { + "type": "feat", + "section": "Features" + }, + { + "type": "fix", + "section": "Bug Fixes" + }, + { + "type": "perf", + "section": "Performance Improvements" + }, + { + "type": "revert", + "section": "Reverts" + }, + { + "type": "deps", + "section": "Dependencies" + } + ], + "separate-pull-requests": true } diff --git a/scripts/ci/ci_scope.py b/scripts/ci/ci_scope.py index 0e07d8f2f..1b5098348 100644 --- a/scripts/ci/ci_scope.py +++ b/scripts/ci/ci_scope.py @@ -80,6 +80,7 @@ def scope_for_paths(paths: list[str]) -> Scope: elif path.startswith( ( "src/adcp/reporting/", + "packages/adcp-reporting-storage/src/", "src/adcp/decisioning/", "src/adcp/signing/", "src/adcp/server/", diff --git a/scripts/ci/reporting_interop/python_core_server.py b/scripts/ci/reporting_interop/python_core_server.py index 8089affb3..a7f684295 100644 --- a/scripts/ci/reporting_interop/python_core_server.py +++ b/scripts/ci/reporting_interop/python_core_server.py @@ -9,6 +9,7 @@ from __future__ import annotations import argparse +import importlib.util import os from dataclasses import fields from datetime import datetime, timedelta, timezone @@ -17,7 +18,6 @@ from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ( - PgReportingLedgerStore, ReportingConfiguration, ReportingDefinitionBinding, ReportingObligationRecord, @@ -28,6 +28,12 @@ derive_period, revision_content_sha256, ) + +if importlib.util.find_spec("adcp.reporting.ledger.pg") is not None: + # Published wheels before the storage split (#1510) carry the store in adcp. + from adcp.reporting.ledger import PgReportingLedgerStore +else: + from adcp_reporting_storage import PgReportingLedgerStore from adcp.reporting.ledger.status_server import ReportingStatusNotificationHandler from adcp.server import serve from adcp.server.auth import BearerTokenAuth, Principal, auth_context_factory diff --git a/scripts/export_resolution.py b/scripts/export_resolution.py index 702a9e994..efb4e362a 100644 --- a/scripts/export_resolution.py +++ b/scripts/export_resolution.py @@ -22,9 +22,9 @@ #: Namespaces the snapshot covers: the two public surfaces an adopter imports -#: from, the consolidated generated namespace behind them, and every generated -#: domain module, plus the reporting and ledger public surfaces. Built on first -#: use so this module imports nothing from +#: from, the consolidated generated namespace behind them, every generated +#: domain module, the reporting and ledger surfaces (#1490), and the storage +#: distribution's facade. Built on first use so this module imports nothing from #: ``adcp`` until asked. #: #: ``error_details`` is deliberately absent. Every name it carries is a class @@ -42,6 +42,7 @@ def snapshot_modules() -> tuple[tuple[str, str], ...]: ("adcp.types._generated", "adcp.types._generated"), ("adcp.reporting", "adcp.reporting"), ("adcp.reporting.ledger", "adcp.reporting.ledger"), + ("adcp_reporting_storage", "adcp_reporting_storage"), *( (f"adcp.types.domains.{domain}", f"adcp.types.domains.{domain}") for domain in adcp.types.domains.DOMAINS diff --git a/scripts/generate_row_storage_catalog.py b/scripts/generate_row_storage_catalog.py index d4dae587c..f511949a2 100644 --- a/scripts/generate_row_storage_catalog.py +++ b/scripts/generate_row_storage_catalog.py @@ -20,14 +20,16 @@ ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "src")) +sys.path.insert(0, str(ROOT / "packages/adcp-reporting-storage/src")) -from adcp.reporting.ledger.row_storage import ( # noqa: E402 +from adcp_reporting_storage.ledger.row_storage import ( # noqa: E402 REPORTING_ROW_STORAGE_DDL_PATH, REPORTING_ROW_STORAGE_TABLES, ) -from adcp.reporting.outbox._schema import _catalog_objects # noqa: E402 +from adcp_reporting_storage.outbox._schema import _catalog_objects # noqa: E402 -TARGET = ROOT / "src/adcp/reporting/ledger/required_row_storage_schema.json" +STORAGE_SRC = ROOT / "packages/adcp-reporting-storage/src/adcp_reporting_storage" +TARGET = STORAGE_SRC / "ledger/required_row_storage_schema.json" async def generate(url: str) -> dict[str, dict[str, object]]: diff --git a/scripts/normalize_pyproject_prerelease.py b/scripts/normalize_pyproject_prerelease.py index 742e0cb81..d076fba7f 100644 --- a/scripts/normalize_pyproject_prerelease.py +++ b/scripts/normalize_pyproject_prerelease.py @@ -28,9 +28,13 @@ "rc": "rc", } _REPOSITORY = "adcontextprotocol/adcp-client-python" +#: Release Please branch -> the pyproject.toml that branch's component versions. _RELEASE_BRANCHES = { - "release-please--branches--main", - "release-please--branches--main--components--adcp", + "release-please--branches--main": "pyproject.toml", + "release-please--branches--main--components--adcp": "pyproject.toml", + "release-please--branches--main--components--adcp-reporting-storage": ( + "packages/adcp-reporting-storage/pyproject.toml" + ), } @@ -104,9 +108,15 @@ def _github(path: str, token: str, *, body: dict[str, Any] | None = None) -> Any def normalize_release_prs(prs: list[dict[str, Any]], token: str) -> None: - """Normalize the one Release Please PR with the App credential that opened it.""" - if len(prs) > 1: - raise ValueError("expected at most one release PR") + """Normalize each component's Release Please PR with the App credential that opened it. + + Release Please opens one PR per component (``separate-pull-requests``), so a + run may propose both the ``adcp`` and the ``adcp-reporting-storage`` PR; each + is normalized in its own ``pyproject.toml``. + """ + if len(prs) > len(_RELEASE_BRANCHES): + raise ValueError("more release PRs than release components") + seen: set[str] = set() for proposed in prs: number = int(proposed["number"]) pr = _github(f"pulls/{number}", token) @@ -116,16 +126,17 @@ def normalize_release_prs(prs: list[dict[str, Any]], token: str) -> None: or pr["base"]["repo"]["full_name"] != _REPOSITORY or pr["head"]["repo"]["full_name"] != _REPOSITORY or branch not in _RELEASE_BRANCHES + or branch in seen ): raise ValueError("unexpected release PR identity") - original = _github( - f"contents/pyproject.toml?ref={urllib.parse.quote(branch, safe='')}", token - ) + seen.add(branch) + pyproject = _RELEASE_BRANCHES[branch] + original = _github(f"contents/{pyproject}?ref={urllib.parse.quote(branch, safe='')}", token) contents = base64.b64decode(original["content"], validate=False).decode() normalized = normalize_pyproject_text(contents) if normalized != contents: _github( - "contents/pyproject.toml", + f"contents/{pyproject}", token, body={ "message": "chore: normalize prerelease version to PEP 440", diff --git a/scripts/regenerate_row_storage_interop_fixtures.py b/scripts/regenerate_row_storage_interop_fixtures.py new file mode 100644 index 000000000..faf83c668 --- /dev/null +++ b/scripts/regenerate_row_storage_interop_fixtures.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Refresh ``tests/fixtures/reporting-row-storage-interop-v1/`` from a live run. + +Runs the same steps as the live cross-SDK test +(``tests/conformance/reporting/test_reporting_row_storage_interop_pg.py``): +the JavaScript SDK writes the golden row sets into one PostgreSQL schema and +one filesystem bucket, this SDK verifies them and writes its own, and what +each writer left behind is recorded for the replay tests. + +Usage:: + + ADCP_JS_SDK_DIR=/path/to/adcp-client ADCP_PG_TEST_URL=postgresql://... \\ + python scripts/regenerate_row_storage_interop_fixtures.py + +Needs Node and a built adcp-client checkout (``npm ci && npm run build && +npm run build --workspace packages/reporting-storage``). The fixtures are +written one item per line (#1491); commit the diff with the change that +motivated it. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT)) + +from tests.conformance.reporting import _row_storage_interop as interop # noqa: E402 +from tests.conformance.reporting._generation_support import isolated_reporting_pool # noqa: E402 +from tests.conformance.reporting.test_reporting_row_storage_interop_pg import ( # noqa: E402 + run_js, + setup, +) + + +async def regenerate(js_sdk: str, workspace: Path) -> None: + root = workspace / "bucket" + async with isolated_reporting_pool() as pool: + _, storage = await setup(pool, root) + async with pool.connection() as connection: + schema = (await (await connection.execute("SELECT current_schema()")).fetchone())[0] + environment = { + "ADCP_JS_SDK_DIR": js_sdk, + "ADCP_PG_URL": os.environ["ADCP_PG_TEST_URL"], + "ADCP_PG_SCHEMA": schema, + "ADCP_ROWS_ROOT": str(root), + "ADCP_ROWS_NAMESPACE": interop.NAMESPACE, + } + js_out = workspace / "js-written.json" + await run_js("write", "--out", str(js_out), environment=environment) + js_written = json.loads(js_out.read_text()) + assert await interop.python_verify(storage, pool, js_written) == len(js_written) + python_written = await interop.python_write(storage, pool) + py_out = workspace / "python-written.json" + py_out.write_text(json.dumps(python_written)) + await run_js("read", "--in", str(py_out), environment=environment) + for name, written in (("js-written", js_written), ("python-written", python_written)): + state = await interop.dump_state(pool, root, [item["row_set_id"] for item in written]) + path = interop.INTEROP_DIR / f"{name}.json" + interop.write_fixture(path, written, state) + print(f"wrote {path.relative_to(ROOT)} ({len(written)} row sets)") + + +def main() -> int: + js_sdk = os.environ.get("ADCP_JS_SDK_DIR") + if not js_sdk or not os.environ.get("ADCP_PG_TEST_URL"): + raise SystemExit("set ADCP_JS_SDK_DIR (a built adcp-client checkout) and ADCP_PG_TEST_URL") + with tempfile.TemporaryDirectory(prefix="adcp-row-storage-interop-") as workspace: + asyncio.run(regenerate(js_sdk, Path(workspace))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/adcp/reporting/feed/__init__.py b/src/adcp/reporting/feed/__init__.py index b623d4394..7a53bc664 100644 --- a/src/adcp/reporting/feed/__init__.py +++ b/src/adcp/reporting/feed/__init__.py @@ -1,29 +1,15 @@ -"""Frozen authorized reporting feeds; PostgreSQL remains an optional lazy import.""" - -from typing import TYPE_CHECKING, Any +"""Frozen authorized reporting feeds; the PostgreSQL store is in ``adcp_reporting_storage``.""" from adcp.reporting.feed.errors import FeedErrorCode, ReportingFeedError from adcp.reporting.feed.memory import InMemoryReportingFeedStore from adcp.reporting.feed.snapshot import ReportingFeedRecord, ReportingFeedSnapshot from adcp.reporting.feed.store import ReportingFeedStore -if TYPE_CHECKING: - from adcp.reporting.feed.pg import PgReportingFeedStore - __all__ = [ "FeedErrorCode", "InMemoryReportingFeedStore", - "PgReportingFeedStore", "ReportingFeedError", "ReportingFeedRecord", "ReportingFeedSnapshot", "ReportingFeedStore", ] - - -def __getattr__(name: str) -> Any: - if name == "PgReportingFeedStore": - from adcp.reporting.feed.pg import PgReportingFeedStore - - return PgReportingFeedStore - raise AttributeError(name) diff --git a/src/adcp/reporting/inline_storage.py b/src/adcp/reporting/inline_storage.py index f32d15cfa..5f06d2f5b 100644 --- a/src/adcp/reporting/inline_storage.py +++ b/src/adcp/reporting/inline_storage.py @@ -46,18 +46,14 @@ import asyncio import hashlib import re -from collections.abc import AsyncIterator, Awaitable, Callable, Coroutine, Mapping -from contextlib import asynccontextmanager +from collections.abc import Awaitable, Callable, Coroutine from dataclasses import dataclass from functools import wraps -from importlib.resources import files -from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal, ParamSpec, TypeVar +from typing import TYPE_CHECKING, Any, Generic, Literal, ParamSpec, TypeVar from anyio import CancelScope, current_effective_deadline -from adcp.reporting._inline_storage_schema import REQUIRED_OBJECTS from adcp.reporting.inline_source import SealedSlice -from adcp.reporting.outbox._schema import schema_objects from adcp.reporting.source import ( SOURCE_BATCH_MANIFEST_MAX_BYTES_V1, SourceBatchManifestReferenceV1, @@ -65,13 +61,11 @@ ) if TYPE_CHECKING: - from psycopg_pool import AsyncConnectionPool + pass __all__ = [ "INLINE_STAGING_MAX_BYTES", "InlineStorageError", - "PgReportingSealStore", - "PgReportingStagingStore", ] INLINE_STAGING_MAX_BYTES = 16_777_216 @@ -330,264 +324,3 @@ def _seal(account_id: str, key: str, sealed: SealedSlice, code: _Code) -> Sealed except Exception: failure = InlineStorageError(code) raise failure - - -class _PgStorage: - is_durable: ClassVar[bool] = True - - def __init__(self, *, pool: AsyncConnectionPool) -> None: - try: - from psycopg_pool import AsyncConnectionPool as Pool - except ImportError: - pass - else: - if not isinstance(pool, Pool): - raise InlineStorageError("INVALID_INPUT") - self._pool = pool - return - raise ImportError("PostgreSQL inline storage requires adcp[pg]") - - def __repr__(self) -> str: - return f"{type(self).__name__}()" - - async def _ready_on(self, connection: Any) -> None: - installed = await schema_objects(connection) - if not REQUIRED_OBJECTS or any( - installed.get(key, {}).get(field) != value[field] - for key, value in REQUIRED_OBJECTS.items() - for field in ("fingerprint", "enabled") - ): - raise InlineStorageError("SCHEMA_UNREADY") - - @asynccontextmanager - async def _transaction(self) -> AsyncIterator[Any]: - from psycopg.errors import UndefinedTable - - async with self._pool.connection() as connection, connection.transaction(): - # DO NOTHING's conflict winner must be visible to the next SELECT, - # regardless of a pool's default isolation level. - await connection.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED") - try: - await connection.execute( - "LOCK TABLE reporting_inline_objects, reporting_inline_seals " - "IN ACCESS SHARE MODE" - ) - except UndefinedTable: - raise InlineStorageError("SCHEMA_UNREADY") from None - await self._ready_on(connection) - yield connection - - @_redact - async def create_schema(self) -> None: - """Install the standalone additive migration and verify it atomically.""" - async with self._pool.connection() as connection, connection.transaction(): - await connection.execute("SET TRANSACTION ISOLATION LEVEL READ COMMITTED") - await connection.execute( - files("adcp.reporting.ledger").joinpath("reporting_inline_storage.sql").read_text() - ) - await self._ready_on(connection) - - @_redact - async def check_ready(self) -> None: - """Verify required schema objects, including constraints and write guards.""" - async with self._pool.connection() as connection, connection.transaction(): - await self._ready_on(connection) - - -class PgReportingStagingStore(_PgStorage): - """Immutable account-qualified content storage using a borrowed async pool.""" - - def __init__( - self, *, pool: AsyncConnectionPool, max_payload_bytes: int = INLINE_STAGING_MAX_BYTES - ) -> None: - if ( - type(max_payload_bytes) is not int - or not 1 <= max_payload_bytes <= INLINE_STAGING_MAX_BYTES - ): - raise InlineStorageError("INVALID_INPUT") - super().__init__(pool=pool) - self._max_payload_bytes = max_payload_bytes - - async def _read_on(self, connection: Any, account_id: str, digest: str) -> bytes: - row = await ( - await connection.execute( - "SELECT CASE WHEN octet_length(payload) <= %s THEN payload END" - " FROM reporting_inline_objects WHERE account_id = %s AND payload_sha256 = %s", - (self._max_payload_bytes, account_id, digest), - ) - ).fetchone() - if row is None: - raise InlineStorageError("NOT_FOUND") - payload = row[0] - if type(payload) is not bytes or await _payload_digest(payload) != digest: - raise InlineStorageError("INTEGRITY_FAILED") - return payload - - async def _prepare_object( - self, *, account_id: str, source_execution_key: str, ordinal: int, payload: bytes - ) -> PreparedBackendObjectV1: - _object_inputs(account_id, source_execution_key, ordinal, payload, self._max_payload_bytes) - digest = await _payload_digest(payload) - return PreparedBackendObjectV1(account_id, source_execution_key, ordinal, payload, digest) - - async def _stage_on( - self, connection: Any, prepared: PreparedBackendObjectV1 - ) -> tuple[str, str]: - """Stage on the caller's audited transaction; the returned identity is tentative. - - Caller owns account-lock ordering and the full transaction/error boundary. - No checkout, task, transaction, commit, drain or fence is owned here. - """ - if type(prepared) is not PreparedBackendObjectV1: - raise InlineStorageError("INVALID_INPUT") - prepared._validate(self._max_payload_bytes) - await connection.execute( - "INSERT INTO reporting_inline_objects (account_id, payload_sha256, payload)" - " VALUES (%s, %s, %s) ON CONFLICT (account_id, payload_sha256) DO NOTHING", - (prepared.account_id, prepared.payload_sha256, prepared.payload), - ) - if ( - await self._read_on(connection, prepared.account_id, prepared.payload_sha256) - != prepared.payload - ): - raise InlineStorageError("INTEGRITY_FAILED") - return _reference(prepared.account_id, prepared.payload_sha256), prepared.payload_sha256 - - @_redact - async def stage( - self, *, account_id: str, source_execution_key: str, ordinal: int, payload: bytes - ) -> tuple[str, str]: - prepared = await self._prepare_object( - account_id=account_id, - source_execution_key=source_execution_key, - ordinal=ordinal, - payload=payload, - ) - async with self._transaction() as connection: - return await self._stage_on(connection, prepared) - - @_redact - async def read( - self, - *, - object_ref: str, - object_generation: str, - account_id: str, - source_scope: Mapping[str, Any], - cancel: asyncio.Event, - ) -> bytes: - _account(account_id) - if type(object_generation) is not str or _DIGEST.fullmatch(object_generation) is None: - raise InlineStorageError("INVALID_INPUT") - if type(object_ref) is not str or object_ref != _reference(account_id, object_generation): - raise InlineStorageError("NOT_FOUND") - if cancel.is_set(): - raise asyncio.CancelledError - async with self._transaction() as connection: - payload = await self._read_on(connection, account_id, object_generation) - if cancel.is_set(): - raise asyncio.CancelledError - return payload - - -class PgReportingSealStore(_PgStorage): - """First committed seal wins for each trusted account and execution key.""" - - async def _get_on(self, connection: Any, account_id: str, key: str) -> SealedSlice | None: - row = await ( - await connection.execute( - "SELECT staged_commit_ref, manifest_sha256, byte_count," - " CASE WHEN octet_length(manifest) <= %s THEN manifest END" - " FROM reporting_inline_seals WHERE account_id = %s AND source_execution_key = %s", - (SOURCE_BATCH_MANIFEST_MAX_BYTES_V1, account_id, key), - ) - ).fetchone() - if row is None: - return None - reference: SourceBatchManifestReferenceV1 | None - try: - reference = SourceBatchManifestReferenceV1( - staged_commit_ref=row[0], manifest_sha256=row[1], byte_count=row[2] - ) - except Exception: - reference = None - if reference is None: - raise InlineStorageError("INTEGRITY_FAILED") - return _seal( - account_id, - key, - SealedSlice(reference=reference, manifest_bytes=row[3]), - "INTEGRITY_FAILED", - ) - - @_redact - async def get(self, *, account_id: str, source_execution_key: str) -> SealedSlice | None: - _identity(account_id, source_execution_key) - async with self._transaction() as connection: - return await self._get_on(connection, account_id, source_execution_key) - - def _prepare_seal( - self, *, account_id: str, source_execution_key: str, sealed: SealedSlice - ) -> PreparedBackendSealV1: - _prepared_identity(account_id, source_execution_key) - candidate = _seal(account_id, source_execution_key, sealed, "INVALID_INPUT") - reference = candidate.reference - return PreparedBackendSealV1( - account_id, - source_execution_key, - reference.staged_commit_ref, - reference.manifest_sha256, - reference.byte_count, - candidate.manifest_bytes, - ) - - async def _put_on(self, connection: Any, prepared: PreparedBackendSealV1) -> SealedSlice: - """Return the verified stored winner on the caller's audited transaction. - - The neutral value proves no admission, referenced-object existence or - commit. Caller owns account locks, cancellation, settlement and any fence. - """ - if type(prepared) is not PreparedBackendSealV1: - raise InlineStorageError("INVALID_INPUT") - prepared._validate() - # Build a fresh reference, then revalidate it inside the existing closed - # candidate validator. No mutable caller model is retained or trusted. - reference = SourceBatchManifestReferenceV1.model_construct( - staged_commit_ref=prepared.staged_commit_ref, - manifest_sha256=prepared.manifest_sha256, - byte_count=prepared.byte_count, - ) - candidate = _seal( - prepared.account_id, - prepared.source_execution_key, - SealedSlice(reference=reference, manifest_bytes=prepared.manifest_bytes), - "INVALID_INPUT", - ) - await connection.execute( - "INSERT INTO reporting_inline_seals (account_id, source_execution_key," - " staged_commit_ref, manifest_sha256, byte_count, manifest)" - " VALUES (%s, %s, %s, %s, %s, %s)" - " ON CONFLICT (account_id, source_execution_key) DO NOTHING", - ( - prepared.account_id, - prepared.source_execution_key, - candidate.reference.staged_commit_ref, - candidate.reference.manifest_sha256, - candidate.reference.byte_count, - candidate.manifest_bytes, - ), - ) - winner = await self._get_on(connection, prepared.account_id, prepared.source_execution_key) - if winner is None: - raise InlineStorageError("INTEGRITY_FAILED") - return winner - - @_redact - async def put( - self, *, account_id: str, source_execution_key: str, sealed: SealedSlice - ) -> SealedSlice: - prepared = self._prepare_seal( - account_id=account_id, source_execution_key=source_execution_key, sealed=sealed - ) - async with self._transaction() as connection: - return await self._put_on(connection, prepared) diff --git a/src/adcp/reporting/ledger/__init__.py b/src/adcp/reporting/ledger/__init__.py index 9ec33ee75..52c46d7ef 100644 --- a/src/adcp/reporting/ledger/__init__.py +++ b/src/adcp/reporting/ledger/__init__.py @@ -21,9 +21,10 @@ from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ( - PgReportingLedgerStore, ProducerOfferings, ReportingProducer, - ReportingStatusCaller, ReportingStatusHandler, + ProducerOfferings, ReportingProducer, ReportingStatusCaller, ReportingStatusHandler, ) + # pip install "adcp-reporting-storage[pg]" + from adcp_reporting_storage import PgReportingLedgerStore store = PgReportingLedgerStore(pool=pool) await store.create_schema() @@ -43,7 +44,9 @@ No Temporal, no Celery, no Redis lock. Durability lives in the store; the worker is a stateless leased turn, and the status handler is a pure projection -you can mount from :mod:`adcp.server` or any framework. +you can mount from :mod:`adcp.server` or any framework. This package holds +the store *protocols* and the in-memory reference store; the PostgreSQL store +and the row storage providers ship separately as ``adcp-reporting-storage``. Health is derived, never stored -- a pure function of obligations, revisions, and the snapshot clock, so it cannot go stale and two readers of one snapshot @@ -67,12 +70,6 @@ validate_currency, ) from adcp.reporting.evidence import ReportingCanonicalDigest -from adcp.reporting.ledger.change_feed import ( - ReportingChangeCursorExpiredError, - ReportingChangeKindV1, - ReportingChangeRecordV1, - ReportingChangesPageV1, -) from adcp.reporting.ledger.consumer_status import ( ConsumerMismatch, ConsumerStatusDisabledError, @@ -160,7 +157,6 @@ derive_period, iso_duration_to_timedelta, ) -from adcp.reporting.ledger.period_retirement import ReportingPeriodRetirementV1 from adcp.reporting.ledger.producer import ( CurrencyResolver, FixedCurrencyResolver, @@ -171,12 +167,7 @@ revision_binding_algorithm, revision_content_sha256, ) -from adcp.reporting.ledger.row_retention import ReportingRowIntentSweepV1, ReportingRowPruneV1 -from adcp.reporting.ledger.row_storage import ( - ReportingObjectRowBindingV1, - ReportingPostgresRowBindingV1, - ReportingRowBindingSelectionInputV1, - ReportingRowStorageOptionsV1, +from adcp.reporting.ledger.row_storage_errors import ( ReportingRowStoreError, ReportingRowStoreErrorCode, ) @@ -200,6 +191,9 @@ LedgerPage, ReportingLedgerStore, ReportingRowPage, + ReportingRowStagingStore, + ReportingSourcePublicationStore, + ReportingWorkerRetryPolicy, RestatementCheckpoint, RestatementCheckpointStore, RetryScheduleEntry, @@ -218,8 +212,6 @@ ) if TYPE_CHECKING: - from adcp.reporting.ledger.delivery_pg import PgReportingReconciliationStore - from adcp.reporting.ledger.pg import PgReportingLedgerStore from adcp.reporting.ledger.status_server import ( ReportingStatusCallerResolver, ReportingStatusNotificationHandler, @@ -256,16 +248,10 @@ "LedgerPage", "LedgerSnapshot", "ObligationProjection", - "PgReportingLedgerStore", - "PgReportingReconciliationStore", "ProducerOfferings", "ReportingAdjustmentReceiptRecord", "ReportingAdjustmentRecord", "ReportingCanonicalDigest", - "ReportingChangeCursorExpiredError", - "ReportingChangeKindV1", - "ReportingChangeRecordV1", - "ReportingChangesPageV1", "ReportingConfiguration", "ReportingConfigurationGenerationKey", "ReportingControlTotalRecord", @@ -294,10 +280,7 @@ "ReportingObligationDeliveryRecord", "ReportingObligationRecord", "ReportingPeriodBoundary", - "ReportingObjectRowBindingV1", - "ReportingPeriodRetirementV1", "ReportingPhysicalChecksum", - "ReportingPostgresRowBindingV1", "ReportingProducer", "ReportingProductionStatus", "ReportingReceiptKey", @@ -317,11 +300,10 @@ "ReportingResourceRecord", "ReportingRevisionReceiptRecord", "ReportingRevisionRecord", - "ReportingRowBindingSelectionInputV1", - "ReportingRowIntentSweepV1", "ReportingRowPage", - "ReportingRowPruneV1", - "ReportingRowStorageOptionsV1", + "ReportingRowStagingStore", + "ReportingSourcePublicationStore", + "ReportingWorkerRetryPolicy", "ReportingRowStoreError", "ReportingRowStoreErrorCode", "RestatementCheckpoint", @@ -362,15 +344,7 @@ def __getattr__(name: str) -> object: - """Keep core evidence imports independent of database and server adapters.""" - if name == "PgReportingLedgerStore": - from adcp.reporting.ledger.pg import PgReportingLedgerStore - - return PgReportingLedgerStore - if name == "PgReportingReconciliationStore": - from adcp.reporting.ledger.delivery_pg import PgReportingReconciliationStore - - return PgReportingReconciliationStore + """Keep core evidence imports independent of the server adapters.""" if name in {"ReportingStatusCallerResolver", "ReportingStatusNotificationHandler"}: from adcp.reporting.ledger import status_server diff --git a/src/adcp/reporting/ledger/_record_rows.py b/src/adcp/reporting/ledger/_record_rows.py new file mode 100644 index 000000000..50d08fe44 --- /dev/null +++ b/src/adcp/reporting/ledger/_record_rows.py @@ -0,0 +1,314 @@ +"""Row codecs for the shared PostgreSQL record layout. + +These functions translate between the SQL column tuples of the ledger tables and +the record dataclasses, and between records and their canonical JSON payloads. +They touch no database: the PostgreSQL stores (``adcp-reporting-storage``) use +them to read rows, and :func:`adcp.reporting.ledger.status_snapshot.snapshot_from_storage` +uses them to decode a stored status snapshot without a driver. + +The column order is the storage contract the JavaScript SDK shares; change a +column list only together with the DDL in ``adcp-reporting-storage``. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from datetime import datetime, timedelta +from typing import Any + +from adcp.reporting.evidence import ReportingCanonicalDigest, ReportingControlTotalRecord +from adcp.reporting.ledger.models import ( + ConsumerStatusRecord, + ReportingAdjustmentRecord, + ReportingConfiguration, + ReportingDefinitionBinding, + ReportingIssueLifecycle, + ReportingObligationRecord, + ReportingPeriodBoundary, + ReportingRevisionRecord, + ReportingScheduleSpec, +) +from adcp.reporting.ledger.store import _utc, managed_revision_metadata + +_OBLIGATION_COLUMNS = ( + "reporting_obligation_id, account_id, delivery_config_id, delivery_config_version," + " report_definition_id, reporting_profile, feed_purpose, period_key, period_start," + " period_end, source_timezone, expected_at, scope_resolved_at," + " automated_recovery_deadline_at, required_finality, coverage_status, media_buy_ids," + " package_ids, schedule, definition, created_at, currency, consumer_id" +) + + +_REVISION_COLUMNS = ( + "reporting_revision_id, account_id, reporting_obligation_id, finality," + " revision_content_sha256, row_count, control_totals, observed_at, data_through," + " created_at, supersedes_reporting_revision_id, finality_basis, finality_policy_id," + " finalized_at, readable, readable_at_commit, source_publication_id, source_manifest_sha256," + " canonical_content_digest, managed_control_totals" +) + + +_ADJUSTMENT_COLUMNS = ( + "reporting_adjustment_id, account_id, adjusts_reporting_revision_id, reason_code," + " reason_detail, accounting_period_start, accounting_period_end, control_total_deltas," + " correction_observed_at, created_at, managed_control_total_deltas" +) + + +_STATUS_COLUMNS = ( + "s.reporting_status_id, s.account_id, s.consumer_id, s.delivery_config_id," + " s.delivery_config_version, s.report_definition_id, s.period_start, s.period_end," + " s.period_source_timezone, s.consumer_status, s.status_as_of, s.recorded_at," + " s.supersedes_reporting_status_id, s.reporting_obligation_id, s.reporting_revision_id," + " s.observed_revision_content_sha256, s.failure_code, s.mismatch_code," + " s.consumer_commit_ref, s.seller_ledger_snapshot_id, s.seller_ledger_as_of, s.superseded" +) + + +_STATUS_COLUMNS_BARE = _STATUS_COLUMNS.replace("s.", "") + + +_ISSUE_COLUMNS = ( + "issue_key, account_id, generation, issue_id, consumer_id, opened_at, issue_state," + " external_ref, retired_at" +) + + +def _issue_from_row(row: Sequence[Any]) -> ReportingIssueLifecycle: + return ReportingIssueLifecycle( + issue_key=row[0], + account_id=row[1], + generation=row[2], + issue_id=row[3], + consumer_id=row[4], + opened_at=_utc(row[5]), + issue_state=row[6], + external_ref=row[7], + retired_at=_utc(row[8]) if row[8] else None, + waived_reporting_status_id=row[9] if len(row) > 9 else None, + waived_conflict_sha256=row[10] if len(row) > 10 else None, + ) + + +def _schedule_payload(schedule: ReportingScheduleSpec) -> dict[str, Any]: + return { + "period_duration": schedule.period_duration, + "delivery_sla": schedule.delivery_sla, + "alignment": schedule.alignment, + "period_timezone": schedule.period_timezone, + "period_anchor": ( + _utc(schedule.period_anchor).isoformat() if schedule.period_anchor else None + ), + } + + +def _definition_payload( + definition: ReportingDefinitionBinding | None, +) -> dict[str, Any] | None: + return definition.to_storage() if definition is not None else None + + +def _definition_from_payload(payload: dict[str, Any] | None) -> ReportingDefinitionBinding | None: + return ReportingDefinitionBinding(**payload) if payload else None + + +def _schedule_from_payload(payload: dict[str, Any]) -> ReportingScheduleSpec: + anchor = payload.get("period_anchor") + return ReportingScheduleSpec( + period_duration=payload["period_duration"], + delivery_sla=payload["delivery_sla"], + alignment=payload.get("alignment", "utc"), + period_timezone=payload.get("period_timezone"), + period_anchor=datetime.fromisoformat(anchor) if anchor else None, + ) + + +def _configuration_payload(configuration: ReportingConfiguration) -> dict[str, Any]: + return { + "account_id": configuration.account_id, + "report_definition_id": configuration.report_definition_id, + "reporting_profile": configuration.reporting_profile, + "feed_purpose": configuration.feed_purpose, + "required_finality": configuration.required_finality, + "account_timezone": configuration.account_timezone, + "media_buy_ids": sorted(configuration.media_buy_ids), + "schedule": _schedule_payload(configuration.schedule), + "definition": _definition_payload(configuration.definition), + } + + +def _configuration_from_row(row: Sequence[Any]) -> ReportingConfiguration: + return ReportingConfiguration( + delivery_config_id=row[0], + delivery_config_version=row[1], + account_id=row[2], + report_definition_id=row[3], + reporting_profile=row[4], + feed_purpose=row[5], + required_finality=row[6], + account_timezone=row[7], + schedule=_schedule_from_payload(row[8]), + media_buy_ids=tuple(row[9] or ()), + activated_at=_utc(row[10]) if row[10] else None, + deactivated_at=_utc(row[11]) if row[11] else None, + automated_recovery_window=timedelta(seconds=float(row[12])), + status_retention_days=row[13], + definition=_definition_from_payload(row[14]), + authoritative_party=row[15], + consumer_id=row[16], + quarantined=row[17], + ) + + +def _obligation_from_row(row: Sequence[Any]) -> ReportingObligationRecord: + return ReportingObligationRecord( + reporting_obligation_id=row[0], + account_id=row[1], + delivery_config_id=row[2], + delivery_config_version=row[3], + report_definition_id=row[4], + reporting_profile=row[5], + feed_purpose=row[6], + period=ReportingPeriodBoundary( + period_key=row[7], + start=_utc(row[8]), + end=_utc(row[9]), + source_timezone=row[10], + expected_at=_utc(row[11]), + ), + scope_resolved_at=_utc(row[12]), + automated_recovery_deadline_at=_utc(row[13]), + required_finality=row[14], + coverage_status=row[15], + media_buy_ids=tuple(row[16] or ()), + package_ids=tuple(row[17] or ()), + schedule=_schedule_from_payload(row[18]), + definition=_definition_from_payload(row[19]), + created_at=_utc(row[20]), + currency=row[21], + consumer_id=row[22], + ) + + +def _revision_from_row(row: Sequence[Any]) -> ReportingRevisionRecord: + return ReportingRevisionRecord( + reporting_revision_id=row[0], + account_id=row[1], + reporting_obligation_id=row[2], + finality=row[3], + revision_content_sha256=row[4], + row_count=int(row[5]), + control_totals=tuple((name, value) for name, value in (row[6] or [])), + observed_at=_utc(row[7]), + data_through=_utc(row[8]) if row[8] else None, + created_at=_utc(row[9]), + supersedes_reporting_revision_id=row[10], + finality_basis=row[11], + finality_policy_id=row[12], + finalized_at=_utc(row[13]) if row[13] else None, + readable=row[14], + readable_at_commit=row[15], + source_publication_id=row[16], + source_manifest_sha256=row[17], + canonical_content_digest=( + ReportingCanonicalDigest.from_wire(row[18]) if row[18] is not None else None + ), + managed_control_totals=( + tuple(ReportingControlTotalRecord.from_wire(item) for item in row[19]) + if row[19] is not None + else None + ), + ) + + +def _adjustment_from_row(row: Sequence[Any]) -> ReportingAdjustmentRecord: + return ReportingAdjustmentRecord( + reporting_adjustment_id=row[0], + account_id=row[1], + adjusts_reporting_revision_id=row[2], + reason_code=row[3], + reason_detail=row[4], + accounting_period_start=_utc(row[5]), + accounting_period_end=_utc(row[6]), + control_total_deltas=tuple((name, value) for name, value in (row[7] or [])), + correction_observed_at=_utc(row[8]), + created_at=_utc(row[9]), + managed_control_total_deltas=( + tuple(ReportingControlTotalRecord.from_wire(item) for item in row[10]) + if row[10] is not None + else None + ), + ) + + +def _status_from_row(row: Sequence[Any]) -> ConsumerStatusRecord: + return ConsumerStatusRecord( + reporting_status_id=row[0], + account_id=row[1], + consumer_id=row[2], + delivery_config_id=row[3], + delivery_config_version=row[4], + report_definition_id=row[5], + period_start=_utc(row[6]), + period_end=_utc(row[7]), + period_source_timezone=row[8], + consumer_status=row[9], + status_as_of=_utc(row[10]), + recorded_at=_utc(row[11]), + supersedes_reporting_status_id=row[12], + reporting_obligation_id=row[13], + reporting_revision_id=row[14], + observed_revision_content_sha256=row[15], + failure_code=row[16], + mismatch_code=row[17], + consumer_commit_ref=row[18], + seller_ledger_snapshot_id=row[19], + seller_ledger_as_of=_utc(row[20]) if row[20] else None, + superseded=row[21], + ) + + +def _consumer_status_payload(status: ConsumerStatusRecord) -> dict[str, Any]: + payload: dict[str, Any] = { + "chain": list(status.chain_key), + "consumer_status": status.consumer_status, + "status_as_of": _utc(status.status_as_of).isoformat(), + "supersedes": status.supersedes_reporting_status_id, + "obligation": status.reporting_obligation_id, + "revision": status.reporting_revision_id, + "digest": status.observed_revision_content_sha256, + "failure_code": status.failure_code, + } + # Conditional on purpose. ``canonical_json_utf8_v1`` encodes ``None`` as + # ``null``, so including the key unconditionally would change the digest of + # every statement that has no mismatch_code -- i.e. every row an rc.2 SDK + # wrote. After an in-place upgrade a buyer's exact retry would then fail + # with STATUS_IDENTITY_CONFLICT instead of replaying as ``unchanged``, + # which is the one thing an idempotent append-only surface must never do. + # Omitting the key when absent keeps rc.2 digests byte-identical while a + # code-only change is still a conflict: "the metric is missing" and "the + # currency is wrong" are different claims and must not share one immutable + # identity. + if status.mismatch_code is not None: + payload["mismatch_code"] = status.mismatch_code + return payload + + +def _revision_payload(revision: ReportingRevisionRecord) -> dict[str, Any]: + payload = { + "finality": revision.finality, + "revision_content_sha256": revision.revision_content_sha256, + "row_count": revision.row_count, + "control_totals": [list(item) for item in revision.control_totals], + "obligation": revision.reporting_obligation_id, + "supersedes": revision.supersedes_reporting_revision_id, + } + if revision.canonical_content_digest is not None: + payload["canonical_content_digest"] = revision.canonical_content_digest.to_wire() + if revision.managed_control_totals is not None: + payload["managed_control_totals"] = [ + item.to_wire() for item in revision.managed_control_totals + ] + if revision.canonical_content_digest is not None or revision.managed_control_totals is not None: + payload["managed_metadata"] = managed_revision_metadata(revision) + return payload diff --git a/src/adcp/reporting/ledger/producer.py b/src/adcp/reporting/ledger/producer.py index 807769ef8..449acde58 100644 --- a/src/adcp/reporting/ledger/producer.py +++ b/src/adcp/reporting/ledger/producer.py @@ -75,6 +75,9 @@ LeasedConfiguration, LedgerConflictError, ReportingLedgerStore, + ReportingRowStagingStore, + ReportingSourcePublicationStore, + ReportingWorkerRetryPolicy, RestatementCheckpointStore, RetryScheduleEntry, RetryScheduleStore, @@ -454,11 +457,9 @@ async def run_worker(self) -> WorkerTurn: deadlocks and lock timeouts at most twice, after rollback and fenced release. Each retry must acquire a new lease; other failures propagate. """ - from adcp.reporting.ledger.pg import PgReportingLedgerStore - retryable = ( - self._store._worker_lock_errors() - if isinstance(self._store, PgReportingLedgerStore) + self._store.worker_lock_errors() + if isinstance(self._store, ReportingWorkerRetryPolicy) else () ) for attempt in range(3): @@ -1542,10 +1543,11 @@ async def _source_publication( if not isinstance(self._source, ReportingProductionSource): yield None return - publication = getattr(self._store, "_source_publication", None) - if publication is None: + if not isinstance(self._store, ReportingSourcePublicationStore): raise TypeError("production source publication requires an SDK account lock") - async with publication(configuration.account_id, seals=seals) as publish_seal: + async with self._store.source_publication( + configuration.account_id, seals=seals + ) as publish_seal: self._check_source_authorization(configuration, offering_id, admitted_constituents) yield publish_seal @@ -1863,15 +1865,15 @@ async def commit_revision_from_manifest( revision = verified_publication(self._revision_verifier, obligation, revision, rows) # An object-bound revision uploads its chunks before the ledger transaction, # never inside it, so a slow store cannot hold the account lock. - stage = getattr(self._store, "stage_revision_rows", None) - staged = bool(await stage(revision, rows)) if stage is not None else False + staging = self._store if isinstance(self._store, ReportingRowStagingStore) else None + staged = staging is not None and bool(await staging.stage_revision_rows(revision, rows)) try: committed = await self._commit_publication( obligation, manifest, revision, rows, finality, now, acquisition ) finally: - if staged: - self._store.discard_staged_revision_rows(revision) # type: ignore[attr-defined] + if staged and staging is not None: + staging.discard_staged_revision_rows(revision) turn.revisions_committed.append(committed.reporting_revision_id) return committed diff --git a/src/adcp/reporting/ledger/status_snapshot.py b/src/adcp/reporting/ledger/status_snapshot.py index e58be2cf4..3c9b02226 100644 --- a/src/adcp/reporting/ledger/status_snapshot.py +++ b/src/adcp/reporting/ledger/status_snapshot.py @@ -4,7 +4,6 @@ from collections.abc import Callable from dataclasses import replace -from datetime import datetime from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from adcp.reporting._timestamp import aware_timestamp @@ -13,7 +12,6 @@ from adcp.reporting.ledger.status_projection import ( ReportingStatusSnapshot, StatusLifecycleIntent, - apply_intents_to_snapshot, lifecycle_intents, status_matches_obligation, ) @@ -21,7 +19,6 @@ if TYPE_CHECKING: from adcp.reporting.ledger.models import ReportingCaller - from adcp.reporting.ledger.pg import PgReportingLedgerStore from adcp.reporting.ledger.store import InMemoryReportingLedgerStore @@ -173,53 +170,9 @@ def settle_memory_snapshot( raise ReportingNotificationError("status_lifecycle_did_not_converge") -_TABLES = { - "configurations": "reporting_configurations", - "obligations": "reporting_obligations", - "revisions": "reporting_revisions", - "statuses": "reporting_consumer_statuses", - "lifecycles": "reporting_issue_lifecycle", - "waivers": "reporting_issue_waiver_bindings", - "issue_scopes": "reporting_issue_status_scopes", - "adjustments": "reporting_adjustments", - "changes": "reporting_ledger_changes", - "consumers": "reporting_reconciliation_heads", -} - - -async def read_snapshot_on( - connection: Any, - *, - account_id: str, - clock: Callable[[], datetime] | None = None, - as_of: datetime | None = None, - include_issue_scopes: bool = True, -) -> ReportingStatusSnapshot: - from adcp.reporting.ledger.pg import _waiver_storage_on - from adcp.reporting.outbox.pg import database_now - - at = as_of or await database_now(connection, clock) - raw: dict[str, Any] = {"account_id": account_id, "as_of": at.isoformat()} - for key, table in _TABLES.items(): - if key == "issue_scopes" and not include_issue_scopes: - continue - if key == "waivers" and not await _waiver_storage_on(connection): - continue - # Table names are the closed SDK constants above. Parameters are bound. - rows = await ( - await connection.execute( - f"SELECT to_jsonb(r) FROM {table} r WHERE account_id = %s", # nosec B608 - (account_id,), - ) - ).fetchall() - raw[key] = [row[0] for row in rows] - return snapshot_from_storage(raw) - - def snapshot_from_storage(raw: dict[str, Any]) -> ReportingStatusSnapshot: """Decode the exact SQL boundary input through the ledger's existing mappers.""" - from adcp.reporting.ledger.notification_models import decode_status_scope - from adcp.reporting.ledger.pg import ( + from adcp.reporting.ledger._record_rows import ( _ADJUSTMENT_COLUMNS, _ISSUE_COLUMNS, _OBLIGATION_COLUMNS, @@ -232,6 +185,7 @@ def snapshot_from_storage(raw: dict[str, Any]) -> ReportingStatusSnapshot: _revision_from_row, _status_from_row, ) + from adcp.reporting.ledger.notification_models import decode_status_scope dates = { "activated_at", @@ -319,135 +273,3 @@ def decode(key: str, columns: str, builder: Callable[[Any], Any]) -> tuple[Any, for row in raw.get("changes", []) ), ) - - -async def apply_intents_on( - store: PgReportingLedgerStore, connection: Any, intents: tuple[StatusLifecycleIntent, ...] -) -> None: - """No pooled calls. The source/projector owns account and checkpoint locks.""" - for intent in intents: - issue = intent.lifecycle - if intent.action == "ensure_mismatch": - await store._ensure_issue_opened_on( - connection, - issue_key=issue.issue_key, - account_id=issue.account_id, - consumer_id=issue.consumer_id, - observed_at=issue.opened_at, - status_scope=intent.scope, - enqueue=False, - ) - elif intent.action == "retire_mismatch": - assert issue.retired_at is not None - await store._retire_issue_on( - connection, - issue_key=issue.issue_key, - account_id=issue.account_id, - at=issue.retired_at, - status_scope=intent.scope, - enqueue=False, - ) - else: - await store._dirty_issue(connection, issue, intent.scope, enqueue=False) - - -async def persist_replay_lifecycles_on( - store: PgReportingLedgerStore, connection: Any, snapshot: ReportingStatusSnapshot -) -> None: - """Persist derived historical occurrences without regressing later evidence. - - Captured source rows remain immutable. These are the same public lifecycle - rows used by polling, including repairs for an old writer's interrupted - status/issue pair. The enclosing account lock fences concurrent ingestion. - """ - from adcp.reporting.ledger.pg import _save_waiver_binding - - scopes = dict(snapshot.issue_scopes) - for issue in sorted(snapshot.lifecycles, key=lambda i: (i.issue_key, i.generation)): - await connection.execute( - "INSERT INTO reporting_issue_lifecycle" - " (account_id, issue_key, generation, issue_id, consumer_id, opened_at," - " issue_state, external_ref, retired_at) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)" - " ON CONFLICT (account_id, issue_key, generation) DO NOTHING", - ( - issue.account_id, - issue.issue_key, - issue.generation, - issue.issue_id, - issue.consumer_id, - issue.opened_at, - issue.issue_state, - issue.external_ref, - issue.retired_at, - ), - ) - await _save_waiver_binding(connection, issue) - if issue.issue_state != "open": - await connection.execute( - "UPDATE reporting_issue_lifecycle SET issue_state=%s, external_ref=%s," - " retired_at=%s" - " WHERE account_id=%s AND issue_key=%s AND generation=%s" - " AND issue_state IN ('open','acknowledged')", - ( - issue.issue_state, - issue.external_ref, - issue.retired_at, - issue.account_id, - issue.issue_key, - issue.generation, - ), - ) - scope = scopes.get(issue.issue_id) - if scope is not None: - # Existing scope may already be more precise because a later source - # transaction attached this occurrence. Historical replay retains it. - row = await ( - await connection.execute( - "SELECT scope FROM reporting_issue_status_scopes" - " WHERE account_id=%s AND issue_id=%s", - (issue.account_id, issue.issue_id), - ) - ).fetchone() - if row is not None: - from adcp.reporting.ledger.notification_models import ( - decode_status_scope, - validate_scope_refinement, - ) - - old = decode_status_scope(row[0]) - try: - validate_scope_refinement(old, scope) - except ReportingNotificationError: - validate_scope_refinement(scope, old) - scope = old - await store._dirty_issue(connection, issue, scope, enqueue=False) - - -async def settle_snapshot_on( - store: PgReportingLedgerStore, - connection: Any, - *, - account_id: str, - as_of: datetime | None = None, -) -> ReportingStatusSnapshot: - from adcp.reporting.outbox.pg import database_now - - at = as_of or await database_now(connection, store._clock) - has_scope_storage = await store._issue_scope_storage_on(connection) - for _ in range(3): - snapshot = await read_snapshot_on( - connection, account_id=account_id, as_of=at, include_issue_scopes=has_scope_storage - ) - if not has_scope_storage: - # Pre-outbox Core schemas retain lifecycle but have no scope table. - # Derive scope from authenticated status evidence for this read; - # notification-enabled participants require durable scope storage. - snapshot = apply_intents_to_snapshot( - snapshot, - tuple(i for i in lifecycle_intents(snapshot) if i.action == "refine_scope"), - ) - intents = lifecycle_intents(snapshot) - if not intents: - return snapshot - await apply_intents_on(store, connection, intents) - raise ReportingNotificationError("status_lifecycle_did_not_converge") diff --git a/src/adcp/reporting/ledger/store.py b/src/adcp/reporting/ledger/store.py index a5e4cb32d..b91762212 100644 --- a/src/adcp/reporting/ledger/store.py +++ b/src/adcp/reporting/ledger/store.py @@ -26,7 +26,7 @@ conformance target. It is genuinely usable in tests and single-process pilots, and it is not durable -- Reliable Reporting's whole premise is retained evidence, so production wants -:class:`~adcp.reporting.ledger.pg.PgReportingLedgerStore` or your own. +:class:`~adcp_reporting_storage.ledger.pg.PgReportingLedgerStore` or your own. """ from __future__ import annotations @@ -36,7 +36,7 @@ import hashlib import json from collections.abc import AsyncIterator, Callable, Sequence -from contextlib import asynccontextmanager +from contextlib import AbstractAsyncContextManager, asynccontextmanager from contextvars import ContextVar from copy import deepcopy from dataclasses import dataclass, field, replace @@ -319,6 +319,38 @@ def revision_row_offset(cursor: str | None, revision_id: str, limit: int) -> int return offset +@runtime_checkable +class ReportingRowStagingStore(Protocol): + """Optional: a store that uploads a revision's rows before the ledger transaction. + + The producer stages rows when the store offers it and discards the staged + handle when the commit does not happen. A store that keeps rows inline needs + neither method. See adcp-client-python#1489 for the handle-passing variant. + """ + + async def stage_revision_rows( + self, revision: ReportingRevisionRecord, rows: Sequence[dict[str, Any]] + ) -> object | None: ... + + def discard_staged_revision_rows(self, revision: ReportingRevisionRecord) -> None: ... + + +@runtime_checkable +class ReportingSourcePublicationStore(Protocol): + """Optional: a store that publishes a production source under its account lock.""" + + def source_publication( + self, account_id: str, *, seals: ReportingSealStore | None = None + ) -> AbstractAsyncContextManager[Any]: ... + + +@runtime_checkable +class ReportingWorkerRetryPolicy(Protocol): + """Optional: lock errors a standalone worker turn retries after a fenced release.""" + + def worker_lock_errors(self) -> tuple[type[Exception], ...]: ... + + @runtime_checkable class ReportingLedgerStore(Protocol): """Durable home for obligations, revisions, adjustments, and statuses.""" @@ -841,8 +873,13 @@ async def transaction(self) -> AsyncIterator[InMemoryReportingLedgerStore]: async with self._mutation(): yield self + @property + def clock(self) -> Callable[[], datetime]: + """The instant source for change timestamps and snapshot boundaries.""" + return self._clock + @asynccontextmanager - async def _source_publication( + async def source_publication( self, account_id: str, *, seals: ReportingSealStore | None = None ) -> AsyncIterator[None]: # The memory mutation lock serializes all accounts, including seals. diff --git a/src/adcp/reporting/materializer/__init__.py b/src/adcp/reporting/materializer/__init__.py index 28c7edf26..09934d081 100644 --- a/src/adcp/reporting/materializer/__init__.py +++ b/src/adcp/reporting/materializer/__init__.py @@ -5,10 +5,9 @@ writer is exclusively for tests/development. The durable service owns fencing, retry allocation, target reselection and atomic finish. Production tier and notification activation additionally require the complete seller projection. +The PostgreSQL materializer store is ``adcp_reporting_storage.materializer.pg``. """ -from typing import TYPE_CHECKING - from adcp.reporting.ledger.delivery_models import ( ReportingDeliveryPrincipal, ReportingDestinationBinding, @@ -65,13 +64,9 @@ ReportingMaterializerTurn, ) -if TYPE_CHECKING: - from adcp.reporting.materializer.pg import PgReportingMaterializerStore - __all__ = [ "InMemoryReportingMaterializerStore", "MaterializerReason", - "PgReportingMaterializerStore", "ReferenceReportingDestinationWriter", "ReferenceReportingResolver", "ReportingCanonicalization", @@ -114,11 +109,3 @@ "strict_reporting_json", "validate_materialization_target", ] - - -def __getattr__(name: str) -> object: - if name == "PgReportingMaterializerStore": - from adcp.reporting.materializer.pg import PgReportingMaterializerStore - - return PgReportingMaterializerStore - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/adcp/reporting/materializer/capture.py b/src/adcp/reporting/materializer/capture.py index 1898d478e..0ef9aa3b2 100644 --- a/src/adcp/reporting/materializer/capture.py +++ b/src/adcp/reporting/materializer/capture.py @@ -109,26 +109,6 @@ def decode_materializer_boundary(value: dict[str, Any]) -> ReportingMaterializer return result -class _MaterializerQueueConnection: - """Closed table substitution, preserving the finish transaction's connection.""" - - def __init__(self, connection: Any) -> None: - self.connection = connection - - async def execute(self, query: str, params: Any = None) -> Any: - return await self.connection.execute( - query.replace("reporting_notification_", "reporting_materializer_notification_"), params - ) - - -async def enqueue_materializer_event_on(connection: Any, event: ReportingDomainEvent) -> None: - from adcp.reporting.outbox.pg import enqueue_event - - if event.notification_type != "reporting.delivery_ready": - raise ReportingNotificationError("materializer_event_invalid") - await enqueue_event(_MaterializerQueueConnection(connection), event) - - def private_snapshot( snapshot: ReportingStatusSnapshot, caller: ReportingDeliveryPrincipal ) -> ReportingStatusSnapshot: diff --git a/src/adcp/reporting/outbox/__init__.py b/src/adcp/reporting/outbox/__init__.py index f6a0bc090..1987055e6 100644 --- a/src/adcp/reporting/outbox/__init__.py +++ b/src/adcp/reporting/outbox/__init__.py @@ -1,6 +1,7 @@ -"""Optional transactional reporting notifications, activity and status lifecycle.""" +"""Optional transactional reporting notifications, activity and status lifecycle. -from typing import TYPE_CHECKING +The PostgreSQL queues live in ``adcp_reporting_storage.outbox``. +""" from adcp.reporting.ledger.notification_models import ( AdjustmentPublished, @@ -23,6 +24,10 @@ WebhookAttempt, sanitize_activity_url, ) +from adcp.reporting.outbox.durable import ( + ReportingDurableNotificationLedger, + ReportingDurableStatusStore, +) from adcp.reporting.outbox.identity import resolve_reporting_consumer from adcp.reporting.outbox.memory import InMemoryReportingOutbox from adcp.reporting.outbox.models import ( @@ -63,17 +68,13 @@ from adcp.reporting.outbox.support import ReportingActivitySupport from adcp.reporting.outbox.worker import ReportingNotificationWorker -if TYPE_CHECKING: - from adcp.reporting.outbox.pg import PgReportingOutbox - from adcp.reporting.outbox.status_activity_pg import PgReportingActivityUnionStore - from adcp.reporting.outbox.status_pg import PgReportingStatusOutbox, PgStatusNotificationStore - __all__ = [ "ReportingStatusNotificationLifecycle", "ReportingStatusService", "ReportingActivityReader", + "ReportingDurableNotificationLedger", + "ReportingDurableStatusStore", "ReportingStatusSupport", - "PgReportingActivityUnionStore", "StatusChanged", "StatusBoundary", "StatusCheckpoint", @@ -85,8 +86,6 @@ "ReportingStatusSweeper", "InMemoryReportingStatusOutbox", "InMemoryStatusNotificationStore", - "PgReportingStatusOutbox", - "PgStatusNotificationStore", "ActivityOutcome", "ActivityRequest", "ReportingActivityProjector", @@ -102,7 +101,6 @@ "ExpansionLease", "InMemoryReportingOutbox", "MaterializationReady", - "PgReportingOutbox", "ReportingDomainEvent", "ReportingEnvelopeCipher", "ReportingLegacyAuthentication", @@ -120,25 +118,3 @@ "StoredDelivery", "validate_notification_payload", ] - - -def __getattr__(name: str) -> object: - if name == "PgReportingActivityUnionStore": - from adcp.reporting.outbox.status_activity_pg import PgReportingActivityUnionStore - - return PgReportingActivityUnionStore - if name in {"PgReportingStatusOutbox", "PgStatusNotificationStore"}: - from adcp.reporting.outbox.status_pg import ( - PgReportingStatusOutbox, - PgStatusNotificationStore, - ) - - return { - "PgReportingStatusOutbox": PgReportingStatusOutbox, - "PgStatusNotificationStore": PgStatusNotificationStore, - }[name] - if name == "PgReportingOutbox": - from adcp.reporting.outbox.pg import PgReportingOutbox - - return PgReportingOutbox - raise AttributeError(name) diff --git a/src/adcp/reporting/outbox/_capabilities.py b/src/adcp/reporting/outbox/_capabilities.py index 3b36249d3..29ce7b9df 100644 --- a/src/adcp/reporting/outbox/_capabilities.py +++ b/src/adcp/reporting/outbox/_capabilities.py @@ -4,10 +4,14 @@ from typing import TYPE_CHECKING -from adcp.reporting.ledger.delivery import InMemoryReportingReconciliationStore +from adcp.reporting.ledger.delivery import ( + InMemoryReportingReconciliationStore, + ReportingReconciliationStore, +) from adcp.reporting.ledger.delivery_models import ReportingDeliveryScope from adcp.reporting.ledger.notification_models import ReportingNotificationError from adcp.reporting.ledger.store import InMemoryReportingLedgerStore, ReportingLedgerStore +from adcp.reporting.outbox.durable import ReportingDurableNotificationLedger from adcp.reporting.outbox.memory import InMemoryReportingOutbox from adcp.reporting.outbox.routing import ReportingEnvelopeCipher, ReportingNotificationSubscription @@ -39,23 +43,11 @@ async def advertised_notifications( ): raise ReportingNotificationError("notification_chain_unready") else: - # Deliberately lazy: the base SDK and memory implementation need no PG extra. - from adcp.reporting.ledger.delivery_pg import PgReportingReconciliationStore - from adcp.reporting.ledger.pg import PgReportingLedgerStore - from adcp.reporting.outbox._schema import validate_schema - from adcp.reporting.outbox.pg import PgReportingOutbox - - if ( - type(worker.outbox) is not PgReportingOutbox - or not isinstance(worker.outbox, PgReportingOutbox) - or type(ledger) not in {PgReportingLedgerStore, PgReportingReconciliationStore} - or not isinstance(ledger, PgReportingLedgerStore) - or ledger._pool is not worker.outbox._pool - or not ledger._notifications_enabled - ): + # A durable ledger proves its own queue, storage and installed schema; + # the base SDK never imports a storage implementation to ask. + if not isinstance(ledger, ReportingDurableNotificationLedger): raise ReportingNotificationError("notification_chain_unready") - async with ledger._pool.connection() as connection, connection.transaction(): - await validate_schema(connection) + await ledger.validate_notification_chain(worker.outbox) # A successful empty resolution is a configured service, not an error. # Every configured mode must also have usable trusted signing material. @@ -97,11 +89,11 @@ async def advertised_notifications( # A capability string cannot turn Core into Managed. These concrete # stores froze and validated the configuration/destination/obligation # relationship transactionally. All references remain immutable. - if not isinstance(ledger, InMemoryReportingReconciliationStore): - from adcp.reporting.ledger.delivery_pg import PgReportingReconciliationStore - - if not isinstance(ledger, PgReportingReconciliationStore): - raise ReportingNotificationError("core_delivery_ready_forbidden") + reconciles = isinstance(ledger, InMemoryReportingReconciliationStore) or ( + isinstance(ledger, ReportingDurableNotificationLedger) and ledger.durable_reconciliation + ) + if not reconciles or not isinstance(ledger, ReportingReconciliationStore): + raise ReportingNotificationError("core_delivery_ready_forbidden") binding = await ledger.get_destination_binding( caller=ready_scope.principal, generation_key=ready_scope.generation_key ) diff --git a/src/adcp/reporting/outbox/durable.py b/src/adcp/reporting/outbox/durable.py new file mode 100644 index 000000000..d2903079d --- /dev/null +++ b/src/adcp/reporting/outbox/durable.py @@ -0,0 +1,81 @@ +"""What the notification layer asks of a database-backed ledger. + +``adcp`` ships the in-memory stores and checks their composition inline. Every +other ledger (the PostgreSQL store in ``adcp-reporting-storage``, or an +adopter's own) answers the same questions through these protocols, so the +capability checks in :mod:`adcp.reporting.outbox` never import a storage +implementation. Each ``validate_*`` method raises +:class:`~adcp.reporting.ledger.notification_models.ReportingNotificationError` +with the code the inline checks use, and a store that does not implement a +protocol is treated as not durable. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +if TYPE_CHECKING: + from adcp.reporting.outbox.worker import ReportingNotificationWorker + +__all__ = ["ReportingDurableNotificationLedger", "ReportingDurableStatusStore"] + + +@runtime_checkable +class ReportingDurableNotificationLedger(Protocol): + """A ledger whose notification outbox shares its durable storage.""" + + @property + def storage_identity(self) -> object: + """The storage resource every participant must share; compared by identity.""" + ... + + @property + def notifications_enabled(self) -> bool: ... + + @property + def durable_reconciliation(self) -> bool: + """Whether this ledger froze destination bindings transactionally (Managed tier).""" + ... + + async def validate_notification_chain(self, outbox: object) -> None: + """Raise ``notification_chain_unready`` unless ``outbox`` is this ledger's queue. + + Includes the installed-schema check for the notification tables. + """ + ... + + def validate_activity_chain(self, outbox: object) -> None: + """Raise ``activity_chain_unready`` unless ``outbox`` is this ledger's activity queue.""" + ... + + def validate_composite_activity_chain( + self, *, outbox: object, status_store: object, reader: object + ) -> None: + """Raise ``activity_chain_unready`` unless the B+C activity union is closed.""" + ... + + def activity_schema_contract(self, *, composite: bool) -> str: + """A digest of the manifests ``validate_activity_schema`` checks.""" + ... + + async def validate_activity_schema(self, *, composite: bool) -> None: + """Raise ``notification_schema_unready`` unless the activity catalog is installed.""" + ... + + +@runtime_checkable +class ReportingDurableStatusStore(Protocol): + """A status-notification store whose queue shares the ledger's durable storage.""" + + #: The ledger the status queue reads; the activity chain requires the same object. + ledger: Any + #: The store's own status queue, which its worker must drive. + outbox: Any + + async def status_queue_durable(self, worker: ReportingNotificationWorker) -> bool: + """Whether ``worker`` drives this store's own durable queue with its schema installed.""" + ... + + async def managed_delivery_recorded(self, *, account_id: str) -> bool: + """Whether the account holds a frozen destination binding (Managed tier).""" + ... diff --git a/src/adcp/reporting/outbox/memory.py b/src/adcp/reporting/outbox/memory.py index ea5794453..0d49637cf 100644 --- a/src/adcp/reporting/outbox/memory.py +++ b/src/adcp/reporting/outbox/memory.py @@ -181,6 +181,18 @@ async def list_events(self, *, account_id: str) -> tuple[ReportingDomainEvent, . event for (owner, _, _), event in self._state.events.items() if owner == account_id ) + async def next_due_account(self, *, delivery: bool, now: datetime) -> str | None: + """Sample one due queue row; never enumerate adopter accounts.""" + now = aware_utc(now) + async with self._store._lock: + pending = ( + ((key[0], work) for key, (_, work) in self._state.deliveries.items()) + if delivery + else ((key[0], work) for key, work in self._state.expansions.items()) + ) + values = [(work.due_at, account) for account, work in pending if work.available(now)] + return min(values)[1] if values else None + async def claim_expansion( self, *, account_id: str, now: datetime, lease_seconds: float ) -> ExpansionLease | None: diff --git a/src/adcp/reporting/outbox/status_support.py b/src/adcp/reporting/outbox/status_support.py index 33dbb179e..25145b9c2 100644 --- a/src/adcp/reporting/outbox/status_support.py +++ b/src/adcp/reporting/outbox/status_support.py @@ -7,6 +7,7 @@ from adcp.reporting.ledger.notification_models import ReportingNotificationError from adcp.reporting.ledger.status import ReportingStatusHandler +from adcp.reporting.outbox.durable import ReportingDurableStatusStore from adcp.reporting.outbox.routing import ReportingEnvelopeCipher, ReportingNotificationSubscription from adcp.reporting.outbox.status import ( ReportingStatusProjector, @@ -55,47 +56,19 @@ async def queue_durable(self) -> bool: return False if type(self.store) is InMemoryStatusNotificationStore: return False - from adcp.reporting.outbox.status_pg import ( - PgReportingStatusOutbox, - PgStatusNotificationStore, - ) - from adcp.reporting.outbox.status_schema import validate_status_schema - - if ( - type(self.store) is not PgStatusNotificationStore - or not isinstance(self.store, PgStatusNotificationStore) - or type(self.worker.outbox) is not PgReportingStatusOutbox - or self.worker.outbox is not self.store.outbox - or self.store.ledger._pool is not self.store.outbox._pool - or not self.store.ledger._notifications_enabled - or (self.worker.activity is not None and self.worker.activity is not self.worker.outbox) - ): + # A durable store proves its own queue, storage and installed schema. + if not isinstance(self.store, ReportingDurableStatusStore): return False - try: - async with self.store.ledger._pool.connection() as connection: - await validate_status_schema(connection, activity=self.worker.activity is not None) - except ReportingNotificationError: - return False - return True + return await self.store.status_queue_durable(self.worker) async def account_ready(self, *, account_id: str) -> bool: if not await self.queue_durable() or not await self.store.baseline_ready( account_id=account_id ): return False - from adcp.reporting.outbox.status_pg import PgStatusNotificationStore - - assert isinstance(self.store, PgStatusNotificationStore) - async with self.store.ledger._pool.connection() as connection: - managed = await ( - await connection.execute( - "SELECT EXISTS(SELECT 1 FROM reporting_reconciliation_records" - " WHERE account_id=%s AND record_kind='destination_binding')", - (account_id,), - ) - ).fetchone() - if managed is not None and managed[0]: - return False # Managed expiry/reconciliation health ships with D. + assert isinstance(self.store, ReportingDurableStatusStore) + if await self.store.managed_delivery_recorded(account_id=account_id): + return False # Managed expiry/reconciliation health ships with D. subscriptions = await self.worker.subscriptions.list_active( account_id=account_id, notification_type="reporting.status_changed" ) diff --git a/src/adcp/reporting/outbox/support.py b/src/adcp/reporting/outbox/support.py index 031eb669f..ee4a69c9a 100644 --- a/src/adcp/reporting/outbox/support.py +++ b/src/adcp/reporting/outbox/support.py @@ -8,6 +8,10 @@ from adcp.reporting.ledger.notification_models import ReportingNotificationError from adcp.reporting.outbox.activity import ReportingActivityProjector +from adcp.reporting.outbox.durable import ( + ReportingDurableNotificationLedger, + ReportingDurableStatusStore, +) if TYPE_CHECKING: from adcp.reporting.ledger.store import ReportingLedgerStore @@ -15,7 +19,7 @@ from adcp.reporting.outbox.worker import ReportingNotificationWorker -_ProofKey = tuple[str, tuple[int, ...], str, str] +_ProofKey = tuple[str, tuple[int, ...], str] @dataclass @@ -69,9 +73,9 @@ def invalidate_schema_validation(self) -> None: state.positive = None state.flight = None - async def _schema_proven(self, pool: Any, *, composite: bool) -> bool: - from adcp.reporting.outbox import _schema - + async def _schema_proven( + self, ledger: ReportingDurableNotificationLedger, *, composite: bool + ) -> bool: assert self.projector is not None wiring: tuple[object, ...] = ( self.worker, @@ -83,12 +87,9 @@ async def _schema_proven(self, pool: Any, *, composite: bool) -> bool: self.worker.cipher, self.worker.subscriptions, self.worker.signing, - pool, + ledger.storage_identity, ) - status_contract = "" if composite: - from adcp.reporting.outbox import status_schema - assert self.status is not None wiring += ( self.status, @@ -96,12 +97,10 @@ async def _schema_proven(self, pool: Any, *, composite: bool) -> bool: self.status.worker, self.status.worker.outbox, ) - status_contract = _schema._digest(status_schema.REQUIRED_STATUS_OBJECTS) key: _ProofKey = ( "B+C" if composite else "B", tuple(id(component) for component in wiring), - _schema._digest(_schema.REQUIRED_OBJECTS), - status_contract, + ledger.activity_schema_contract(composite=composite), ) state = self._schema_validation if state.positive == key: @@ -110,7 +109,9 @@ async def _schema_proven(self, pool: Any, *, composite: bool) -> bool: if flight is None: flight = _SchemaFlight(key, state.epoch) state.flight = flight - flight.task = asyncio.create_task(self._scan_schema(pool, flight, composite=composite)) + flight.task = asyncio.create_task( + self._scan_schema(ledger, flight, composite=composite) + ) flight.task.add_done_callback(_consume_schema_failure) task = flight.task if task is None or flight.key != key or task.get_loop() is not asyncio.get_running_loop(): @@ -123,27 +124,16 @@ async def _schema_proven(self, pool: Any, *, composite: bool) -> bool: # Rerun the same cheap checks before accepting the completed proof. return await self.durable() - async def _scan_schema(self, pool: Any, flight: _SchemaFlight, *, composite: bool) -> bool: - from adcp.reporting.outbox import _schema - + async def _scan_schema( + self, + ledger: ReportingDurableNotificationLedger, + flight: _SchemaFlight, + *, + composite: bool, + ) -> bool: state = self._schema_validation try: - async with pool.connection() as connection: - try: - installed = await _schema.schema_objects(connection) - except Exception: - raise ReportingNotificationError( - "notification_schema_unready:catalog_unavailable" - ) from None - # One physical catalog capture, each required contract once. - retired = await _schema.legacy_rows_retired(connection, installed) - _schema._validate_schema_objects( - installed, activity=True, **({"legacy_rows_retired": True} if retired else {}) - ) - if composite: - from adcp.reporting.outbox import status_schema - - status_schema._validate_status_objects(installed, activity=True, status=False) + await ledger.validate_activity_schema(composite=composite) if state.epoch != flight.epoch or state.flight is not flight: return False state.positive = flight.key @@ -179,71 +169,46 @@ async def durable(self) -> bool: ): raise ReportingNotificationError("activity_chain_unready") return False - # Lazy imports retain base-install operation without the [pg] extra. - from adcp.reporting.ledger.delivery_pg import PgReportingReconciliationStore - from adcp.reporting.ledger.pg import PgReportingLedgerStore - from adcp.reporting.outbox.pg import PgReportingOutbox - - if ( - type(self.worker.outbox) is not PgReportingOutbox - or not isinstance(self.worker.outbox, PgReportingOutbox) - or type(self.ledger) not in {PgReportingLedgerStore, PgReportingReconciliationStore} - or not isinstance(self.ledger, PgReportingLedgerStore) - or self.ledger._pool is not self.worker.outbox._pool - or not self.ledger._notifications_enabled - ): + # A durable ledger proves its own queue and storage identity. + ledger = self.ledger + if not isinstance(ledger, ReportingDurableNotificationLedger): raise ReportingNotificationError("activity_chain_unready") - return await self._schema_proven(self.ledger._pool, composite=False) + ledger.validate_activity_chain(self.worker.outbox) + return await self._schema_proven(ledger, composite=False) async def _composite_durable(self) -> bool: """Only the closed B+C read/purge union may replace the original B reader.""" assert self.status is not None if not self.status.scheduled: return False - from adcp.reporting.ledger.delivery_pg import PgReportingReconciliationStore - from adcp.reporting.ledger.pg import PgReportingLedgerStore - from adcp.reporting.outbox.pg import PgReportingOutbox from adcp.reporting.outbox.routing import ReportingEnvelopeCipher - from adcp.reporting.outbox.status_activity_pg import PgReportingActivityUnionStore - from adcp.reporting.outbox.status_pg import ( - PgReportingStatusOutbox, - PgStatusNotificationStore, - ) from adcp.reporting.outbox.worker import ReportingNotificationWorker store = self.status.store - if not isinstance(store, PgStatusNotificationStore): + if not isinstance(store, ReportingDurableStatusStore): return False reader = self.projector.store if self.projector is not None else None + ledger = self.ledger if ( type(self.worker) is not ReportingNotificationWorker - or type(store) is not PgStatusNotificationStore - or type(self.ledger) not in {PgReportingLedgerStore, PgReportingReconciliationStore} - or not isinstance(self.ledger, PgReportingLedgerStore) - or type(self.worker.outbox) is not PgReportingOutbox - or not isinstance(self.worker.outbox, PgReportingOutbox) + or not isinstance(ledger, ReportingDurableNotificationLedger) or type(self.worker.cipher) is not ReportingEnvelopeCipher or type(self.status.worker) is not ReportingNotificationWorker or type(self.projector) is not ReportingActivityProjector - or type(reader) is not PgReportingActivityUnionStore - or not isinstance(reader, PgReportingActivityUnionStore) - or reader.b_outbox is not self.worker.outbox - or reader.status_outbox is not store.outbox - or self.worker.activity is not self.worker.outbox - or self.status.worker.activity is not store.outbox - or self.status.worker.outbox is not store.outbox - or self.ledger is not store.ledger - or self.worker.outbox._pool is not store.ledger._pool - or type(store.outbox) is not PgReportingStatusOutbox - or store.outbox._pool is not store.ledger._pool - or self.worker.outbox._clock is not store.outbox._clock - or not store.ledger._notifications_enabled + or id(self.worker.activity) != id(self.worker.outbox) + or id(self.status.worker.activity) != id(store.outbox) + or id(self.status.worker.outbox) != id(store.outbox) + or ledger is not store.ledger or self.worker.cipher is not self.status.worker.cipher or self.worker.subscriptions is not self.status.worker.subscriptions or self.worker.signing is not self.status.worker.signing ): raise ReportingNotificationError("activity_chain_unready") - return await self._schema_proven(store.ledger._pool, composite=True) + # Exact queue, union-reader and storage identity checks are the ledger's. + ledger.validate_composite_activity_chain( + outbox=self.worker.outbox, status_store=store, reader=reader + ) + return await self._schema_proven(ledger, composite=True) async def capability_flags( self, *, account_activity: ReportingActivityProjector | None = None diff --git a/src/adcp/reporting/production/__init__.py b/src/adcp/reporting/production/__init__.py index 6c4d0fa2f..b913355b5 100644 --- a/src/adcp/reporting/production/__init__.py +++ b/src/adcp/reporting/production/__init__.py @@ -2,11 +2,11 @@ Mount one support's handler, start its owned lifecycle, then activate accounts after migration and drain. The memory implementation is for conformance and -does not claim durability. PostgreSQL uses the shared optional driver guard. +does not claim durability. The PostgreSQL production store is +``adcp_reporting_storage.production.pg``; both implement +:class:`~adcp.reporting.production.store.ReportingProductionStore`. """ -from typing import TYPE_CHECKING, Any - from adcp.reporting.production.configuration import ( ConfigurationAdmission, ConfigurationTask, @@ -34,17 +34,16 @@ ReportingProductionSupport, ) from adcp.reporting.production.source_registry import ReportingProductionSourceRegistry - -if TYPE_CHECKING: - from adcp.reporting.production.pg import PgReportingProductionOutbox, PgReportingProductionStore +from adcp.reporting.production.store import ( + ReportingProductionProjection, + ReportingProductionStore, +) __all__ = [ "ConfigurationAdmission", "ConfigurationTask", "InMemoryReportingProductionOutbox", "InMemoryReportingProductionStore", - "PgReportingProductionOutbox", - "PgReportingProductionStore", "ReportingConfigurationAdmission", "ReportingProductionConfigurationTask", "ReportingProductionDestination", @@ -52,18 +51,12 @@ "ReportingProductionHandler", "ReportingProductionMethod", "ReportingProductionOffering", + "ReportingProductionProjection", "ReportingProductionSigning", "ReportingProductionSource", "ReportingProductionSourceBinding", "ReportingProductionSourceRegistry", + "ReportingProductionStore", "ReportingProductionSupport", "production_notification_workers", ] - - -def __getattr__(name: str) -> Any: - if name in {"PgReportingProductionOutbox", "PgReportingProductionStore"}: - from adcp.reporting.production import pg - - return getattr(pg, name) - raise AttributeError(name) diff --git a/src/adcp/reporting/production/delivery_window.py b/src/adcp/reporting/production/delivery_window.py index 244e0cd1e..f52760c56 100644 --- a/src/adcp/reporting/production/delivery_window.py +++ b/src/adcp/reporting/production/delivery_window.py @@ -3,67 +3,53 @@ from __future__ import annotations from dataclasses import dataclass -from datetime import datetime, timedelta -from typing import TYPE_CHECKING +from datetime import datetime from adcp.reporting.evidence import aware_utc from adcp.reporting.ledger.notification_models import ReportingNotificationError from adcp.reporting.outbox.activity import ActivityRequest, ReportingActivityStore, WebhookAttempt -from adcp.reporting.outbox.memory import InMemoryReportingOutbox from adcp.reporting.outbox.models import DeliveryLease -from adcp.reporting.production.memory import InMemoryReportingProductionStore - -if TYPE_CHECKING: - from adcp.reporting.production.pg import PgReportingProductionStore +from adcp.reporting.production.store import ReportingProductionStore, SavedDeliveryWindow RETRY_HORIZON_SECONDS = 86400 +def inspect_window( + saved: SavedDeliveryWindow | None, + expected: tuple[str, str], + moment: datetime, +) -> tuple[datetime | None, bool]: + """The recorded deadline and whether ``moment`` has passed it. + + A window recorded for another queue or body, or one that starts after + ``moment``, is a retry contract violation rather than an expiry. + """ + if saved is None: + return None, False + if saved[:2] != expected or moment < saved[2]: + raise ReportingNotificationError("notification_retry_unready") + return saved[3], moment >= saved[3] + + @dataclass(frozen=True) class ProductionDeliveryWindow: - store: InMemoryReportingProductionStore | PgReportingProductionStore + """One queue's window over the production store that records it. + + The store runs the reservation transaction (its lock, its clock, its + activity participant); this object owns the queue identity and the policy. + """ + + store: ReportingProductionStore queue: str - @staticmethod - def _inspect( - saved: tuple[str, str, datetime, datetime] | None, - expected: tuple[str, str], - moment: datetime, - ) -> tuple[datetime | None, bool]: - if saved is None: - return None, False - if saved[:2] != expected or moment < saved[2]: - raise ReportingNotificationError("notification_retry_unready") - return saved[3], moment >= saved[3] + _inspect = staticmethod(inspect_window) async def inspect(self, lease: DeliveryLease, *, now: datetime) -> tuple[datetime | None, bool]: binding = lease.delivery.binding key = (binding.account_id, binding.idempotency_key) expected = (self.queue, binding.body_sha256) - if isinstance(self.store, InMemoryReportingProductionStore): - async with self.store._lock: - return self._inspect( - self.store._production_delivery_windows.get(key), expected, aware_utc(now) - ) - from adcp.reporting.outbox.pg import database_now - - try: - async with self.store._connection() as connection, connection.transaction(): - saved = await ( - await connection.execute( - "SELECT queue,body_sha256,started_at,expires_at" - " FROM reporting_production_delivery_windows" - " WHERE account_id=%s AND idempotency_key=%s", - key, - ) - ).fetchone() - return self._inspect( - saved, expected, await database_now(connection, self.store._clock) - ) - except ReportingNotificationError: - raise - except Exception: - raise ReportingNotificationError("notification_retry_unready") from None + saved, moment = await self.store.read_delivery_window(key, now=aware_utc(now)) + return inspect_window(saved, expected, moment) async def reserve_attempt( self, @@ -80,81 +66,14 @@ async def reserve_attempt( attempt together. Unknown commit outcomes leave the original identity. """ binding = lease.delivery.binding - key = (binding.account_id, binding.idempotency_key) - expected = (self.queue, binding.body_sha256) if self.queue not in {"core", "status", "ready"}: raise ReportingNotificationError("notification_retry_unready") - if isinstance(self.store, InMemoryReportingProductionStore): - if ( - not isinstance(activity, InMemoryReportingOutbox) - or activity._store is not self.store - ): - raise ReportingNotificationError("notification_retry_unready") - async with self.store._mutation(): - at = aware_utc(now) - saved = self.store._production_delivery_windows.get(key) - deadline, expired = self._inspect(saved, expected, at) - if expired: - return None, deadline, True - attempt = activity._reserve_attempt_locked(lease, request=request, now=at) - if attempt is None: - return None, deadline, False - retained = self.store._production_delivery_windows.setdefault( - key, - ( - *expected, - attempt.fired_at, - attempt.fired_at + timedelta(seconds=RETRY_HORIZON_SECONDS), - ), - ) - return attempt, retained[3], False - - from adcp.reporting.outbox.pg import PgReportingOutbox, database_now - - if not isinstance(activity, PgReportingOutbox) or activity._pool is not self.store._pool: - raise ReportingNotificationError("notification_retry_unready") - try: - # The activity participant supplies its exact queue adapter and - # connection. Take the inherited account lock before its row lock. - async with activity._activity_transaction() as connection: - await self.store._lock_account(connection, binding.account_id) - moment = await database_now(connection, self.store._clock) - saved = await ( - await connection.execute( - "SELECT queue,body_sha256,started_at,expires_at" - " FROM reporting_production_delivery_windows" - " WHERE account_id=%s AND idempotency_key=%s", - key, - ) - ).fetchone() - deadline, expired = self._inspect(saved, expected, moment) - if expired: - return None, deadline, True - attempt = await activity._reserve_attempt_on(connection, lease, request=request) - if attempt is None: - return None, deadline, False - deadline, expired = self._inspect(saved, expected, attempt.fired_at) - if expired: - # Time can cross the deadline while reserving an ordinal. - # Roll back that provisional head and attempt, too. - raise ReportingNotificationError("notification_retry_expired") - await connection.execute( - "INSERT INTO reporting_production_delivery_windows" - " (account_id,idempotency_key,queue,body_sha256,started_at,expires_at)" - " VALUES(%s,%s,%s,%s,%s,%s) ON CONFLICT DO NOTHING", - ( - *key, - *expected, - attempt.fired_at, - attempt.fired_at + timedelta(seconds=RETRY_HORIZON_SECONDS), - ), - ) - return ( - attempt, - deadline or attempt.fired_at + timedelta(seconds=RETRY_HORIZON_SECONDS), - False, - ) - except ReportingNotificationError as error: - if error.code == "notification_retry_expired": - return None, deadline, True - raise + return await self.store.reserve_delivery_attempt( + activity, + lease, + request=request, + now=aware_utc(now), + queue=self.queue, + expected=(self.queue, binding.body_sha256), + horizon_seconds=RETRY_HORIZON_SECONDS, + ) diff --git a/src/adcp/reporting/production/memory.py b/src/adcp/reporting/production/memory.py index 7ac0770e2..cd8898482 100644 --- a/src/adcp/reporting/production/memory.py +++ b/src/adcp/reporting/production/memory.py @@ -4,8 +4,9 @@ import json import weakref +from collections.abc import Sequence from dataclasses import dataclass -from datetime import datetime +from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any from adcp.reporting.canonical_json import canonical_json_utf8_v1 @@ -17,6 +18,7 @@ from adcp.reporting.ledger.models import ( ReportingConfiguration, ReportingConfigurationGenerationKey, + ReportingDeliveryEscalation, ReportingObligationRecord, ) from adcp.reporting.ledger.notification_models import ( @@ -28,10 +30,21 @@ from adcp.reporting.materializer.capture import ReportingMaterializerBoundary from adcp.reporting.materializer.contracts import ReportingVerificationKey, failure from adcp.reporting.materializer.work import MaterializerContext, ReportingMaterializerLease +from adcp.reporting.outbox.activity import ActivityRequest, ReportingActivityStore, WebhookAttempt from adcp.reporting.outbox.memory import InMemoryReportingOutbox, NotificationState +from adcp.reporting.outbox.models import DeliveryLease from adcp.reporting.production.contracts import ReportingProductionSourceBinding from adcp.reporting.production.source_registry import ReportingProductionSourceContext -from adcp.reporting.projection.memory import InMemoryReportingProjectionStore +from adcp.reporting.production.store import ( + MaintenanceOperation, + ReportingProductionProjection, + SavedDeliveryWindow, +) +from adcp.reporting.projection.memory import ( + InMemoryReportingProjectionOutbox, + InMemoryReportingProjectionStore, + InMemoryReportingStatusProjection, +) from adcp.reporting.source import ReportingConstituent if TYPE_CHECKING: @@ -72,7 +85,7 @@ class InMemoryReportingProductionStore(InMemoryReportingProjectionStore): """Matches production atomicity and epochs, without claiming durable storage.""" _materializer_writer_epoch = 2 - _production_support: weakref.ReferenceType[ReportingProductionSupport] | None = None + production_support: weakref.ReferenceType[ReportingProductionSupport] | None = None def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) @@ -101,6 +114,117 @@ def _owner(self) -> ReportingProductionSupport: return production_owner(self) + # -- the production store contract (adcp.reporting.production.store) ------ + + @property + def durable(self) -> bool: + return False + + @property + def storage_identity(self) -> object: + return self + + @property + def storage_open(self) -> bool: + return True + + def is_sdk_production_store(self) -> bool: + return type(self) is InMemoryReportingProductionStore + + def status_projection( + self, + *, + consumer_status_enabled: bool, + escalation: ReportingDeliveryEscalation | None, + revision_ownership: bool, + ) -> InMemoryReportingStatusProjection: + return InMemoryReportingStatusProjection( + self, + consumer_status_enabled=consumer_status_enabled, + escalation=escalation, + revision_ownership=revision_ownership, + ) + + def is_sdk_status_projection(self, projection: object) -> bool: + return type(projection) is InMemoryReportingStatusProjection + + def projection_linked(self, projection: ReportingProductionProjection) -> bool: + return ( + type(projection.outbox) is InMemoryReportingProjectionOutbox + and projection.outbox._store is self + ) + + def production_outboxes( + self, projection: ReportingProductionProjection + ) -> tuple[Any, Any, Any]: + if type(self) is not InMemoryReportingProductionStore: + raise ReportingNotificationError("notification_chain_unready") + return ( + InMemoryReportingOutbox(self), + projection.outbox, + InMemoryReportingProductionOutbox(self), + ) + + def notification_workers_linked(self, workers: Sequence[Any]) -> bool: + expected = ( + InMemoryReportingOutbox, + InMemoryReportingProjectionOutbox, + InMemoryReportingProductionOutbox, + ) + return ( + all(getattr(w.outbox, "_store", None) is self for w in workers) + and tuple(type(w.outbox) for w in workers) == expected + ) + + async def validate_production_schema( + self, *, notifications: bool, service_context: bool + ) -> None: + """Memory has no catalog; it never advertises durability either.""" + + async def read_delivery_window( + self, key: tuple[str, str], *, now: datetime + ) -> tuple[SavedDeliveryWindow | None, datetime]: + async with self._lock: + return self._production_delivery_windows.get(key), now + + async def reserve_delivery_attempt( + self, + activity: ReportingActivityStore, + lease: DeliveryLease, + *, + request: ActivityRequest, + now: datetime, + queue: str, + expected: tuple[str, str], + horizon_seconds: int, + ) -> tuple[WebhookAttempt | None, datetime | None, bool]: + from adcp.reporting.production.delivery_window import inspect_window + + binding = lease.delivery.binding + key = (binding.account_id, binding.idempotency_key) + if not isinstance(activity, InMemoryReportingOutbox) or activity._store is not self: + raise ReportingNotificationError("notification_retry_unready") + async with self._mutation(): + saved = self._production_delivery_windows.get(key) + deadline, expired = inspect_window(saved, expected, now) + if expired: + return None, deadline, True + attempt = activity._reserve_attempt_locked(lease, request=request, now=now) + if attempt is None: + return None, deadline, False + retained = self._production_delivery_windows.setdefault( + key, + ( + *expected, + attempt.fired_at, + attempt.fired_at + timedelta(seconds=horizon_seconds), + ), + ) + return attempt, retained[3], False + + def maintenance_operations(self) -> tuple[MaintenanceOperation, ...]: + return () + async def admit_production_configuration( self, configuration: ReportingConfiguration, @@ -272,7 +396,7 @@ async def finish_producer_acquisition( work.obligation, work.turn, acquisition_state(work.obligation, revisions) ) - async def _activate_production(self, *, account_id: str) -> bool: + async def activate_production(self, *, account_id: str) -> bool: owner = self._owner() async with self._mutation(): projection = self._projection_accounts.get(account_id) diff --git a/src/adcp/reporting/production/notifications.py b/src/adcp/reporting/production/notifications.py index 1b2224cb3..768cd4a1e 100644 --- a/src/adcp/reporting/production/notifications.py +++ b/src/adcp/reporting/production/notifications.py @@ -3,12 +3,12 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from datetime import datetime +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable import httpx from adcp.reporting.ledger.notification_models import ReportingNotificationError -from adcp.reporting.outbox.memory import InMemoryReportingOutbox from adcp.reporting.outbox.routing import ( ReportingEnvelopeCipher, ReportingNotificationSubscription, @@ -21,17 +21,14 @@ RETRY_HORIZON_SECONDS, ProductionDeliveryWindow, ) -from adcp.reporting.production.memory import ( - InMemoryReportingProductionOutbox, - InMemoryReportingProductionStore, +from adcp.reporting.production.store import ( + ReportingProductionProjection, + ReportingProductionStore, ) -from adcp.reporting.projection.memory import InMemoryReportingStatusProjection from adcp.signing.crypto import ALLOWED_ALGS if TYPE_CHECKING: - from adcp.reporting.production.pg import PgReportingProductionStore from adcp.reporting.production.service import ReportingProductionSupport - from adcp.reporting.projection.pg import PgReportingStatusProjection @dataclass(frozen=True) @@ -126,8 +123,8 @@ async def get_active( def production_notification_workers( - store: InMemoryReportingProductionStore | PgReportingProductionStore, - projection: InMemoryReportingStatusProjection | PgReportingStatusProjection, + store: ReportingProductionStore, + projection: ReportingProductionProjection, *, subscriptions: ReportingSubscriptionResolver, cipher: ReportingEnvelopeCipher, @@ -139,32 +136,17 @@ def production_notification_workers( and enabled atomic logical enqueue available, without advertising push. Retained epoch-zero readiness queues are never among these participants. """ - from adcp.reporting.outbox.pg import PgReportingOutbox - from adcp.reporting.production.pg import PgReportingProductionOutbox, PgReportingProductionStore - if ( - projection.ledger is not store + not isinstance(store, ReportingProductionStore) + or projection.ledger is not store or not projection.policy["notifications_enabled"] or type(signing) is not ReportingProductionSigning ): raise ReportingNotificationError("notification_chain_unready") ReportingProductionSigning.__post_init__(signing) signed_subscriptions = _SignedSubscriptions(subscriptions) - outboxes: tuple[Any, ...] - if type(store) is InMemoryReportingProductionStore: - outboxes = ( - InMemoryReportingOutbox(store), - projection.outbox, - InMemoryReportingProductionOutbox(store), - ) - elif type(store) is PgReportingProductionStore: - outboxes = ( - PgReportingOutbox(pool=store._pool, clock=store._clock), - projection.outbox, - PgReportingProductionOutbox(pool=store._pool, clock=store._clock), - ) - else: - raise ReportingNotificationError("notification_chain_unready") + # The store builds its own exact queue classes; a subclass is refused there. + outboxes = store.production_outboxes(projection) return tuple( ReportingNotificationWorker( outbox=outbox, @@ -172,7 +154,7 @@ def production_notification_workers( cipher=cipher, signing=signing, activity=outbox, - clock=store._clock, + clock=store.clock, delivery_window=ProductionDeliveryWindow(store, queue), ) for outbox, queue in zip(outboxes, ("core", "status", "ready")) @@ -222,24 +204,7 @@ def check_workers(support: ReportingProductionSupport) -> None: or tuple(worker_identity(w) for w in workers) != support._notification_identity ): raise ReportingNotificationError("notification_chain_unready") - expected: tuple[type[Any], ...] - if isinstance(support.store, InMemoryReportingProductionStore): - from adcp.reporting.projection.memory import InMemoryReportingProjectionOutbox - - expected = ( - InMemoryReportingOutbox, - InMemoryReportingProjectionOutbox, - InMemoryReportingProductionOutbox, - ) - linked = all(getattr(w.outbox, "_store", None) is support.store for w in workers) - else: - from adcp.reporting.outbox.pg import PgReportingOutbox - from adcp.reporting.production.pg import PgReportingProductionOutbox - from adcp.reporting.projection.notifications import PgReportingProjectionOutbox - - expected = (PgReportingOutbox, PgReportingProjectionOutbox, PgReportingProductionOutbox) - linked = all(getattr(w.outbox, "_pool", None) is support.store._pool for w in workers) - if not linked or tuple(type(w.outbox) for w in workers) != expected: + if not support.store.notification_workers_linked(workers): raise ReportingNotificationError("notification_chain_unready") for worker, queue in zip(workers, ("core", "status", "ready")): window = worker.delivery_window @@ -319,38 +284,17 @@ async def check_account_notifications(support: ReportingProductionSupport, accou check_workers(support) +@runtime_checkable +class _DueAccountQueue(Protocol): + async def next_due_account(self, *, delivery: bool, now: datetime) -> str | None: ... + + async def next_account(worker: ReportingNotificationWorker, *, delivery: bool) -> str | None: """Sample one indexed due queue row; never enumerate adopter accounts.""" - from adcp.reporting.outbox.pg import PgReportingOutbox, database_now - outbox = worker.outbox - if isinstance(outbox, InMemoryReportingOutbox): - now = worker._clock() - async with outbox._store._lock: - pending = ( - ((key[0], work) for key, (_, work) in outbox._state.deliveries.items()) - if delivery - else ((key[0], work) for key, work in outbox._state.expansions.items()) - ) - values = [(work.due_at, account) for account, work in pending if work.available(now)] - return min(values)[1] if values else None - if not isinstance(outbox, PgReportingOutbox): + if not isinstance(outbox, _DueAccountQueue): raise ReportingNotificationError("notification_chain_unready") - # Both identifiers are closed SDK literals. The concrete queue adapter - # selects the matching isolated table on this same actual connection. - table = "reporting_notification_deliveries" if delivery else "reporting_notification_expansions" - async with outbox._connection() as connection: - now = await database_now(connection, outbox._clock) - row = await ( - await connection.execute( - f"SELECT account_id FROM {table}" # nosec B608 - " WHERE due_at<=%s AND (state='pending' OR" - " (state='leased' AND lease_expires_at<=%s))" - " ORDER BY due_at,account_id LIMIT 1", - (now, now), - ) - ).fetchone() - return str(row[0]) if row is not None else None + return await outbox.next_due_account(delivery=delivery, now=worker._clock()) async def notification_turn(support: ReportingProductionSupport) -> None: diff --git a/src/adcp/reporting/production/service.py b/src/adcp/reporting/production/service.py index fbbbf61d6..d3600c997 100644 --- a/src/adcp/reporting/production/service.py +++ b/src/adcp/reporting/production/service.py @@ -39,21 +39,21 @@ ReportingProductionMethod, ReportingProductionSourceBinding, ) -from adcp.reporting.production.memory import InMemoryReportingProductionStore from adcp.reporting.production.offerings import ReportingProductionOffering from adcp.reporting.production.source_registry import ( ReportingProductionSourceContext, ReportingProductionSourceRegistry, ) -from adcp.reporting.projection.memory import InMemoryReportingStatusProjection +from adcp.reporting.production.store import ( + ReportingProductionProjection, + ReportingProductionStore, +) from adcp.reporting.receipts.handler import ReceiptAccountResolver if TYPE_CHECKING: from adcp.decisioning.registry import BuyerAgentRegistry from adcp.reporting.ledger.producer import ReportingProducer from adcp.reporting.outbox.worker import ReportingNotificationWorker - from adcp.reporting.production.pg import PgReportingProductionStore - from adcp.reporting.projection.pg import PgReportingStatusProjection from adcp.reporting.service_lifecycle import _ServiceLifecycle from adcp.server.base import ADCPHandler @@ -98,10 +98,8 @@ def _method_ready( ) -def production_owner( - store: InMemoryReportingProductionStore | PgReportingProductionStore, -) -> ReportingProductionSupport: - reference = store._production_support +def production_owner(store: ReportingProductionStore) -> ReportingProductionSupport: + reference = store.production_support owner = reference() if reference is not None else None if owner is None or owner.store is not store: raise ReportingNotificationError("reporting_production_component_unready") @@ -161,7 +159,7 @@ class ReportingProductionSupport: def __init__( self, materializer: ReportingMaterializerService, - projection: InMemoryReportingStatusProjection | PgReportingStatusProjection, + projection: ReportingProductionProjection, *, offerings: tuple[ReportingProductionOffering, ...], configuration_task: ReportingProductionConfigurationTask, @@ -177,16 +175,14 @@ def __init__( ) -> None: from adcp.reporting.outbox.worker import ReportingNotificationWorker from adcp.reporting.production.handler import ReportingProductionHandler - from adcp.reporting.production.pg import PgReportingProductionStore - from adcp.reporting.projection.pg import PgReportingStatusProjection + store = materializer.store if ( type(materializer) is not ReportingMaterializerService - or type(materializer.store) - not in {InMemoryReportingProductionStore, PgReportingProductionStore} - or type(projection) - not in {InMemoryReportingStatusProjection, PgReportingStatusProjection} - or projection.ledger is not materializer.store + or not isinstance(store, ReportingProductionStore) + or not store.is_sdk_production_store() + or not store.is_sdk_status_projection(projection) + or projection.ledger is not store or type(configuration_task) is not ReportingProductionConfigurationTask or type(offerings) is not tuple or not offerings @@ -203,11 +199,9 @@ def __init__( raise ValueError( "production support requires exact SDK components and bounded promises" ) - assert isinstance( - materializer.store, (InMemoryReportingProductionStore, PgReportingProductionStore) - ) - self.store = materializer.store - previous = self.store._production_support + assert isinstance(store, ReportingProductionStore) + self.store: ReportingProductionStore = store + previous = self.store.production_support if previous is not None and previous() is not None: raise ReportingNotificationError("reporting_production_owner_conflict") self.materializer, self.projection = materializer, projection @@ -253,7 +247,7 @@ def __init__( self._protocol_version = self.handler.get_adcp_version() self._components = self._component_ids() self._methods = self._handler_methods() - self.store._production_support = weakref.ref(self) + self.store.production_support = weakref.ref(self) self._assert_components(running=False, mounted=False) from adcp.reporting.production._declaration import declared_reporting_delivery from adcp.reporting.production.notifications import signing_capabilities, signing_identity @@ -264,7 +258,7 @@ def __init__( "reporting_delivery": declared_reporting_delivery( offerings=[o.wire() for o in self.offerings], destination=writer, - durable=not isinstance(self.store, InMemoryReportingProductionStore), + durable=self.store.durable, escalation=self.projection.escalation or ReportingDeliveryEscalation(), consumer_status_enabled=self.projection.consumer_status_enabled, automated_recovery_window=self.automated_recovery_window, @@ -301,7 +295,7 @@ def _component_ids(self) -> tuple[int, ...]: id(v) for v in ( self.store, - getattr(self.store, "_pool", None), + self.store.storage_identity, self.materializer, self.materializer.io, self.materializer.io.registry, @@ -366,18 +360,14 @@ def _assert_components(self, *, running: bool = True, mounted: bool = True) -> N or self._methods != self._handler_methods() or type(self.handler._adcp_version) is not str or self.handler._adcp_version != self._protocol_version - or ( - (running or mounted) - and not isinstance(self.store, InMemoryReportingProductionStore) - and self.store._pool.closed is not False - ) + or ((running or mounted) and not self.store.storage_open) or self.materializer.store is not self.store or self.projection.ledger is not self.store - or not self._projection_outbox_linked() + or not self.store.projection_linked(self.projection) or self.handler.receipt_store is not self.store or self.handler.reporting_feed_store is not self.store or self.handler._feed_consumer_status_enabled != self.projection.consumer_status_enabled - or self.store._projection_read_policy != self.projection.policy + or self.store.projection_read_policy != self.projection.policy or type(self.materializer.io) is not ReportingDestinationIO or isinstance(writer, ReferenceReportingDestinationWriter) or isinstance(self.materializer.io.resolver, ReferenceReportingResolver) @@ -392,24 +382,6 @@ def _assert_components(self, *, running: bool = True, mounted: bool = True) -> N ): raise ReportingNotificationError("reporting_production_component_unready") - def _projection_outbox_linked(self) -> bool: - # The projection queue participates even when optional HTTP delivery - # workers are absent. Its connection ownership cannot ride a cached - # catalog proof belonging to a different pool or in-memory ledger. - if isinstance(self.store, InMemoryReportingProductionStore): - from adcp.reporting.projection.memory import InMemoryReportingProjectionOutbox - - return ( - type(self.projection.outbox) is InMemoryReportingProjectionOutbox - and self.projection.outbox._store is self.store - ) - from adcp.reporting.projection.notifications import PgReportingProjectionOutbox - - return ( - type(self.projection.outbox) is PgReportingProjectionOutbox - and self.projection.outbox._pool is self.store._pool - ) - def _offering_ready(self, offering: ReportingProductionOffering) -> bool: try: if ( @@ -617,14 +589,11 @@ def invalidate_schema_validation(self) -> None: self._schema_task = None async def _schema_ready(self) -> bool: - from adcp.reporting.production.pg import PgReportingProductionStore - from adcp.reporting.production.schema import validate_production_schema - - if not isinstance(self.store, PgReportingProductionStore): - return False store = self.store - pool, epoch = store._pool, self._schema_epoch - identity = (id(pool), epoch) + if not store.durable: + return False + storage, epoch = store.storage_identity, self._schema_epoch + identity = (id(storage), epoch) if self._schema_positive == identity: return True task = self._schema_task @@ -632,13 +601,11 @@ async def _schema_ready(self) -> bool: async def scan() -> bool: try: - async with pool.connection() as connection: - await validate_production_schema( - connection, - notifications=self.notifications_enabled, - service_context=self.source_registry is not None, - ) - if epoch == self._schema_epoch and pool is store._pool: + await store.validate_production_schema( + notifications=self.notifications_enabled, + service_context=self.source_registry is not None, + ) + if epoch == self._schema_epoch and storage is store.storage_identity: self._schema_positive = identity return True return False @@ -658,10 +625,7 @@ async def start(self) -> None: self._assert_components() return self._assert_components(running=False) - if ( - not isinstance(self.store, InMemoryReportingProductionStore) - and not await self._schema_ready() - ): + if self.store.durable and not await self._schema_ready(): raise ReportingNotificationError("reporting_production_schema_unready") self._assert_components(running=False) self._task = asyncio.create_task(self._run(), name="adcp-reporting-production") @@ -731,20 +695,7 @@ async def _maintain(self) -> None: _maintenance_stalled, ) - store: Any = self.store - operations: list[tuple[str, Any]] = [] - if getattr(store, "_row_storage", None) is not None: - operations.append(("intent_sweep", lambda: store.sweep_row_write_intents(limit=100))) - # Frees row bytes of periods past retention even while their records stay (protected - # by append-only guards), so storage is reclaimed whether or not records can be. - operations.append(("row_pruning", lambda: store.prune_expired_row_sets(limit=20))) - if getattr(store, "_row_storage", None) is not None or getattr( - store, "_change_feed", False - ): - operations.append(("period_retirement", lambda: store.retire_expired_periods(limit=20))) - if getattr(store, "_change_feed", False): - operations.append(("change_pruning", lambda: store.prune_changes())) - for name, run in operations: + for name, run in self.store.maintenance_operations(): try: result = await run() except asyncio.CancelledError: @@ -810,7 +761,7 @@ async def _check_notifications(self, account_id: str) -> None: async def activate(self, *, account_id: str) -> bool: await self._check_notifications(account_id) await self.projection.activate(account_id=account_id) - return await self.store._activate_production(account_id=account_id) + return await self.store.activate_production(account_id=account_id) async def aclose(self) -> None: self._stop.set() @@ -827,8 +778,8 @@ async def aclose(self) -> None: proof = self._schema_task if proof is not None: await asyncio.gather(proof, return_exceptions=True) - if self.store._production_support is not None and self.store._production_support() is self: - self.store._production_support = None + if self.store.production_support is not None and self.store.production_support() is self: + self.store.production_support = None async def reporting_delivery(self, *, extra: Mapping[str, Any] | None = None) -> dict[str, Any]: # Validate protected extra even when the concrete surface is unready. diff --git a/src/adcp/reporting/production/store.py b/src/adcp/reporting/production/store.py new file mode 100644 index 000000000..398d40873 --- /dev/null +++ b/src/adcp/reporting/production/store.py @@ -0,0 +1,182 @@ +"""The store contract the production composition depends on. + +:class:`~adcp.reporting.production.service.ReportingProductionSupport` composes +a materializer, a status projection and notification workers over one store. +It used to recognize the two shipped stores by class. The in-memory store now +lives in ``adcp`` and the PostgreSQL store in ``adcp-reporting-storage``, so +the composition asks the store instead: which projection and queues belong to +it, whether its schema is installed, what maintenance it needs. Both shipped +stores keep the exact-class checks inside their own answers, so an adopter +subclass is still refused where it was refused before. +""" + +from __future__ import annotations + +import weakref +from collections.abc import Awaitable, Callable, Sequence +from datetime import datetime +from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable + +from adcp.reporting.feed.store import ReportingFeedStore +from adcp.reporting.ledger.delivery import ReportingReconciliationStore +from adcp.reporting.ledger.store import ReportingLedgerStore +from adcp.reporting.materializer.work import ReportingMaterializerStore +from adcp.reporting.receipts.store import ReportingReceiptBatchStore + +if TYPE_CHECKING: + from adcp.reporting.ledger.delivery_models import ReportingDestinationBinding + from adcp.reporting.ledger.models import ReportingConfiguration, ReportingDeliveryEscalation + from adcp.reporting.outbox.activity import ( + ActivityRequest, + ReportingActivityStore, + WebhookAttempt, + ) + from adcp.reporting.outbox.models import DeliveryLease + from adcp.reporting.outbox.status import StatusTurn + from adcp.reporting.outbox.worker import ReportingNotificationWorker + from adcp.reporting.production.service import ReportingProductionSupport + from adcp.reporting.production.source_registry import ReportingProductionSourceContext + +__all__ = [ + "MaintenanceOperation", + "ReportingProductionProjection", + "ReportingProductionStore", + "SavedDeliveryWindow", +] + +#: ``(queue, body_sha256, started_at, expires_at)`` of a recorded first attempt. +SavedDeliveryWindow = tuple[str, str, datetime, datetime] +#: A named, bounded, resumable retention pass the production worker schedules. +MaintenanceOperation = tuple[str, Callable[[], Awaitable[Any]]] + + +@runtime_checkable +class ReportingProductionProjection(Protocol): + """The v2 status projection a production store builds over itself.""" + + ledger: Any + outbox: Any + escalation: ReportingDeliveryEscalation | None + consumer_status_enabled: bool + + @property + def policy(self) -> dict[str, Any]: ... + + async def rebuild_one(self) -> StatusTurn: ... + + async def sweep_one(self) -> StatusTurn: ... + + async def activate(self, *, account_id: str) -> bool: ... + + +@runtime_checkable +class ReportingProductionStore( + ReportingLedgerStore, + ReportingReconciliationStore, + ReportingMaterializerStore, + ReportingReceiptBatchStore, + ReportingFeedStore, + Protocol, +): + """A ledger that can carry the production composition. + + It is a Core ledger, a reconciliation store, a materializer store, a + receipt-batch store and a frozen feed store at once, plus the composition + hooks below. + """ + + #: Set by :class:`ReportingProductionSupport` when it takes ownership of the store. + production_support: weakref.ReferenceType[ReportingProductionSupport] | None + #: Set by the status projection; the composition checks it matches its policy. + projection_read_policy: dict[str, Any] + + @property + def durable(self) -> bool: + """Whether storage survives the process. Only durable stores advertise production.""" + ... + + @property + def clock(self) -> Callable[[], datetime] | None: + """The explicit clock override, or ``None`` for the store's own time source.""" + ... + + @property + def storage_identity(self) -> object: + """The resource shared by every participant (a pool, or the store itself).""" + ... + + @property + def storage_open(self) -> bool: + """Whether that resource still accepts work.""" + ... + + def is_sdk_production_store(self) -> bool: + """Exactly a shipped production store, not a subclass.""" + ... + + def status_projection( + self, + *, + consumer_status_enabled: bool, + escalation: ReportingDeliveryEscalation | None, + revision_ownership: bool, + ) -> ReportingProductionProjection: ... + + def is_sdk_status_projection(self, projection: object) -> bool: + """Exactly the shipped projection class for this store.""" + ... + + def projection_linked(self, projection: ReportingProductionProjection) -> bool: + """Whether the projection's queue shares this store's storage.""" + ... + + def production_outboxes( + self, projection: ReportingProductionProjection + ) -> tuple[Any, Any, Any]: + """The core, status and readiness queues, in that order.""" + ... + + def notification_workers_linked(self, workers: Sequence[ReportingNotificationWorker]) -> bool: + """Whether every worker drives this store's exact queue classes over its storage.""" + ... + + async def validate_production_schema( + self, *, notifications: bool, service_context: bool + ) -> None: + """Raise ``reporting_production_schema_unready`` unless the catalog is installed.""" + ... + + async def read_delivery_window( + self, key: tuple[str, str], *, now: datetime + ) -> tuple[SavedDeliveryWindow | None, datetime]: + """The recorded window for ``(account_id, idempotency_key)`` and the store's instant.""" + ... + + async def reserve_delivery_attempt( + self, + activity: ReportingActivityStore, + lease: DeliveryLease, + *, + request: ActivityRequest, + now: datetime, + queue: str, + expected: tuple[str, str], + horizon_seconds: int, + ) -> tuple[WebhookAttempt | None, datetime | None, bool]: + """Reserve one HTTP attempt and its immutable first-attempt deadline atomically.""" + ... + + def maintenance_operations(self) -> tuple[MaintenanceOperation, ...]: + """Retention passes for the features this store opted into, in run order.""" + ... + + async def activate_production(self, *, account_id: str) -> bool: ... + + async def admit_production_configuration( + self, + configuration: ReportingConfiguration, + binding: ReportingDestinationBinding, + *, + offering_id: str, + service_context: ReportingProductionSourceContext | None = None, + ) -> None: ... diff --git a/src/adcp/reporting/projection/__init__.py b/src/adcp/reporting/projection/__init__.py index 22823c9b0..6cbdd1fa1 100644 --- a/src/adcp/reporting/projection/__init__.py +++ b/src/adcp/reporting/projection/__init__.py @@ -1,6 +1,8 @@ -"""Versioned private reporting projection, independent of optional delivery workers.""" +"""Versioned private reporting projection, independent of optional delivery workers. -from typing import TYPE_CHECKING, Any +The PostgreSQL projection store and status projection are in +``adcp_reporting_storage.projection``. +""" from adcp.reporting.projection.capture import ReportingProjectionInput from adcp.reporting.projection.memory import ( @@ -9,28 +11,9 @@ InMemoryReportingStatusProjection, ) -if TYPE_CHECKING: - from adcp.reporting.projection.notifications import PgReportingProjectionOutbox - from adcp.reporting.projection.pg import PgReportingProjectionStore, PgReportingStatusProjection - __all__ = [ "InMemoryReportingProjectionOutbox", "InMemoryReportingProjectionStore", "InMemoryReportingStatusProjection", - "PgReportingProjectionOutbox", - "PgReportingProjectionStore", - "PgReportingStatusProjection", "ReportingProjectionInput", ] - - -def __getattr__(name: str) -> Any: - if name in {"PgReportingProjectionStore", "PgReportingStatusProjection"}: - from adcp.reporting.projection import pg - - return getattr(pg, name) - if name == "PgReportingProjectionOutbox": - from adcp.reporting.projection.notifications import PgReportingProjectionOutbox - - return PgReportingProjectionOutbox - raise AttributeError(name) diff --git a/src/adcp/reporting/projection/memory.py b/src/adcp/reporting/projection/memory.py index 258b04734..4146ac3d5 100644 --- a/src/adcp/reporting/projection/memory.py +++ b/src/adcp/reporting/projection/memory.py @@ -78,8 +78,10 @@ def _state(self) -> NotificationState: class InMemoryReportingProjectionStore(InMemoryReportingFeedStore): """Reference semantics, never a production durability claim.""" - _projection_read_policy: dict[str, Any] - _projection_read_escalation: ReportingDeliveryEscalation | None + # Replaced (never mutated) by the status projection that reads this store; + # present from construction so the production store protocol matches. + projection_read_policy: dict[str, Any] = {} + projection_read_escalation: ReportingDeliveryEscalation | None = None def __init__(self, **kwargs: Any) -> None: super().__init__(**kwargs) @@ -170,8 +172,8 @@ def __init__( if ledger._status_notification_state is None: ledger._status_notification_state = _StatusMemoryState() self.outbox = InMemoryReportingProjectionOutbox(ledger) - ledger._projection_read_policy = self.policy - ledger._projection_read_escalation = escalation + ledger.projection_read_policy = self.policy + ledger.projection_read_escalation = escalation def _enqueue_status(self, event: ReportingDomainEvent) -> None: self.outbox._state.enqueue(event) diff --git a/src/adcp/reporting/projection/wire.py b/src/adcp/reporting/projection/wire.py index b0272dcff..497d5540c 100644 --- a/src/adcp/reporting/projection/wire.py +++ b/src/adcp/reporting/projection/wire.py @@ -51,7 +51,7 @@ def render_tier_status( consumer_status_enabled: bool, ) -> dict[str, Any]: if ( - getattr(store, "_projection_read_policy", None) != policy + getattr(store, "projection_read_policy", None) != policy or policy["consumer_status_enabled"] != consumer_status_enabled ): raise LedgerConflictError( @@ -60,7 +60,7 @@ def render_tier_status( return ReportingStatusHandler( store, consumer_status_enabled=consumer_status_enabled, - escalation=getattr(store, "_projection_read_escalation", None), + escalation=getattr(store, "projection_read_escalation", None), ).render_snapshot( request, caller=ReportingStatusCaller(caller.account_id, caller.consumer_id), diff --git a/src/adcp/reporting/receipts/__init__.py b/src/adcp/reporting/receipts/__init__.py index 551286cc7..a38e61fd2 100644 --- a/src/adcp/reporting/receipts/__init__.py +++ b/src/adcp/reporting/receipts/__init__.py @@ -1,6 +1,4 @@ -"""Durable seller receipt ingress. Optional PG driver is imported lazily.""" - -from typing import TYPE_CHECKING, Any +"""Durable seller receipt ingress. The PostgreSQL store is in ``adcp_reporting_storage``.""" from adcp.reporting.receipts.capture import ReportingReceiptBoundary from adcp.reporting.receipts.errors import ReceiptErrorCode, ReportingReceiptError @@ -8,12 +6,8 @@ from adcp.reporting.receipts.memory import InMemoryReportingReceiptStore from adcp.reporting.receipts.store import ReportingReceiptBatchStore, ReportingReceiptCaptureStore -if TYPE_CHECKING: - from adcp.reporting.receipts.pg import PgReportingReceiptStore - __all__ = [ "InMemoryReportingReceiptStore", - "PgReportingReceiptStore", "ReceiptAccountResolver", "ReceiptErrorCode", "ReportingReceiptBatchStore", @@ -22,11 +16,3 @@ "ReportingReceiptError", "ReportingReceiptHandler", ] - - -def __getattr__(name: str) -> Any: - if name == "PgReportingReceiptStore": - from adcp.reporting.receipts.pg import PgReportingReceiptStore - - return PgReportingReceiptStore - raise AttributeError(name) diff --git a/src/adcp/reporting/service.py b/src/adcp/reporting/service.py index ae2ba5ab6..ba973cc65 100644 --- a/src/adcp/reporting/service.py +++ b/src/adcp/reporting/service.py @@ -478,7 +478,13 @@ def __init__( production: ReportingProductionOptions | None = None, automated_recovery_window: timedelta = timedelta(hours=6), status_retention_days: int = 400, + staging: ReportingStagingStore | None = None, + seals: ReportingSealStore | None = None, + initialize_source_storage: Callable[[], Awaitable[None]] | None = None, ) -> None: + """``staging``/``seals`` are the default source stores adapters register with; + ``initialize_source_storage`` runs once at startup, after ``store.create_schema()``, + so a durable storage distribution can install its own schema.""" if production is not None and ( caller_resolver is not None or producer_factory is not ReportingProducer @@ -498,8 +504,8 @@ def __init__( self.store = store self._production: ReportingProductionSupport | None = None self._production_options = production - self._initialize_source_storage: Callable[[], Awaitable[None]] | None = None - self.sources = ReportingAdapterRegistry(clock=effective_clock) + self._initialize_source_storage = initialize_source_storage + self.sources = ReportingAdapterRegistry(clock=effective_clock, staging=staging, seals=seals) self._context_resolver = account_context self._caller_resolver = caller_resolver or self._default_caller self._consumer_status_enabled = consumer_status_enabled @@ -617,58 +623,14 @@ def memory( **kwargs, ) - @classmethod - def postgres( - cls, - *, - pool: Any, - account_context: ReportingContextResolver, - caller_resolver: ReportingCallerResolver | None = None, - clock: Callable[[], datetime] | None = None, - production: ReportingProductionOptions | None = None, - **kwargs: Any, - ) -> ReliableReportingService: - """Create durable ledger, staging and seal stores over a borrowed pool. - - ``production`` additionally composes the managed pipeline, receipts and - optional signed notifications when ``install`` freezes registered sources. - Startup installs all SDK-owned schemas. Explicit source-store overrides - remain the adopter's responsibility. No pool is opened or closed here. - """ - from adcp.reporting.inline_storage import PgReportingSealStore, PgReportingStagingStore - from adcp.reporting.ledger.pg import PgReportingLedgerStore - from adcp.reporting.production.pg import PgReportingProductionStore - - store = ( - PgReportingProductionStore( - pool=pool, clock=clock, notifications=production.notifications - ) - if production is not None - else PgReportingLedgerStore(pool=pool, clock=clock) - ) - service = cls( - store=store, - account_context=account_context, - caller_resolver=caller_resolver, - clock=clock, - production=production, - **kwargs, - ) - staging = PgReportingStagingStore(pool=pool) - service.sources = ReportingAdapterRegistry( - clock=service._clock, staging=staging, seals=PgReportingSealStore(pool=pool) - ) - service._initialize_source_storage = staging.create_schema - return service - def _compose_production(self) -> None: if self._production_options is None or self._production is not None: return - from adcp.reporting.production.memory import InMemoryReportingProductionStore - from adcp.reporting.production.pg import PgReportingProductionStore + from adcp.reporting.production.store import ReportingProductionStore - if not isinstance( - self.store, (InMemoryReportingProductionStore, PgReportingProductionStore) + if ( + not isinstance(self.store, ReportingProductionStore) + or not self.store.is_sdk_production_store() ): raise ReliableReportingConfigurationError( "production options require a memory or postgres production store" diff --git a/src/adcp/reporting/service_production.py b/src/adcp/reporting/service_production.py index a5b66be9b..2c1a049e4 100644 --- a/src/adcp/reporting/service_production.py +++ b/src/adcp/reporting/service_production.py @@ -30,15 +30,13 @@ ReportingProductionSupport, production_notification_workers, ) -from adcp.reporting.production.memory import InMemoryReportingProductionStore -from adcp.reporting.projection import InMemoryReportingStatusProjection, PgReportingStatusProjection +from adcp.reporting.production.store import ReportingProductionStore from adcp.reporting.receipts import ReceiptAccountResolver from adcp.types import ReportingDeliveryOffering if TYPE_CHECKING: from adcp.decisioning.registry import BuyerAgentRegistry from adcp.reporting.ledger import ReportingLedgerStore - from adcp.reporting.production.pg import PgReportingProductionStore from adcp.reporting.service import ReportingAdapterRegistry, ReportingContextResolver __all__ = ["ReportingServiceOffering", "ReportingProductionOptions"] @@ -108,7 +106,6 @@ def _capability_block( consumer_status_enabled: bool, ) -> dict[str, Any]: from adcp.reporting.production._declaration import declared_reporting_delivery - from adcp.reporting.production.pg import PgReportingProductionStore from adcp.reporting.service import _wire if set(sources.names) != {item.adapter for item in self.offerings}: @@ -116,7 +113,9 @@ def _capability_block( return declared_reporting_delivery( offerings=[_wire(item.offering) for item in self.offerings], destination=self.destination, - durable=type(store) is PgReportingProductionStore, + durable=isinstance(store, ReportingProductionStore) + and store.is_sdk_production_store() + and store.durable, escalation=escalation, consumer_status_enabled=consumer_status_enabled, automated_recovery_window=self.automated_recovery_window, @@ -127,17 +126,17 @@ def _capability_block( def _compose( self, *, - store: InMemoryReportingProductionStore | PgReportingProductionStore, + store: ReportingProductionStore, sources: ReportingAdapterRegistry, account_context: ReportingContextResolver, clock: Callable[[], datetime], escalation: ReportingDeliveryEscalation, consumer_status_enabled: bool, ) -> ReportingProductionSupport: - from adcp.reporting.production.pg import PgReportingProductionStore - if set(sources.names) != {item.adapter for item in self.offerings}: raise ValueError("every registered production adapter must have an offering") + if not isinstance(store, ReportingProductionStore) or not store.is_sdk_production_store(): + raise ValueError("production requires the factory's matching production store") source_registry = ReportingProductionSourceRegistry(account_context=account_context) profiles: dict[str, ReportingServiceOffering] = {} producers: dict[str, ReportingProducer] = {} @@ -173,23 +172,11 @@ def _compose( item.source_offering_id, ) ) - projection: InMemoryReportingStatusProjection | PgReportingStatusProjection - if type(store) is PgReportingProductionStore: - projection = PgReportingStatusProjection( - store, - consumer_status_enabled=consumer_status_enabled, - escalation=escalation, - revision_ownership=True, - ) - elif type(store) is InMemoryReportingProductionStore: - projection = InMemoryReportingStatusProjection( - store, - consumer_status_enabled=consumer_status_enabled, - escalation=escalation, - revision_ownership=True, - ) - else: - raise ValueError("production requires the factory's matching production store") + projection = store.status_projection( + consumer_status_enabled=consumer_status_enabled, + escalation=escalation, + revision_ownership=True, + ) workers: tuple[ReportingNotificationWorker, ...] = () if self.subscriptions is not None and self.cipher is not None and self.signing is not None: workers = production_notification_workers( diff --git a/src/adcp/reporting/submissions/__init__.py b/src/adcp/reporting/submissions/__init__.py index c4cff83d4..eb8d106c1 100644 --- a/src/adcp/reporting/submissions/__init__.py +++ b/src/adcp/reporting/submissions/__init__.py @@ -2,8 +2,9 @@ Public imports are additive and work without the optional PostgreSQL driver. The application supplies a trusted authorizer, a receipt client, and an explicit -intent store. Use PgReportingSubmissionIntentStore for restart durability; the -memory implementation is a volatile reference for tests. No checkpoint, seller +intent store. Use ``PgReportingSubmissionIntentStore`` from ``adcp_reporting_storage`` +for restart durability; the memory implementation is a volatile reference for tests. +No checkpoint, seller service, consumer-status, or client.reporting facade behavior is changed. Rollout: use a dedicated buyer schema/pool; explicitly call create_schema before @@ -25,7 +26,6 @@ ReportingSubmissionScope, prepare_reporting_receipt_submission, ) -from adcp.reporting.submissions.pg import PgReportingSubmissionIntentStore from adcp.reporting.submissions.store import ( InMemoryReportingSubmissionIntentStore, ReportingSubmissionIntentStore, @@ -38,7 +38,6 @@ __all__ = [ "InMemoryReportingSubmissionIntentStore", - "PgReportingSubmissionIntentStore", "ReportingReceiptFailureCode", "ReportingReceiptOutcome", "ReportingReceiptSubmission", diff --git a/tests/conformance/reporting/_buyer_submission_benchmark.py b/tests/conformance/reporting/_buyer_submission_benchmark.py index 644c86a12..b2f73490a 100644 --- a/tests/conformance/reporting/_buyer_submission_benchmark.py +++ b/tests/conformance/reporting/_buyer_submission_benchmark.py @@ -12,6 +12,18 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import argparse import asyncio import json @@ -20,9 +32,13 @@ import time from contextlib import asynccontextmanager +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.submissions import PgReportingSubmissionIntentStore +else: + from adcp_reporting_storage import PgReportingSubmissionIntentStore + from adcp.reporting.submissions import ( InMemoryReportingSubmissionIntentStore, - PgReportingSubmissionIntentStore, models, prepare_reporting_receipt_submission, submit_reporting_receipts, diff --git a/tests/conformance/reporting/_buyer_submission_installed.py b/tests/conformance/reporting/_buyer_submission_installed.py index dd6ebbb5a..f53118874 100644 --- a/tests/conformance/reporting/_buyer_submission_installed.py +++ b/tests/conformance/reporting/_buyer_submission_installed.py @@ -1,15 +1,35 @@ """Public Python 3.10 wheel checks, executed with -I outside the source tree.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib import importlib.util import json import sys -from importlib.resources import files from pathlib import Path +def _asset_files(package): + """Packaged reporting assets: the storage distribution's, or adcp's before the split.""" + from importlib.resources import files as _files + + if _storage_split(): + return _files("adcp.reporting" + package) + return _files("adcp_reporting_storage" + package) + + async def main(): settings = json.loads(sys.stdin.read()) assert sys.version_info[:2] == (3, 10) @@ -23,9 +43,13 @@ async def main(): origins[name] = str(origin) assert not any(Path(entry).resolve().is_relative_to(workspace) for entry in sys.path) + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.submissions import PgReportingSubmissionIntentStore + else: + from adcp_reporting_storage import PgReportingSubmissionIntentStore + from adcp.reporting.submissions import ( InMemoryReportingSubmissionIntentStore, - PgReportingSubmissionIntentStore, ReportingSubmissionCode, ReportingSubmissionError, ReportingSubmissionScope, @@ -40,7 +64,7 @@ async def main(): from adcp.types.core import TaskResult, TaskStatus assert ReportingAdjustment.__name__ == "ReportingAdjustment" - sql = files("adcp.reporting.ledger").joinpath("reporting_buyer_submissions.sql").read_bytes() + sql = _asset_files(".ledger").joinpath("reporting_buyer_submissions.sql").read_bytes() assert hashlib.sha256(sql).hexdigest() == settings["sql_sha256"] if not settings["drivers"]: assert importlib.util.find_spec("psycopg") is None diff --git a/tests/conformance/reporting/_buyer_submission_process.py b/tests/conformance/reporting/_buyer_submission_process.py index b625f9075..d418f81b3 100644 --- a/tests/conformance/reporting/_buyer_submission_process.py +++ b/tests/conformance/reporting/_buyer_submission_process.py @@ -1,5 +1,17 @@ """Independent buyer + actual seller PG worker, paused for external SIGKILL.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -10,16 +22,16 @@ async def main(): settings = json.loads(await asyncio.to_thread(sys.stdin.readline)) + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.receipts import PgReportingReceiptStore + from adcp.reporting.submissions import PgReportingSubmissionIntentStore + else: + from adcp_reporting_storage import PgReportingReceiptStore, PgReportingSubmissionIntentStore from psycopg import AsyncConnection from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ReportingDeliveryPrincipal - from adcp.reporting.receipts import PgReportingReceiptStore - from adcp.reporting.submissions import ( - PgReportingSubmissionIntentStore, - ReportingSubmissionScope, - submit_reporting_receipts, - ) + from adcp.reporting.submissions import ReportingSubmissionScope, submit_reporting_receipts from adcp.types import ( ReportingAdjustmentReceipt, ReportingReceipt, diff --git a/tests/conformance/reporting/_buyer_submission_support.py b/tests/conformance/reporting/_buyer_submission_support.py index 53fc3161a..a3b959bc2 100644 --- a/tests/conformance/reporting/_buyer_submission_support.py +++ b/tests/conformance/reporting/_buyer_submission_support.py @@ -2,15 +2,31 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + from collections.abc import AsyncIterator from dataclasses import dataclass from typing import Any import pytest +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.submissions import PgReportingSubmissionIntentStore +else: + from adcp_reporting_storage import PgReportingSubmissionIntentStore + from adcp.reporting.submissions import ( InMemoryReportingSubmissionIntentStore, - PgReportingSubmissionIntentStore, ReportingReceiptSubmissionClient, ReportingSubmissionIntentStore, ReportingSubmissionReceipt, diff --git a/tests/conformance/reporting/_durable_materializer_support.py b/tests/conformance/reporting/_durable_materializer_support.py index 733a5570b..6690e086d 100644 --- a/tests/conformance/reporting/_durable_materializer_support.py +++ b/tests/conformance/reporting/_durable_materializer_support.py @@ -1,5 +1,17 @@ """Shared deterministic vectors; PostgreSQL work always uses real database time.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import hashlib from contextlib import asynccontextmanager from copy import deepcopy @@ -157,7 +169,10 @@ async def durable_harness(backend, *, notifications=False): InMemoryReportingMaterializerStore(clock=clock, notifications=notifications), clock ) else: - from adcp.reporting.materializer.pg import PgReportingMaterializerStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.materializer.pg import PgReportingMaterializerStore + else: + from adcp_reporting_storage.materializer.pg import PgReportingMaterializerStore async with isolated_reporting_pool(autocommit=True) as pool: store = PgReportingMaterializerStore(pool=pool, notifications=notifications) diff --git a/tests/conformance/reporting/_empty_ledger_support.py b/tests/conformance/reporting/_empty_ledger_support.py index e0443da4a..0c1f2d93c 100644 --- a/tests/conformance/reporting/_empty_ledger_support.py +++ b/tests/conformance/reporting/_empty_ledger_support.py @@ -7,6 +7,18 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import os import secrets @@ -169,11 +181,11 @@ def install_legacy(connection: Any) -> None: def install_owned(connection: Any) -> None: - root = files("adcp.reporting.ledger") + root = files("adcp_reporting_storage.ledger") for name in OWNED_SQL: connection.execute(root.joinpath(name).read_text()) connection.execute( - files("adcp.reporting.production").joinpath("service_context.sql").read_text() + files("adcp_reporting_storage.production").joinpath("service_context.sql").read_text() ) @@ -182,7 +194,10 @@ def create_owned_schema(schema: str, **options: Any) -> None: pytest.importorskip("psycopg_pool") from psycopg_pool import AsyncConnectionPool - from adcp.reporting.ledger.pg import PgReportingLedgerStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger.pg import PgReportingLedgerStore + else: + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore async def create() -> None: async with AsyncConnectionPool( diff --git a/tests/conformance/reporting/_feed_installed_base.py b/tests/conformance/reporting/_feed_installed_base.py index 0d58155ed..358dd92eb 100644 --- a/tests/conformance/reporting/_feed_installed_base.py +++ b/tests/conformance/reporting/_feed_installed_base.py @@ -1,15 +1,35 @@ """Run outside the checkout on Python 3.10 without the optional PG drivers.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib import importlib.util import json import sys -from importlib.resources import files from pathlib import Path +def _asset_files(package): + """Packaged reporting assets: the storage distribution's, or adcp's before the split.""" + from importlib.resources import files as _files + + if _storage_split(): + return _files("adcp.reporting" + package) + return _files("adcp_reporting_storage" + package) + + async def main(settings): assert sys.version_info[:2] == (3, 10) assert importlib.util.find_spec("psycopg") is None @@ -21,12 +41,12 @@ async def main(settings): from adcp.server.mcp_tools import get_tools_for_handler from adcp.types import GetReportingStatusRequest, GetReportingStatusResponse - assert "adcp.reporting.feed.pg" not in sys.modules + assert "adcp_reporting_storage.feed.pg" not in sys.modules for name in feed.__all__: assert getattr(feed, name) is not None assert "psycopg" not in sys.modules and "psycopg_pool" not in sys.modules for relative, digest in settings["assets"].items(): - raw = files("adcp.reporting").joinpath(relative).read_bytes() + raw = _asset_files("").joinpath(relative).read_bytes() assert hashlib.sha256(raw).hexdigest() == digest prefix = "https://buyer.example.test/" caller = ReportingDeliveryPrincipal("installed-account", prefix + "a" * (2048 - len(prefix))) diff --git a/tests/conformance/reporting/_feed_process.py b/tests/conformance/reporting/_feed_process.py index b6c25c93a..622aa44e0 100644 --- a/tests/conformance/reporting/_feed_process.py +++ b/tests/conformance/reporting/_feed_process.py @@ -1,5 +1,17 @@ """Actual installed/source transport process; durable snapshot crash boundaries.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -7,16 +19,28 @@ import json import sys import traceback -from importlib.resources import files from pathlib import Path from types import SimpleNamespace +def _asset_files(package): + """Packaged reporting assets: the storage distribution's, or adcp's before the split.""" + from importlib.resources import files as _files + + if _storage_split(): + return _files("adcp.reporting" + package) + return _files("adcp_reporting_storage" + package) + + async def main(settings): + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.receipts import PgReportingReceiptStore + else: + from adcp_reporting_storage import PgReportingReceiptStore from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ReportingDeliveryPrincipal - from adcp.reporting.receipts import PgReportingReceiptStore, ReportingReceiptHandler + from adcp.reporting.receipts import ReportingReceiptHandler installed = settings.get("installed") origins = {} @@ -41,7 +65,10 @@ async def pause(point, **evidence): if receipt_only: store_type = PgReportingReceiptStore else: - from adcp.reporting.feed import PgReportingFeedStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.feed import PgReportingFeedStore + else: + from adcp_reporting_storage import PgReportingFeedStore class Store(PgReportingFeedStore): async def _capture_feed_on(self, connection, *args, **kwargs): @@ -68,8 +95,14 @@ async def _save_feed_snapshot_on(self, connection, stored): feed_operation_3 = await store.reporting_feed_ready() assert feed_operation_3 if settings.get("legacy_status_schema"): - from adcp.reporting.ledger import PgReportingReconciliationStore - from adcp.reporting.outbox import PgStatusNotificationStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger import PgReportingReconciliationStore + from adcp.reporting.outbox import PgStatusNotificationStore + else: + from adcp_reporting_storage import ( + PgReportingReconciliationStore, + PgStatusNotificationStore, + ) await PgStatusNotificationStore( PgReportingReconciliationStore(pool=pool, notifications=True) @@ -78,24 +111,18 @@ async def _save_feed_snapshot_on(self, connection, stored): "installed": True, "materializer_objects": len( json.loads( - files("adcp.reporting.materializer") - .joinpath("required_schema.json") - .read_text() + _asset_files(".materializer").joinpath("required_schema.json").read_text() ) ), "receipt_objects": len( json.loads( - files("adcp.reporting.receipts") - .joinpath("required_schema.json") - .read_text() + _asset_files(".receipts").joinpath("required_schema.json").read_text() ) ), } if not receipt_only: result["feed_objects"] = len( - json.loads( - files("adcp.reporting.feed").joinpath("required_schema.json").read_text() - ) + json.loads(_asset_files(".feed").joinpath("required_schema.json").read_text()) ) else: spec = importlib.util.spec_from_file_location("frozen_transport", settings["helper"]) diff --git a/tests/conformance/reporting/_feed_support.py b/tests/conformance/reporting/_feed_support.py index 26cb3a6a4..80aef5a2d 100644 --- a/tests/conformance/reporting/_feed_support.py +++ b/tests/conformance/reporting/_feed_support.py @@ -1,5 +1,17 @@ """Shared memory/PostgreSQL feed vectors and real mounted reporting reads.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import hashlib import json from contextlib import AsyncExitStack, asynccontextmanager @@ -37,7 +49,10 @@ async def feed_harness(backend, *, notifications=False): InMemoryReportingFeedStore(clock=clock, notifications=notifications), clock ) else: - from adcp.reporting.feed import PgReportingFeedStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.feed import PgReportingFeedStore + else: + from adcp_reporting_storage import PgReportingFeedStore async with isolated_reporting_pool(autocommit=True) as pool: # Autonomous writers use DB time. A historical application clock @@ -115,7 +130,10 @@ async def restart(h): if key not in {"_clock", "_lock"}: vars(store)[key] = deepcopy(value) else: - from adcp.reporting.feed import PgReportingFeedStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.feed import PgReportingFeedStore + else: + from adcp_reporting_storage import PgReportingFeedStore store = PgReportingFeedStore( pool=h.pool, clock=h.clock, notifications=h.store._notifications_enabled diff --git a/tests/conformance/reporting/_frozen_status_c.py b/tests/conformance/reporting/_frozen_status_c.py index 20e709f6e..2fed4049a 100644 --- a/tests/conformance/reporting/_frozen_status_c.py +++ b/tests/conformance/reporting/_frozen_status_c.py @@ -4,6 +4,18 @@ source. It is a test driver, not a production migration or recovery service. """ +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import json import sys @@ -24,13 +36,20 @@ async def main(): config = await read() root = Path(config["source"]).resolve() sys.path.insert(0, str(root / "src")) + if _storage_split(): # wheels before the storage split (#1510) + import adcp.reporting.outbox.status_pg as module + else: + import adcp_reporting_storage.outbox.status_pg as module + + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger import PgReportingReconciliationStore + from adcp.reporting.outbox import PgStatusNotificationStore + else: + from adcp_reporting_storage import PgReportingReconciliationStore, PgStatusNotificationStore from psycopg import AsyncConnection, Error from psycopg_pool import AsyncConnectionPool - import adcp.reporting.outbox.status_pg as module - from adcp.reporting.ledger import PgReportingReconciliationStore from adcp.reporting.ledger.notification_models import ReportingNotificationError - from adcp.reporting.outbox import PgStatusNotificationStore assert Path(module.__file__).is_relative_to(root) gate = None diff --git a/tests/conformance/reporting/_hardening_installed.py b/tests/conformance/reporting/_hardening_installed.py index c88169cef..6b13128da 100644 --- a/tests/conformance/reporting/_hardening_installed.py +++ b/tests/conformance/reporting/_hardening_installed.py @@ -1,5 +1,17 @@ """Run copied conformance fixtures against non-editable installed SDK bytes only.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import contextlib import hashlib import importlib @@ -7,12 +19,20 @@ import os import sys import time -from importlib.resources import files from pathlib import Path import pytest +def _asset_files(package): + """Packaged reporting assets: the storage distribution's, or adcp's before the split.""" + from importlib.resources import files as _files + + if _storage_split(): + return _files("adcp.reporting" + package) + return _files("adcp_reporting_storage" + package) + + class Results: def __init__(self): self.passed = self.failed = self.skipped = self.errors = self.deselected = 0 @@ -53,7 +73,7 @@ def main(settings): assert hashlib.sha256(path.read_bytes()).hexdigest() == expected origins[name] = str(path) for name, expected in settings["assets"].items(): - raw = files("adcp.reporting").joinpath(name).read_bytes() + raw = _asset_files("").joinpath(name).read_bytes() assert hashlib.sha256(raw).hexdigest() == expected if settings["driver_absent"]: assert importlib.util.find_spec("psycopg") is None diff --git a/tests/conformance/reporting/_hardening_packaging.py b/tests/conformance/reporting/_hardening_packaging.py index 80855d237..131bca685 100644 --- a/tests/conformance/reporting/_hardening_packaging.py +++ b/tests/conformance/reporting/_hardening_packaging.py @@ -1,5 +1,17 @@ """Installed hardening probes reuse fixtures, never current SDK source modules.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import hashlib import json import os @@ -8,14 +20,20 @@ import zipfile from pathlib import Path -from .test_reporting_notification_packaging import ROOT, run_step +from .test_reporting_notification_packaging import ( + ROOT, + STORAGE_SRC, + is_current_build, + run_step, + storage_distribution, +) MODULES = ( "adcp.reporting.outbox.support", - "adcp.reporting.outbox._schema", - "adcp.reporting.outbox.status_schema", + "adcp_reporting_storage.outbox._schema", + "adcp_reporting_storage.outbox.status_schema", "adcp.reporting.receipts.handler", - "adcp.reporting.receipts.pg", + "adcp_reporting_storage.receipts.pg", "adcp.reporting.receipts._diagnostics", ) ASSETS = ( @@ -56,26 +74,56 @@ def installed_hardening(root, python, wheel, *, label, parent=False, driver_abse cwd=root, timeout=180, ) - with zipfile.ZipFile(wheel) as archive: - modules = { - name: hashlib.sha256(archive.read(name.replace(".", "/") + ".py")).hexdigest() - for name in MODULES - if name.replace(".", "/") + ".py" in archive.namelist() - } - assets = { - name: hashlib.sha256(archive.read("adcp/reporting/" + name)).hexdigest() - for name in ASSETS - } - for path in [ - *(name.replace(".", "/") + ".py" for name in modules), - *("adcp/reporting/" + name for name in assets), - ]: - expected = ( - subprocess.check_output(["git", "show", f"{B23}:src/{path}"], cwd=ROOT) - if parent - else (ROOT / "src" / path).read_bytes() - ) - assert archive.read(path) == expected + # A current build is two wheels: the stores, DDL and catalog manifests are in + # the storage wheel. A historical wheel (the B23 parent) carries them itself. + current = is_current_build(wheel) + storage = zipfile.ZipFile(storage_distribution(wheel)) if current else None + + def located(name): + """(archive, member path, expected bytes) for a module or asset name.""" + if name in ASSETS: + if current: + member, source = "adcp_reporting_storage/" + name, STORAGE_SRC / name + else: + member, source = "adcp/reporting/" + name, ROOT / "src/adcp/reporting" / name + elif name.startswith("adcp_reporting_storage."): + member = name.replace(".", "/") + ".py" + source = STORAGE_SRC.parent / member + if not current: + member = member.replace("adcp_reporting_storage/", "adcp/reporting/", 1) + else: + member = name.replace(".", "/") + ".py" + source = ROOT / "src" / member + archive = ( + storage if current and member.startswith("adcp_reporting_storage/") else wheel_archive + ) + + def expected(): + # Resolved only for members the archive carries: a module newer than + # the parent has no bytes at the parent commit. + if parent: + return subprocess.check_output(["git", "show", f"{B23}:src/{member}"], cwd=ROOT) + return source.read_bytes() + + return archive, member, expected + + with zipfile.ZipFile(wheel) as wheel_archive: + modules, assets = {}, {} + for name in MODULES: + archive, member, expected = located(name) + if member not in archive.namelist(): + continue + raw = archive.read(member) + assert raw == expected(), member + key = name if current else name.replace("adcp_reporting_storage.", "adcp.reporting.", 1) + modules[key] = hashlib.sha256(raw).hexdigest() + for name in ASSETS: + archive, member, expected = located(name) + raw = archive.read(member) + assert raw == expected(), member + assets[name] = hashlib.sha256(raw).hexdigest() + if storage is not None: + storage.close() settings = { "workspace": str(ROOT), "fixtures": str(fixture_root), diff --git a/tests/conformance/reporting/_inline_storage_worker.py b/tests/conformance/reporting/_inline_storage_worker.py index 227c2347e..638bc66f4 100644 --- a/tests/conformance/reporting/_inline_storage_worker.py +++ b/tests/conformance/reporting/_inline_storage_worker.py @@ -2,6 +2,18 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import argparse import asyncio import base64 @@ -10,11 +22,14 @@ from datetime import datetime, timezone from pathlib import Path +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.inline_storage import PgReportingSealStore, PgReportingStagingStore +else: + from adcp_reporting_storage.inline_storage import PgReportingSealStore, PgReportingStagingStore from psycopg_pool import AsyncConnectionPool from adcp.reporting.fixtures import redacted_capabilities, redacted_snapshot_request from adcp.reporting.inline_source import InlineReportingSource -from adcp.reporting.inline_storage import PgReportingSealStore, PgReportingStagingStore async def run(mode: str, schema: str, output: Path, counter: Path, value: int) -> None: diff --git a/tests/conformance/reporting/_late_account_server.py b/tests/conformance/reporting/_late_account_server.py index dce87dce0..46acbb24c 100644 --- a/tests/conformance/reporting/_late_account_server.py +++ b/tests/conformance/reporting/_late_account_server.py @@ -1,5 +1,17 @@ """Public production support starts empty; all enrollment arrives over real HTTP.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import argparse import importlib.metadata import json @@ -7,6 +19,11 @@ from datetime import datetime, timezone from pathlib import Path +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.production import PgReportingProductionStore + from adcp.reporting.projection import PgReportingStatusProjection +else: + from adcp_reporting_storage import PgReportingProductionStore, PgReportingStatusProjection from psycopg_pool import AsyncConnectionPool import adcp @@ -25,13 +42,11 @@ ReportingWriterCapability, ) from adcp.reporting.production import ( - PgReportingProductionStore, ReportingConfigurationAdmission, ReportingProductionConfigurationTask, ReportingProductionOffering, ReportingProductionSupport, ) -from adcp.reporting.projection import PgReportingStatusProjection from adcp.reporting.receipts import ReportingReceiptError from adcp.reporting.source import parse_verified_source_batch_manifest_v1 from adcp.server import serve diff --git a/tests/conformance/reporting/_legacy_row_first_lease.py b/tests/conformance/reporting/_legacy_row_first_lease.py index 82f119712..0327b8555 100644 --- a/tests/conformance/reporting/_legacy_row_first_lease.py +++ b/tests/conformance/reporting/_legacy_row_first_lease.py @@ -5,9 +5,25 @@ behavior. Fixing the live base method must not silently disable this control. """ +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + from datetime import datetime, timedelta -from adcp.reporting.ledger.pg import _utc +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger.pg import _utc +else: + from adcp_reporting_storage.ledger.pg import _utc + from adcp.reporting.ledger.store import LeasedConfiguration diff --git a/tests/conformance/reporting/_materializer_frozen.py b/tests/conformance/reporting/_materializer_frozen.py index 4a61553f6..1146d76a7 100644 --- a/tests/conformance/reporting/_materializer_frozen.py +++ b/tests/conformance/reporting/_materializer_frozen.py @@ -1,5 +1,17 @@ """Copied out of the checkout and run with -I in an installed frozen wheel.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -14,10 +26,13 @@ async def main(settings): + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger import PgReportingLedgerStore + else: + from adcp_reporting_storage import PgReportingLedgerStore from psycopg_pool import AsyncConnectionPool import adcp.reporting.ledger as ledger_module - from adcp.reporting.ledger import PgReportingLedgerStore origins = {} for name, expected in settings["modules"].items(): @@ -51,13 +66,19 @@ async def main(settings): if settings["action"] == "install": await store.create_schema() if settings["artifact"] in {"c", "b1"}: - from adcp.reporting.outbox import PgStatusNotificationStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox import PgStatusNotificationStore + else: + from adcp_reporting_storage import PgStatusNotificationStore await PgStatusNotificationStore( store_type(pool=pool, notifications=True) ).create_schema() if settings["artifact"] in {"a", "b", "c", "b1"}: - from adcp.reporting.outbox._schema import validate_schema + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox._schema import validate_schema + else: + from adcp_reporting_storage.outbox._schema import validate_schema async with pool.connection() as connection: await validate_schema(connection) @@ -75,8 +96,12 @@ async def main(settings): notifications_ready = None if settings["artifact"] in {"a", "b", "c", "b1"}: + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox._schema import validate_schema + else: + from adcp_reporting_storage.outbox._schema import validate_schema + from adcp.reporting.ledger.notification_models import ReportingNotificationError - from adcp.reporting.outbox._schema import validate_schema async with pool.connection() as connection: try: @@ -134,7 +159,10 @@ async def main(settings): status = None projector_turns = 0 if settings["artifact"] in {"c", "b1"} and settings["action"] != "baseline": - from adcp.reporting.outbox import PgStatusNotificationStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox import PgStatusNotificationStore + else: + from adcp_reporting_storage import PgStatusNotificationStore store = store_type(pool=pool, notifications=True) status = PgStatusNotificationStore(store) @@ -160,12 +188,13 @@ async def main(settings): workers = {} event_identities = {} if settings["artifact"] in {"a", "b", "c", "b1"} and settings["action"] != "baseline": + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox import PgReportingOutbox + else: + from adcp_reporting_storage import PgReportingOutbox + from adcp.reporting.ledger.notification_models import decode_event - from adcp.reporting.outbox import ( - PgReportingOutbox, - ReportingEnvelopeCipher, - ReportingNotificationWorker, - ) + from adcp.reporting.outbox import ReportingEnvelopeCipher, ReportingNotificationWorker class EmptySubscriptions: async def list_active(self, **kwargs): @@ -177,7 +206,10 @@ async def get_active(self, **kwargs): outboxes = {"ordinary": PgReportingOutbox(pool=pool)} if settings["artifact"] in {"c", "b1"}: - from adcp.reporting.outbox.status_pg import PgReportingStatusOutbox + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox.status_pg import PgReportingStatusOutbox + else: + from adcp_reporting_storage.outbox.status_pg import PgReportingStatusOutbox outboxes["status"] = PgReportingStatusOutbox(pool=pool) for name, outbox in outboxes.items(): diff --git a/tests/conformance/reporting/_materializer_installed.py b/tests/conformance/reporting/_materializer_installed.py index 22ec004ac..e8e96cb04 100644 --- a/tests/conformance/reporting/_materializer_installed.py +++ b/tests/conformance/reporting/_materializer_installed.py @@ -1,5 +1,17 @@ """Copied outside the checkout and executed with Python 3.10 -I against a wheel.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -14,6 +26,15 @@ from pydantic import TypeAdapter +def _asset_files(package): + """Packaged reporting assets: the storage distribution's, or adcp's before the split.""" + from importlib.resources import files as _files + + if _storage_split(): + return _files("adcp.reporting" + package) + return _files("adcp_reporting_storage" + package) + + async def main(): config = json.load(sys.stdin) assert sys.version_info[:2] == (3, 10) @@ -67,14 +88,12 @@ async def main(): for name in config["assets"] } assert actual == config["assets"] - assert ( - files("adcp.reporting.ledger").joinpath("reporting_status_selector_version.sql").is_file() - ) - assert files("adcp.reporting.outbox").joinpath("required_status_selector_schema.json").is_file() - assert files("adcp.reporting.ledger").joinpath("reporting_materializer.sql").is_file() - assert files("adcp.reporting.materializer").joinpath("required_schema.json").is_file() - assert files("adcp.reporting.ledger").joinpath("reporting_receipt_ingestion.sql").is_file() - assert files("adcp.reporting.receipts").joinpath("required_schema.json").is_file() + assert _asset_files(".ledger").joinpath("reporting_status_selector_version.sql").is_file() + assert _asset_files(".outbox").joinpath("required_status_selector_schema.json").is_file() + assert _asset_files(".ledger").joinpath("reporting_materializer.sql").is_file() + assert _asset_files(".materializer").joinpath("required_schema.json").is_file() + assert _asset_files(".ledger").joinpath("reporting_receipt_ingestion.sql").is_file() + assert _asset_files(".receipts").joinpath("required_schema.json").is_file() start = datetime(2026, 9, 1, tzinfo=timezone.utc) schedule = ReportingScheduleSpec("PT1H", "PT1H", period_anchor=start) period = derive_period(schedule, account_timezone="UTC", ordinal=0) diff --git a/tests/conformance/reporting/_materializer_process.py b/tests/conformance/reporting/_materializer_process.py index 6574bf24b..ae45e2d62 100644 --- a/tests/conformance/reporting/_materializer_process.py +++ b/tests/conformance/reporting/_materializer_process.py @@ -1,5 +1,17 @@ """Real worker process and conditional file destination, solely for crash tests.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -21,13 +33,16 @@ async def read(): async def main(): settings = await read() + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.materializer import PgReportingMaterializerStore + else: + from adcp_reporting_storage import PgReportingMaterializerStore from psycopg import AsyncConnection from psycopg_pool import AsyncConnectionPool from pydantic import TypeAdapter from adcp.reporting.ledger._delivery_state import decode_record from adcp.reporting.materializer import ( - PgReportingMaterializerStore, ReferenceReportingDestinationWriter, ReferenceReportingResolver, ReportingDestinationIO, diff --git a/tests/conformance/reporting/_ownership_archive_restart.py b/tests/conformance/reporting/_ownership_archive_restart.py index 2aeddb1a9..629ac5f94 100644 --- a/tests/conformance/reporting/_ownership_archive_restart.py +++ b/tests/conformance/reporting/_ownership_archive_restart.py @@ -1,5 +1,17 @@ """Installed floor process proves an archived lease cannot become runnable.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib.util @@ -9,12 +21,15 @@ async def main(settings): + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.feed import PgReportingFeedStore + else: + from adcp_reporting_storage import PgReportingFeedStore from psycopg import sql from psycopg_pool import AsyncConnectionPool from pydantic import TypeAdapter import adcp - from adcp.reporting.feed import PgReportingFeedStore from adcp.reporting.materializer import ReportingVerificationKey from adcp.reporting.materializer.work import ReportingMaterializerLease diff --git a/tests/conformance/reporting/_ownership_legacy_seed.py b/tests/conformance/reporting/_ownership_legacy_seed.py index 972cbd060..65e2c2a4c 100644 --- a/tests/conformance/reporting/_ownership_legacy_seed.py +++ b/tests/conformance/reporting/_ownership_legacy_seed.py @@ -4,6 +4,18 @@ operator mapping from the test's authoritative registry, never a buyer request. """ +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -58,16 +70,24 @@ async def main(settings): ) as pool: if settings["action"] == "seed": sys.path.insert(0, settings["fixtures"]) + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.feed import PgReportingFeedStore + from adcp.reporting.ledger import PgReportingReconciliationStore + from adcp.reporting.outbox import PgStatusNotificationStore + else: + from adcp_reporting_storage import ( + PgReportingFeedStore, + PgReportingReconciliationStore, + PgStatusNotificationStore, + ) + from adcp._version import resolve_adcp_version - from adcp.reporting.feed import PgReportingFeedStore from adcp.reporting.ledger import ( - PgReportingReconciliationStore, ReportingAdjustmentRecord, ReportingControlTotalRecord, ReportingRevisionReceiptRecord, ) from adcp.reporting.ledger.delivery import adjustment_to_wire, receipt_to_wire - from adcp.reporting.outbox import PgStatusNotificationStore from tests.conformance.reporting._durable_materializer_support import durable_case from tests.conformance.reporting._generation_support import END @@ -240,17 +260,31 @@ async def main(settings): "pending": pending, } else: + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.feed import PgReportingFeedStore + from adcp.reporting.ledger import PgReportingLedgerStore + else: + from adcp_reporting_storage import PgReportingFeedStore, PgReportingLedgerStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.migration import ( + ReportingOwnershipBackfill, + ReportingOwnershipMigrationError, + backfill_legacy_reporting, + legacy_generation_digest, + migrate_legacy_reporting, + ) + else: + from adcp_reporting_storage.migration import ( + ReportingOwnershipBackfill, + ReportingOwnershipMigrationError, + backfill_legacy_reporting, + legacy_generation_digest, + migrate_legacy_reporting, + ) from psycopg import sql - from adcp.reporting.feed import PgReportingFeedStore, ReportingFeedError - from adcp.reporting.ledger import PgReportingLedgerStore, ReportingStatusCaller - from adcp.reporting.migration import ( - ReportingOwnershipBackfill, - ReportingOwnershipMigrationError, - backfill_legacy_reporting, - legacy_generation_digest, - migrate_legacy_reporting, - ) + from adcp.reporting.feed import ReportingFeedError + from adcp.reporting.ledger import ReportingStatusCaller store = PgReportingFeedStore(pool=pool, notifications=settings["notifications"]) legacy = settings["legacy"] diff --git a/tests/conformance/reporting/_production_delivery_process.py b/tests/conformance/reporting/_production_delivery_process.py index 655238e12..5caab821c 100644 --- a/tests/conformance/reporting/_production_delivery_process.py +++ b/tests/conformance/reporting/_production_delivery_process.py @@ -1,5 +1,17 @@ """Real SIGKILL after receiver acceptance; cold retry uses the durable deadline.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import json @@ -11,6 +23,15 @@ async def main(settings): import pytest + + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.production.pg import PgReportingProductionStore + else: + from adcp_reporting_storage.production.pg import PgReportingProductionStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.projection.pg import PgReportingStatusProjection + else: + from adcp_reporting_storage.projection.pg import PgReportingStatusProjection from psycopg_pool import AsyncConnectionPool import adcp.reporting.production.delivery_window as window_module @@ -19,8 +40,6 @@ async def main(settings): ReportingProductionSigning, production_notification_workers, ) - from adcp.reporting.production.pg import PgReportingProductionStore - from adcp.reporting.projection.pg import PgReportingStatusProjection from ._reliable_support import ( Barrier, diff --git a/tests/conformance/reporting/_production_installed.py b/tests/conformance/reporting/_production_installed.py index 7e6bbc3e3..e6c8c5293 100644 --- a/tests/conformance/reporting/_production_installed.py +++ b/tests/conformance/reporting/_production_installed.py @@ -1,5 +1,17 @@ """Floor-runtime conformance with complete installed module/schema provenance.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import contextlib import hashlib import importlib @@ -16,6 +28,15 @@ import pytest +def _asset_files(package): + """Packaged reporting assets: the storage distribution's, or adcp's before the split.""" + from importlib.resources import files as _files + + if _storage_split(): + return _files("adcp.reporting" + package) + return _files("adcp_reporting_storage" + package) + + def assert_installed_schema_keys(schemas, pinned): assert set(schemas) == {pinned} @@ -54,10 +75,7 @@ def enter_phase(name): origins[name] = str(path) enter_phase("installed_assets") for name, expected in settings["assets"].items(): - assert ( - hashlib.sha256(files("adcp.reporting").joinpath(name).read_bytes()).hexdigest() - == expected - ) + assert hashlib.sha256(_asset_files("").joinpath(name).read_bytes()).hexdigest() == expected from adcp.validation import schema_loader enter_phase("installed_current_schemas") diff --git a/tests/conformance/reporting/_production_installed_process.py b/tests/conformance/reporting/_production_installed_process.py index 12ad004e0..65f2a1ec6 100644 --- a/tests/conformance/reporting/_production_installed_process.py +++ b/tests/conformance/reporting/_production_installed_process.py @@ -1,16 +1,36 @@ """Installed activation, SIGKILL, and immutable legacy/new representation walks.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib import json import sys from dataclasses import asdict -from importlib.resources import files from pathlib import Path from types import SimpleNamespace +def _asset_files(package): + """Packaged reporting assets: the storage distribution's, or adcp's before the split.""" + from importlib.resources import files as _files + + if _storage_split(): + return _files("adcp.reporting" + package) + return _files("adcp_reporting_storage" + package) + + async def main(settings): root = Path(settings["fixtures"]) assert not (root / "src").exists() and not (root / "adcp").exists() @@ -23,10 +43,7 @@ async def main(settings): assert hashlib.sha256(path.read_bytes()).hexdigest() == digest origins[name] = str(path) for name, digest in settings["assets"].items(): - assert ( - hashlib.sha256(files("adcp.reporting").joinpath(name).read_bytes()).hexdigest() - == digest - ) + assert hashlib.sha256(_asset_files("").joinpath(name).read_bytes()).hexdigest() == digest from psycopg_pool import AsyncConnectionPool from pydantic import TypeAdapter diff --git a/tests/conformance/reporting/_production_legacy_process.py b/tests/conformance/reporting/_production_legacy_process.py index 2328e2ebb..e89683fd4 100644 --- a/tests/conformance/reporting/_production_legacy_process.py +++ b/tests/conformance/reporting/_production_legacy_process.py @@ -1,5 +1,17 @@ """Executed by the real historical binary: incompatible C writers fail closed.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import json import sys @@ -11,15 +23,28 @@ async def main(settings): import hashlib + if _storage_split(): # wheels before the storage split (#1510) + import adcp.reporting.materializer.pg as materializer + else: + import adcp_reporting_storage.materializer.pg as materializer + if _storage_split(): # wheels before the storage split (#1510) + import adcp.reporting.outbox.status_pg as implementation + else: + import adcp_reporting_storage.outbox.status_pg as implementation + + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.feed import PgReportingFeedStore + else: + from adcp_reporting_storage import PgReportingFeedStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox.status_pg import PgStatusNotificationStore + else: + from adcp_reporting_storage.outbox.status_pg import PgStatusNotificationStore from psycopg_pool import AsyncConnectionPool from pydantic import TypeAdapter - import adcp.reporting.materializer.pg as materializer - import adcp.reporting.outbox.status_pg as implementation - from adcp.reporting.feed import PgReportingFeedStore from adcp.reporting.materializer import ReportingVerificationKey from adcp.reporting.materializer.work import ReportingMaterializerLease - from adcp.reporting.outbox.status_pg import PgStatusNotificationStore path = Path(implementation.__file__).resolve() assert path.is_relative_to(Path(sys.prefix)) diff --git a/tests/conformance/reporting/_production_packaging.py b/tests/conformance/reporting/_production_packaging.py index a9991b106..0f246e56c 100644 --- a/tests/conformance/reporting/_production_packaging.py +++ b/tests/conformance/reporting/_production_packaging.py @@ -1,5 +1,17 @@ """Build and inspect actual production distributions; fixtures never alias SDK source.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import hashlib import json import os @@ -9,7 +21,13 @@ import zipfile from pathlib import Path -from .test_reporting_notification_packaging import ROOT, run_step +from .test_reporting_notification_packaging import ( + ROOT, + STORAGE_SRC, + run_step, + storage_distribution, + storage_source, +) ASSETS = ( "ledger/reporting_materializer.sql", @@ -107,32 +125,51 @@ def production_modules(): "types/domains/bundled/protocol/get_adcp_capabilities_response.py", ) ] - return { - "adcp." - + str(p.relative_to(ROOT / "src/adcp")) - .removesuffix(".py") - .replace("/", ".") - .removesuffix(".__init__"): hashlib.sha256(p.read_bytes()) - .hexdigest() - for p in paths - } + modules = {} + for p in paths: + relative = p.relative_to(ROOT / "src/adcp") + if not p.exists() and str(relative).startswith("reporting/"): + # The PostgreSQL stores moved to the storage distribution (#1510). + p = STORAGE_SRC / relative.relative_to("reporting") + name = "adcp_reporting_storage." + str(relative.relative_to("reporting")) + else: + name = "adcp." + str(relative) + modules[name.removesuffix(".py").replace("/", ".").removesuffix(".__init__")] = ( + hashlib.sha256(p.read_bytes()).hexdigest() + ) + return modules def inspect_distribution(wheel, source): + """Check both wheels of a current build; the stores and DDL are in the storage wheel.""" modules = production_modules() - assets = {name: (ROOT / "src/adcp/reporting" / name).read_bytes() for name in ASSETS} - with zipfile.ZipFile(wheel) as archive, tarfile.open(source) as sdist: + assets = {name: (STORAGE_SRC / name).read_bytes() for name in ASSETS} + with ( + zipfile.ZipFile(wheel) as archive, + tarfile.open(source) as sdist, + zipfile.ZipFile(storage_distribution(wheel)) as storage_archive, + tarfile.open(storage_source(wheel)) as storage_sdist, + ): prefix = sdist.getnames()[0].split("/")[0] + storage_prefix = storage_sdist.getnames()[0].split("/")[0] for name, digest in modules.items(): member = name.replace(".", "/") + ".py" - if member not in archive.namelist(): + zipped, tarred, tar_prefix = archive, sdist, prefix + if name.startswith("adcp_reporting_storage."): + zipped, tarred, tar_prefix = storage_archive, storage_sdist, storage_prefix + if member not in zipped.namelist(): member = name.replace(".", "/") + "/__init__.py" - raw = archive.read(member) + raw = zipped.read(member) assert hashlib.sha256(raw).hexdigest() == digest - assert sdist.extractfile(f"{prefix}/src/{member}").read() == raw + assert tarred.extractfile(f"{tar_prefix}/src/{member}").read() == raw for name, raw in assets.items(): - assert archive.read("adcp/reporting/" + name) == raw - assert sdist.extractfile(f"{prefix}/src/adcp/reporting/{name}").read() == raw + assert storage_archive.read("adcp_reporting_storage/" + name) == raw + assert ( + storage_sdist.extractfile( + f"{storage_prefix}/src/adcp_reporting_storage/{name}" + ).read() + == raw + ) for version in RETAINED_SCHEMA_VERSIONS: for path in sorted((ROOT / "src/adcp/_compliance" / version).rglob("*")): if path.is_file(): diff --git a/tests/conformance/reporting/_production_support.py b/tests/conformance/reporting/_production_support.py index d0db383bf..28bbae998 100644 --- a/tests/conformance/reporting/_production_support.py +++ b/tests/conformance/reporting/_production_support.py @@ -4,6 +4,18 @@ eligibility flag. Its immutable rows and grants survive a new provider process. """ +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import hashlib import inspect import json @@ -393,7 +405,10 @@ async def production_harness( elif backend == "memory": store = InMemoryReportingProductionStore(clock=clock, notifications=notifications) else: - from adcp.reporting.production.pg import PgReportingProductionStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.production.pg import PgReportingProductionStore + else: + from adcp_reporting_storage.production.pg import PgReportingProductionStore if pool is None: pool = await stack.enter_async_context(isolated_reporting_pool(autocommit=True)) @@ -534,7 +549,10 @@ async def production_harness( consumer_status_enabled=feedback, ) else: - from adcp.reporting.projection.pg import PgReportingStatusProjection + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.projection.pg import PgReportingStatusProjection + else: + from adcp_reporting_storage.projection.pg import PgReportingStatusProjection projection = PgReportingStatusProjection( store, diff --git a/tests/conformance/reporting/_projection_support.py b/tests/conformance/reporting/_projection_support.py index 5feeb53a5..aae46572c 100644 --- a/tests/conformance/reporting/_projection_support.py +++ b/tests/conformance/reporting/_projection_support.py @@ -1,5 +1,17 @@ """B2.4 shared execution: actual stores, no forged readiness certificate.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + from contextlib import asynccontextmanager import pytest @@ -25,10 +37,16 @@ async def projection_harness(backend, *, notifications=False, feedback=False, ow ) yield h else: - from adcp.reporting.projection.pg import ( - PgReportingProjectionStore, - PgReportingStatusProjection, - ) + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.projection.pg import ( + PgReportingProjectionStore, + PgReportingStatusProjection, + ) + else: + from adcp_reporting_storage.projection.pg import ( + PgReportingProjectionStore, + PgReportingStatusProjection, + ) async with isolated_reporting_pool(autocommit=True) as pool: store = PgReportingProjectionStore(pool=pool, notifications=notifications) diff --git a/tests/conformance/reporting/_provisional_catalog.py b/tests/conformance/reporting/_provisional_catalog.py index 7be0062d9..801def976 100644 --- a/tests/conformance/reporting/_provisional_catalog.py +++ b/tests/conformance/reporting/_provisional_catalog.py @@ -1,5 +1,22 @@ """Exact additive observation catalog; historical manifests remain unchanged.""" -from adcp.reporting.outbox._schema import PROVISIONAL_REQUIRED_OBJECTS as PROVISIONAL_OBJECTS +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox._schema import PROVISIONAL_REQUIRED_OBJECTS as PROVISIONAL_OBJECTS +else: + from adcp_reporting_storage.outbox._schema import ( + PROVISIONAL_REQUIRED_OBJECTS as PROVISIONAL_OBJECTS, + ) assert len(PROVISIONAL_OBJECTS) == 27 diff --git a/tests/conformance/reporting/_rc6_continuation_process.py b/tests/conformance/reporting/_rc6_continuation_process.py index 387be6231..20a57e054 100644 --- a/tests/conformance/reporting/_rc6_continuation_process.py +++ b/tests/conformance/reporting/_rc6_continuation_process.py @@ -2,6 +2,18 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -55,13 +67,25 @@ async def fresh_read_after_legacy_refusals(mount, account_id, positions): async def main(settings): origins = verify_installation(settings) + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.feed import pg as feed_pg + else: + from adcp_reporting_storage.feed import pg as feed_pg + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.projection.pg import ( + PgReportingProjectionStore, + PgReportingStatusProjection, + ) + else: + from adcp_reporting_storage.projection.pg import ( + PgReportingProjectionStore, + PgReportingStatusProjection, + ) from psycopg_pool import AsyncConnectionPool from adcp.reporting.canonical_json import canonical_json_utf8_v1 - from adcp.reporting.feed import pg as feed_pg from adcp.reporting.ledger import ProducerOfferings, ReportingProducer from adcp.reporting.ledger.delivery_models import ReportingDeliveryPrincipal - from adcp.reporting.projection.pg import PgReportingProjectionStore, PgReportingStatusProjection from adcp.types import GetReportingStatusRequest from adcp.validation.schema_loader import get_named_validator, get_validator from tests.conformance.reporting._feed_support import MountedFeed diff --git a/tests/conformance/reporting/_receipt_frozen.py b/tests/conformance/reporting/_receipt_frozen.py index 1a99dccb0..4446d35bd 100644 --- a/tests/conformance/reporting/_receipt_frozen.py +++ b/tests/conformance/reporting/_receipt_frozen.py @@ -1,5 +1,26 @@ """Installed historical reader/writer probe, copied out and run with python -I.""" +import importlib.util as _importlib_util + + +def _asset_files(package): + """Packaged reporting assets: the storage distribution's, or adcp's before the split.""" + from importlib.resources import files as _files + + if _storage_split(): + return _files("adcp.reporting" + package) + return _files("adcp_reporting_storage" + package) + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -9,7 +30,6 @@ import traceback from dataclasses import replace from datetime import datetime, timezone -from importlib.resources import files from pathlib import Path from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller @@ -37,11 +57,17 @@ async def main(settings): settings["conninfo"], kwargs=settings["kwargs"], min_size=1, max_size=2, open=False ) as pool: if settings["artifact"] == "b22": - from adcp.reporting.receipts import PgReportingReceiptStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.receipts import PgReportingReceiptStore + else: + from adcp_reporting_storage import PgReportingReceiptStore store_type = PgReportingReceiptStore elif settings["artifact"] == "b21": - from adcp.reporting.materializer import PgReportingMaterializerStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.materializer import PgReportingMaterializerStore + else: + from adcp_reporting_storage import PgReportingMaterializerStore store_type = PgReportingMaterializerStore else: @@ -61,7 +87,10 @@ async def main(settings): # Use this actual parent binary's reviewed Core composition when # preparing the rolling baseline; the materializer migration does # not implicitly install or activate C's projector. - from adcp.reporting.outbox import PgStatusNotificationStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox import PgStatusNotificationStore + else: + from adcp_reporting_storage import PgStatusNotificationStore await PgStatusNotificationStore( ledger.PgReportingReconciliationStore(pool=pool, notifications=True) @@ -69,7 +98,7 @@ async def main(settings): feed_operation_1 = await store.materializer_ready() assert feed_operation_1 manifest = json.loads( - files("adcp.reporting.materializer").joinpath("required_schema.json").read_text() + _asset_files(".materializer").joinpath("required_schema.json").read_text() ) assert len(manifest) == 187 return {"installed": True, "manifest_objects": len(manifest), "origins": origins} @@ -91,7 +120,10 @@ async def main(settings): assert len(revisions) == 1 and revisions[0].finality == "official" status, projector_turns = None, 0 if settings["notifications"] and settings["artifact"] in {"c", "b1", "b21", "b22"}: - from adcp.reporting.outbox import PgStatusNotificationStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox import PgStatusNotificationStore + else: + from adcp_reporting_storage import PgStatusNotificationStore status = PgStatusNotificationStore( ledger.PgReportingReconciliationStore(pool=pool, notifications=True) @@ -166,8 +198,12 @@ async def project_ordinary_status(): await project_ordinary_status() readiness = None if settings["artifact"] in {"a", "b", "c", "b1", "b21", "b22"}: + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox._schema import validate_schema + else: + from adcp_reporting_storage.outbox._schema import validate_schema + from adcp.reporting.ledger.notification_models import ReportingNotificationError - from adcp.reporting.outbox._schema import validate_schema async with pool.connection() as connection: try: @@ -197,20 +233,19 @@ async def project_ordinary_status(): assert epoch == (0, "acked") materializer = len( json.loads( - files("adcp.reporting.materializer") - .joinpath("required_schema.json") - .read_text() + _asset_files(".materializer").joinpath("required_schema.json").read_text() ) ) assert materializer == 187 workers = {} if settings["notifications"]: + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox import PgReportingOutbox + else: + from adcp_reporting_storage import PgReportingOutbox + from adcp.reporting.ledger.notification_models import decode_event - from adcp.reporting.outbox import ( - PgReportingOutbox, - ReportingEnvelopeCipher, - ReportingNotificationWorker, - ) + from adcp.reporting.outbox import ReportingEnvelopeCipher, ReportingNotificationWorker class EmptySubscriptions: async def list_active(self, **kwargs): diff --git a/tests/conformance/reporting/_receipt_installed.py b/tests/conformance/reporting/_receipt_installed.py index ef9381a26..79bbcbc2a 100644 --- a/tests/conformance/reporting/_receipt_installed.py +++ b/tests/conformance/reporting/_receipt_installed.py @@ -1,5 +1,17 @@ """Installed Python 3.10 SQL and authenticated HTTP replay, run outside the checkout.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -12,9 +24,13 @@ async def main(settings): + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.receipts import PgReportingReceiptStore + else: + from adcp_reporting_storage import PgReportingReceiptStore from psycopg_pool import AsyncConnectionPool - from adcp.reporting.receipts import PgReportingReceiptStore, ReportingReceiptError + from adcp.reporting.receipts import ReportingReceiptError installed = settings["installed"] workspace = Path(installed["workspace"]).resolve() diff --git a/tests/conformance/reporting/_receipt_process.py b/tests/conformance/reporting/_receipt_process.py index 7a336fe11..7e2604bb8 100644 --- a/tests/conformance/reporting/_receipt_process.py +++ b/tests/conformance/reporting/_receipt_process.py @@ -1,5 +1,17 @@ """Real receipt worker. Pause while holding an ordinal transaction, then SIGKILL.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import importlib @@ -10,11 +22,14 @@ async def main(): settings = json.loads(await asyncio.to_thread(sys.stdin.readline)) + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.receipts import PgReportingReceiptStore + else: + from adcp_reporting_storage import PgReportingReceiptStore from psycopg import AsyncConnection from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ReportingDeliveryPrincipal - from adcp.reporting.receipts import PgReportingReceiptStore origins = {} if settings.get("installed"): diff --git a/tests/conformance/reporting/_receipt_support.py b/tests/conformance/reporting/_receipt_support.py index e7ac80dce..d31373c7e 100644 --- a/tests/conformance/reporting/_receipt_support.py +++ b/tests/conformance/reporting/_receipt_support.py @@ -1,5 +1,17 @@ """Shared receipt-ingress vectors; production PG receipt time is database time.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + from contextlib import asynccontextmanager from dataclasses import dataclass, replace from datetime import timedelta @@ -30,7 +42,10 @@ async def receipt_harness(backend, *, notifications=False, pool=None): InMemoryReportingReceiptStore(clock=clock, notifications=notifications), clock ) return - from adcp.reporting.receipts import PgReportingReceiptStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.receipts import PgReportingReceiptStore + else: + from adcp_reporting_storage import PgReportingReceiptStore async with isolated_reporting_pool(autocommit=True) as pool: store = PgReportingReceiptStore(pool=pool, clock=clock, notifications=notifications) diff --git a/tests/conformance/reporting/_reconciliation_support.py b/tests/conformance/reporting/_reconciliation_support.py index acbe7d7ef..11fdf1c4a 100644 --- a/tests/conformance/reporting/_reconciliation_support.py +++ b/tests/conformance/reporting/_reconciliation_support.py @@ -2,6 +2,18 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import hashlib from collections.abc import AsyncIterator from dataclasses import dataclass, replace @@ -10,10 +22,14 @@ import pytest +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger import PgReportingReconciliationStore +else: + from adcp_reporting_storage import PgReportingReconciliationStore + from adcp.reporting.canonical_json import canonical_json_utf8_v1 from adcp.reporting.ledger import ( InMemoryReportingReconciliationStore, - PgReportingReconciliationStore, ReportingCanonicalDigest, ReportingControlTotalRecord, ReportingDeliveryScope, diff --git a/tests/conformance/reporting/_reliable_process.py b/tests/conformance/reporting/_reliable_process.py index b2d3659e9..7778fa31f 100644 --- a/tests/conformance/reporting/_reliable_process.py +++ b/tests/conformance/reporting/_reliable_process.py @@ -7,6 +7,18 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import base64 import json @@ -17,15 +29,15 @@ from datetime import timedelta from types import SimpleNamespace +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger import PgReportingReconciliationStore + from adcp.reporting.outbox import PgReportingOutbox +else: + from adcp_reporting_storage import PgReportingOutbox, PgReportingReconciliationStore from psycopg import AsyncConnection from psycopg_pool import AsyncConnectionPool -from adcp.reporting.ledger import PgReportingReconciliationStore -from adcp.reporting.outbox import ( - PgReportingOutbox, - ReportingEnvelopeCipher, - ReportingNotificationWorker, -) +from adcp.reporting.outbox import ReportingEnvelopeCipher, ReportingNotificationWorker from ._generation_support import configuration, obligation_for, revision_for from ._reliable_support import ( diff --git a/tests/conformance/reporting/_reliable_support.py b/tests/conformance/reporting/_reliable_support.py index c385f6c9d..81caed671 100644 --- a/tests/conformance/reporting/_reliable_support.py +++ b/tests/conformance/reporting/_reliable_support.py @@ -9,6 +9,18 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import hashlib import json @@ -23,6 +35,12 @@ import pytest +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger import PgReportingReconciliationStore + from adcp.reporting.outbox import PgReportingOutbox +else: + from adcp_reporting_storage import PgReportingOutbox, PgReportingReconciliationStore + from adcp.reporting.canonical_json import canonical_json_utf8_v1 from adcp.reporting.fixtures import ( OFFICIAL_OFFERING_ID, @@ -40,7 +58,6 @@ ) from adcp.reporting.ledger import ( InMemoryReportingReconciliationStore, - PgReportingReconciliationStore, ProducerOfferings, ReportingCanonicalDigest, ReportingConfiguration, @@ -63,7 +80,6 @@ ) from adcp.reporting.outbox import ( InMemoryReportingOutbox, - PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationSubscription, ReportingNotificationWorker, diff --git a/tests/conformance/reporting/_row_storage_interop.py b/tests/conformance/reporting/_row_storage_interop.py index bcf00917c..bc630b832 100644 --- a/tests/conformance/reporting/_row_storage_interop.py +++ b/tests/conformance/reporting/_row_storage_interop.py @@ -10,22 +10,49 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import base64 import json from pathlib import Path from typing import Any -from adcp.reporting.ledger.row_storage import ( - ReportingObjectRowBindingV1, - ReportingPostgresRowBindingV1, - ReportingRowStorageOptionsV1, - ReportingRowStorageV1, -) -from adcp.reporting.ledger.row_storage_object import ( - ReportingRowObjectContextV1, - ReportingRowObjectPutInputV1, - create_filesystem_reporting_row_object_provider_v1, -) +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger.row_storage import ( + ReportingObjectRowBindingV1, + ReportingPostgresRowBindingV1, + ReportingRowStorageOptionsV1, + ReportingRowStorageV1, + ) +else: + from adcp_reporting_storage.ledger.row_storage import ( + ReportingObjectRowBindingV1, + ReportingPostgresRowBindingV1, + ReportingRowStorageOptionsV1, + ReportingRowStorageV1, + ) +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger.row_storage_object import ( + ReportingRowObjectContextV1, + ReportingRowObjectPutInputV1, + create_filesystem_reporting_row_object_provider_v1, + ) +else: + from adcp_reporting_storage.ledger.row_storage_object import ( + ReportingRowObjectContextV1, + ReportingRowObjectPutInputV1, + create_filesystem_reporting_row_object_provider_v1, + ) NAMESPACE = "interop" #: A zone whose local date differs from UTC, so the object key proves the timezone rule. @@ -273,3 +300,39 @@ async def replay_state(pool: Any, root: Path, state: dict[str, Any]) -> dict[str ), ) return versions + + +def write_fixture(path: Path, expected: list[dict[str, Any]], state: dict[str, Any]) -> None: + """Commit a writer's rows compactly: one row set, chunk, body or object per line. + + ``indent=1`` made each fixture 12,926 lines for 12 row sets (#1491); one + JSON item per line keeps a diff per row set and the file under 100 KB. + """ + + def items(values: list[dict[str, Any]]) -> str: + return ( + "[\n" + + ",\n".join( + " " + json.dumps(value, separators=(",", ":"), sort_keys=True) for value in values + ) + + "\n ]" + ) + + objects = ",\n".join( + f" {json.dumps(key)}:{json.dumps(value)}" + for key, value in sorted(state["objects"].items()) + ) + text = ( + "{\n" + ' "expected": ' + items(expected) + ",\n" + ' "state": {\n' + ' "chunk_bodies": ' + items(state["chunk_bodies"]).replace("\n ", "\n ") + ",\n" + ' "objects": {\n' + objects.replace("\n ", "\n ") + "\n },\n" + ' "row_chunks": ' + items(state["row_chunks"]).replace("\n ", "\n ") + ",\n" + ' "row_sets": ' + items(state["row_sets"]).replace("\n ", "\n ") + "\n" + " }\n" + "}\n" + ) + assert json.loads(text) == {"expected": expected, "state": state} + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text) diff --git a/tests/conformance/reporting/_status_old_process.py b/tests/conformance/reporting/_status_old_process.py index 64d11ba96..4dbeb99ca 100644 --- a/tests/conformance/reporting/_status_old_process.py +++ b/tests/conformance/reporting/_status_old_process.py @@ -6,6 +6,18 @@ from __future__ import annotations +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import json import sys @@ -18,18 +30,24 @@ async def main(settings, release): source = Path(settings["old_source"]).resolve() sys.path.insert(0, str(source / "src")) + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger import PgReportingLedgerStore + from adcp.reporting.outbox import PgReportingOutbox + else: + from adcp_reporting_storage import PgReportingLedgerStore, PgReportingOutbox + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox._schema import validate_schema + else: + from adcp_reporting_storage.outbox._schema import validate_schema from psycopg_pool import AsyncConnectionPool import adcp.reporting.ledger.notification_models as models import adcp.reporting.outbox.worker as worker_module - from adcp.reporting.ledger import PgReportingLedgerStore from adcp.reporting.outbox import ( - PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationError, ReportingNotificationWorker, ) - from adcp.reporting.outbox._schema import validate_schema assert not hasattr(models, "StatusChanged") assert Path(worker_module.__file__).resolve().is_relative_to(source) diff --git a/tests/conformance/reporting/_status_process.py b/tests/conformance/reporting/_status_process.py index adfe639e8..26d242199 100644 --- a/tests/conformance/reporting/_status_process.py +++ b/tests/conformance/reporting/_status_process.py @@ -1,14 +1,30 @@ """Real database-clock roles in the shared process harness. No injected clock.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp on sys.path predates the storage split (#1510). + + Its stores still live in adcp.reporting and the storage package must not + be used. Evaluated at each import, after the script has set up sys.path. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + from dataclasses import replace from datetime import datetime, timedelta, timezone +if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.ledger import PgReportingReconciliationStore + from adcp.reporting.outbox import PgStatusNotificationStore +else: + from adcp_reporting_storage import PgReportingReconciliationStore, PgStatusNotificationStore from psycopg import AsyncConnection from psycopg_pool import AsyncConnectionPool -from adcp.reporting.ledger import PgReportingReconciliationStore, ReportingDeliveryEscalation +from adcp.reporting.ledger import ReportingDeliveryEscalation from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller -from adcp.reporting.outbox import PgStatusNotificationStore from .test_reporting_notification_outbox import statement @@ -65,7 +81,10 @@ async def execute(self, query, params=None, **kwargs): if settings.get("start_paused"): await barrier("status_worker_ready", backend_pid=pid) - import adcp.reporting.outbox.status_pg as module + if _storage_split(): # wheels before the storage split (#1510) + import adcp.reporting.outbox.status_pg as module + else: + import adcp_reporting_storage.outbox.status_pg as module enqueue, apply = module._enqueue_on, status._apply_on diff --git a/tests/conformance/reporting/test_pg_reporting_ledger.py b/tests/conformance/reporting/test_pg_reporting_ledger.py index 5840b0654..2de8aff41 100644 --- a/tests/conformance/reporting/test_pg_reporting_ledger.py +++ b/tests/conformance/reporting/test_pg_reporting_ledger.py @@ -42,6 +42,8 @@ allow_module_level=True, ) +from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore # noqa: E402 + from adcp.reporting.canonical_json import canonical_json_utf8_v1 # noqa: E402 from adcp.reporting.ledger import ( # noqa: E402 ConsumerStatusIngest, @@ -59,7 +61,6 @@ derive_period, revision_content_sha256, ) -from adcp.reporting.ledger.pg import PgReportingLedgerStore # noqa: E402 ACCOUNT = "acct_pg" CALLER = ReportingStatusCaller(account_id=ACCOUNT, consumer_id="buyer_pg") diff --git a/tests/conformance/reporting/test_reliable_reporting_core_recovery.py b/tests/conformance/reporting/test_reliable_reporting_core_recovery.py index e91891871..3ddfe48a5 100644 --- a/tests/conformance/reporting/test_reliable_reporting_core_recovery.py +++ b/tests/conformance/reporting/test_reliable_reporting_core_recovery.py @@ -8,6 +8,7 @@ from typing import Any import pytest +from adcp_reporting_storage import postgres_reliable_reporting_service from adcp.reporting.fixtures import redacted_capabilities from adcp.reporting.ledger import InMemoryReportingLedgerStore, ReportingStatusCaller @@ -86,7 +87,7 @@ def memory(**kwargs: Any) -> ReliableReportingService: now = row[0] def postgres(**kwargs: Any) -> ReliableReportingService: - return ReliableReportingService.postgres(pool=pool, clock=lambda: now, **kwargs) + return postgres_reliable_reporting_service(pool=pool, clock=lambda: now, **kwargs) yield postgres, now diff --git a/tests/conformance/reporting/test_reliable_reporting_production_admission.py b/tests/conformance/reporting/test_reliable_reporting_production_admission.py index 0da31855b..4f005c41a 100644 --- a/tests/conformance/reporting/test_reliable_reporting_production_admission.py +++ b/tests/conformance/reporting/test_reliable_reporting_production_admission.py @@ -277,7 +277,7 @@ async def test_failed_context_admission_rolls_back_configuration_and_destination async def test_context_insert_guard_rejects_tampering_and_keeps_legacy_documents(tmp_path): raise_exception = pytest.importorskip("psycopg").errors.RaiseException - from adcp.reporting.production.schema import validate_production_schema + from adcp_reporting_storage.production.schema import validate_production_schema selected = {} async with production_harness( diff --git a/tests/conformance/reporting/test_reliable_reporting_service_lifecycle.py b/tests/conformance/reporting/test_reliable_reporting_service_lifecycle.py index 3e1048fda..403654744 100644 --- a/tests/conformance/reporting/test_reliable_reporting_service_lifecycle.py +++ b/tests/conformance/reporting/test_reliable_reporting_service_lifecycle.py @@ -9,10 +9,11 @@ from typing import Any import pytest +from adcp_reporting_storage import postgres_reliable_reporting_service +from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore from adcp.reporting.fixtures import redacted_capabilities from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller -from adcp.reporting.ledger.pg import PgReportingLedgerStore from adcp.reporting.service import ( ReliableReportingService, ReliableReportingShutdownTimeoutError, @@ -174,7 +175,7 @@ async def close_owned() -> None: await asyncio.wait_for(service.close(), 5) assert configurations_at_close == [0 if cancel_call else 1] assert pool.closed is False # an injected PostgreSQL pool is borrowed - restarted = ReliableReportingService.postgres(pool=pool, account_context=_account_context) + restarted = postgres_reliable_reporting_service(pool=pool, account_context=_account_context) await restarted.start() try: retained = await restarted.store.list_configurations( diff --git a/tests/conformance/reporting/test_reporting_activity_migration.py b/tests/conformance/reporting/test_reporting_activity_migration.py index 1632a6c49..79c8f0197 100644 --- a/tests/conformance/reporting/test_reporting_activity_migration.py +++ b/tests/conformance/reporting/test_reporting_activity_migration.py @@ -12,16 +12,15 @@ from pathlib import Path import pytest +from adcp_reporting_storage import PgReportingLedgerStore, PgReportingOutbox +from adcp_reporting_storage.outbox._schema import REQUIRED_OBJECTS, schema_objects, validate_schema -from adcp.reporting.ledger import PgReportingLedgerStore from adcp.reporting.outbox import ( ActivityOutcome, ActivityRequest, - PgReportingOutbox, ReportingActivityProjector, ReportingNotificationError, ) -from adcp.reporting.outbox._schema import REQUIRED_OBJECTS, schema_objects, validate_schema from ._generation_support import ( NOW, @@ -42,7 +41,7 @@ from .test_reporting_notification_packaging import run_step from .test_reporting_webhook_activity import prepare, reserve, rows -SQL = files("adcp.reporting.ledger").joinpath("reporting_webhook_activity.sql").read_text() +SQL = files("adcp_reporting_storage.ledger").joinpath("reporting_webhook_activity.sql").read_text() # The integrated A predecessor includes checkpoint and locale-portable readiness. BASE = "17ee407ae3978c8a2bb54437287afbf9dafb8130" ROOT = Path(__file__).resolve().parents[3] @@ -51,13 +50,13 @@ async def install_a(pool): async with pool.connection() as conn, conn.transaction(): for name in CHAIN: - await conn.execute(files("adcp.reporting.ledger").joinpath(name).read_text()) + await conn.execute(files("adcp_reporting_storage.ledger").joinpath(name).read_text()) async def test_required_manifest_is_identical_across_random_schemas_and_repeated_migration(): snapshots = [] identities = [] - manifest = files("adcp.reporting.outbox").joinpath("required_schema.json").read_text() + manifest = files("adcp_reporting_storage.outbox").joinpath("required_schema.json").read_text() async with ( isolated_reporting_pool(autocommit=True) as first, isolated_reporting_pool(autocommit=True) as second, @@ -559,6 +558,7 @@ def actual_a_source(tmp_path_factory): values = json.load(sys.stdin) sys.path.insert(0, str(Path.cwd() / 'src')) from psycopg_pool import AsyncConnectionPool +# The actual A source predates the storage split: its stores live in adcp.reporting. from adcp.reporting.ledger import PgReportingLedgerStore from adcp.reporting.outbox import ( PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationSubscription, @@ -643,10 +643,12 @@ async def test_actual_a_binary_refuses_owned_schema_without_rewriting_delivery(a async def test_actual_a_schema_requires_ownership_maintenance_before_activity(actual_a_source): + from adcp_reporting_storage.migration import ( + ReportingOwnershipMigrationError, + migrate_legacy_reporting, + ) from psycopg import sql - from adcp.reporting.migration import ReportingOwnershipMigrationError, migrate_legacy_reporting - async with isolated_reporting_pool(autocommit=True) as pool: await run_a(actual_a_source, pool, action="bootstrap") ledger = PgReportingLedgerStore(pool=pool, notifications=True) diff --git a/tests/conformance/reporting/test_reporting_activity_schema_proof.py b/tests/conformance/reporting/test_reporting_activity_schema_proof.py index 5c6052197..175cdea91 100644 --- a/tests/conformance/reporting/test_reporting_activity_schema_proof.py +++ b/tests/conformance/reporting/test_reporting_activity_schema_proof.py @@ -1,5 +1,17 @@ """Real catalog accounting through the supported activity/capability composition.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp under test predates the storage split (#1510). + + The installed hardening controls run this module inside historical wheels, + whose stores still live in adcp.reporting. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio from concurrent.futures import ThreadPoolExecutor from contextlib import AsyncExitStack, asynccontextmanager, contextmanager @@ -106,8 +118,16 @@ async def compose_activity(reliable, composite): status = None reader = outbox if composite: - from adcp.reporting.outbox.status_activity_pg import PgReportingActivityUnionStore - from adcp.reporting.outbox.status_pg import PgStatusNotificationStore + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox.status_activity_pg import PgReportingActivityUnionStore + else: + from adcp_reporting_storage.outbox.status_activity_pg import ( + PgReportingActivityUnionStore, + ) + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox.status_pg import PgStatusNotificationStore + else: + from adcp_reporting_storage.outbox.status_pg import PgStatusNotificationStore store = PgStatusNotificationStore(reliable.store) await store.create_schema() @@ -461,7 +481,10 @@ async def test_separate_search_paths_and_instances_do_not_share_positive_proof(m async def test_b_and_composite_prove_each_packaged_contract_once(monkeypatch): - from adcp.reporting.outbox import _schema, status_schema + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox import _schema, status_schema + else: + from adcp_reporting_storage.outbox import _schema, status_schema async with reliable_factory("postgres", notifications=True, autocommit=True) as reliable: composite = await compose_activity(reliable, True) @@ -514,7 +537,10 @@ def c_contract(installed, **options): async def test_positive_proof_is_bound_to_packaged_manifest_contract(activity_proof, monkeypatch): - from adcp.reporting.outbox import _schema + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.outbox import _schema + else: + from adcp_reporting_storage.outbox import _schema support, handler, accounting, _ = activity_proof await handler.get_adcp_capabilities() diff --git a/tests/conformance/reporting/test_reporting_buyer_submission_cas.py b/tests/conformance/reporting/test_reporting_buyer_submission_cas.py index 78b3fd3c2..8580e1fdb 100644 --- a/tests/conformance/reporting/test_reporting_buyer_submission_cas.py +++ b/tests/conformance/reporting/test_reporting_buyer_submission_cas.py @@ -7,14 +7,14 @@ import threading import pytest +from adcp_reporting_storage import PgReportingSubmissionIntentStore +from adcp_reporting_storage.submissions import pg as submission_pg from adcp.reporting.submissions import ( - PgReportingSubmissionIntentStore, ReportingSubmissionCode, ReportingSubmissionError, prepare_reporting_receipt_submission, ) -from adcp.reporting.submissions import pg as submission_pg from ._buyer_submission_support import SCOPE, mixed, receipt, response_for from ._generation_support import isolated_reporting_pool diff --git a/tests/conformance/reporting/test_reporting_buyer_submission_migration.py b/tests/conformance/reporting/test_reporting_buyer_submission_migration.py index 2794ce811..a2ce46c5b 100644 --- a/tests/conformance/reporting/test_reporting_buyer_submission_migration.py +++ b/tests/conformance/reporting/test_reporting_buyer_submission_migration.py @@ -5,9 +5,9 @@ import traceback import pytest +from adcp_reporting_storage import PgReportingSubmissionIntentStore from adcp.reporting.submissions import ( - PgReportingSubmissionIntentStore, ReportingSubmissionCode, ReportingSubmissionError, prepare_reporting_receipt_submission, diff --git a/tests/conformance/reporting/test_reporting_buyer_submission_packaging.py b/tests/conformance/reporting/test_reporting_buyer_submission_packaging.py index 0ef664db4..440465475 100644 --- a/tests/conformance/reporting/test_reporting_buyer_submission_packaging.py +++ b/tests/conformance/reporting/test_reporting_buyer_submission_packaging.py @@ -12,15 +12,22 @@ from pathlib import Path import pytest +from adcp_reporting_storage import PgReportingSubmissionIntentStore from adcp.reporting.ledger.delivery import receipt_to_wire -from adcp.reporting.submissions import PgReportingSubmissionIntentStore, ReportingSubmissionScope +from adcp.reporting.submissions import ReportingSubmissionScope from ._generation_support import require_rolling_database from ._receipt_support import adjustment_for, batch_state, receipt_case, receipt_harness from .test_reporting_buyer_submission_process import buyer_worker from .test_reporting_materializer_packaging import b1_wheels, built_distribution -from .test_reporting_notification_packaging import ROOT, run_step +from .test_reporting_notification_packaging import ( + ROOT, + STORAGE_SRC, + run_step, + storage_distribution, + storage_source, +) __all__ = ["b1_wheels", "built_distribution"] @@ -51,7 +58,12 @@ async def test_python310_installed_buyer_submission_contract(request, kind, driv ) await asyncio.to_thread( run_step, - [*installer, str(wheels[kind]) + ("[pg]" if drivers else ""), "mypy==1.20.2"], + [ + *installer, + str(wheels[kind]) + ("[pg]" if drivers else ""), + str(storage_distribution(wheels[kind])), + "mypy==1.20.2", + ], label=label + "-install", cwd=root, timeout=180, @@ -63,10 +75,20 @@ async def test_python310_installed_buyer_submission_contract(request, kind, driv ).hexdigest() for path in (ROOT / "src/adcp/reporting/submissions").glob("*.py") } - sql_path = ROOT / "src/adcp/reporting/ledger/reporting_buyer_submissions.sql" - with zipfile.ZipFile(wheels[kind]) as wheel, tarfile.open(source) as sdist: + modules["adcp_reporting_storage.submissions.pg"] = hashlib.sha256( + (STORAGE_SRC / "submissions/pg.py").read_bytes() + ).hexdigest() + sql_path = ( + ROOT + / "packages/adcp-reporting-storage/src/adcp_reporting_storage" + / "ledger/reporting_buyer_submissions.sql" + ) + with ( + zipfile.ZipFile(storage_distribution(wheels[kind])) as wheel, + tarfile.open(storage_source(source)) as sdist, + ): prefix = sdist.getnames()[0].split("/")[0] - member = "adcp/reporting/ledger/reporting_buyer_submissions.sql" + member = "adcp_reporting_storage/ledger/reporting_buyer_submissions.sql" assert ( wheel.read(member) == sdist.extractfile(f"{prefix}/src/{member}").read() diff --git a/tests/conformance/reporting/test_reporting_buyer_submission_process.py b/tests/conformance/reporting/test_reporting_buyer_submission_process.py index 96fb95b47..e43d60367 100644 --- a/tests/conformance/reporting/test_reporting_buyer_submission_process.py +++ b/tests/conformance/reporting/test_reporting_buyer_submission_process.py @@ -8,9 +8,10 @@ from pathlib import Path import pytest +from adcp_reporting_storage import PgReportingSubmissionIntentStore from adcp.reporting.ledger.delivery import receipt_to_wire -from adcp.reporting.submissions import PgReportingSubmissionIntentStore, ReportingSubmissionScope +from adcp.reporting.submissions import ReportingSubmissionScope from ._receipt_support import adjustment_for, batch_state, receipt_case, receipt_harness from .test_reporting_materializer_process import Child diff --git a/tests/conformance/reporting/test_reporting_buyer_submission_seller.py b/tests/conformance/reporting/test_reporting_buyer_submission_seller.py index 49b1a79d2..3e92f65e3 100644 --- a/tests/conformance/reporting/test_reporting_buyer_submission_seller.py +++ b/tests/conformance/reporting/test_reporting_buyer_submission_seller.py @@ -3,11 +3,11 @@ from __future__ import annotations import pytest +from adcp_reporting_storage import PgReportingSubmissionIntentStore from adcp.reporting.ledger.delivery import receipt_to_wire from adcp.reporting.submissions import ( InMemoryReportingSubmissionIntentStore, - PgReportingSubmissionIntentStore, ReportingReceiptFailureCode, ReportingSubmissionScope, submit_reporting_receipts, diff --git a/tests/conformance/reporting/test_reporting_buyer_submissions.py b/tests/conformance/reporting/test_reporting_buyer_submissions.py index 2ac1cb6be..403c7d817 100644 --- a/tests/conformance/reporting/test_reporting_buyer_submissions.py +++ b/tests/conformance/reporting/test_reporting_buyer_submissions.py @@ -8,10 +8,10 @@ from dataclasses import replace import pytest +from adcp_reporting_storage import PgReportingSubmissionIntentStore from adcp.reporting.canonical_json import canonical_json_utf8_v1 from adcp.reporting.submissions import ( - PgReportingSubmissionIntentStore, ReportingReceiptFailureCode, ReportingSubmissionCode, ReportingSubmissionError, diff --git a/tests/conformance/reporting/test_reporting_caller_ownership.py b/tests/conformance/reporting/test_reporting_caller_ownership.py index 5dade9351..7059e090d 100644 --- a/tests/conformance/reporting/test_reporting_caller_ownership.py +++ b/tests/conformance/reporting/test_reporting_caller_ownership.py @@ -8,6 +8,15 @@ from pathlib import Path import pytest +from adcp_reporting_storage import postgres_reliable_reporting_service +from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore +from adcp_reporting_storage.migration import ( + ReportingOwnershipBackfill, + ReportingOwnershipMigrationError, + backfill_legacy_reporting, + legacy_generation_digest, + migrate_legacy_reporting, +) from adcp.exceptions import ADCPTaskError from adcp.reporting.ledger import ( @@ -17,15 +26,7 @@ ReportingStatusCaller, ReportingStatusHandler, ) -from adcp.reporting.ledger.pg import PgReportingLedgerStore from adcp.reporting.ledger.store import encode_cursor -from adcp.reporting.migration import ( - ReportingOwnershipBackfill, - ReportingOwnershipMigrationError, - backfill_legacy_reporting, - legacy_generation_digest, - migrate_legacy_reporting, -) from ._generation_support import ( NOW, @@ -173,10 +174,9 @@ async def test_maintenance_archive_backfill_preserves_evidence_and_never_replays import json require_rolling_database() + from adcp_reporting_storage.ledger.pg import _configuration_payload, _fingerprint from psycopg import sql - from adcp.reporting.ledger.pg import _configuration_payload, _fingerprint - archive = "adcp_reporting_quarantine_" + secrets.token_hex(6) async with isolated_reporting_pool(autocommit=True) as pool: async with pool.connection() as c: @@ -370,12 +370,11 @@ async def test_maintenance_archive_backfill_preserves_evidence_and_never_replays assert await store.list_all_configurations() == () # A new service process starts over retained data without asking # for source authority or constructing workers for the old work. - from adcp.reporting.service import ReliableReportingService def no_legacy_dispatch(configuration): raise AssertionError("quarantined generation cannot acquire source authority") - restarted = ReliableReportingService.postgres( + restarted = postgres_reliable_reporting_service( pool=pool, account_context=no_legacy_dispatch ) await restarted.start() @@ -493,7 +492,7 @@ async def check(service): else: async with isolated_reporting_pool() as pool: await check( - ReliableReportingService.postgres( + postgres_reliable_reporting_service( pool=pool, account_context=lambda _: None, caller_resolver=resolve ) ) diff --git a/tests/conformance/reporting/test_reporting_change_feed_consumers_pg.py b/tests/conformance/reporting/test_reporting_change_feed_consumers_pg.py index edf3a1a17..b7f719594 100644 --- a/tests/conformance/reporting/test_reporting_change_feed_consumers_pg.py +++ b/tests/conformance/reporting/test_reporting_change_feed_consumers_pg.py @@ -11,9 +11,8 @@ from typing import Any import pytest - -from adcp.reporting.ledger import PgReportingLedgerStore -from adcp.reporting.ledger.change_feed import encode_reporting_change_cursor_v1 +from adcp_reporting_storage import PgReportingLedgerStore +from adcp_reporting_storage.ledger.change_feed import encode_reporting_change_cursor_v1 from ._generation_support import isolated_reporting_pool from .test_reporting_row_storage_pg import fetch_all diff --git a/tests/conformance/reporting/test_reporting_change_feed_horizon_pg.py b/tests/conformance/reporting/test_reporting_change_feed_horizon_pg.py index 9a4aaf99d..9948666ec 100644 --- a/tests/conformance/reporting/test_reporting_change_feed_horizon_pg.py +++ b/tests/conformance/reporting/test_reporting_change_feed_horizon_pg.py @@ -16,9 +16,8 @@ from typing import Any import pytest - -from adcp.reporting.ledger import PgReportingLedgerStore -from adcp.reporting.ledger.change_feed import ( +from adcp_reporting_storage import PgReportingLedgerStore +from adcp_reporting_storage.ledger.change_feed import ( ReportingChangeCursorExpiredError, encode_reporting_change_cursor_v1, ) diff --git a/tests/conformance/reporting/test_reporting_change_feed_pg.py b/tests/conformance/reporting/test_reporting_change_feed_pg.py index 8b2f74c6e..fb3bcbe4a 100644 --- a/tests/conformance/reporting/test_reporting_change_feed_pg.py +++ b/tests/conformance/reporting/test_reporting_change_feed_pg.py @@ -6,9 +6,8 @@ from typing import Any import pytest - -from adcp.reporting.ledger import PgReportingLedgerStore -from adcp.reporting.ledger.change_feed import ( +from adcp_reporting_storage import PgReportingLedgerStore +from adcp_reporting_storage.ledger.change_feed import ( ReportingChangeCursorExpiredError, decode_reporting_change_cursor_v1, encode_reporting_change_cursor_v1, @@ -367,7 +366,7 @@ async def test_a_committed_prefix_is_returned_and_the_cursor_stops_before_the_fi async def test_the_account_feed_query_is_served_by_the_account_seq_index() -> None: - from adcp.reporting.ledger.change_feed import _page_query + from adcp_reporting_storage.ledger.change_feed import _page_query async with isolated_reporting_pool() as pool: await feed_store(pool) diff --git a/tests/conformance/reporting/test_reporting_conditional_metrics.py b/tests/conformance/reporting/test_reporting_conditional_metrics.py index 2f561ce37..2398781cb 100644 --- a/tests/conformance/reporting/test_reporting_conditional_metrics.py +++ b/tests/conformance/reporting/test_reporting_conditional_metrics.py @@ -9,6 +9,7 @@ from typing import Any import pytest +from adcp_reporting_storage import postgres_reliable_reporting_service from adcp.reporting.conformance import ( ReportingSourceConformanceError, @@ -143,7 +144,7 @@ def context(config: ReportingConfiguration) -> ReportingAccountContext: async with AsyncExitStack() as stack: if request.param == "postgres": pool = await stack.enter_async_context(isolated_reporting_pool()) - service = ReliableReportingService.postgres( + service = postgres_reliable_reporting_service( pool=pool, account_context=context, clock=clock ) else: diff --git a/tests/conformance/reporting/test_reporting_core_lifecycle.py b/tests/conformance/reporting/test_reporting_core_lifecycle.py index 2dcfdb4d0..88a45b68c 100644 --- a/tests/conformance/reporting/test_reporting_core_lifecycle.py +++ b/tests/conformance/reporting/test_reporting_core_lifecycle.py @@ -3,7 +3,7 @@ Every other test in this suite checks one piece. This one checks that the pieces compose: a simulated reporting source drives :class:`~adcp.reporting.ledger.ReportingProducer`, which writes a -:class:`~adcp.reporting.ledger.pg.PgReportingLedgerStore`, which +:class:`~adcp_reporting_storage.ledger.pg.PgReportingLedgerStore`, which :class:`~adcp.reporting.ledger.ReportingStatusHandler` projects onto the wire, which the SDK's **own buyer-side** ``reconcile_reporting_core`` then reconciles. @@ -54,6 +54,8 @@ allow_module_level=True, ) +from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore # noqa: E402 + from adcp.reporting import ( # noqa: E402 ExpectedReportingPeriod, ReportingReconciliationError, @@ -81,7 +83,6 @@ first_ordinal_after, iso_duration_to_timedelta, ) -from adcp.reporting.ledger.pg import PgReportingLedgerStore # noqa: E402 from adcp.reporting.ledger.producer import recompute_revision_content_sha256 # noqa: E402 from adcp.reporting.source import reporting_source_capabilities_sha256_v1 # noqa: E402 from adcp.types import ( # noqa: E402 diff --git a/tests/conformance/reporting/test_reporting_currency.py b/tests/conformance/reporting/test_reporting_currency.py index 77a85f080..18bdec28c 100644 --- a/tests/conformance/reporting/test_reporting_currency.py +++ b/tests/conformance/reporting/test_reporting_currency.py @@ -10,6 +10,7 @@ from typing import Any import pytest +from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore from adcp.reporting import ( ReportingContentReading, @@ -55,7 +56,6 @@ revision_content_sha256, ) from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller -from adcp.reporting.ledger.pg import PgReportingLedgerStore from adcp.reporting.ledger.producer import recompute_revision_content_sha256 from adcp.reporting.source import ( ReportingSourceCapabilitiesV1, diff --git a/tests/conformance/reporting/test_reporting_currency_migration.py b/tests/conformance/reporting/test_reporting_currency_migration.py index fb2b0c670..321f0a3d3 100644 --- a/tests/conformance/reporting/test_reporting_currency_migration.py +++ b/tests/conformance/reporting/test_reporting_currency_migration.py @@ -8,13 +8,13 @@ from typing import TYPE_CHECKING, Any import pytest +from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore from adcp.reporting.ledger import ( ProducerOfferings, ReportingProducer, ReportingStatusCaller, ) -from adcp.reporting.ledger.pg import PgReportingLedgerStore from ._generation_support import ( NOW, @@ -29,8 +29,10 @@ from psycopg_pool import AsyncConnectionPool FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" -MIGRATION = files("adcp.reporting.ledger").joinpath("reporting_ledger_obligation_currency.sql") -ACCOUNT_MIGRATION = files("adcp.reporting.ledger").joinpath( +MIGRATION = files("adcp_reporting_storage.ledger").joinpath( + "reporting_ledger_obligation_currency.sql" +) +ACCOUNT_MIGRATION = files("adcp_reporting_storage.ledger").joinpath( "reporting_ledger_account_generations.sql" ) @@ -74,12 +76,11 @@ async def test_migration_preserves_evidence_and_quarantines_unknown_currency( autocommit: bool, ) -> None: require_rolling_database() - from psycopg import sql - - from adcp.reporting.migration import ( + from adcp_reporting_storage.migration import ( ReportingOwnershipMigrationError, migrate_legacy_reporting, ) + from psycopg import sql async with isolated_reporting_pool(autocommit=autocommit) as pool: async with pool.connection() as connection: @@ -160,7 +161,7 @@ async def test_currency_constraints_and_immutability_cover_direct_sql() -> None: async def test_unexpected_default_rolls_back_without_rewriting_evidence() -> None: async with isolated_reporting_pool() as pool: - from adcp.reporting.migration import ReportingOwnershipMigrationError + from adcp_reporting_storage.migration import ReportingOwnershipMigrationError async with pool.connection() as connection: await connection.execute((FIXTURES / "reporting_ledger_beta15.sql").read_text()) diff --git a/tests/conformance/reporting/test_reporting_empty_legacy_replacement.py b/tests/conformance/reporting/test_reporting_empty_legacy_replacement.py index d1611e561..df06b25f8 100644 --- a/tests/conformance/reporting/test_reporting_empty_legacy_replacement.py +++ b/tests/conformance/reporting/test_reporting_empty_legacy_replacement.py @@ -7,9 +7,8 @@ from pathlib import Path import pytest - -from adcp.reporting.ledger.pg import PgReportingLedgerStore -from adcp.reporting.migration import ( +from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore +from adcp_reporting_storage.migration import ( ReportingOwnershipMigrationError, replace_empty_legacy_reporting, ) diff --git a/tests/conformance/reporting/test_reporting_empty_legacy_replacement_sync.py b/tests/conformance/reporting/test_reporting_empty_legacy_replacement_sync.py index 07610a912..1b5922c6f 100644 --- a/tests/conformance/reporting/test_reporting_empty_legacy_replacement_sync.py +++ b/tests/conformance/reporting/test_reporting_empty_legacy_replacement_sync.py @@ -3,8 +3,7 @@ from __future__ import annotations import pytest - -from adcp.reporting.migration import ( +from adcp_reporting_storage.migration import ( ReportingOwnershipMigrationError, replace_empty_legacy_reporting_sync, ) diff --git a/tests/conformance/reporting/test_reporting_empty_owned_replacement.py b/tests/conformance/reporting/test_reporting_empty_owned_replacement.py index d24145da5..a264d2415 100644 --- a/tests/conformance/reporting/test_reporting_empty_owned_replacement.py +++ b/tests/conformance/reporting/test_reporting_empty_owned_replacement.py @@ -5,8 +5,7 @@ import asyncio import pytest - -from adcp.reporting.migration import ( +from adcp_reporting_storage.migration import ( ReportingOwnershipMigrationError, replace_empty_legacy_reporting, replace_empty_owned_reporting, @@ -256,7 +255,7 @@ def test_snapshot_isolation_refuses_rollback(isolation): async def test_async_rollback_matches_the_sync_guarantees(): require_rolling_database() - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore async with isolated_reporting_pool(autocommit=True) as pool: await PgReportingLedgerStore(pool=pool, change_feed=True).create_schema() diff --git a/tests/conformance/reporting/test_reporting_evidence_currency_integration.py b/tests/conformance/reporting/test_reporting_evidence_currency_integration.py index 7b21916d8..ecac503b8 100644 --- a/tests/conformance/reporting/test_reporting_evidence_currency_integration.py +++ b/tests/conformance/reporting/test_reporting_evidence_currency_integration.py @@ -907,15 +907,14 @@ async def test_populated_ledger_upgrade_then_granular_publication_and_exact_reco await connection.execute((fixture / "reporting_ledger_beta15.sql").read_text()) await connection.execute((fixture / "reporting_ledger_beta15_data.sql").read_text()) before = await _retained_rows(h.blobs.pool) - from psycopg import sql - - from adcp.reporting.migration import ( + from adcp_reporting_storage.migration import ( ReportingOwnershipBackfill, ReportingOwnershipMigrationError, backfill_legacy_reporting, legacy_generation_digest, migrate_legacy_reporting, ) + from psycopg import sql with pytest.raises(ReportingOwnershipMigrationError, match="Stop reporting workers"): await h.store.create_schema() @@ -1136,8 +1135,9 @@ async def test_postgres_fresh_process_reads_exact_seals_currency_rows_and_receip from psycopg_pool import AsyncConnectionPool from adcp.reporting.inline_source import InlineFetchResult, MetricEvidence from adcp.reporting.ledger import ( - PgReportingReconciliationStore, ReportingDeliveryPrincipal, ReportingReceiptKey, + ReportingDeliveryPrincipal, ReportingReceiptKey, ) +from adcp_reporting_storage import PgReportingReconciliationStore from adcp.reporting.source import ReportingSourceSliceRequestV1 from tests.conformance.reporting._reliable_support import ( ManualClock, ReliableHarness, _BytesStore, verified, diff --git a/tests/conformance/reporting/test_reporting_feed_hardening_installed.py b/tests/conformance/reporting/test_reporting_feed_hardening_installed.py index dc039aa1a..783cbd1db 100644 --- a/tests/conformance/reporting/test_reporting_feed_hardening_installed.py +++ b/tests/conformance/reporting/test_reporting_feed_hardening_installed.py @@ -62,6 +62,8 @@ def approved_b23(tmp_path_factory, request): with zipfile.ZipFile(wheel) as archive: modules = {} for name in set(feed_modules()) | (set(MODULES) - {"adcp.reporting.receipts._diagnostics"}): + # B23 predates the storage split: its stores live under adcp/reporting. + name = name.replace("adcp_reporting_storage.", "adcp.reporting.", 1) path = name.replace(".", "/") + ".py" if path not in archive.namelist(): path = name.replace(".", "/") + "/__init__.py" diff --git a/tests/conformance/reporting/test_reporting_feed_history.py b/tests/conformance/reporting/test_reporting_feed_history.py index 66e5e92ff..51a1bf6f2 100644 --- a/tests/conformance/reporting/test_reporting_feed_history.py +++ b/tests/conformance/reporting/test_reporting_feed_history.py @@ -342,7 +342,7 @@ async def test_accepted_artifact_and_canonical_evidence_remain_frozen_after_late # preserved. Actual unmodified DB-time selection has a separate vector. h.clock.now += timedelta(days=500) if h.pool is not None: - from adcp.reporting.feed import pg as feed_pg + from adcp_reporting_storage.feed import pg as feed_pg async def future(connection): return ( diff --git a/tests/conformance/reporting/test_reporting_feed_installed_pg.py b/tests/conformance/reporting/test_reporting_feed_installed_pg.py index 728256222..c0d73b76c 100644 --- a/tests/conformance/reporting/test_reporting_feed_installed_pg.py +++ b/tests/conformance/reporting/test_reporting_feed_installed_pg.py @@ -11,8 +11,8 @@ from pathlib import Path import pytest +from adcp_reporting_storage import PgReportingFeedStore -from adcp.reporting.feed import PgReportingFeedStore from adcp.reporting.ledger import ReportingMaterializationCheck from adcp.reporting.ledger.status_projection import mismatch_key @@ -30,6 +30,7 @@ ) from .test_reporting_feed_process import feed_process from .test_reporting_notification_outbox import statement +from .test_reporting_notification_packaging import storage_distribution __all__ = ["b1_wheels", "built_distribution", "feed_wheels"] @@ -52,7 +53,12 @@ def installed_feed(request): else [str(python), "-m", "pip", "install"] ) run_step( - [*installer, f"{wheels[request.param]}[pg]", "asgi-lifespan==2.1.0"], + [ + *installer, + f"{wheels[request.param]}[pg]", + str(storage_distribution(wheels[request.param])), + "asgi-lifespan==2.1.0", + ], label=f"feed-{request.param}-pg-install", cwd=root, timeout=180, diff --git a/tests/conformance/reporting/test_reporting_feed_migration.py b/tests/conformance/reporting/test_reporting_feed_migration.py index 3a92343af..07ff2a81d 100644 --- a/tests/conformance/reporting/test_reporting_feed_migration.py +++ b/tests/conformance/reporting/test_reporting_feed_migration.py @@ -5,12 +5,12 @@ from importlib.resources import files import pytest +from adcp_reporting_storage import PgReportingFeedStore, PgReportingReceiptStore +from adcp_reporting_storage.outbox._schema import REQUIRED_OBJECTS, schema_objects +from adcp_reporting_storage.outbox.status_schema import REQUIRED_STATUS_OBJECTS -from adcp.reporting.feed import PgReportingFeedStore, ReportingFeedError +from adcp.reporting.feed import ReportingFeedError from adcp.reporting.materializer import ReportingMaterializerLease -from adcp.reporting.outbox._schema import REQUIRED_OBJECTS, schema_objects -from adcp.reporting.outbox.status_schema import REQUIRED_STATUS_OBJECTS -from adcp.reporting.receipts import PgReportingReceiptStore from ._durable_materializer_support import DurableHarness, durable_case from ._feed_support import feed_harness, feed_request, mixed_case, walk, without_feed @@ -19,8 +19,10 @@ from ._receipt_support import receipt_case, request_for from ._reconciliation_support import Clock -SQL = files("adcp.reporting.ledger").joinpath("reporting_feed.sql").read_text() -MANIFEST = json.loads(files("adcp.reporting.feed").joinpath("required_schema.json").read_text()) +SQL = files("adcp_reporting_storage.ledger").joinpath("reporting_feed.sql").read_text() +MANIFEST = json.loads( + files("adcp_reporting_storage.feed").joinpath("required_schema.json").read_text() +) async def fairness(pool): @@ -73,7 +75,9 @@ async def test_feed_migration_preserves_parent_catalog_receipts_pending_and_fair for package in ("materializer", "receipts"): parent_manifest.update( json.loads( - files("adcp.reporting." + package).joinpath("required_schema.json").read_text() + files("adcp_reporting_storage." + package) + .joinpath("required_schema.json") + .read_text() ) ) assert len(parent_manifest) == 767 @@ -94,7 +98,9 @@ async def test_feed_migration_preserves_parent_catalog_receipts_pending_and_fair assert len(MANIFEST) == 33 for package, count in (("materializer", 187), ("receipts", 102)): required = json.loads( - files("adcp.reporting." + package).joinpath("required_schema.json").read_text() + files("adcp_reporting_storage." + package) + .joinpath("required_schema.json") + .read_text() ) assert len(required) == count and all(actual[k] == v for k, v in required.items()) assert without_feed(await h.image()) == before diff --git a/tests/conformance/reporting/test_reporting_feed_packaging.py b/tests/conformance/reporting/test_reporting_feed_packaging.py index 36fef48c3..532e75a82 100644 --- a/tests/conformance/reporting/test_reporting_feed_packaging.py +++ b/tests/conformance/reporting/test_reporting_feed_packaging.py @@ -12,6 +12,11 @@ import pytest from .test_reporting_materializer_packaging import ROOT, b1_wheels, built_distribution, run_step +from .test_reporting_notification_packaging import ( + STORAGE_SRC, + storage_distribution, + storage_source, +) __all__ = ["b1_wheels", "built_distribution"] @@ -43,9 +48,10 @@ def feed_modules(): @pytest.fixture(scope="module") def feed_wheels(b1_wheels, built_distribution): root, wheels, _ = b1_wheels - _, _, source = built_distribution + _, _, adcp_source = built_distribution + source = storage_source(adcp_source) assets = { - name: (ROOT / "src/adcp/reporting" / name).read_bytes() + name: (STORAGE_SRC / name).read_bytes() for name in ( "ledger/reporting_feed.sql", "feed/required_schema.json", @@ -58,11 +64,11 @@ def feed_wheels(b1_wheels, built_distribution): with tarfile.open(source) as sdist: prefix = sdist.getnames()[0].split("/")[0] for name, raw in assets.items(): - assert sdist.extractfile(f"{prefix}/src/adcp/reporting/{name}").read() == raw + assert sdist.extractfile(f"{prefix}/src/adcp_reporting_storage/{name}").read() == raw for wheel in wheels.values(): - with zipfile.ZipFile(wheel) as archive: + with zipfile.ZipFile(storage_distribution(wheel)) as archive: for name, raw in assets.items(): - assert archive.read("adcp/reporting/" + name) == raw + assert archive.read("adcp_reporting_storage/" + name) == raw return root, wheels, {name: hashlib.sha256(raw).hexdigest() for name, raw in assets.items()} @@ -87,7 +93,7 @@ def test_python310_feed_without_pg_exports_sql_and_strict_adopter(request, kind) else [str(python), "-m", "pip", "install"] ) run_step( - [*installer, str(wheels[kind]), "mypy==1.20.2"], + [*installer, str(wheels[kind]), str(storage_distribution(wheels[kind])), "mypy==1.20.2"], label=f"feed-{kind}-base-install", cwd=root, timeout=180, diff --git a/tests/conformance/reporting/test_reporting_feed_transactions.py b/tests/conformance/reporting/test_reporting_feed_transactions.py index 69cffe0ab..0da75074c 100644 --- a/tests/conformance/reporting/test_reporting_feed_transactions.py +++ b/tests/conformance/reporting/test_reporting_feed_transactions.py @@ -10,9 +10,10 @@ from uuid import UUID import pytest +from adcp_reporting_storage import PgReportingFeedStore from adcp.reporting.canonical_json import canonical_json_utf8_v1 -from adcp.reporting.feed import PgReportingFeedStore, ReportingFeedError +from adcp.reporting.feed import ReportingFeedError from adcp.reporting.feed.snapshot import StoredFeedSnapshot from ._durable_materializer_support import DurableHarness, durable_case @@ -37,7 +38,7 @@ @asynccontextmanager async def paused_feed_projection(monkeypatch, *, failure=False): """Pause the real pure projector, without blocking the test event loop.""" - from adcp.reporting.feed import pg + from adcp_reporting_storage.feed import pg loop = asyncio.get_running_loop() loop_thread = threading.get_ident() @@ -432,7 +433,9 @@ def failing(*args, **kwargs): if fault == "assembly": monkeypatch.setattr(StoredFeedSnapshot, "page", failing) else: - from adcp.reporting.feed import memory, pg + from adcp_reporting_storage.feed import pg + + from adcp.reporting.feed import memory monkeypatch.setattr(memory if h.pool is None else pg, "inject_context", failing) elif h.pool is None: diff --git a/tests/conformance/reporting/test_reporting_feed_transports.py b/tests/conformance/reporting/test_reporting_feed_transports.py index f0b89f5b7..79d91ab6d 100644 --- a/tests/conformance/reporting/test_reporting_feed_transports.py +++ b/tests/conformance/reporting/test_reporting_feed_transports.py @@ -346,10 +346,11 @@ async def forbidden_projection(*args, **kwargs): async def test_feed_mount_preserves_closed_tier_and_new_store_notification_admission(feeds): + from adcp_reporting_storage import PgReportingOutbox + from adcp.reporting.ledger.notification_models import ReportingNotificationError from adcp.reporting.outbox import ( InMemoryReportingOutbox, - PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationWorker, ) diff --git a/tests/conformance/reporting/test_reporting_generation_identity.py b/tests/conformance/reporting/test_reporting_generation_identity.py index 81145e450..d8ed953e7 100644 --- a/tests/conformance/reporting/test_reporting_generation_identity.py +++ b/tests/conformance/reporting/test_reporting_generation_identity.py @@ -8,6 +8,8 @@ from datetime import timedelta import pytest +from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore +from adcp_reporting_storage.outbox._schema import schema_objects from adcp.reporting.fixtures import redacted_capabilities from adcp.reporting.inline_source import InlineReportingSource @@ -26,8 +28,6 @@ consumer_mismatch_issue_key, ) from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller -from adcp.reporting.ledger.pg import PgReportingLedgerStore -from adcp.reporting.outbox._schema import schema_objects from adcp.reporting.source import ReportingSourceSliceRequestV1 from tests.conformance.reporting._generation_support import ( END, diff --git a/tests/conformance/reporting/test_reporting_generation_migration.py b/tests/conformance/reporting/test_reporting_generation_migration.py index f4a2766b8..24c6f096f 100644 --- a/tests/conformance/reporting/test_reporting_generation_migration.py +++ b/tests/conformance/reporting/test_reporting_generation_migration.py @@ -9,8 +9,8 @@ from typing import TYPE_CHECKING, Any import pytest +from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore -from adcp.reporting.ledger.pg import PgReportingLedgerStore from tests.conformance.reporting._generation_support import ( isolated_reporting_pool, ) @@ -23,7 +23,9 @@ _FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" _BETA15_SCHEMA = _FIXTURES / "reporting_ledger_beta15.sql" _BETA15_DATA = _FIXTURES / "reporting_ledger_beta15_data.sql" -_MIGRATION = files("adcp.reporting.ledger").joinpath("reporting_ledger_account_generations.sql") +_MIGRATION = files("adcp_reporting_storage.ledger").joinpath( + "reporting_ledger_account_generations.sql" +) _TABLES = ( "reporting_configurations", "reporting_obligations", @@ -142,7 +144,7 @@ async def _other_constraints(pool: AsyncConnectionPool) -> list[Any]: async def test_beta15_boot_fails_closed_and_preserves_all_evidence() -> None: - from adcp.reporting.migration import ReportingOwnershipMigrationError + from adcp_reporting_storage.migration import ReportingOwnershipMigrationError async with isolated_reporting_pool() as pool: await _load_beta15(pool) @@ -189,7 +191,7 @@ async def test_standalone_key_upgrade_requires_explicit_maintenance() -> None: ], ) async def test_legacy_key_variants_do_not_authorize_implicit_ownership(key_change: str) -> None: - from adcp.reporting.migration import ReportingOwnershipMigrationError + from adcp_reporting_storage.migration import ReportingOwnershipMigrationError async with isolated_reporting_pool() as pool: await _load_beta15(pool) @@ -202,7 +204,7 @@ async def test_legacy_key_variants_do_not_authorize_implicit_ownership(key_chang async def test_adopter_foreign_keys_survive_refused_implicit_upgrade() -> None: - from adcp.reporting.migration import ReportingOwnershipMigrationError + from adcp_reporting_storage.migration import ReportingOwnershipMigrationError async with isolated_reporting_pool() as pool: await _load_beta15(pool) diff --git a/tests/conformance/reporting/test_reporting_host_reads_pg.py b/tests/conformance/reporting/test_reporting_host_reads_pg.py index 284bb7779..a508d5e45 100644 --- a/tests/conformance/reporting/test_reporting_host_reads_pg.py +++ b/tests/conformance/reporting/test_reporting_host_reads_pg.py @@ -8,8 +8,7 @@ from typing import Any import pytest - -from adcp.reporting.ledger import PgReportingLedgerStore +from adcp_reporting_storage import PgReportingLedgerStore from ._generation_support import START, configuration, isolated_reporting_pool, obligation_for from .test_reporting_row_storage_pg import fetch_all, many_rows, typed_revision diff --git a/tests/conformance/reporting/test_reporting_inline_storage.py b/tests/conformance/reporting/test_reporting_inline_storage.py index c061578a4..6bb765a50 100644 --- a/tests/conformance/reporting/test_reporting_inline_storage.py +++ b/tests/conformance/reporting/test_reporting_inline_storage.py @@ -22,6 +22,7 @@ import anyio import pytest +from adcp_reporting_storage.inline_storage import PgReportingSealStore, PgReportingStagingStore from adcp.reporting.conformance import validate_reporting_source_execution from adcp.reporting.fixtures import redacted_capabilities, redacted_snapshot_request @@ -29,8 +30,6 @@ from adcp.reporting.inline_storage import ( INLINE_STAGING_MAX_BYTES, InlineStorageError, - PgReportingSealStore, - PgReportingStagingStore, PreparedBackendObjectV1, PreparedBackendSealV1, ) @@ -109,12 +108,12 @@ def find_spec(self, fullname, path=None, target=None): if fullname.split('.')[0] in {'psycopg', 'psycopg_pool'}: raise ModuleNotFoundError('driver deliberately absent') sys.meta_path.insert(0, MissingDriver()) -from adcp.reporting.inline_storage import PgReportingStagingStore, PgReportingSealStore +from adcp_reporting_storage.inline_storage import PgReportingStagingStore, PgReportingSealStore for cls in [PgReportingStagingStore, PgReportingSealStore]: try: cls(pool=object()) except ImportError as error: - assert str(error) == 'PostgreSQL inline storage requires adcp[pg]' + assert str(error) == 'PostgreSQL inline storage requires adcp-reporting-storage[pg]' assert error.__context__ is None else: raise AssertionError('construction requires the optional driver') @@ -691,7 +690,7 @@ async def test_unmigrated_operations_are_schema_unready(database, operation) -> async def test_catalog_additive_metadata_does_not_change_readiness(stores, monkeypatch) -> None: - import adcp.reporting.inline_storage as module + import adcp_reporting_storage.inline_storage as module staging, _, _ = stores original = module.schema_objects @@ -709,7 +708,7 @@ async def with_metadata(connection): @pytest.mark.parametrize("field", ["fingerprint", "enabled"]) async def test_catalog_required_fields_still_fail_closed(stores, monkeypatch, field) -> None: - import adcp.reporting.inline_storage as module + import adcp_reporting_storage.inline_storage as module staging, _, _ = stores original = module.schema_objects @@ -853,7 +852,7 @@ async def forged_transaction(): async def test_large_payload_hash_yields_and_matches_exact_bytes(stores, monkeypatch) -> None: - import adcp.reporting.inline_storage as module + import adcp_reporting_storage.inline_storage as module staging, _, _ = stores payload = b"x" * INLINE_STAGING_MAX_BYTES @@ -893,7 +892,7 @@ async def test_cancellation_during_payload_hashing_settles_without_partial_write ) -> None: staging, _, pool = stores payload = b"x" * INLINE_STAGING_MAX_BYTES - import adcp.reporting.inline_storage as module + import adcp_reporting_storage.inline_storage as module original_digest = module._payload_digest original_transaction = staging._transaction diff --git a/tests/conformance/reporting/test_reporting_materializer_durable.py b/tests/conformance/reporting/test_reporting_materializer_durable.py index 153c252d8..b865f3332 100644 --- a/tests/conformance/reporting/test_reporting_materializer_durable.py +++ b/tests/conformance/reporting/test_reporting_materializer_durable.py @@ -33,10 +33,11 @@ async def test_core_records_do_not_create_managed_work_capture_or_readiness(back async def test_durable_verified_success_still_cannot_advertise_unactivated_readiness(backend): + from adcp_reporting_storage import PgReportingOutbox + from adcp.reporting.ledger.notification_models import ReportingNotificationError from adcp.reporting.outbox import ( InMemoryReportingOutbox, - PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationWorker, ) @@ -79,10 +80,11 @@ async def test_materializer_store_closes_core_advertisement_until_b24_admits_it( Managed/Reconciled claims. Fail closed is correct here, but it is broader than a tier veto and an adopter switching store classes must be told. """ + from adcp_reporting_storage import PgReportingOutbox + from adcp.reporting.ledger.notification_models import ReportingNotificationError from adcp.reporting.outbox import ( InMemoryReportingOutbox, - PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationWorker, ) @@ -109,7 +111,7 @@ async def advertise(store, outbox): reviewed_outbox = InMemoryReportingOutbox(reviewed) outbox = InMemoryReportingOutbox(h.store) else: - from adcp.reporting.ledger.delivery_pg import PgReportingReconciliationStore + from adcp_reporting_storage.ledger.delivery_pg import PgReportingReconciliationStore reviewed = PgReportingReconciliationStore(pool=h.pool, notifications=True) reviewed_outbox = PgReportingOutbox(pool=h.pool) diff --git a/tests/conformance/reporting/test_reporting_materializer_installed_pg.py b/tests/conformance/reporting/test_reporting_materializer_installed_pg.py index 86e4d7e49..fe081d8a1 100644 --- a/tests/conformance/reporting/test_reporting_materializer_installed_pg.py +++ b/tests/conformance/reporting/test_reporting_materializer_installed_pg.py @@ -9,13 +9,13 @@ from pathlib import Path import pytest - -from adcp.reporting.materializer import PgReportingMaterializerStore +from adcp_reporting_storage import PgReportingMaterializerStore from ._durable_materializer_support import DurableHarness, durable_case from ._generation_support import isolated_reporting_pool, require_rolling_database from .test_reporting_materializer_packaging import ROOT, b1_wheels, built_distribution, run_step from .test_reporting_materializer_process import worker +from .test_reporting_notification_packaging import STORAGE_SRC, storage_distribution # Expose the exact existing fixtures without rebuilding a current module as an # alleged historical artifact. These are the new head's two distribution paths. @@ -41,7 +41,11 @@ def installed_materializer(request): else [str(python), "-m", "pip", "install"] ) run_step( - [*installer, f"{wheels[request.param]}[pg]"], + [ + *installer, + f"{wheels[request.param]}[pg]", + str(storage_distribution(wheels[request.param])), + ], label=f"materializer-{request.param}-pg-install", cwd=path, timeout=180, @@ -58,10 +62,13 @@ def installed_materializer(request): if os.environ.get("ADCP_PYTHON310"): assert version == [3, 10] modules = {} - for name in ("materializer/pg.py", "materializer/service.py", "materializer/verification.py"): + for name in ("materializer/service.py", "materializer/verification.py"): modules["adcp.reporting." + name.removesuffix(".py").replace("/", ".")] = hashlib.sha256( (ROOT / "src/adcp/reporting" / name).read_bytes() ).hexdigest() + modules["adcp_reporting_storage.materializer.pg"] = hashlib.sha256( + (STORAGE_SRC / "materializer/pg.py").read_bytes() + ).hexdigest() return path, python, script, {"workspace": str(ROOT), "python": version, "modules": modules} diff --git a/tests/conformance/reporting/test_reporting_materializer_migration.py b/tests/conformance/reporting/test_reporting_materializer_migration.py index 16872d0b8..2a6503df7 100644 --- a/tests/conformance/reporting/test_reporting_materializer_migration.py +++ b/tests/conformance/reporting/test_reporting_materializer_migration.py @@ -6,19 +6,19 @@ from importlib.resources import files import pytest +from adcp_reporting_storage import PgReportingMaterializerStore, PgReportingReconciliationStore +from adcp_reporting_storage.outbox._schema import REQUIRED_OBJECTS, schema_objects, validate_schema +from adcp_reporting_storage.outbox.status_schema import REQUIRED_STATUS_OBJECTS -from adcp.reporting.ledger import LedgerConflictError, PgReportingReconciliationStore, derive_period -from adcp.reporting.materializer import PgReportingMaterializerStore -from adcp.reporting.outbox._schema import REQUIRED_OBJECTS, schema_objects, validate_schema -from adcp.reporting.outbox.status_schema import REQUIRED_STATUS_OBJECTS +from adcp.reporting.ledger import LedgerConflictError, derive_period from ._durable_materializer_support import durable_case, durable_harness from ._generation_support import isolated_reporting_pool, obligation_for from ._provisional_catalog import PROVISIONAL_OBJECTS -SQL = files("adcp.reporting.ledger").joinpath("reporting_materializer.sql").read_text() +SQL = files("adcp_reporting_storage.ledger").joinpath("reporting_materializer.sql").read_text() MANIFEST = json.loads( - files("adcp.reporting.materializer").joinpath("required_schema.json").read_text() + files("adcp_reporting_storage.materializer").joinpath("required_schema.json").read_text() ) @@ -251,8 +251,9 @@ async def test_bounded_sampling_walks_past_a_full_page_of_busy_account_locks(): async def test_pre_activation_event_never_promotes_and_old_outbox_cannot_claim_it(): + from adcp_reporting_storage import PgReportingOutbox + from adcp.reporting.ledger.notification_models import decode_event - from adcp.reporting.outbox import PgReportingOutbox async with durable_harness("postgres", notifications=True) as h: case = await durable_case(h.store) diff --git a/tests/conformance/reporting/test_reporting_materializer_packaging.py b/tests/conformance/reporting/test_reporting_materializer_packaging.py index 348c20cd6..b7ff195f2 100644 --- a/tests/conformance/reporting/test_reporting_materializer_packaging.py +++ b/tests/conformance/reporting/test_reporting_materializer_packaging.py @@ -43,11 +43,27 @@ def b1_wheels(built_distribution): timeout=180, ) vcs_wheel = next(direct.glob("*.whl")) + # The storage distribution's VCS wheel, from its own tree, next to adcp's. + storage_direct = path / "vcs-wheel-storage" + run_step( + distribution.build_command(distribution.STORAGE_ROOT, storage_direct, wheel=True), + label="b1-vcs-storage-wheel", + cwd=ROOT, + timeout=180, + ) + storage_vcs_wheel = next(storage_direct.glob("*.whl")) + distribution.register_storage( + vcs_wheel, + wheel=storage_vcs_wheel, + source=distribution.storage_source(sdist_wheel), + ) expected = {f.name: f.read_bytes() for f in ASSETS.glob("*.json")} with ( zipfile.ZipFile(vcs_wheel) as vcs, zipfile.ZipFile(sdist_wheel) as wheel, tarfile.open(source) as tar, + zipfile.ZipFile(storage_vcs_wheel) as storage_vcs, + zipfile.ZipFile(distribution.storage_distribution(sdist_wheel)) as storage_wheel, ): prefix = tar.getnames()[0].split("/")[0] for name, raw in expected.items(): @@ -63,9 +79,9 @@ def b1_wheels(built_distribution): "receipts/required_schema.json", ): assert ( - vcs.read(f"adcp/reporting/{relative}") - == wheel.read(f"adcp/reporting/{relative}") - == (ROOT / "src/adcp/reporting" / relative).read_bytes() + storage_vcs.read(f"adcp_reporting_storage/{relative}") + == storage_wheel.read(f"adcp_reporting_storage/{relative}") + == (distribution.STORAGE_SRC / relative).read_bytes() ) return ( path, @@ -123,7 +139,12 @@ def test_python310_installed_wheel_exports_verifier_reference_and_strict_adopter else [str(python), "-m", "pip", "install"] ) run_step( - [*installer, str(wheels[kind]), "mypy==1.20.2"], + [ + *installer, + str(wheels[kind]), + str(distribution.storage_distribution(wheels[kind])), + "mypy==1.20.2", + ], label=f"b1-{kind}-noneditable-install", cwd=path, timeout=180, diff --git a/tests/conformance/reporting/test_reporting_materializer_progress.py b/tests/conformance/reporting/test_reporting_materializer_progress.py index 39c82e7da..98154a0cf 100644 --- a/tests/conformance/reporting/test_reporting_materializer_progress.py +++ b/tests/conformance/reporting/test_reporting_materializer_progress.py @@ -7,12 +7,9 @@ from contextlib import asynccontextmanager import pytest +from adcp_reporting_storage import PgReportingMaterializerStore -from adcp.reporting.materializer import ( - PgReportingMaterializerStore, - ReportingMaterializerLease, - ReportingWriterError, -) +from adcp.reporting.materializer import ReportingMaterializerLease, ReportingWriterError from ._durable_materializer_support import durable_case diff --git a/tests/conformance/reporting/test_reporting_materializer_service.py b/tests/conformance/reporting/test_reporting_materializer_service.py index 97292ab4e..22e5d9971 100644 --- a/tests/conformance/reporting/test_reporting_materializer_service.py +++ b/tests/conformance/reporting/test_reporting_materializer_service.py @@ -59,7 +59,7 @@ async def test_cancellation_and_restart_never_allocate_a_new_external_identity( async def test_service_heartbeat_keeps_long_io_alive_with_size_one_postgres_pool( stage, tmp_path, monkeypatch ): - from adcp.reporting.materializer import PgReportingMaterializerStore + from adcp_reporting_storage import PgReportingMaterializerStore async with durable_harness("postgres", notifications=True) as h: from psycopg_pool import AsyncConnectionPool diff --git a/tests/conformance/reporting/test_reporting_materializer_transactions.py b/tests/conformance/reporting/test_reporting_materializer_transactions.py index 9788fba5a..7f04ac5a8 100644 --- a/tests/conformance/reporting/test_reporting_materializer_transactions.py +++ b/tests/conformance/reporting/test_reporting_materializer_transactions.py @@ -72,7 +72,7 @@ async def test_reservation_rolls_back_attempt_work_delivery_and_sequence_heads( # Count explicitly so attempt failure happens after the delivery's # own complete insertion, not at its earlier feed append. if position == "attempt": - from adcp.reporting.materializer.pg import PgReportingMaterializerStore + from adcp_reporting_storage.materializer.pg import PgReportingMaterializerStore original = PgReportingMaterializerStore._commit_record_on @@ -261,7 +261,7 @@ def empty(self, event): monkeypatch.setattr(MaterializerNotificationState, "enqueue", empty) else: - import adcp.reporting.materializer.pg as pg + import adcp_reporting_storage.materializer.pg as pg async def empty(connection, event): pass diff --git a/tests/conformance/reporting/test_reporting_notification_migration.py b/tests/conformance/reporting/test_reporting_notification_migration.py index b470363ce..0f34c401d 100644 --- a/tests/conformance/reporting/test_reporting_notification_migration.py +++ b/tests/conformance/reporting/test_reporting_notification_migration.py @@ -8,11 +8,11 @@ from importlib.resources import files import pytest +from adcp_reporting_storage import PgReportingOutbox, PgReportingReconciliationStore +from adcp_reporting_storage.outbox._schema import schema_contract, validate_schema +from adcp_reporting_storage.outbox.status_schema import REQUIRED_STATUS_OBJECTS -from adcp.reporting.ledger import PgReportingReconciliationStore -from adcp.reporting.outbox import PgReportingOutbox, ReportingNotificationError -from adcp.reporting.outbox._schema import schema_contract, validate_schema -from adcp.reporting.outbox.status_schema import REQUIRED_STATUS_OBJECTS +from adcp.reporting.outbox import ReportingNotificationError from ._generation_support import ( NOW, @@ -24,7 +24,7 @@ from ._reliable_support import Barrier from .test_reporting_reconciliation_migration import FIXTURES -RESOURCES = files("adcp.reporting.ledger") +RESOURCES = files("adcp_reporting_storage.ledger") CHAIN = ( "reporting_ledger.sql", "reporting_ledger_account_generations.sql", @@ -159,10 +159,12 @@ async def test_populated_pre_outbox_upgrade_never_rewrites_or_backfills(autocomm @pytest.mark.parametrize("hopwise", [False, True]) async def test_direct_and_hopwise_historical_schema_chain(source, hopwise): require_rolling_database() + from adcp_reporting_storage.migration import ( + ReportingOwnershipMigrationError, + migrate_legacy_reporting, + ) from psycopg import sql - from adcp.reporting.migration import ReportingOwnershipMigrationError, migrate_legacy_reporting - async with isolated_reporting_pool(autocommit=True) as pool: async with pool.connection() as conn: await conn.execute((FIXTURES / source).read_text()) @@ -314,7 +316,7 @@ async def test_readiness_validates_the_installed_chain_not_table_presence(damage async def test_malformed_outbox_upgrade_rolls_back_entire_chain(): async with isolated_reporting_pool(autocommit=True) as pool: - from adcp.reporting.migration import ReportingOwnershipMigrationError + from adcp_reporting_storage.migration import ReportingOwnershipMigrationError async with pool.connection() as conn: await conn.execute((FIXTURES / "reporting_ledger_beta15.sql").read_text()) @@ -357,7 +359,7 @@ async def test_default_off_upgrade_preserves_adopter_index_and_opt_in_checks_rea )[0] assert retained == original # B validates the required subset: an unrelated index remains compatible. - from adcp.reporting.outbox import PgReportingOutbox + from adcp_reporting_storage import PgReportingOutbox await PgReportingOutbox(pool=pool).create_schema() async with pool.connection() as conn: diff --git a/tests/conformance/reporting/test_reporting_notification_packaging.py b/tests/conformance/reporting/test_reporting_notification_packaging.py index 615786fb5..c5a1bdadc 100644 --- a/tests/conformance/reporting/test_reporting_notification_packaging.py +++ b/tests/conformance/reporting/test_reporting_notification_packaging.py @@ -31,6 +31,44 @@ from .test_reporting_notification_migration import CHAIN, WAIVER_OBJECTS ROOT = Path(__file__).resolve().parents[3] +#: The storage distribution ships separately (adcp-client-python#1510); a current +#: build is two wheels. Historical wheels predate the split and carry the stores. +STORAGE_ROOT = ROOT / "packages/adcp-reporting-storage" +STORAGE_SRC = STORAGE_ROOT / "src/adcp_reporting_storage" +#: adcp wheel or sdist of the current tree -> (storage wheel, storage sdist) built with it. +STORAGE_DISTRIBUTIONS: dict[Path, tuple[Path, Path]] = {} + + +def register_storage(*adcp_artifacts, wheel, source): + for artifact in adcp_artifacts: + STORAGE_DISTRIBUTIONS[Path(artifact)] = (Path(wheel), Path(source)) + + +def is_current_build(distribution): + """Whether ``distribution`` is an adcp artifact of this tree (so needs the storage wheel).""" + return Path(distribution) in STORAGE_DISTRIBUTIONS + + +def storage_distribution(distribution): + """The adcp-reporting-storage wheel to install next to this adcp artifact.""" + return STORAGE_DISTRIBUTIONS[Path(distribution)][0] + + +def storage_source(distribution): + return STORAGE_DISTRIBUTIONS[Path(distribution)][1] + + +def storage_sources(): + """The storage package inputs, for build fingerprints and copies.""" + return [ + p + for p in STORAGE_ROOT.rglob("*") + if p.is_file() + and not any( + part in {"__pycache__", "build", "dist"} or part.endswith(".egg-info") + for part in p.parts + ) + ] def build_command(project, output, *, wheel=False): @@ -291,6 +329,7 @@ def built_distribution(tmp_path_factory, request): ) sources.extend(p for p in (ROOT / "schemas/cache").rglob("*.json")) sources.extend(p for p in (ROOT / "schemas/releases").rglob("*.json")) + sources.extend(storage_sources()) digest = hashlib.sha256() for item in sorted(sources): digest.update(str(item.relative_to(ROOT)).encode()) @@ -304,6 +343,10 @@ def built_distribution(tmp_path_factory, request): "distribution source changed; rebuild the release artifact" ) wheel, source = next(cached.glob("*.whl")), next(cached.glob("*.tar.gz")) + storage = cached / "storage" + register_storage( + wheel, source, wheel=next(storage.glob("*.whl")), source=next(storage.glob("*.tar.gz")) + ) print("notification_distribution stage=reuse-verified-wheel-sdist passed", flush=True) return path, wheel, source project = path / "project" @@ -336,12 +379,34 @@ def built_distribution(tmp_path_factory, request): timeout=300, ) wheel, source = next(dist.glob("*.whl")), next(dist.glob("*.tar.gz")) + # The storage distribution builds from its own tree; its wheel declares the + # adcp range and installs next to the adcp wheel built above. + storage_project, storage_dist = path / "project-storage", path / "dist-storage" + shutil.copytree( + STORAGE_ROOT, + storage_project, + ignore=shutil.ignore_patterns("__pycache__", "*.egg-info", "build", "dist"), + ) + run_step( + build_command(storage_project, storage_dist), + label="build-storage-sdist-and-wheel", + cwd=path, + timeout=300, + ) + storage_wheel = next(storage_dist.glob("*.whl")) + storage_sdist = next(storage_dist.glob("*.tar.gz")) if cached is not None: cached.mkdir(parents=True, exist_ok=True) shutil.copy2(wheel, cached / wheel.name) shutil.copy2(source, cached / source.name) + (cached / "storage").mkdir(exist_ok=True) + shutil.copy2(storage_wheel, cached / "storage" / storage_wheel.name) + shutil.copy2(storage_sdist, cached / "storage" / storage_sdist.name) (cached / "source.sha256").write_text(fingerprint + "\n") wheel, source = cached / wheel.name, cached / source.name + storage_wheel = cached / "storage" / storage_wheel.name + storage_sdist = cached / "storage" / storage_sdist.name + register_storage(wheel, source, wheel=storage_wheel, source=storage_sdist) return path, wheel, source @@ -363,15 +428,19 @@ def installed_distribution(built_distribution, request): if shutil.which("uv") else [str(python), "-m", "pip", "install"] ) - run_step([*installer, str(distribution)], label=f"{request.param}-base-install", cwd=path) + run_step( + [*installer, str(distribution), str(storage_distribution(distribution))], + label=f"{request.param}-base-install", + cwd=path, + ) base_check = r""" import importlib.util from importlib.resources import files from pathlib import Path import adcp +from adcp_reporting_storage import PgReportingOutbox from adcp.reporting.outbox import ( InMemoryReportingOutbox, - PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationWorker, ReportingActivityProjector, @@ -390,7 +459,7 @@ def installed_distribution(built_distribution, request): assert importlib.util.find_spec("psycopg") is None assert importlib.util.find_spec("psycopg_pool") is None -assert "adcp.reporting.outbox.status_pg" not in sys.modules +assert "adcp_reporting_storage.outbox.status_pg" not in sys.modules assert "installed" in str(Path(adcp.__file__)) assert "notifications" not in inspect.signature(ReportingProducer).parameters assert not hasattr(InMemoryReportingLedgerStore(), "commit_materialization") @@ -400,10 +469,10 @@ def installed_distribution(built_distribution, request): try: PgReportingOutbox(pool=None) except ImportError as error: - assert "adcp[pg]" in str(error), str(error) + assert "adcp-reporting-storage[pg]" in str(error), str(error) else: raise AssertionError("PgReportingOutbox must raise the [pg] install hint") -from adcp.reporting.outbox import ( +from adcp_reporting_storage import ( PgStatusNotificationStore, PgReportingStatusOutbox, PgReportingActivityUnionStore, ) for constructor in (lambda: PgStatusNotificationStore(None), @@ -412,16 +481,18 @@ def installed_distribution(built_distribution, request): try: constructor() except ImportError as error: - assert "adcp[pg]" in str(error) + assert "adcp-reporting-storage[pg]" in str(error) else: raise AssertionError("every C PG constructor must explain its extra") -assert files("adcp.reporting.ledger").joinpath("reporting_notification_outbox.sql").is_file() -assert files("adcp.reporting.ledger").joinpath("reporting_webhook_activity.sql").is_file() -assert files("adcp.reporting.outbox").joinpath("required_schema.json").is_file() -assert files("adcp.reporting.outbox").joinpath("required_status_schema.json").is_file() -assert files("adcp.reporting.ledger").joinpath("reporting_status_notifications.sql").is_file() -assert files("adcp.reporting.ledger").joinpath("reporting_status_selector_version.sql").is_file() -assert files("adcp.reporting.outbox").joinpath("required_status_selector_schema.json").is_file() +ledger_assets = files("adcp_reporting_storage.ledger") +outbox_assets = files("adcp_reporting_storage.outbox") +assert ledger_assets.joinpath("reporting_notification_outbox.sql").is_file() +assert ledger_assets.joinpath("reporting_webhook_activity.sql").is_file() +assert outbox_assets.joinpath("required_schema.json").is_file() +assert outbox_assets.joinpath("required_status_schema.json").is_file() +assert ledger_assets.joinpath("reporting_status_notifications.sql").is_file() +assert ledger_assets.joinpath("reporting_status_selector_version.sql").is_file() +assert outbox_assets.joinpath("required_status_selector_schema.json").is_file() version = files("adcp").joinpath("ADCP_VERSION").read_text().strip() assert get_named_validator( "core/reporting-status-changed-webhook.json", version=version) is not None @@ -440,7 +511,9 @@ def installed_distribution(built_distribution, request): def test_wheel_and_sdist_contain_exact_complete_sql_chain(built_distribution): - _, wheel, source = built_distribution + """The DDL chain and catalog manifests ship in the storage distribution, not adcp.""" + _, adcp_wheel, _ = built_distribution + wheel, source = storage_distribution(adcp_wheel), storage_source(adcp_wheel) with zipfile.ZipFile(wheel) as archive, tarfile.open(source) as tar: prefix = tar.getnames()[0].split("/")[0] for name in ( @@ -449,22 +522,22 @@ def test_wheel_and_sdist_contain_exact_complete_sql_chain(built_distribution): "reporting_status_notifications.sql", "reporting_status_selector_version.sql", ): - expected = (ROOT / "src" / "adcp" / "reporting" / "ledger" / name).read_bytes() - assert archive.read(f"adcp/reporting/ledger/{name}") == expected - member = tar.extractfile(f"{prefix}/src/adcp/reporting/ledger/{name}") + expected = (STORAGE_SRC / "ledger" / name).read_bytes() + assert archive.read(f"adcp_reporting_storage/ledger/{name}") == expected + member = tar.extractfile(f"{prefix}/src/adcp_reporting_storage/ledger/{name}") assert member is not None and member.read() == expected assert ( - archive.read("adcp/reporting/outbox/_schema.py") - == (ROOT / "src" / "adcp" / "reporting" / "outbox" / "_schema.py").read_bytes() + archive.read("adcp_reporting_storage/outbox/_schema.py") + == (STORAGE_SRC / "outbox" / "_schema.py").read_bytes() ) - expected = (ROOT / "src/adcp/reporting/outbox/required_schema.json").read_bytes() - assert archive.read("adcp/reporting/outbox/required_schema.json") == expected - member = tar.extractfile(f"{prefix}/src/adcp/reporting/outbox/required_schema.json") - assert member is not None and member.read() == expected - expected = (ROOT / "src/adcp/reporting/outbox/required_status_schema.json").read_bytes() - assert archive.read("adcp/reporting/outbox/required_status_schema.json") == expected - member = tar.extractfile(f"{prefix}/src/adcp/reporting/outbox/required_status_schema.json") - assert member is not None and member.read() == expected + for name in ("required_schema.json", "required_status_schema.json"): + expected = (STORAGE_SRC / "outbox" / name).read_bytes() + assert archive.read(f"adcp_reporting_storage/outbox/{name}") == expected + member = tar.extractfile(f"{prefix}/src/adcp_reporting_storage/outbox/{name}") + assert member is not None and member.read() == expected + with zipfile.ZipFile(adcp_wheel) as archive: + shipped = [n for n in archive.namelist() if n.startswith("adcp/reporting/ledger/")] + assert not [n for n in shipped if n.endswith((".sql", ".json"))], shipped def test_installed_base_import_needs_no_pg_extra(installed_distribution): @@ -489,7 +562,11 @@ async def test_installed_pg_extra_migrates_commits_and_restarts(installed_distri "now": NOW.isoformat(), "rows": rows, "required_objects": json.loads( - (ROOT / "src/adcp/reporting/outbox/required_schema.json").read_text() + ( + ROOT + / "packages/adcp-reporting-storage/src/adcp_reporting_storage" + / "outbox/required_schema.json" + ).read_text() ), "waiver_objects": WAIVER_OBJECTS, "provisional_objects": PROVISIONAL_OBJECTS, @@ -508,23 +585,24 @@ async def test_installed_pg_extra_migrates_commits_and_restarts(installed_distri from pydantic import TypeAdapter from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ( - PgReportingReconciliationStore, ReportingConfiguration, ReportingObligationRecord, ReportingRevisionRecord, ) from adcp.reporting.outbox import ( - ActivityOutcome, ActivityRequest, PgReportingOutbox, ReportingEnvelopeCipher, + ActivityOutcome, ActivityRequest, ReportingEnvelopeCipher, ReportingLegacyAuthentication, ReportingNotificationSubscription, ReportingNotificationWorker, ReportingActivityProjector, ReportingActivitySupport, - PgStatusNotificationStore, ) -from adcp.reporting.outbox._schema import schema_objects +from adcp_reporting_storage import ( + PgReportingOutbox, PgReportingReconciliationStore, PgStatusNotificationStore, +) +from adcp_reporting_storage.outbox._schema import schema_objects values = json.load(sys.stdin) assert len(values["waiver_objects"]) == 10 assert len(values["provisional_objects"]) == 27 -assert json.loads(files("adcp.reporting.ledger").joinpath( +assert json.loads(files("adcp_reporting_storage.ledger").joinpath( "required_provisional_schema.json").read_text()) == values["provisional_objects"] expected_objects = { **values["required_objects"], **values["waiver_objects"], **values["provisional_objects"] @@ -561,7 +639,7 @@ async def get_active(self, *, account_id, subscriber_id, notification_type): assert objects == expected_objects required = {key: objects[key] for key in values["required_objects"]} assert json.dumps(required, indent=2, sort_keys=True) + "\n" == files( - "adcp.reporting.outbox" + "adcp_reporting_storage.outbox" ).joinpath("required_schema.json").read_text() await store.put_configuration( TypeAdapter(ReportingConfiguration).validate_python(values["config"]) diff --git a/tests/conformance/reporting/test_reporting_notification_process_matrix.py b/tests/conformance/reporting/test_reporting_notification_process_matrix.py index c918bbe4f..bbdaa12f9 100644 --- a/tests/conformance/reporting/test_reporting_notification_process_matrix.py +++ b/tests/conformance/reporting/test_reporting_notification_process_matrix.py @@ -13,9 +13,9 @@ from functools import wraps import pytest +from adcp_reporting_storage import PgReportingOutbox, PgReportingReconciliationStore -from adcp.reporting.ledger import PgReportingReconciliationStore -from adcp.reporting.outbox import DeliveryLease, PgReportingOutbox +from adcp.reporting.outbox import DeliveryLease from ._generation_support import isolated_reporting_pool from ._reliable_support import ManualClock, _BytesStore, service_process diff --git a/tests/conformance/reporting/test_reporting_notification_transactions.py b/tests/conformance/reporting/test_reporting_notification_transactions.py index 2c6fc70a5..176faeaee 100644 --- a/tests/conformance/reporting/test_reporting_notification_transactions.py +++ b/tests/conformance/reporting/test_reporting_notification_transactions.py @@ -8,17 +8,16 @@ from datetime import timedelta import pytest +from adcp_reporting_storage import PgReportingOutbox, PgReportingReconciliationStore from adcp.reporting.ledger import ( InMemoryReportingLedgerStore, - PgReportingReconciliationStore, ReportingAdjustmentRecord, ReportingDeliveryScope, ReportingMaterializationCheck, ReportingObligationDeliveryRecord, derive_period, ) -from adcp.reporting.outbox import PgReportingOutbox from ._generation_support import END, NOW, START, configuration, obligation_for, revision_for from ._reconciliation_support import scenario diff --git a/tests/conformance/reporting/test_reporting_ownership_packaging.py b/tests/conformance/reporting/test_reporting_ownership_packaging.py index 1f5406722..85135ac17 100644 --- a/tests/conformance/reporting/test_reporting_ownership_packaging.py +++ b/tests/conformance/reporting/test_reporting_ownership_packaging.py @@ -13,7 +13,13 @@ from ._generation_support import require_rolling_database from .test_reporting_materializer_packaging import b1_wheels, built_distribution -from .test_reporting_notification_packaging import ROOT, run_step +from .test_reporting_notification_packaging import ( + ROOT, + STORAGE_SRC, + run_step, + storage_distribution, + storage_source, +) __all__ = ["b1_wheels", "built_distribution"] @@ -28,19 +34,20 @@ ReportingScheduleSpec, ReportingStatusCaller, ) -from adcp.reporting.migration import ReportingOwnershipBackfill +from adcp_reporting_storage import postgres_reliable_reporting_service +from adcp_reporting_storage.migration import ReportingOwnershipBackfill from adcp.reporting.service import ReliableReportingService from adcp.server import ADCPHandler from psycopg import AsyncConnection, sql from psycopg_pool import AsyncConnectionPool settings = json.load(sys.stdin) -assert Path(__import__("adcp.reporting.migration", fromlist=["*"]).__file__).is_relative_to( +assert Path(__import__("adcp_reporting_storage.migration", fromlist=["*"]).__file__).is_relative_to( Path(sys.prefix) ) assert ( "retain_without_replay" - in files("adcp.reporting.ledger").joinpath("reporting_caller_ownership.sql").read_text() + in files("adcp_reporting_storage.ledger").joinpath("reporting_caller_ownership.sql").read_text() ) @@ -100,7 +107,7 @@ async def main(): ) as pool: await pool.wait() await verify( - ReliableReportingService.postgres( + postgres_reliable_reporting_service( pool=pool, account_context=lambda _: None, caller_resolver=resolver ) ) @@ -145,13 +152,18 @@ async def test_owned_resources_and_private_reads_installed(request, kind): "outbox/required_schema.json", "materializer/required_schema.json", ] - with zipfile.ZipFile(wheels[kind]) as wheel, tarfile.open(source) as archive: + # Every one of these resources moved to the storage distribution (#1510). + with ( + zipfile.ZipFile(storage_distribution(wheels[kind])) as wheel, + tarfile.open(storage_source(source)) as archive, + ): prefix = archive.getnames()[0].split("/")[0] for relative in resources: - expected = (ROOT / "src/adcp/reporting" / relative).read_bytes() - assert wheel.read("adcp/reporting/" + relative) == expected + expected = (STORAGE_SRC / relative).read_bytes() + assert wheel.read("adcp_reporting_storage/" + relative) == expected assert ( - archive.extractfile(prefix + "/src/adcp/reporting/" + relative).read() == expected + archive.extractfile(prefix + "/src/adcp_reporting_storage/" + relative).read() + == expected ) environment = root / ("owned-installed-" + kind) await asyncio.to_thread( @@ -168,7 +180,14 @@ async def test_owned_resources_and_private_reads_installed(request, kind): ) await asyncio.to_thread( run_step, - [*installer, str(wheels[kind]) + "[pg]", "pytest", "pytest-asyncio", "asgi-lifespan"], + [ + *installer, + str(wheels[kind]) + "[pg]", + str(storage_distribution(wheels[kind])), + "pytest", + "pytest-asyncio", + "asgi-lifespan", + ], label="owned-install", cwd=root, timeout=180, diff --git a/tests/conformance/reporting/test_reporting_period_retirement_pg.py b/tests/conformance/reporting/test_reporting_period_retirement_pg.py index 814228a9b..543d528ca 100644 --- a/tests/conformance/reporting/test_reporting_period_retirement_pg.py +++ b/tests/conformance/reporting/test_reporting_period_retirement_pg.py @@ -6,9 +6,10 @@ from typing import Any import pytest +from adcp_reporting_storage import PgReportingLedgerStore +from adcp_reporting_storage.ledger.change_feed import encode_reporting_change_cursor_v1 -from adcp.reporting.ledger import PgReportingLedgerStore, ReportingRowStoreError -from adcp.reporting.ledger.change_feed import encode_reporting_change_cursor_v1 +from adcp.reporting.ledger import ReportingRowStoreError from adcp.reporting.ledger.store import LedgerConflictError from ._generation_support import configuration, isolated_reporting_pool, obligation_for @@ -242,8 +243,8 @@ async def test_a_retiring_period_refuses_new_revisions(tmp_path: Path) -> None: async def test_a_period_that_stopped_qualifying_is_not_retired(tmp_path: Path) -> None: - from adcp.reporting.ledger.period_retirement import _retire_period - from adcp.reporting.ledger.row_retention import NoLongerEligibleError + from adcp_reporting_storage.ledger.period_retirement import _retire_period + from adcp_reporting_storage.ledger.row_retention import NoLongerEligibleError async with isolated_reporting_pool() as pool: store = PgReportingLedgerStore(pool=pool, row_storage=options(tmp_path), change_feed=True) diff --git a/tests/conformance/reporting/test_reporting_production_lease_generations.py b/tests/conformance/reporting/test_reporting_production_lease_generations.py index 801f7fbee..f96502532 100644 --- a/tests/conformance/reporting/test_reporting_production_lease_generations.py +++ b/tests/conformance/reporting/test_reporting_production_lease_generations.py @@ -4,10 +4,11 @@ from datetime import datetime, timedelta, timezone import pytest +from adcp_reporting_storage import PgReportingLedgerStore, PgReportingMaterializerStore +from adcp_reporting_storage.materializer.schema import validate_materializer_schema -from adcp.reporting.ledger import LedgerConflictError, PgReportingLedgerStore -from adcp.reporting.materializer import PgReportingMaterializerStore, ReportingWriterError -from adcp.reporting.materializer.schema import validate_materializer_schema +from adcp.reporting.ledger import LedgerConflictError +from adcp.reporting.materializer import ReportingWriterError from ._durable_materializer_support import durable_case, durable_harness from ._generation_support import NOW, configuration, isolated_reporting_pool diff --git a/tests/conformance/reporting/test_reporting_production_lease_order.py b/tests/conformance/reporting/test_reporting_production_lease_order.py index 4adbbcd03..f36e7b58a 100644 --- a/tests/conformance/reporting/test_reporting_production_lease_order.py +++ b/tests/conformance/reporting/test_reporting_production_lease_order.py @@ -5,13 +5,16 @@ from dataclasses import replace import pytest +from adcp_reporting_storage import ( + PgReportingFeedStore, + PgReportingLedgerStore, + PgReportingMaterializerStore, + PgReportingProjectionStore, + PgReportingReceiptStore, + PgReportingReconciliationStore, +) -from adcp.reporting.feed import PgReportingFeedStore -from adcp.reporting.ledger import PgReportingLedgerStore, PgReportingReconciliationStore from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller -from adcp.reporting.materializer import PgReportingMaterializerStore -from adcp.reporting.projection import PgReportingProjectionStore -from adcp.reporting.receipts import PgReportingReceiptStore from ._generation_support import ( NOW, diff --git a/tests/conformance/reporting/test_reporting_production_lock_retry.py b/tests/conformance/reporting/test_reporting_production_lock_retry.py index 4ad751983..d2084f37d 100644 --- a/tests/conformance/reporting/test_reporting_production_lock_retry.py +++ b/tests/conformance/reporting/test_reporting_production_lock_retry.py @@ -4,15 +4,10 @@ from contextlib import asynccontextmanager import pytest +from adcp_reporting_storage import PgReportingLedgerStore, PgReportingMaterializerStore -from adcp.reporting.ledger import ( - LedgerConflictError, - PgReportingLedgerStore, - ProducerOfferings, - ReportingProducer, -) +from adcp.reporting.ledger import LedgerConflictError, ProducerOfferings, ReportingProducer from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller -from adcp.reporting.materializer import PgReportingMaterializerStore from ._generation_support import START, UncalledSource, configuration, isolated_reporting_pool from ._production_support import production_harness diff --git a/tests/conformance/reporting/test_reporting_production_migration.py b/tests/conformance/reporting/test_reporting_production_migration.py index 5c06050c8..8e32a102b 100644 --- a/tests/conformance/reporting/test_reporting_production_migration.py +++ b/tests/conformance/reporting/test_reporting_production_migration.py @@ -7,15 +7,15 @@ from importlib.resources import files import pytest +from adcp_reporting_storage import PgReportingFeedStore +from adcp_reporting_storage.outbox._schema import schema_objects +from adcp_reporting_storage.outbox.status_pg import PgStatusNotificationStore +from adcp_reporting_storage.production.pg import PgReportingProductionStore +from adcp_reporting_storage.production.schema import validate_production_schema +from adcp_reporting_storage.projection.schema import validate_projection_schema -from adcp.reporting.feed import PgReportingFeedStore from adcp.reporting.ledger.notification_models import ReportingNotificationError from adcp.reporting.materializer import ReportingMaterializerLease -from adcp.reporting.outbox._schema import schema_objects -from adcp.reporting.outbox.status_pg import PgStatusNotificationStore -from adcp.reporting.production.pg import PgReportingProductionStore -from adcp.reporting.production.schema import validate_production_schema -from adcp.reporting.projection.schema import validate_projection_schema from ._durable_materializer_support import DurableHarness, durable_case from ._feed_support import feed_request, mixed_case, walk @@ -27,13 +27,15 @@ def manifests(): required = { package: json.loads( - files("adcp.reporting." + package).joinpath("required_schema.json").read_text() + files("adcp_reporting_storage." + package).joinpath("required_schema.json").read_text() ) for package in ("materializer", "receipts", "feed", "projection", "production") } required["production"].update( json.loads( - files("adcp.reporting.production").joinpath("service_context_schema.json").read_text() + files("adcp_reporting_storage.production") + .joinpath("service_context_schema.json") + .read_text() ) ) return required diff --git a/tests/conformance/reporting/test_reporting_production_notifications.py b/tests/conformance/reporting/test_reporting_production_notifications.py index c0792364a..725ecfd35 100644 --- a/tests/conformance/reporting/test_reporting_production_notifications.py +++ b/tests/conformance/reporting/test_reporting_production_notifications.py @@ -179,7 +179,7 @@ def use_clock(h, moment): async def pin_current_clock(h): moment = h.clock() if h.pool is not None: - from adcp.reporting.outbox.pg import database_now + from adcp_reporting_storage.outbox.pg import database_now async with h.pool.connection() as connection: moment = await database_now(connection, None) diff --git a/tests/conformance/reporting/test_reporting_production_packaging.py b/tests/conformance/reporting/test_reporting_production_packaging.py index e31497061..a3cb414d1 100644 --- a/tests/conformance/reporting/test_reporting_production_packaging.py +++ b/tests/conformance/reporting/test_reporting_production_packaging.py @@ -10,7 +10,7 @@ from ._production_installed import assert_installed_schema_keys from ._production_packaging import installed_production, production_schema_hashes from .test_reporting_materializer_packaging import b1_wheels, built_distribution -from .test_reporting_notification_packaging import run_step +from .test_reporting_notification_packaging import run_step, storage_distribution __all__ = ["b1_wheels", "built_distribution"] @@ -54,7 +54,11 @@ async def test_floor_installed_production_contract(request, kind, drivers): ) await asyncio.to_thread( run_step, - [*installer, str(wheels[kind]) + ("[pg]" if drivers else "")], + [ + *installer, + str(wheels[kind]) + ("[pg]" if drivers else ""), + str(storage_distribution(wheels[kind])), + ], label=label + "-install", cwd=root, timeout=180, diff --git a/tests/conformance/reporting/test_reporting_production_readiness.py b/tests/conformance/reporting/test_reporting_production_readiness.py index bfc39a3b5..b71721451 100644 --- a/tests/conformance/reporting/test_reporting_production_readiness.py +++ b/tests/conformance/reporting/test_reporting_production_readiness.py @@ -37,7 +37,7 @@ async def test_warm_proof_rechecks_the_actual_mount(transport, mutation, tmp_pat async def test_shared_scan_cancellation_and_post_scan_dynamic_check(tmp_path, monkeypatch): - import adcp.reporting.production.schema as schema + import adcp_reporting_storage.production.schema as schema async with production_harness("postgres", tmp_path / "destination.sqlite") as h: support = h.production @@ -83,7 +83,7 @@ async def test_discovery_preserves_declaration_while_admission_rechecks_pool( ): from contextlib import AsyncExitStack - import adcp.reporting.production.schema as schema + import adcp_reporting_storage.production.schema as schema pool_type = pytest.importorskip("psycopg_pool").AsyncConnectionPool async with production_harness( @@ -160,7 +160,7 @@ async def test_discovery_preserves_declaration_while_admission_rechecks_queue_po ): from contextlib import AsyncExitStack - import adcp.reporting.production.schema as schema + import adcp_reporting_storage.production.schema as schema pool_type = pytest.importorskip("psycopg_pool").AsyncConnectionPool # The projection queue also participates without optional HTTP workers. @@ -223,7 +223,7 @@ async def no_scan(*args, **kwargs): async def test_warm_mounted_discovery_does_not_checkout_a_saturated_valid_pool( notifications, tmp_path, monkeypatch ): - import adcp.reporting.production.schema as schema + import adcp_reporting_storage.production.schema as schema async with production_harness( "postgres", @@ -314,7 +314,7 @@ async def test_optional_delivery_is_owned_and_broken_enabled_chain_fails_closed( async def test_discovery_preserves_signing_declaration_while_admission_rechecks_wiring( tmp_path, monkeypatch ): - import adcp.reporting.production.schema as schema + import adcp_reporting_storage.production.schema as schema async with production_harness( "postgres", tmp_path / "destination.sqlite", notifications=True, notification_delivery=True diff --git a/tests/conformance/reporting/test_reporting_production_rolling.py b/tests/conformance/reporting/test_reporting_production_rolling.py index 01f6d6f5b..f09bb6694 100644 --- a/tests/conformance/reporting/test_reporting_production_rolling.py +++ b/tests/conformance/reporting/test_reporting_production_rolling.py @@ -58,7 +58,9 @@ def production_parent(request, tmp_path_factory): with zipfile.ZipFile(wheel) as archive: modules = {} - for name in {*feed_modules(), "adcp.reporting.outbox.status_pg"}: + for name in {*feed_modules(), "adcp_reporting_storage.outbox.status_pg"}: + # The hardening parent predates the storage split: its stores live in adcp. + name = name.replace("adcp_reporting_storage.", "adcp.reporting.", 1) member = name.replace(".", "/") + ".py" if member not in archive.namelist(): member = name.replace(".", "/") + "/__init__.py" diff --git a/tests/conformance/reporting/test_reporting_production_service_factory.py b/tests/conformance/reporting/test_reporting_production_service_factory.py index 8d32220f6..822412377 100644 --- a/tests/conformance/reporting/test_reporting_production_service_factory.py +++ b/tests/conformance/reporting/test_reporting_production_service_factory.py @@ -1,3 +1,5 @@ +from adcp_reporting_storage import postgres_reliable_reporting_service + """Adapter registration builds a durable source and the actual production graph.""" import asyncio @@ -8,6 +10,7 @@ from types import SimpleNamespace import pytest +from adcp_reporting_storage.inline_storage import PgReportingSealStore, PgReportingStagingStore from adcp.decisioning.capabilities import Account as AccountCapabilities from adcp.reporting import ( @@ -20,7 +23,6 @@ ) from adcp.reporting.fixtures import redacted_capabilities, redacted_snapshot_request from adcp.reporting.inline_source import InMemorySealStore, InMemoryStagingStore -from adcp.reporting.inline_storage import PgReportingSealStore, PgReportingStagingStore from adcp.reporting.ledger import ProducerOfferings, ReportingDestinationBinding from adcp.reporting.materializer import ReportingRevisionVerifierRegistry from adcp.reporting.materializer.contracts import ReportingWriterCapability, ReportingWriterError @@ -53,7 +55,7 @@ async def test_postgres_factory_replays_source_after_restart_without_refetch(aut async with isolated_reporting_pool(autocommit=autocommit) as pool: request = redacted_snapshot_request() clock = DeterministicReportingClock(request.period.source_read_cutoff_at) - first = ReliableReportingService.postgres( + first = postgres_reliable_reporting_service( pool=pool, account_context=_account_context, clock=clock ) adapter = ScriptedReportingAdapter(redacted_capabilities(), [_rows(9)]) @@ -67,7 +69,7 @@ async def test_postgres_factory_replays_source_after_restart_without_refetch(aut finally: await first.close() - restarted = ReliableReportingService.postgres( + restarted = postgres_reliable_reporting_service( pool=pool, account_context=_account_context, clock=clock ) empty = ScriptedReportingAdapter(redacted_capabilities()) @@ -100,7 +102,7 @@ async def test_postgres_factory_replays_source_after_restart_without_refetch(aut async def test_postgres_factory_preserves_explicit_source_store_overrides(): async with isolated_reporting_pool() as pool: - service = ReliableReportingService.postgres(pool=pool, account_context=_account_context) + service = postgres_reliable_reporting_service(pool=pool, account_context=_account_context) staging, seals = InMemoryStagingStore(), InMemorySealStore() registered = service.sources.register( "gam", ScriptedReportingAdapter(redacted_capabilities()), staging=staging, seals=seals @@ -297,7 +299,7 @@ async def authorize(account, context, consumer): **notification_inputs, ) factory = ( - ReliableReportingService.memory if pool is None else ReliableReportingService.postgres + ReliableReportingService.memory if pool is None else postgres_reliable_reporting_service ) h.service = factory( account_context=context, diff --git a/tests/conformance/reporting/test_reporting_production_testing_kit.py b/tests/conformance/reporting/test_reporting_production_testing_kit.py index 43a0a05b2..5eead694b 100644 --- a/tests/conformance/reporting/test_reporting_production_testing_kit.py +++ b/tests/conformance/reporting/test_reporting_production_testing_kit.py @@ -71,8 +71,11 @@ async def backend(request: pytest.FixtureRequest) -> AsyncIterator[Backend]: ) yield Backend(clock, lambda: retained) else: - from adcp.reporting.inline_storage import PgReportingSealStore, PgReportingStagingStore - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.inline_storage import ( + PgReportingSealStore, + PgReportingStagingStore, + ) + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore async with isolated_reporting_pool(autocommit=request.param == "pg-autocommit") as pool: async with pool.connection() as connection: diff --git a/tests/conformance/reporting/test_reporting_production_transactions.py b/tests/conformance/reporting/test_reporting_production_transactions.py index ab07902e8..1a6d814fc 100644 --- a/tests/conformance/reporting/test_reporting_production_transactions.py +++ b/tests/conformance/reporting/test_reporting_production_transactions.py @@ -153,7 +153,7 @@ async def finish(): else: with monkeypatch.context() as patch: if h.pool is not None: - import adcp.reporting.materializer.pg as pg + import adcp_reporting_storage.materializer.pg as pg async def empty(connection, event): pass diff --git a/tests/conformance/reporting/test_reporting_projection_history.py b/tests/conformance/reporting/test_reporting_projection_history.py index 6a69890ad..4b6fa3dfa 100644 --- a/tests/conformance/reporting/test_reporting_projection_history.py +++ b/tests/conformance/reporting/test_reporting_projection_history.py @@ -139,7 +139,7 @@ async def history_replay(h, monkeypatch, *, legacy_baseline): old = InMemoryStatusNotificationStore(h.store) else: - from adcp.reporting.outbox.status_pg import PgStatusNotificationStore + from adcp_reporting_storage.outbox.status_pg import PgStatusNotificationStore old = PgStatusNotificationStore(h.store) production_operation_5 = await old.baseline(account_id=account) diff --git a/tests/conformance/reporting/test_reporting_projection_notifications.py b/tests/conformance/reporting/test_reporting_projection_notifications.py index 83b2604f4..f1cb5dbd7 100644 --- a/tests/conformance/reporting/test_reporting_projection_notifications.py +++ b/tests/conformance/reporting/test_reporting_projection_notifications.py @@ -17,7 +17,7 @@ async def test_status_queue_isolation_and_atomic_projection(backend, fault, monk if h.pool is None: old = InMemoryReportingStatusOutbox(h.store) else: - from adcp.reporting.outbox.status_pg import PgReportingStatusOutbox + from adcp_reporting_storage.outbox.status_pg import PgReportingStatusOutbox old = PgReportingStatusOutbox(pool=h.pool) assert await old.list_events(account_id=account) == () diff --git a/tests/conformance/reporting/test_reporting_projection_rc6.py b/tests/conformance/reporting/test_reporting_projection_rc6.py index b698b077a..26ad999d7 100644 --- a/tests/conformance/reporting/test_reporting_projection_rc6.py +++ b/tests/conformance/reporting/test_reporting_projection_rc6.py @@ -131,7 +131,7 @@ def request(version, view="summary"): def capture_clock(h, monkeypatch): if h.pool is not None: - from adcp.reporting.feed import pg + from adcp_reporting_storage.feed import pg async def now(connection): # Same account-locked SQL read with an explicit conformance instant. @@ -283,7 +283,7 @@ async def test_frozen_continuations_retain_bytes_and_reject_cross_version_positi if key not in {"_clock", "_lock"}: vars(store)[key] = deepcopy(value) else: - from adcp.reporting.projection.pg import PgReportingProjectionStore + from adcp_reporting_storage.projection.pg import PgReportingProjectionStore store = PgReportingProjectionStore(pool=h.pool, clock=h.clock) h.store = store diff --git a/tests/conformance/reporting/test_reporting_provisional_catalog.py b/tests/conformance/reporting/test_reporting_provisional_catalog.py index 6c2da83bc..c1e97a845 100644 --- a/tests/conformance/reporting/test_reporting_provisional_catalog.py +++ b/tests/conformance/reporting/test_reporting_provisional_catalog.py @@ -5,11 +5,11 @@ from importlib.resources import files import pytest +from adcp_reporting_storage import PgReportingLedgerStore, PgReportingReceiptStore +from adcp_reporting_storage.outbox._schema import REQUIRED_OBJECTS, schema_objects +from adcp_reporting_storage.outbox.status_schema import REQUIRED_STATUS_OBJECTS -from adcp.reporting.ledger import PgReportingLedgerStore -from adcp.reporting.outbox._schema import REQUIRED_OBJECTS, schema_objects -from adcp.reporting.outbox.status_schema import REQUIRED_STATUS_OBJECTS -from adcp.reporting.receipts import PgReportingReceiptStore, ReportingReceiptError +from adcp.reporting.receipts import ReportingReceiptError from ._durable_materializer_support import DurableHarness, durable_case from ._generation_support import ( @@ -29,7 +29,7 @@ async def test_atomic_observation_bootstrap_preserves_exact_767_parent_objects( ): # This is a source-level DDL control, not installed historical-binary proof. # All eight pre-observation migration files and old manifests stay unchanged. - root = files("adcp.reporting.ledger") + root = files("adcp_reporting_storage.ledger") chain = ( "reporting_ledger.sql", "reporting_ledger_account_generations.sql", @@ -51,7 +51,9 @@ async def test_atomic_observation_bootstrap_preserves_exact_767_parent_objects( for package in ("materializer", "receipts"): expected_parent.update( json.loads( - files("adcp.reporting." + package).joinpath("required_schema.json").read_text() + files("adcp_reporting_storage." + package) + .joinpath("required_schema.json") + .read_text() ) ) assert len(expected_parent) == 767 diff --git a/tests/conformance/reporting/test_reporting_provisional_catalog_scope.py b/tests/conformance/reporting/test_reporting_provisional_catalog_scope.py index 61025154b..5dde7569d 100644 --- a/tests/conformance/reporting/test_reporting_provisional_catalog_scope.py +++ b/tests/conformance/reporting/test_reporting_provisional_catalog_scope.py @@ -4,17 +4,17 @@ from importlib.resources import files import pytest - -from adcp.reporting.ledger import LedgerConflictError -from adcp.reporting.outbox._schema import ( +from adcp_reporting_storage import PgReportingReceiptStore +from adcp_reporting_storage.outbox._schema import ( PROVISIONAL_REQUIRED_OBJECTS, REQUIRED_OBJECTS, _catalog_objects, schema_objects, validate_provisional_schema, ) -from adcp.reporting.outbox.status_schema import REQUIRED_STATUS_OBJECTS -from adcp.reporting.receipts import PgReportingReceiptStore +from adcp_reporting_storage.outbox.status_schema import REQUIRED_STATUS_OBJECTS + +from adcp.reporting.ledger import LedgerConflictError from ._generation_support import isolated_reporting_pool @@ -63,7 +63,9 @@ async def test_exact_catalog_scope_preserves_full_contract_and_excludes_overload for package in ("materializer", "receipts"): expected.update( json.loads( - files("adcp.reporting." + package).joinpath("required_schema.json").read_text() + files("adcp_reporting_storage." + package) + .joinpath("required_schema.json") + .read_text() ) ) assert len(expected) == 794 and len(PROVISIONAL_REQUIRED_OBJECTS) == 27 diff --git a/tests/conformance/reporting/test_reporting_provisional_readiness.py b/tests/conformance/reporting/test_reporting_provisional_readiness.py index 5543027e7..47b8ad476 100644 --- a/tests/conformance/reporting/test_reporting_provisional_readiness.py +++ b/tests/conformance/reporting/test_reporting_provisional_readiness.py @@ -1,8 +1,9 @@ """Provisional persistence cannot operate with a partial or unguarded catalog.""" import pytest +from adcp_reporting_storage import PgReportingLedgerStore -from adcp.reporting.ledger import LedgerConflictError, PgReportingLedgerStore +from adcp.reporting.ledger import LedgerConflictError from ._generation_support import isolated_reporting_pool @@ -75,8 +76,9 @@ async def test_provisional_operations_require_the_complete_immutable_extension( @pytest.mark.parametrize("damage", DAMAGE) async def test_notification_readiness_checks_a_present_known_extension(damage): + from adcp_reporting_storage.outbox._schema import validate_schema + from adcp.reporting.outbox import ReportingNotificationError - from adcp.reporting.outbox._schema import validate_schema async with isolated_reporting_pool(autocommit=True) as pool: await PgReportingLedgerStore(pool=pool).create_schema() diff --git a/tests/conformance/reporting/test_reporting_publication_time.py b/tests/conformance/reporting/test_reporting_publication_time.py index c478c2cfd..96bbba85b 100644 --- a/tests/conformance/reporting/test_reporting_publication_time.py +++ b/tests/conformance/reporting/test_reporting_publication_time.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone import pytest +from adcp_reporting_storage import PgReportingLedgerStore from pydantic import ValidationError from adcp.reporting import ( @@ -18,7 +19,6 @@ from adcp.reporting.ledger import ( InMemoryReportingLedgerStore, LedgerConflictError, - PgReportingLedgerStore, ProducerOfferings, ReportingConfiguration, ReportingProducer, diff --git a/tests/conformance/reporting/test_reporting_receipt_diagnostics.py b/tests/conformance/reporting/test_reporting_receipt_diagnostics.py index 392f661fd..68654df71 100644 --- a/tests/conformance/reporting/test_reporting_receipt_diagnostics.py +++ b/tests/conformance/reporting/test_reporting_receipt_diagnostics.py @@ -1,5 +1,17 @@ """Unexpected original failures are useful to operators without retaining payloads.""" +import importlib.util as _importlib_util + + +def _storage_split(): + """True when the adcp under test predates the storage split (#1510). + + The installed hardening controls run this module inside historical wheels, + whose stores still live in adcp.reporting. + """ + return _importlib_util.find_spec("adcp.reporting.ledger.pg") is not None + + import asyncio import json import logging @@ -540,10 +552,13 @@ async def test_raw_store_boundary_label_assertion_is_load_bearing(caplog, monkey """ caplog.set_level(logging.ERROR) async with receipt_harness("postgres") as h: + if _storage_split(): # wheels before the storage split (#1510) + from adcp.reporting.receipts import pg as receipt_pg + else: + from adcp_reporting_storage.receipts import pg as receipt_pg from psycopg import AsyncConnection, OperationalError from adcp.reporting.receipts import _diagnostics - from adcp.reporting.receipts import pg as receipt_pg s, request = await sensitive_case(h) fired = [] diff --git a/tests/conformance/reporting/test_reporting_receipt_graph.py b/tests/conformance/reporting/test_reporting_receipt_graph.py index 92637ac71..27ad1af7b 100644 --- a/tests/conformance/reporting/test_reporting_receipt_graph.py +++ b/tests/conformance/reporting/test_reporting_receipt_graph.py @@ -300,13 +300,14 @@ async def test_complete_receipt_chain_rejects_damage_with_one_apparent_leaf(faul "fault", ["fork", "cycle", "gap", "cross_target", "accepted_predecessor", "disconnected_cycle"] ) async def test_literal_sql_blocks_forks_and_detects_damaged_existing_chains(fault): + from adcp_reporting_storage.ledger.delivery_pg import _IDENTITY_COLUMNS + from adcp.reporting.ledger._delivery_state import ( change_id, fingerprint, payload, storage_identity, ) - from adcp.reporting.ledger.delivery_pg import _IDENTITY_COLUMNS from ._generation_support import NOW @@ -561,13 +562,14 @@ async def test_complete_adjustment_receipt_chain_rejects_damage(fault): async def test_literal_sql_blocks_damaged_adjustment_chains(fault): from contextlib import nullcontext + from adcp_reporting_storage.ledger.delivery_pg import _IDENTITY_COLUMNS + from adcp.reporting.ledger._delivery_state import ( change_id, fingerprint, payload, storage_identity, ) - from adcp.reporting.ledger.delivery_pg import _IDENTITY_COLUMNS from ._generation_support import NOW diff --git a/tests/conformance/reporting/test_reporting_receipt_installed_pg.py b/tests/conformance/reporting/test_reporting_receipt_installed_pg.py index 3d2488963..4c47733e7 100644 --- a/tests/conformance/reporting/test_reporting_receipt_installed_pg.py +++ b/tests/conformance/reporting/test_reporting_receipt_installed_pg.py @@ -7,9 +7,9 @@ from pathlib import Path import pytest +from adcp_reporting_storage import PgReportingReceiptStore from adcp.reporting.ledger.delivery import receipt_to_wire -from adcp.reporting.receipts import PgReportingReceiptStore from ._durable_materializer_support import DurableHarness from ._generation_support import isolated_reporting_pool @@ -17,6 +17,7 @@ from ._reconciliation_support import Clock from .test_reporting_materializer_installed_pg import installed_materializer from .test_reporting_materializer_packaging import ROOT, b1_wheels, built_distribution, run_step +from .test_reporting_notification_packaging import STORAGE_SRC from .test_reporting_receipt_process import worker __all__ = ["installed_materializer", "b1_wheels", "built_distribution"] @@ -37,8 +38,10 @@ def installed_receipts(installed_materializer): timeout=60, ) modules = dict(original["modules"]) + modules["adcp_reporting_storage.receipts.pg"] = hashlib.sha256( + (STORAGE_SRC / "receipts/pg.py").read_bytes() + ).hexdigest() for name in ( - "reporting/receipts/pg.py", "reporting/receipts/handler.py", "reporting/receipts/wire.py", "reporting/receipts/transport.py", diff --git a/tests/conformance/reporting/test_reporting_receipt_migration.py b/tests/conformance/reporting/test_reporting_receipt_migration.py index 71955facc..020e0bd61 100644 --- a/tests/conformance/reporting/test_reporting_receipt_migration.py +++ b/tests/conformance/reporting/test_reporting_receipt_migration.py @@ -5,20 +5,22 @@ from importlib.resources import files import pytest +from adcp_reporting_storage import PgReportingMaterializerStore, PgReportingReceiptStore +from adcp_reporting_storage.outbox._schema import schema_objects -from adcp.reporting.materializer import PgReportingMaterializerStore -from adcp.reporting.outbox._schema import schema_objects -from adcp.reporting.receipts import PgReportingReceiptStore, ReportingReceiptError +from adcp.reporting.receipts import ReportingReceiptError from ._durable_materializer_support import DurableHarness, durable_case from ._generation_support import isolated_reporting_pool from ._receipt_support import receipt_case, receipt_harness, request_for from ._reconciliation_support import Clock -SQL = files("adcp.reporting.ledger").joinpath("reporting_receipt_ingestion.sql").read_text() -MANIFEST = json.loads(files("adcp.reporting.receipts").joinpath("required_schema.json").read_text()) +SQL = files("adcp_reporting_storage.ledger").joinpath("reporting_receipt_ingestion.sql").read_text() +MANIFEST = json.loads( + files("adcp_reporting_storage.receipts").joinpath("required_schema.json").read_text() +) PARENT_MANIFEST = json.loads( - files("adcp.reporting.materializer").joinpath("required_schema.json").read_text() + files("adcp_reporting_storage.materializer").joinpath("required_schema.json").read_text() ) diff --git a/tests/conformance/reporting/test_reporting_receipt_transports.py b/tests/conformance/reporting/test_reporting_receipt_transports.py index 90f0cd144..f9f78d6c5 100644 --- a/tests/conformance/reporting/test_reporting_receipt_transports.py +++ b/tests/conformance/reporting/test_reporting_receipt_transports.py @@ -389,7 +389,7 @@ async def test_a2a_to_mcp_replay_after_mount_restart_keeps_every_original_ordina _, first = await mount.a2a(client, request, v1=v1) assert [r["result"] for r in first["results"]] == ["failed", "recorded", "recorded"], first if h.pool is not None: - from adcp.reporting.receipts import PgReportingReceiptStore + from adcp_reporting_storage import PgReportingReceiptStore h.store = PgReportingReceiptStore(pool=h.pool, notifications=h.store._notifications_enabled) restarted = MountedReceipts(h, hydrated=True) diff --git a/tests/conformance/reporting/test_reporting_reconciliation_boundaries.py b/tests/conformance/reporting/test_reporting_reconciliation_boundaries.py index 0db00b36a..ffd254a1c 100644 --- a/tests/conformance/reporting/test_reporting_reconciliation_boundaries.py +++ b/tests/conformance/reporting/test_reporting_reconciliation_boundaries.py @@ -559,7 +559,7 @@ def find_spec(self, fullname, path=None, target=None): ) ReportingStatusHandler(InMemoryReportingLedgerStore()) ReportingStatusHandler(InMemoryReportingReconciliationStore()) -assert 'adcp.reporting.ledger.delivery_pg' not in sys.modules +assert 'adcp_reporting_storage.ledger.delivery_pg' not in sys.modules """ result = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True) assert result.returncode == 0, result.stderr diff --git a/tests/conformance/reporting/test_reporting_reconciliation_changes.py b/tests/conformance/reporting/test_reporting_reconciliation_changes.py index ebcc95e53..cb1a2ba64 100644 --- a/tests/conformance/reporting/test_reporting_reconciliation_changes.py +++ b/tests/conformance/reporting/test_reporting_reconciliation_changes.py @@ -6,10 +6,10 @@ from typing import get_args import pytest +from adcp_reporting_storage import PgReportingReconciliationStore from adcp.reporting.ledger import ( LedgerConflictError, - PgReportingReconciliationStore, ReportingAdjustmentReceiptRecord, ReportingAdjustmentRecord, ReportingControlTotalRecord, diff --git a/tests/conformance/reporting/test_reporting_reconciliation_migration.py b/tests/conformance/reporting/test_reporting_reconciliation_migration.py index 1da11d95d..d72199e10 100644 --- a/tests/conformance/reporting/test_reporting_reconciliation_migration.py +++ b/tests/conformance/reporting/test_reporting_reconciliation_migration.py @@ -11,10 +11,10 @@ from typing import Any import pytest +from adcp_reporting_storage import PgReportingReconciliationStore from adcp.reporting.ledger import ( LedgerConflictError, - PgReportingReconciliationStore, ReportingDeliveryPrincipal, ReportingDeliveryRecord, ReportingMaterializationCheck, @@ -26,7 +26,7 @@ from .test_reporting_currency_migration import retained FIXTURES = Path(__file__).resolve().parents[2] / "fixtures" -RESOURCES = files("adcp.reporting.ledger") +RESOURCES = files("adcp_reporting_storage.ledger") MIGRATION = RESOURCES.joinpath("reporting_ledger_reconciliation.sql") @@ -43,10 +43,12 @@ async def test_upgrade_preserves_history_and_is_concurrent_idempotent( source: str, autocommit: bool ) -> None: require_rolling_database() + from adcp_reporting_storage.migration import ( + ReportingOwnershipMigrationError, + migrate_legacy_reporting, + ) from psycopg import sql - from adcp.reporting.migration import ReportingOwnershipMigrationError, migrate_legacy_reporting - async with isolated_reporting_pool(autocommit=autocommit) as pool: async with pool.connection() as connection: await connection.execute((FIXTURES / source).read_text()) @@ -86,7 +88,7 @@ async def test_upgrade_preserves_history_and_is_concurrent_idempotent( @pytest.mark.parametrize("autocommit", [False, True]) async def test_failed_extension_upgrade_rolls_back_prerequisites(autocommit: bool) -> None: pytest.importorskip("psycopg") - from adcp.reporting.migration import ReportingOwnershipMigrationError + from adcp_reporting_storage.migration import ReportingOwnershipMigrationError async with isolated_reporting_pool(autocommit=autocommit) as pool: async with pool.connection() as connection: @@ -336,10 +338,12 @@ async def initial_retained_state(pool, *, kind="revision_receipt", write_change= async def assert_retained_quarantine(pool, before): require_rolling_database() + from adcp_reporting_storage.migration import ( + ReportingOwnershipMigrationError, + migrate_legacy_reporting, + ) from psycopg import sql - from adcp.reporting.migration import ReportingOwnershipMigrationError, migrate_legacy_reporting - store = PgReportingReconciliationStore(pool=pool) with pytest.raises(ReportingOwnershipMigrationError): await store.create_schema() diff --git a/tests/conformance/reporting/test_reporting_reconciliation_sql.py b/tests/conformance/reporting/test_reporting_reconciliation_sql.py index 2f8996863..8d251bdaf 100644 --- a/tests/conformance/reporting/test_reporting_reconciliation_sql.py +++ b/tests/conformance/reporting/test_reporting_reconciliation_sql.py @@ -11,11 +11,12 @@ from typing import Any import pytest +from adcp_reporting_storage import PgReportingReconciliationStore +from adcp_reporting_storage.ledger.delivery_pg import _IDENTITY_COLUMNS from adcp.reporting.canonical_json import canonical_json_utf8_v1 from adcp.reporting.ledger import ( LedgerConflictError, - PgReportingReconciliationStore, ReportingAdjustmentReceiptRecord, ReportingAdjustmentRecord, ReportingControlTotalRecord, @@ -33,9 +34,8 @@ payload, storage_identity, ) -from adcp.reporting.ledger.delivery_pg import _IDENTITY_COLUMNS -RESOURCES = files("adcp.reporting.ledger") +RESOURCES = files("adcp_reporting_storage.ledger") from ._generation_support import END, NOW, configuration, isolated_reporting_pool from ._reconciliation_support import Scenario, scenario diff --git a/tests/conformance/reporting/test_reporting_row_migration_pg.py b/tests/conformance/reporting/test_reporting_row_migration_pg.py index 02d8f86ee..8c378a381 100644 --- a/tests/conformance/reporting/test_reporting_row_migration_pg.py +++ b/tests/conformance/reporting/test_reporting_row_migration_pg.py @@ -6,9 +6,9 @@ from typing import Any import pytest +from adcp_reporting_storage import PgReportingLedgerStore from adcp.reporting.ledger import ( - PgReportingLedgerStore, ReportingObligationRecord, ReportingRevisionRecord, ReportingRowStoreError, @@ -316,8 +316,9 @@ async def test_migration_requires_row_storage_and_a_valid_batch_size() -> None: async def test_the_installed_schema_stays_ready_after_the_legacy_table_is_retired() -> None: + from adcp_reporting_storage.outbox._schema import validate_schema + from adcp.reporting.ledger.notification_models import ReportingNotificationError - from adcp.reporting.outbox._schema import validate_schema async with isolated_reporting_pool() as pool: await seed_legacy(pool) diff --git a/tests/conformance/reporting/test_reporting_row_retention_pg.py b/tests/conformance/reporting/test_reporting_row_retention_pg.py index 6d8526ba4..3e494315f 100644 --- a/tests/conformance/reporting/test_reporting_row_retention_pg.py +++ b/tests/conformance/reporting/test_reporting_row_retention_pg.py @@ -7,14 +7,15 @@ from typing import Any import pytest - -from adcp.reporting.ledger import PgReportingLedgerStore, ReportingRowStoreError -from adcp.reporting.ledger.row_storage_object import ( +from adcp_reporting_storage import PgReportingLedgerStore +from adcp_reporting_storage.ledger.row_storage_object import ( ReportingRowObjectContextV1, ReportingRowObjectPutInputV1, create_filesystem_reporting_row_object_provider_v1, ) -from adcp.reporting.ledger.row_write import prepare_revision_row_set +from adcp_reporting_storage.ledger.row_write import prepare_revision_row_set + +from adcp.reporting.ledger import ReportingRowStoreError from adcp.reporting.ledger.store import LedgerConflictError from ._generation_support import isolated_reporting_pool diff --git a/tests/conformance/reporting/test_reporting_row_storage_alignment_pg.py b/tests/conformance/reporting/test_reporting_row_storage_alignment_pg.py index 9a40c3603..1dd2b8b86 100644 --- a/tests/conformance/reporting/test_reporting_row_storage_alignment_pg.py +++ b/tests/conformance/reporting/test_reporting_row_storage_alignment_pg.py @@ -12,12 +12,11 @@ from typing import Any import pytest - -from adcp.reporting.ledger.row_storage import ( +from adcp_reporting_storage.ledger.row_storage import ( REPORTING_ROW_STORAGE_DDL_PATH, REPORTING_ROW_STORAGE_TABLES, ) -from adcp.reporting.outbox._schema import _catalog_objects +from adcp_reporting_storage.outbox._schema import _catalog_objects from ._generation_support import isolated_reporting_pool diff --git a/tests/conformance/reporting/test_reporting_row_storage_interop_pg.py b/tests/conformance/reporting/test_reporting_row_storage_interop_pg.py index 232f168e7..9bf46fc8b 100644 --- a/tests/conformance/reporting/test_reporting_row_storage_interop_pg.py +++ b/tests/conformance/reporting/test_reporting_row_storage_interop_pg.py @@ -10,10 +10,13 @@ ADCP_JS_SDK_DIR=/path/to/adcp-client ADCP_PG_TEST_URL=postgresql://... \\ pytest tests/conformance/reporting/test_reporting_row_storage_interop_pg.py - Set ``ADCP_UPDATE_INTEROP_FIXTURES=1`` to refresh the committed fixtures from a live run. + CI runs it with ``ADCP_REQUIRE_JS_INTEROP=1``, so a missing build fails instead of skipping. + ``scripts/regenerate_row_storage_interop_fixtures.py`` runs the same steps to refresh the + committed fixtures. * The replay tests always run. They load the rows each SDK wrote, as recorded in ``tests/fixtures/reporting-row-storage-interop-v1/``, into a fresh database and bucket - and verify them through this SDK, so Python CI needs no Node. + and verify them through this SDK, so Python CI needs no Node. The fixtures are + committed, so a missing one is a repository defect, not an environment one. """ from __future__ import annotations @@ -26,9 +29,8 @@ from typing import Any import pytest - -from adcp.reporting.ledger import PgReportingLedgerStore -from adcp.reporting.ledger.row_storage import ReportingRowStorageV1 +from adcp_reporting_storage import PgReportingLedgerStore +from adcp_reporting_storage.ledger.row_storage import ReportingRowStorageV1 from . import _row_storage_interop as interop from ._generation_support import isolated_reporting_pool @@ -126,23 +128,11 @@ async def test_one_database_and_one_bucket_in_both_directions(tmp_path: Path) -> ("reports", "object", "filesystem"), ] - if os.environ.get("ADCP_UPDATE_INTEROP_FIXTURES") == "1": - interop.INTEROP_DIR.mkdir(parents=True, exist_ok=True) - for name, written in (("js-written", js_written), ("python-written", python_written)): - state = await interop.dump_state( - pool, root, [item["row_set_id"] for item in written] - ) - (interop.INTEROP_DIR / f"{name}.json").write_text( - json.dumps({"expected": written, "state": state}, indent=1, sort_keys=True) - + "\n" - ) - @pytest.mark.parametrize("writer", ["js-written", "python-written"]) async def test_rows_written_by_the_other_sdk_verify_here(writer: str, tmp_path: Path) -> None: path = interop.INTEROP_DIR / f"{writer}.json" - if not path.exists(): - pytest.skip("interop fixtures have not been generated") + assert path.is_file(), f"committed interop fixture is missing: {path}" recorded = json.loads(path.read_text()) root = tmp_path / "bucket" async with isolated_reporting_pool() as pool: @@ -158,8 +148,7 @@ async def test_the_recorded_object_bytes_decompress_to_the_golden_chunks(tmp_pat import gzip path = interop.INTEROP_DIR / "js-written.json" - if not path.exists(): - pytest.skip("interop fixtures have not been generated") + assert path.is_file(), f"committed interop fixture is missing: {path}" recorded = json.loads(path.read_text()) golden = { vector["id"]: [base64.b64decode(chunk) for chunk in vector["expected"]["chunks_base64"]] diff --git a/tests/conformance/reporting/test_reporting_row_storage_object_pg.py b/tests/conformance/reporting/test_reporting_row_storage_object_pg.py index f055d5955..aa2410045 100644 --- a/tests/conformance/reporting/test_reporting_row_storage_object_pg.py +++ b/tests/conformance/reporting/test_reporting_row_storage_object_pg.py @@ -7,19 +7,19 @@ from typing import Any import pytest - -from adcp.reporting.ledger import PgReportingLedgerStore, ReportingRowStorageOptionsV1 -from adcp.reporting.ledger.row_storage import ( +from adcp_reporting_storage import PgReportingLedgerStore, ReportingRowStorageOptionsV1 +from adcp_reporting_storage.ledger.row_storage import ( ReportingObjectRowBindingV1, ReportingPostgresRowBindingV1, ) -from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError -from adcp.reporting.ledger.row_storage_object import ( +from adcp_reporting_storage.ledger.row_storage_object import ( ReportingRowObjectContextV1, ReportingRowObjectPutInputV1, create_filesystem_reporting_row_object_provider_v1, ) -from adcp.reporting.ledger.row_write import prepare_revision_row_set +from adcp_reporting_storage.ledger.row_write import prepare_revision_row_set + +from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError from ._generation_support import isolated_reporting_pool from .test_reporting_row_storage_pg import fetch_all, many_rows, seeded, typed_revision, walk @@ -398,7 +398,7 @@ def test_bindings_are_validated_before_any_write(tmp_path: Path) -> None: providers = options(tmp_path).providers def make(**binding: Any) -> None: - from adcp.reporting.ledger.row_storage import ReportingRowStorageV1 + from adcp_reporting_storage.ledger.row_storage import ReportingRowStorageV1 defaults: dict[str, Any] = { "provider": "filesystem", @@ -430,7 +430,7 @@ def make(**binding: Any) -> None: def storage_with_bindings(bindings: Any) -> Any: - from adcp.reporting.ledger.row_storage import ReportingRowStorageV1 + from adcp_reporting_storage.ledger.row_storage import ReportingRowStorageV1 return ReportingRowStorageV1(ReportingRowStorageOptionsV1(bindings=bindings)) @@ -469,7 +469,7 @@ async def test_the_producer_stages_object_uploads_outside_its_publication_transa async def test_object_deletes_keep_referenced_objects_and_adopters_fail_closed( tmp_path: Path, ) -> None: - from adcp.reporting.ledger.row_storage import ReportingRowChunkLocatorV1 + from adcp_reporting_storage.ledger.row_storage import ReportingRowChunkLocatorV1 async with isolated_reporting_pool() as pool: store = PgReportingLedgerStore(pool=pool, row_storage=options(tmp_path)) @@ -556,7 +556,7 @@ async def probe(self, *args: Any, **kwargs: Any) -> None: await self.inner.probe(*args, **kwargs) async def put_if_absent(self, put: Any, context: Any) -> Any: - from adcp.reporting.ledger.row_storage_object import ReportingRowObjectPutResultV1 + from adcp_reporting_storage.ledger.row_storage_object import ReportingRowObjectPutResultV1 result = await self.inner.put_if_absent(put, context) return ReportingRowObjectPutResultV1( @@ -571,7 +571,7 @@ async def delete(self, delete: Any, context: Any) -> Any: return await self.inner.delete(delete, context) async def _stat(self, stat: Any, context: Any) -> Any: - from adcp.reporting.ledger.row_storage_object import ReportingRowObjectStatResultV1 + from adcp_reporting_storage.ledger.row_storage_object import ReportingRowObjectStatResultV1 self.calls.append("stat") found = await self.inner.stat(stat, context) @@ -788,8 +788,10 @@ async def test_rows_written_under_an_older_template_stay_readable_through_a_read ) -> None: from dataclasses import replace - from adcp.reporting.ledger import row_storage - from adcp.reporting.ledger.row_storage_object import assert_reporting_row_key_template_v1 + from adcp_reporting_storage.ledger import row_storage + from adcp_reporting_storage.ledger.row_storage_object import ( + assert_reporting_row_key_template_v1, + ) from .test_reporting_row_retention_pg import age diff --git a/tests/conformance/reporting/test_reporting_row_storage_pg.py b/tests/conformance/reporting/test_reporting_row_storage_pg.py index 9c2dc27a1..e8c7d4fce 100644 --- a/tests/conformance/reporting/test_reporting_row_storage_pg.py +++ b/tests/conformance/reporting/test_reporting_row_storage_pg.py @@ -12,19 +12,19 @@ from typing import Any import pytest +from adcp_reporting_storage import PgReportingLedgerStore +from adcp_reporting_storage.ledger.row_storage import ( + REPORTING_ROW_STORAGE_DDL_PATH, + ReportingRowStorageOptionsV1, + ReportingRowStoreError, +) from adcp.reporting.ledger import ( - PgReportingLedgerStore, ReportingControlTotalRecord, ReportingObligationRecord, ReportingRevisionRecord, revision_content_sha256, ) -from adcp.reporting.ledger.row_storage import ( - REPORTING_ROW_STORAGE_DDL_PATH, - ReportingRowStorageOptionsV1, - ReportingRowStoreError, -) from adcp.reporting.ledger.store import LedgerConflictError from ._generation_support import ( @@ -569,8 +569,8 @@ async def test_rows_with_fractional_and_exponent_numbers_round_trip_and_verify() async def test_the_packaged_catalog_manifest_matches_a_clean_migration() -> None: - from adcp.reporting.ledger.row_storage import REPORTING_ROW_STORAGE_TABLES - from adcp.reporting.outbox._schema import _catalog_objects + from adcp_reporting_storage.ledger.row_storage import REPORTING_ROW_STORAGE_TABLES + from adcp_reporting_storage.outbox._schema import _catalog_objects async with isolated_reporting_pool() as pool: async with pool.connection() as connection: @@ -584,7 +584,9 @@ async def test_the_packaged_catalog_manifest_matches_a_clean_migration() -> None from importlib.resources import files packaged = json.loads( - files("adcp.reporting.ledger").joinpath("required_row_storage_schema.json").read_text() + files("adcp_reporting_storage.ledger") + .joinpath("required_row_storage_schema.json") + .read_text() ) assert installed == packaged, "run scripts/generate_row_storage_catalog.py" diff --git a/tests/conformance/reporting/test_reporting_row_storage_readiness_pg.py b/tests/conformance/reporting/test_reporting_row_storage_readiness_pg.py index 41a7050a2..78b0ce3d8 100644 --- a/tests/conformance/reporting/test_reporting_row_storage_readiness_pg.py +++ b/tests/conformance/reporting/test_reporting_row_storage_readiness_pg.py @@ -3,9 +3,8 @@ from __future__ import annotations import pytest - -from adcp.reporting.ledger import PgReportingLedgerStore -from adcp.reporting.ledger.row_storage import ReportingRowStorageV1, ReportingRowStoreError +from adcp_reporting_storage import PgReportingLedgerStore +from adcp_reporting_storage.ledger.row_storage import ReportingRowStorageV1, ReportingRowStoreError from ._generation_support import isolated_reporting_pool diff --git a/tests/conformance/reporting/test_reporting_source_authorization.py b/tests/conformance/reporting/test_reporting_source_authorization.py index 5d868d30c..b457a9b7d 100644 --- a/tests/conformance/reporting/test_reporting_source_authorization.py +++ b/tests/conformance/reporting/test_reporting_source_authorization.py @@ -151,7 +151,7 @@ async def test_remapping_during_publication_refuses_result_and_allows_retry( async with harness(backend, tmp_path / "destination.sqlite") as h: await h.production.activate(account_id=h.item.config.account_id) source = h.production.offerings[0].producer._source - publication = h.store._source_publication + publication = h.store.source_publication publications = 0 @asynccontextmanager @@ -163,7 +163,7 @@ async def remap_under_lock(account_id, **kwargs): source.bind_generation(h.item.config, product_id="catalog-5820") yield publish_seal - monkeypatch.setattr(h.store, "_source_publication", remap_under_lock) + monkeypatch.setattr(h.store, "source_publication", remap_under_lock) source.release.clear() running = asyncio.create_task(source_turn(h.production)) try: @@ -185,7 +185,7 @@ async def remap_under_lock(account_id, **kwargs): ) assert (seal is not None) == (boundary == "ledger_lock") - monkeypatch.setattr(h.store, "_source_publication", publication) + monkeypatch.setattr(h.store, "source_publication", publication) source.bind_generation(h.item.config, product_id="catalog-7391") source.rows = reference_rows(2) restored = await source_turn(h.production) @@ -245,7 +245,7 @@ async def test_seal_authorization_checked_after_obtaining_account_lock( source.release.clear() running = asyncio.create_task(source_turn(h.production)) waiting = asyncio.Event() - publication = h.store._source_publication + publication = h.store.source_publication @asynccontextmanager async def observe_publication(account_id, **kwargs): @@ -255,7 +255,7 @@ async def observe_publication(account_id, **kwargs): try: await asyncio.wait_for(source.started.wait(), 10) - monkeypatch.setattr(h.store, "_source_publication", observe_publication) + monkeypatch.setattr(h.store, "source_publication", observe_publication) async with publication(h.item.config.account_id): source.release.set() await asyncio.wait_for(waiting.wait(), 10) @@ -368,7 +368,7 @@ def account_context(config): if backend == "memory": store = InMemoryReportingLedgerStore(clock=lambda: END) else: - from adcp.reporting.ledger.pg import PgReportingLedgerStore + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore pool = await stack.enter_async_context(isolated_reporting_pool(autocommit=True)) store = PgReportingLedgerStore(pool=pool, clock=lambda: END) @@ -417,12 +417,10 @@ async def test_postgres_inline_publication_shares_size_one_pool( pools = pytest.importorskip("psycopg_pool") psycopg = pytest.importorskip("psycopg") + from adcp_reporting_storage.inline_storage import PgReportingSealStore, PgReportingStagingStore + from adcp.reporting.inline_source import InlineReportingSource - from adcp.reporting.inline_storage import ( - InlineStorageError, - PgReportingSealStore, - PgReportingStagingStore, - ) + from adcp.reporting.inline_storage import InlineStorageError async with isolated_reporting_pool(autocommit=autocommit) as isolated: async with pools.AsyncConnectionPool( diff --git a/tests/conformance/reporting/test_reporting_status_boundaries.py b/tests/conformance/reporting/test_reporting_status_boundaries.py index 3db7336bf..20c376a3b 100644 --- a/tests/conformance/reporting/test_reporting_status_boundaries.py +++ b/tests/conformance/reporting/test_reporting_status_boundaries.py @@ -5,17 +5,12 @@ from datetime import timedelta import pytest +from adcp_reporting_storage import PgReportingLedgerStore, PgStatusNotificationStore +from adcp_reporting_storage.outbox.status_schema import validate_status_schema -from adcp.reporting.ledger import ( - PgReportingLedgerStore, - derive_period, -) from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller -from adcp.reporting.outbox import ( - PgStatusNotificationStore, - ReportingNotificationError, -) -from adcp.reporting.outbox.status_schema import validate_status_schema +from adcp.reporting.ledger import derive_period +from adcp.reporting.outbox import ReportingNotificationError from . import test_reporting_status_projection_contract as _contract from ._generation_support import ( @@ -241,7 +236,7 @@ async def blocked(): ], ) async def test_status_manifest_damage_fails_only_its_owned_feature(damage): - from adcp.reporting.outbox._schema import validate_schema + from adcp_reporting_storage.outbox._schema import validate_schema async with isolated_reporting_pool(autocommit=True) as pool: status = PgStatusNotificationStore(PgReportingLedgerStore(pool=pool, notifications=True)) diff --git a/tests/conformance/reporting/test_reporting_status_capabilities.py b/tests/conformance/reporting/test_reporting_status_capabilities.py index 1bffe0d75..93e07cdcc 100644 --- a/tests/conformance/reporting/test_reporting_status_capabilities.py +++ b/tests/conformance/reporting/test_reporting_status_capabilities.py @@ -5,13 +5,13 @@ from itertools import product import pytest +from adcp_reporting_storage import PgReportingActivityUnionStore from adcp.reporting.ledger import InMemoryReportingLedgerStore from adcp.reporting.ledger.status import ReportingStatusCaller, ReportingStatusHandler from adcp.reporting.ledger.status_projection import mismatch_key from adcp.reporting.ledger.status_server import ReportingStatusNotificationHandler from adcp.reporting.outbox import ( - PgReportingActivityUnionStore, ReportingActivityProjector, ReportingActivitySupport, ReportingNotificationError, diff --git a/tests/conformance/reporting/test_reporting_status_delivery.py b/tests/conformance/reporting/test_reporting_status_delivery.py index a58898e8b..5c6a98be3 100644 --- a/tests/conformance/reporting/test_reporting_status_delivery.py +++ b/tests/conformance/reporting/test_reporting_status_delivery.py @@ -8,12 +8,12 @@ from itertools import cycle import pytest +from adcp_reporting_storage import PgReportingActivityUnionStore from adcp.reporting.ledger import InMemoryReportingLedgerStore from adcp.reporting.ledger.status import ReportingStatusCaller, ReportingStatusHandler from adcp.reporting.ledger.status_server import ReportingStatusNotificationHandler from adcp.reporting.outbox import ( - PgReportingActivityUnionStore, ReportingActivityProjector, ReportingActivitySupport, ReportingNotificationError, diff --git a/tests/conformance/reporting/test_reporting_status_identity.py b/tests/conformance/reporting/test_reporting_status_identity.py index c5a0c3605..108ba38a6 100644 --- a/tests/conformance/reporting/test_reporting_status_identity.py +++ b/tests/conformance/reporting/test_reporting_status_identity.py @@ -5,14 +5,11 @@ from urllib.parse import quote import pytest +from adcp_reporting_storage import PgReportingReconciliationStore from adcp.decisioning.context import AuthInfo from adcp.decisioning.registry import BuyerAgent, HttpSigCredential -from adcp.reporting.ledger import ( - PgReportingReconciliationStore, - ReportingDeliveryPrincipal, - ReportingIssueLifecycle, -) +from adcp.reporting.ledger import ReportingDeliveryPrincipal, ReportingIssueLifecycle from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller from adcp.reporting.outbox import ( ReportingNotificationError, diff --git a/tests/conformance/reporting/test_reporting_status_migration.py b/tests/conformance/reporting/test_reporting_status_migration.py index b6b479653..0bc6b82cc 100644 --- a/tests/conformance/reporting/test_reporting_status_migration.py +++ b/tests/conformance/reporting/test_reporting_status_migration.py @@ -10,22 +10,22 @@ from pathlib import Path import pytest - -from adcp.reporting.ledger import PgReportingReconciliationStore -from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller -from adcp.reporting.ledger.status_projection import lifecycle_intents, mismatch_key -from adcp.reporting.outbox import ( +from adcp_reporting_storage import ( PgReportingOutbox, + PgReportingReconciliationStore, PgStatusNotificationStore, - ReportingNotificationError, ) -from adcp.reporting.outbox._schema import REQUIRED_OBJECTS, schema_objects, validate_schema -from adcp.reporting.outbox.status_schema import ( +from adcp_reporting_storage.outbox._schema import REQUIRED_OBJECTS, schema_objects, validate_schema +from adcp_reporting_storage.outbox.status_schema import ( REQUIRED_STATUS_OBJECTS, REQUIRED_STATUS_SELECTOR_OBJECTS, validate_status_schema, ) +from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller +from adcp.reporting.ledger.status_projection import lifecycle_intents, mismatch_key +from adcp.reporting.outbox import ReportingNotificationError + from . import test_reporting_notification_process_matrix as _process from ._generation_support import ( NOW, @@ -50,7 +50,11 @@ "a": "17ee407ae3978c8a2bb54437287afbf9dafb8130", "b": "0f34c666ac1961e9832fce43ef0ef6937b3c1dde", } -SQL = files("adcp.reporting.ledger").joinpath("reporting_status_notifications.sql").read_text() +SQL = ( + files("adcp_reporting_storage.ledger") + .joinpath("reporting_status_notifications.sql") + .read_text() +) C_QUEUES = ( "reporting_status_notification_events", "reporting_status_notification_expansions", @@ -209,7 +213,7 @@ async def test_populated_repeated_c_manifest_preserves_every_a_b_object_and_row( } assert ( json.dumps(REQUIRED_STATUS_OBJECTS, sort_keys=True, indent=2) + "\n" - == files("adcp.reporting.outbox") + == files("adcp_reporting_storage.outbox") .joinpath("required_status_schema.json") .read_text() ) @@ -277,12 +281,11 @@ async def migrate(selected): @pytest.mark.parametrize("release", ["a", "b"]) @case_deadline async def test_actual_unowned_artifact_requires_stopped_worker_migration(release, actual_sources): - from psycopg import sql - - from adcp.reporting.migration import ( + from adcp_reporting_storage.migration import ( ReportingOwnershipMigrationError, migrate_legacy_reporting, ) + from psycopg import sql async with isolated_reporting_pool(autocommit=True) as pool: async with service_process( diff --git a/tests/conformance/reporting/test_reporting_status_process_matrix.py b/tests/conformance/reporting/test_reporting_status_process_matrix.py index e3c8e2d4d..4adf13bc7 100644 --- a/tests/conformance/reporting/test_reporting_status_process_matrix.py +++ b/tests/conformance/reporting/test_reporting_status_process_matrix.py @@ -6,16 +6,13 @@ from datetime import timedelta import pytest +from adcp_reporting_storage import PgReportingReconciliationStore, PgStatusNotificationStore +from adcp_reporting_storage.ledger.status_snapshot import read_snapshot_on +from adcp_reporting_storage.outbox.status_pg import _replay_storage -from adcp.reporting.ledger import ( - PgReportingReconciliationStore, - ReportingDeliveryEscalation, - ReportingScheduleSpec, -) -from adcp.reporting.ledger.status_snapshot import read_snapshot_on -from adcp.reporting.outbox import PgStatusNotificationStore, ReportingStatusSweeper +from adcp.reporting.ledger import ReportingDeliveryEscalation, ReportingScheduleSpec +from adcp.reporting.outbox import ReportingStatusSweeper from adcp.reporting.outbox.status import escalation_identity -from adcp.reporting.outbox.status_pg import _replay_storage from ._generation_support import ( configuration, diff --git a/tests/conformance/reporting/test_reporting_status_projection_contract.py b/tests/conformance/reporting/test_reporting_status_projection_contract.py index 46c3f776e..a31314161 100644 --- a/tests/conformance/reporting/test_reporting_status_projection_contract.py +++ b/tests/conformance/reporting/test_reporting_status_projection_contract.py @@ -7,6 +7,7 @@ from datetime import timedelta import pytest +from adcp_reporting_storage import PgStatusNotificationStore from adcp.reporting.ledger import InMemoryReportingLedgerStore from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller @@ -14,7 +15,6 @@ from adcp.reporting.ledger.status_projection import StatusProjectionInput, project_status_scope from adcp.reporting.outbox import ( InMemoryStatusNotificationStore, - PgStatusNotificationStore, ReportingStatusScope, ReportingStatusSweeper, StatusChanged, diff --git a/tests/conformance/reporting/test_reporting_status_restatement_transactions.py b/tests/conformance/reporting/test_reporting_status_restatement_transactions.py index 2752781ca..da499092e 100644 --- a/tests/conformance/reporting/test_reporting_status_restatement_transactions.py +++ b/tests/conformance/reporting/test_reporting_status_restatement_transactions.py @@ -3,9 +3,9 @@ import asyncio import pytest +from adcp_reporting_storage import PgReportingLedgerStore, PgStatusNotificationStore -from adcp.reporting.ledger import PgReportingLedgerStore, RestatementCheckpoint -from adcp.reporting.outbox import PgStatusNotificationStore +from adcp.reporting.ledger import RestatementCheckpoint from . import test_reporting_status_projection_contract as _contract from ._generation_support import configuration, isolated_reporting_pool, obligation_for diff --git a/tests/conformance/reporting/test_reporting_status_selector_epoch.py b/tests/conformance/reporting/test_reporting_status_selector_epoch.py index 790ca17ce..96d1e3615 100644 --- a/tests/conformance/reporting/test_reporting_status_selector_epoch.py +++ b/tests/conformance/reporting/test_reporting_status_selector_epoch.py @@ -113,7 +113,9 @@ async def two_feeds(h, *, issue=True): async def test_directional_feed_public_private_configuration_obligation_scopes(status_harness): h = status_harness owners, issues = await two_feeds(h) - from adcp.reporting.ledger.status_snapshot import memory_snapshot, read_snapshot_on + from adcp_reporting_storage.ledger.status_snapshot import read_snapshot_on + + from adcp.reporting.ledger.status_snapshot import memory_snapshot if isinstance(h.ledger, InMemoryReportingLedgerStore): snapshot = memory_snapshot(h.ledger, "acct_a") @@ -275,7 +277,9 @@ async def test_checkpoint_event_failure_rolls_back_epoch_turn_and_restart_is_onc status_scope=ReportingStatusScope("acct_a"), ) before = await h.status.checkpoints(account_id="acct_a") - from adcp.reporting.outbox import status_memory, status_pg + from adcp_reporting_storage.outbox import status_pg + + from adcp.reporting.outbox import status_memory original = status_pg.advance_checkpoint called = 0 @@ -355,7 +359,9 @@ async def test_retained_scopes_outside_current_discovery_are_reprojected_without await c.execute( "UPDATE reporting_status_scope_checkpoints SET next_due_at=%s", (expired,) ) - from adcp.reporting.outbox import status_memory, status_pg + from adcp_reporting_storage.outbox import status_pg + + from adcp.reporting.outbox import status_memory with monkeypatch.context() as patch: patch.setattr(status_memory, "projection_scopes", lambda snapshot: ()) diff --git a/tests/conformance/reporting/test_reporting_status_selector_pg.py b/tests/conformance/reporting/test_reporting_status_selector_pg.py index b43d95b6f..b04938180 100644 --- a/tests/conformance/reporting/test_reporting_status_selector_pg.py +++ b/tests/conformance/reporting/test_reporting_status_selector_pg.py @@ -10,14 +10,14 @@ from pathlib import Path import pytest +from adcp_reporting_storage import PgReportingReconciliationStore +from adcp_reporting_storage.outbox.status_schema import validate_status_schema -from adcp.reporting.ledger import PgReportingReconciliationStore from adcp.reporting.outbox import ( ActivityRequest, ReportingEnvelopeCipher, ReportingNotificationWorker, ) -from adcp.reporting.outbox.status_schema import validate_status_schema from ._generation_support import ( NOW, @@ -156,10 +156,9 @@ async def populate_status_activity(status): async def test_actual_c_status_rows_are_archived_after_worker_stop(frozen_c): + from adcp_reporting_storage.migration import migrate_legacy_reporting from psycopg import sql - from adcp.reporting.migration import migrate_legacy_reporting - async with isolated_reporting_pool(autocommit=True) as pool: async with old_c(pool, frozen_c) as old: assert await old.call(action="schema") == {"result": True} diff --git a/tests/conformance/reporting/test_reporting_status_transactions.py b/tests/conformance/reporting/test_reporting_status_transactions.py index 830cbd76a..9ebd44242 100644 --- a/tests/conformance/reporting/test_reporting_status_transactions.py +++ b/tests/conformance/reporting/test_reporting_status_transactions.py @@ -5,8 +5,8 @@ from datetime import timedelta import pytest +from adcp_reporting_storage import PgReportingLedgerStore, PgStatusNotificationStore -from adcp.reporting.ledger import PgReportingLedgerStore from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller from adcp.reporting.ledger.status import ReportingStatusCaller, ReportingStatusHandler from adcp.reporting.ledger.status_projection import ( @@ -14,11 +14,7 @@ mismatch_key, project_status_scope, ) -from adcp.reporting.outbox import ( - PgStatusNotificationStore, - ReportingStatusScope, - ReportingStatusSweeper, -) +from adcp.reporting.outbox import ReportingStatusScope, ReportingStatusSweeper from . import test_reporting_status_projection_contract as _contract from ._generation_support import ( @@ -130,7 +126,9 @@ async def test_consumer_status_pre_lifecycle_crash_rolls_back_both_and_self_dirt await h.status.baseline(account_id="acct_a") outbox = NotificationHarness(h.reliable).outbox before = await outbox.read_status_dirty(account_id="acct_a") - import adcp.reporting.ledger.status_snapshot as snapshots + import adcp_reporting_storage.ledger.status_snapshot as snapshots + + import adcp.reporting.ledger.status_snapshot as memory_snapshots def crash_memory(*args, **kwargs): raise SimulatedCrash("consumer_status_pre_lifecycle") @@ -144,7 +142,7 @@ async def crash_pg(*args, **kwargs): reporting_revision_id=revision.reporting_revision_id, ) with monkeypatch.context() as patch: - patch.setattr(snapshots, "apply_memory_intents", crash_memory) + patch.setattr(memory_snapshots, "apply_memory_intents", crash_memory) patch.setattr(snapshots, "apply_intents_on", crash_pg) with pytest.raises(SimulatedCrash): await h.ledger.record_consumer_status_with_lifecycle(record) @@ -185,7 +183,9 @@ async def test_fanout_failure_cannot_advance_half_a_source_transaction(status_ha reporting_revision_id=revision.reporting_revision_id, readable=False, ) - from adcp.reporting.outbox import status_memory, status_pg + from adcp_reporting_storage.outbox import status_pg + + from adcp.reporting.outbox import status_memory called = 0 original = status_pg.advance_checkpoint @@ -224,7 +224,9 @@ async def test_expiry_during_checkpoint_event_work_rolls_back_the_entire_turn( h.clock.now = obligation.period.expected_at lease = await h.status.claim_due(account_id="acct_a", lease_seconds=30) assert lease is not None - from adcp.reporting.outbox import status_memory, status_pg + from adcp_reporting_storage.outbox import status_pg + + from adcp.reporting.outbox import status_memory original = status_pg.advance_checkpoint diff --git a/tests/conformance/reporting/test_reporting_status_waiver_rc6.py b/tests/conformance/reporting/test_reporting_status_waiver_rc6.py index 644356e5b..cc30d52fa 100644 --- a/tests/conformance/reporting/test_reporting_status_waiver_rc6.py +++ b/tests/conformance/reporting/test_reporting_status_waiver_rc6.py @@ -3,12 +3,13 @@ from dataclasses import replace import pytest +from adcp_reporting_storage import PgStatusNotificationStore +from adcp_reporting_storage.outbox.status_schema import validate_status_schema from adcp.reporting.ledger import ReportingStatusCaller as OwnershipCaller from adcp.reporting.ledger.status import ReportingStatusCaller, ReportingStatusHandler from adcp.reporting.ledger.status_projection import mismatch_key -from adcp.reporting.outbox import PgStatusNotificationStore, ReportingNotificationError -from adcp.reporting.outbox.status_schema import validate_status_schema +from adcp.reporting.outbox import ReportingNotificationError from . import test_reporting_status_projection_contract as _status_contract from ._generation_support import configuration, isolated_reporting_pool, revision_for @@ -178,7 +179,7 @@ async def test_successive_waivers_preserve_each_terminal_record_and_other_caller @pytest.mark.parametrize("column", ["reporting_status_id", "conflict_sha256"]) async def test_missing_waiver_binding_column_fails_readiness(column): - from adcp.reporting.ledger import PgReportingLedgerStore + from adcp_reporting_storage import PgReportingLedgerStore async with isolated_reporting_pool(autocommit=True) as pool: status = PgStatusNotificationStore(PgReportingLedgerStore(pool=pool, notifications=True)) diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index e5cbc964a..084450cae 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -826,18 +826,12 @@ "LedgerPage": "adcp.reporting.ledger.store", "LedgerSnapshot": "adcp.reporting.ledger.models", "ObligationProjection": "adcp.reporting.ledger.health", - "PgReportingLedgerStore": "adcp.reporting.ledger.pg", - "PgReportingReconciliationStore": "adcp.reporting.ledger.delivery_pg", "ProducerOfferings": "adcp.reporting.ledger.producer", "REPORTING_SELECTOR_VERSION": "value:2", "ReportingAdjustmentReceiptRecord": "adcp.reporting.ledger.delivery_models", "ReportingAdjustmentRecord": "adcp.reporting.ledger.models", "ReportingCaller": "adcp.reporting.ledger.models", "ReportingCanonicalDigest": "adcp.reporting.evidence", - "ReportingChangeCursorExpiredError": "adcp.reporting.ledger.change_feed", - "ReportingChangeKindV1": "union[value:'adjustment',value:'obligation',value:'retirement',value:'revision']", - "ReportingChangeRecordV1": "adcp.reporting.ledger.change_feed", - "ReportingChangesPageV1": "adcp.reporting.ledger.change_feed", "ReportingConfiguration": "adcp.reporting.ledger.models", "ReportingConfigurationGenerationKey": "adcp.reporting.ledger.models", "ReportingControlTotalRecord": "adcp.reporting.evidence", @@ -863,13 +857,10 @@ "ReportingMaterializationStore": "adcp.reporting.ledger.delivery", "ReportingMaterializationView": "adcp.reporting.ledger.delivery", "ReportingMismatchCode": "union[value:'coverage_short',value:'currency_mismatch',value:'metric_missing',value:'period_mismatch',value:'schema_nonconformant',value:'scope_media_buy_missing']", - "ReportingObjectRowBindingV1": "adcp.reporting.ledger.row_storage", "ReportingObligationDeliveryRecord": "adcp.reporting.ledger.delivery_models", "ReportingObligationRecord": "adcp.reporting.ledger.models", "ReportingPeriodBoundary": "adcp.reporting.ledger.models", - "ReportingPeriodRetirementV1": "adcp.reporting.ledger.period_retirement", "ReportingPhysicalChecksum": "adcp.reporting.ledger.delivery_models", - "ReportingPostgresRowBindingV1": "adcp.reporting.ledger.row_storage", "ReportingProducer": "adcp.reporting.ledger.producer", "ReportingProductionStatus": "union[value:'failed',value:'not_due',value:'pending',value:'published']", "ReportingReceiptKey": "adcp.reporting.ledger.delivery_models", @@ -893,14 +884,12 @@ "ReportingRevisionRecord": "adcp.reporting.ledger.models", "ReportingRevisionSelected": "adcp.reporting.revision_selection", "ReportingRevisionSelection": "union[adcp.reporting.revision_selection:ReportingRevisionCorrupt,adcp.reporting.revision_selection:ReportingRevisionNotReady,union[instance:typing:TypeVar]]", - "ReportingRowBindingSelectionInputV1": "adcp.reporting.ledger.row_storage", - "ReportingRowIntentSweepV1": "adcp.reporting.ledger.row_retention", "ReportingRowPage": "adcp.reporting.ledger.store", - "ReportingRowPruneV1": "adcp.reporting.ledger.row_retention", - "ReportingRowStorageOptionsV1": "adcp.reporting.ledger.row_storage", + "ReportingRowStagingStore": "adcp.reporting.ledger.store", "ReportingRowStoreError": "adcp.reporting.ledger.row_storage_errors", "ReportingRowStoreErrorCode": "union[value:'ABORTED',value:'CONTENT_CONFLICT',value:'DEADLINE_EXCEEDED',value:'INVALID_INPUT',value:'PROVIDER_UNAVAILABLE',value:'ROWS_EXPIRED',value:'ROWS_INTEGRITY_FAILED',value:'ROWS_UNAVAILABLE',value:'STATE_UNAVAILABLE',value:'UNSAFE_BINDING']", "ReportingScheduleSpec": "adcp.reporting.ledger.models", + "ReportingSourcePublicationStore": "adcp.reporting.ledger.store", "ReportingStatusCaller": "adcp.reporting.ledger.status", "ReportingStatusCallerResolver": "union[instance:builtins:list,union[adcp.reporting.ledger.status:ReportingStatusCaller]]", "ReportingStatusHandler": "adcp.reporting.ledger.status", @@ -909,6 +898,7 @@ "ReportingStatusSnapshot": "adcp.reporting.ledger.status_projection", "ReportingStatusView": "union[value:'periods',value:'revision',value:'summary']", "ReportingVerificationRecord": "adcp.reporting.ledger.delivery_models", + "ReportingWorkerRetryPolicy": "adcp.reporting.ledger.store", "RestatementCheckpoint": "adcp.reporting.ledger.store", "RestatementCheckpointStore": "adcp.reporting.ledger.store", "RetryScheduleEntry": "adcp.reporting.ledger.store", @@ -8176,5 +8166,67 @@ "Type": "trusted_match.context_match_request", "VerificationLevel": "trusted_match.identity_match_request" } + }, + "adcp_reporting_storage": { + "resolves": { + "PgReportingActivityUnionStore": "adcp_reporting_storage.outbox.status_activity_pg", + "PgReportingFeedStore": "adcp_reporting_storage.feed.pg", + "PgReportingLedgerStore": "adcp_reporting_storage.ledger.pg", + "PgReportingMaterializerStore": "adcp_reporting_storage.materializer.pg", + "PgReportingOutbox": "adcp_reporting_storage.outbox.pg", + "PgReportingProductionOutbox": "adcp_reporting_storage.production.pg", + "PgReportingProductionStore": "adcp_reporting_storage.production.pg", + "PgReportingProjectionOutbox": "adcp_reporting_storage.projection.notifications", + "PgReportingProjectionStore": "adcp_reporting_storage.projection.pg", + "PgReportingReceiptStore": "adcp_reporting_storage.receipts.pg", + "PgReportingReconciliationStore": "adcp_reporting_storage.ledger.delivery_pg", + "PgReportingSealStore": "adcp_reporting_storage.inline_storage", + "PgReportingStagingStore": "adcp_reporting_storage.inline_storage", + "PgReportingStatusOutbox": "adcp_reporting_storage.outbox.status_pg", + "PgReportingStatusProjection": "adcp_reporting_storage.projection.pg", + "PgReportingSubmissionIntentStore": "adcp_reporting_storage.submissions.pg", + "PgStatusNotificationStore": "adcp_reporting_storage.outbox.status_pg", + "ReportingChangeCursorExpiredError": "adcp_reporting_storage.ledger.change_feed", + "ReportingChangeKindV1": "union[value:'adjustment',value:'obligation',value:'retirement',value:'revision']", + "ReportingChangeRecordV1": "adcp_reporting_storage.ledger.change_feed", + "ReportingChangesPageV1": "adcp_reporting_storage.ledger.change_feed", + "ReportingObjectRowBindingV1": "adcp_reporting_storage.ledger.row_storage", + "ReportingOwnershipBackfill": "adcp_reporting_storage.migration", + "ReportingOwnershipMigrationError": "adcp_reporting_storage.migration", + "ReportingPeriodRetirementV1": "adcp_reporting_storage.ledger.period_retirement", + "ReportingPostgresRowBindingV1": "adcp_reporting_storage.ledger.row_storage", + "ReportingRowBindingSelectionInputV1": "adcp_reporting_storage.ledger.row_storage", + "ReportingRowIntentSweepV1": "adcp_reporting_storage.ledger.row_retention", + "ReportingRowMigrationBatchV1": "adcp_reporting_storage.ledger.row_migration", + "ReportingRowObjectContextV1": "adcp_reporting_storage.ledger.row_storage_object", + "ReportingRowObjectDeleteInputV1": "adcp_reporting_storage.ledger.row_storage_object", + "ReportingRowObjectGetInputV1": "adcp_reporting_storage.ledger.row_storage_object", + "ReportingRowObjectProviderV1": "adcp_reporting_storage.ledger.row_storage_object", + "ReportingRowObjectPutInputV1": "adcp_reporting_storage.ledger.row_storage_object", + "ReportingRowObjectPutResultV1": "adcp_reporting_storage.ledger.row_storage_object", + "ReportingRowObjectStatInputV1": "adcp_reporting_storage.ledger.row_storage_object", + "ReportingRowObjectStatResultV1": "adcp_reporting_storage.ledger.row_storage_object", + "ReportingRowPruneV1": "adcp_reporting_storage.ledger.row_retention", + "ReportingRowQuarantineReason": "union[value:'BINDING_MISMATCH',value:'ROWS_NOT_PORTABLE',value:'ROW_SEQUENCE_INVALID']", + "ReportingRowStorageOptionsV1": "adcp_reporting_storage.ledger.row_storage", + "ReportingRowStorageV1": "adcp_reporting_storage.ledger.row_storage", + "assert_gcs_reporting_row_bucket_policy_v1": "adcp_reporting_storage.ledger.row_storage_gcs", + "backfill_legacy_reporting": "adcp_reporting_storage.migration", + "create_azure_blob_reporting_row_object_provider_v1": "adcp_reporting_storage.ledger.row_storage_azure", + "create_filesystem_reporting_row_object_provider_v1": "adcp_reporting_storage.ledger.row_storage_object", + "create_gcs_reporting_row_object_provider_v1": "adcp_reporting_storage.ledger.row_storage_gcs", + "create_s3_reporting_row_object_provider_v1": "adcp_reporting_storage.ledger.row_storage_s3", + "legacy_generation_digest": "adcp_reporting_storage.migration", + "migrate_legacy_reporting": "adcp_reporting_storage.migration", + "postgres_reliable_reporting_service": "adcp_reporting_storage.service", + "render_reporting_row_object_key_v1": "adcp_reporting_storage.ledger.row_storage_object", + "replace_empty_legacy_reporting": "adcp_reporting_storage.migration", + "replace_empty_legacy_reporting_sync": "adcp_reporting_storage.migration", + "replace_empty_owned_reporting": "adcp_reporting_storage.migration", + "replace_empty_owned_reporting_sync": "adcp_reporting_storage.migration", + "reporting_period_date_v1": "adcp_reporting_storage.ledger.row_storage_object", + "require_owned_schema_or_empty": "adcp_reporting_storage.migration", + "run_reporting_row_object_provider_conformance_v1": "adcp_reporting_storage.ledger.row_storage_object" + } } } diff --git a/tests/fixtures/reporting-row-storage-interop-v1/js-written.json b/tests/fixtures/reporting-row-storage-interop-v1/js-written.json index aea1e1918..3751f645c 100644 --- a/tests/fixtures/reporting-row-storage-interop-v1/js-written.json +++ b/tests/fixtures/reporting-row-storage-interop-v1/js-written.json @@ -1,12926 +1,59 @@ { "expected": [ - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "69403a2a705dcce0c564849c26c55a899f0c1158dd12a56ce370b004069fd5fa", - "content_size_bytes": 344, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_single-row_revision_inline", - "row_set_kind": "revision", - "rows": [ - { - "date": "2026-10-01", - "impressions": 1200, - "media_buy_id": "mb_1", - "package_id": "pkg_1", - "spend": "42.17" - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "b5367a164b48f0c0e31537bda570038687db83416988840fc2bacd96300ad3e8", - "content_size_bytes": 345, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_single-row_revision_reports", - "row_set_kind": "revision", - "rows": [ - { - "date": "2026-10-01", - "impressions": 1200, - "media_buy_id": "mb_1", - "package_id": "pkg_1", - "spend": "42.17" - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187", - "content_size_bytes": 101, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_single-row_adjustment_inline", - "row_set_kind": "adjustment", - "rows": [ - { - "date": "2026-10-01", - "impressions": 1200, - "media_buy_id": "mb_1", - "package_id": "pkg_1", - "spend": "42.17" - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187", - "content_size_bytes": 101, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_single-row_adjustment_reports", - "row_set_kind": "adjustment", - "rows": [ - { - "date": "2026-10-01", - "impressions": 1200, - "media_buy_id": "mb_1", - "package_id": "pkg_1", - "spend": "42.17" - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "39d5233af55a871656ea98bc512f3c6bc3446ff30a97747f26cd117913af83af", - "content_size_bytes": 467, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_unicode-and-numbers_revision_inline", - "row_set_kind": "revision", - "rows": [ - { - "a": 2, - "control": "\u0007\n\"\\", - "name": "caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e", - "sep": "\u2028", - "z": 1 - }, - { - "float": 0.30000000000000004, - "max_int": 9007199254740991, - "n": 0, - "neg": -12.5, - "nested": { - "a": [], - "z": [ - 1, - { - "a": true, - "b": null - } - ] - }, - "tiny": 1e-07 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "20391f076cea7702c50fa4ce949705afca0529fd3de4787ac66dbbafe448b6cd", - "content_size_bytes": 468, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_unicode-and-numbers_revision_reports", - "row_set_kind": "revision", - "rows": [ - { - "a": 2, - "control": "\u0007\n\"\\", - "name": "caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e", - "sep": "\u2028", - "z": 1 - }, - { - "float": 0.30000000000000004, - "max_int": 9007199254740991, - "n": 0, - "neg": -12.5, - "nested": { - "a": [], - "z": [ - 1, - { - "a": true, - "b": null - } - ] - }, - "tiny": 1e-07 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095", - "content_size_bytes": 215, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_unicode-and-numbers_adjustment_inline", - "row_set_kind": "adjustment", - "rows": [ - { - "a": 2, - "control": "\u0007\n\"\\", - "name": "caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e", - "sep": "\u2028", - "z": 1 - }, - { - "float": 0.30000000000000004, - "max_int": 9007199254740991, - "n": 0, - "neg": -12.5, - "nested": { - "a": [], - "z": [ - 1, - { - "a": true, - "b": null - } - ] - }, - "tiny": 1e-07 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095", - "content_size_bytes": 215, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_unicode-and-numbers_adjustment_reports", - "row_set_kind": "adjustment", - "rows": [ - { - "a": 2, - "control": "\u0007\n\"\\", - "name": "caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e", - "sep": "\u2028", - "z": 1 - }, - { - "float": 0.30000000000000004, - "max_int": 9007199254740991, - "n": 0, - "neg": -12.5, - "nested": { - "a": [], - "z": [ - 1, - { - "a": true, - "b": null - } - ] - }, - "tiny": 1e-07 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "de89268832ff75b1fe6f77a61de3c174ed49289172bde20d537b10c4b067fbc0", - "content_size_bytes": 10152, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_three-segments_revision_inline", - "row_set_kind": "revision", - "rows": [ - { - "o": 0 - }, - { - "o": 1 - }, - { - "o": 2 - }, - { - "o": 3 - }, - { - "o": 4 - }, - { - "o": 5 - }, - { - "o": 6 - }, - { - "o": 7 - }, - { - "o": 8 - }, - { - "o": 9 - }, - { - "o": 10 - }, - { - "o": 11 - }, - { - "o": 12 - }, - { - "o": 13 - }, - { - "o": 14 - }, - { - "o": 15 - }, - { - "o": 16 - }, - { - "o": 17 - }, - { - "o": 18 - }, - { - "o": 19 - }, - { - "o": 20 - }, - { - "o": 21 - }, - { - "o": 22 - }, - { - "o": 23 - }, - { - "o": 24 - }, - { - "o": 25 - }, - { - "o": 26 - }, - { - "o": 27 - }, - { - "o": 28 - }, - { - "o": 29 - }, - { - "o": 30 - }, - { - "o": 31 - }, - { - "o": 32 - }, - { - "o": 33 - }, - { - "o": 34 - }, - { - "o": 35 - }, - { - "o": 36 - }, - { - "o": 37 - }, - { - "o": 38 - }, - { - "o": 39 - }, - { - "o": 40 - }, - { - "o": 41 - }, - { - "o": 42 - }, - { - "o": 43 - }, - { - "o": 44 - }, - { - "o": 45 - }, - { - "o": 46 - }, - { - "o": 47 - }, - { - "o": 48 - }, - { - "o": 49 - }, - { - "o": 50 - }, - { - "o": 51 - }, - { - "o": 52 - }, - { - "o": 53 - }, - { - "o": 54 - }, - { - "o": 55 - }, - { - "o": 56 - }, - { - "o": 57 - }, - { - "o": 58 - }, - { - "o": 59 - }, - { - "o": 60 - }, - { - "o": 61 - }, - { - "o": 62 - }, - { - "o": 63 - }, - { - "o": 64 - }, - { - "o": 65 - }, - { - "o": 66 - }, - { - "o": 67 - }, - { - "o": 68 - }, - { - "o": 69 - }, - { - "o": 70 - }, - { - "o": 71 - }, - { - "o": 72 - }, - { - "o": 73 - }, - { - "o": 74 - }, - { - "o": 75 - }, - { - "o": 76 - }, - { - "o": 77 - }, - { - "o": 78 - }, - { - "o": 79 - }, - { - "o": 80 - }, - { - "o": 81 - }, - { - "o": 82 - }, - { - "o": 83 - }, - { - "o": 84 - }, - { - "o": 85 - }, - { - "o": 86 - }, - { - "o": 87 - }, - { - "o": 88 - }, - { - "o": 89 - }, - { - "o": 90 - }, - { - "o": 91 - }, - { - "o": 92 - }, - { - "o": 93 - }, - { - "o": 94 - }, - { - "o": 95 - }, - { - "o": 96 - }, - { - "o": 97 - }, - { - "o": 98 - }, - { - "o": 99 - }, - { - "o": 100 - }, - { - "o": 101 - }, - { - "o": 102 - }, - { - "o": 103 - }, - { - "o": 104 - }, - { - "o": 105 - }, - { - "o": 106 - }, - { - "o": 107 - }, - { - "o": 108 - }, - { - "o": 109 - }, - { - "o": 110 - }, - { - "o": 111 - }, - { - "o": 112 - }, - { - "o": 113 - }, - { - "o": 114 - }, - { - "o": 115 - }, - { - "o": 116 - }, - { - "o": 117 - }, - { - "o": 118 - }, - { - "o": 119 - }, - { - "o": 120 - }, - { - "o": 121 - }, - { - "o": 122 - }, - { - "o": 123 - }, - { - "o": 124 - }, - { - "o": 125 - }, - { - "o": 126 - }, - { - "o": 127 - }, - { - "o": 128 - }, - { - "o": 129 - }, - { - "o": 130 - }, - { - "o": 131 - }, - { - "o": 132 - }, - { - "o": 133 - }, - { - "o": 134 - }, - { - "o": 135 - }, - { - "o": 136 - }, - { - "o": 137 - }, - { - "o": 138 - }, - { - "o": 139 - }, - { - "o": 140 - }, - { - "o": 141 - }, - { - "o": 142 - }, - { - "o": 143 - }, - { - "o": 144 - }, - { - "o": 145 - }, - { - "o": 146 - }, - { - "o": 147 - }, - { - "o": 148 - }, - { - "o": 149 - }, - { - "o": 150 - }, - { - "o": 151 - }, - { - "o": 152 - }, - { - "o": 153 - }, - { - "o": 154 - }, - { - "o": 155 - }, - { - "o": 156 - }, - { - "o": 157 - }, - { - "o": 158 - }, - { - "o": 159 - }, - { - "o": 160 - }, - { - "o": 161 - }, - { - "o": 162 - }, - { - "o": 163 - }, - { - "o": 164 - }, - { - "o": 165 - }, - { - "o": 166 - }, - { - "o": 167 - }, - { - "o": 168 - }, - { - "o": 169 - }, - { - "o": 170 - }, - { - "o": 171 - }, - { - "o": 172 - }, - { - "o": 173 - }, - { - "o": 174 - }, - { - "o": 175 - }, - { - "o": 176 - }, - { - "o": 177 - }, - { - "o": 178 - }, - { - "o": 179 - }, - { - "o": 180 - }, - { - "o": 181 - }, - { - "o": 182 - }, - { - "o": 183 - }, - { - "o": 184 - }, - { - "o": 185 - }, - { - "o": 186 - }, - { - "o": 187 - }, - { - "o": 188 - }, - { - "o": 189 - }, - { - "o": 190 - }, - { - "o": 191 - }, - { - "o": 192 - }, - { - "o": 193 - }, - { - "o": 194 - }, - { - "o": 195 - }, - { - "o": 196 - }, - { - "o": 197 - }, - { - "o": 198 - }, - { - "o": 199 - }, - { - "o": 200 - }, - { - "o": 201 - }, - { - "o": 202 - }, - { - "o": 203 - }, - { - "o": 204 - }, - { - "o": 205 - }, - { - "o": 206 - }, - { - "o": 207 - }, - { - "o": 208 - }, - { - "o": 209 - }, - { - "o": 210 - }, - { - "o": 211 - }, - { - "o": 212 - }, - { - "o": 213 - }, - { - "o": 214 - }, - { - "o": 215 - }, - { - "o": 216 - }, - { - "o": 217 - }, - { - "o": 218 - }, - { - "o": 219 - }, - { - "o": 220 - }, - { - "o": 221 - }, - { - "o": 222 - }, - { - "o": 223 - }, - { - "o": 224 - }, - { - "o": 225 - }, - { - "o": 226 - }, - { - "o": 227 - }, - { - "o": 228 - }, - { - "o": 229 - }, - { - "o": 230 - }, - { - "o": 231 - }, - { - "o": 232 - }, - { - "o": 233 - }, - { - "o": 234 - }, - { - "o": 235 - }, - { - "o": 236 - }, - { - "o": 237 - }, - { - "o": 238 - }, - { - "o": 239 - }, - { - "o": 240 - }, - { - "o": 241 - }, - { - "o": 242 - }, - { - "o": 243 - }, - { - "o": 244 - }, - { - "o": 245 - }, - { - "o": 246 - }, - { - "o": 247 - }, - { - "o": 248 - }, - { - "o": 249 - }, - { - "o": 250 - }, - { - "o": 251 - }, - { - "o": 252 - }, - { - "o": 253 - }, - { - "o": 254 - }, - { - "o": 255 - }, - { - "o": 256 - }, - { - "o": 257 - }, - { - "o": 258 - }, - { - "o": 259 - }, - { - "o": 260 - }, - { - "o": 261 - }, - { - "o": 262 - }, - { - "o": 263 - }, - { - "o": 264 - }, - { - "o": 265 - }, - { - "o": 266 - }, - { - "o": 267 - }, - { - "o": 268 - }, - { - "o": 269 - }, - { - "o": 270 - }, - { - "o": 271 - }, - { - "o": 272 - }, - { - "o": 273 - }, - { - "o": 274 - }, - { - "o": 275 - }, - { - "o": 276 - }, - { - "o": 277 - }, - { - "o": 278 - }, - { - "o": 279 - }, - { - "o": 280 - }, - { - "o": 281 - }, - { - "o": 282 - }, - { - "o": 283 - }, - { - "o": 284 - }, - { - "o": 285 - }, - { - "o": 286 - }, - { - "o": 287 - }, - { - "o": 288 - }, - { - "o": 289 - }, - { - "o": 290 - }, - { - "o": 291 - }, - { - "o": 292 - }, - { - "o": 293 - }, - { - "o": 294 - }, - { - "o": 295 - }, - { - "o": 296 - }, - { - "o": 297 - }, - { - "o": 298 - }, - { - "o": 299 - }, - { - "o": 300 - }, - { - "o": 301 - }, - { - "o": 302 - }, - { - "o": 303 - }, - { - "o": 304 - }, - { - "o": 305 - }, - { - "o": 306 - }, - { - "o": 307 - }, - { - "o": 308 - }, - { - "o": 309 - }, - { - "o": 310 - }, - { - "o": 311 - }, - { - "o": 312 - }, - { - "o": 313 - }, - { - "o": 314 - }, - { - "o": 315 - }, - { - "o": 316 - }, - { - "o": 317 - }, - { - "o": 318 - }, - { - "o": 319 - }, - { - "o": 320 - }, - { - "o": 321 - }, - { - "o": 322 - }, - { - "o": 323 - }, - { - "o": 324 - }, - { - "o": 325 - }, - { - "o": 326 - }, - { - "o": 327 - }, - { - "o": 328 - }, - { - "o": 329 - }, - { - "o": 330 - }, - { - "o": 331 - }, - { - "o": 332 - }, - { - "o": 333 - }, - { - "o": 334 - }, - { - "o": 335 - }, - { - "o": 336 - }, - { - "o": 337 - }, - { - "o": 338 - }, - { - "o": 339 - }, - { - "o": 340 - }, - { - "o": 341 - }, - { - "o": 342 - }, - { - "o": 343 - }, - { - "o": 344 - }, - { - "o": 345 - }, - { - "o": 346 - }, - { - "o": 347 - }, - { - "o": 348 - }, - { - "o": 349 - }, - { - "o": 350 - }, - { - "o": 351 - }, - { - "o": 352 - }, - { - "o": 353 - }, - { - "o": 354 - }, - { - "o": 355 - }, - { - "o": 356 - }, - { - "o": 357 - }, - { - "o": 358 - }, - { - "o": 359 - }, - { - "o": 360 - }, - { - "o": 361 - }, - { - "o": 362 - }, - { - "o": 363 - }, - { - "o": 364 - }, - { - "o": 365 - }, - { - "o": 366 - }, - { - "o": 367 - }, - { - "o": 368 - }, - { - "o": 369 - }, - { - "o": 370 - }, - { - "o": 371 - }, - { - "o": 372 - }, - { - "o": 373 - }, - { - "o": 374 - }, - { - "o": 375 - }, - { - "o": 376 - }, - { - "o": 377 - }, - { - "o": 378 - }, - { - "o": 379 - }, - { - "o": 380 - }, - { - "o": 381 - }, - { - "o": 382 - }, - { - "o": 383 - }, - { - "o": 384 - }, - { - "o": 385 - }, - { - "o": 386 - }, - { - "o": 387 - }, - { - "o": 388 - }, - { - "o": 389 - }, - { - "o": 390 - }, - { - "o": 391 - }, - { - "o": 392 - }, - { - "o": 393 - }, - { - "o": 394 - }, - { - "o": 395 - }, - { - "o": 396 - }, - { - "o": 397 - }, - { - "o": 398 - }, - { - "o": 399 - }, - { - "o": 400 - }, - { - "o": 401 - }, - { - "o": 402 - }, - { - "o": 403 - }, - { - "o": 404 - }, - { - "o": 405 - }, - { - "o": 406 - }, - { - "o": 407 - }, - { - "o": 408 - }, - { - "o": 409 - }, - { - "o": 410 - }, - { - "o": 411 - }, - { - "o": 412 - }, - { - "o": 413 - }, - { - "o": 414 - }, - { - "o": 415 - }, - { - "o": 416 - }, - { - "o": 417 - }, - { - "o": 418 - }, - { - "o": 419 - }, - { - "o": 420 - }, - { - "o": 421 - }, - { - "o": 422 - }, - { - "o": 423 - }, - { - "o": 424 - }, - { - "o": 425 - }, - { - "o": 426 - }, - { - "o": 427 - }, - { - "o": 428 - }, - { - "o": 429 - }, - { - "o": 430 - }, - { - "o": 431 - }, - { - "o": 432 - }, - { - "o": 433 - }, - { - "o": 434 - }, - { - "o": 435 - }, - { - "o": 436 - }, - { - "o": 437 - }, - { - "o": 438 - }, - { - "o": 439 - }, - { - "o": 440 - }, - { - "o": 441 - }, - { - "o": 442 - }, - { - "o": 443 - }, - { - "o": 444 - }, - { - "o": 445 - }, - { - "o": 446 - }, - { - "o": 447 - }, - { - "o": 448 - }, - { - "o": 449 - }, - { - "o": 450 - }, - { - "o": 451 - }, - { - "o": 452 - }, - { - "o": 453 - }, - { - "o": 454 - }, - { - "o": 455 - }, - { - "o": 456 - }, - { - "o": 457 - }, - { - "o": 458 - }, - { - "o": 459 - }, - { - "o": 460 - }, - { - "o": 461 - }, - { - "o": 462 - }, - { - "o": 463 - }, - { - "o": 464 - }, - { - "o": 465 - }, - { - "o": 466 - }, - { - "o": 467 - }, - { - "o": 468 - }, - { - "o": 469 - }, - { - "o": 470 - }, - { - "o": 471 - }, - { - "o": 472 - }, - { - "o": 473 - }, - { - "o": 474 - }, - { - "o": 475 - }, - { - "o": 476 - }, - { - "o": 477 - }, - { - "o": 478 - }, - { - "o": 479 - }, - { - "o": 480 - }, - { - "o": 481 - }, - { - "o": 482 - }, - { - "o": 483 - }, - { - "o": 484 - }, - { - "o": 485 - }, - { - "o": 486 - }, - { - "o": 487 - }, - { - "o": 488 - }, - { - "o": 489 - }, - { - "o": 490 - }, - { - "o": 491 - }, - { - "o": 492 - }, - { - "o": 493 - }, - { - "o": 494 - }, - { - "o": 495 - }, - { - "o": 496 - }, - { - "o": 497 - }, - { - "o": 498 - }, - { - "o": 499 - }, - { - "o": 500 - }, - { - "o": 501 - }, - { - "o": 502 - }, - { - "o": 503 - }, - { - "o": 504 - }, - { - "o": 505 - }, - { - "o": 506 - }, - { - "o": 507 - }, - { - "o": 508 - }, - { - "o": 509 - }, - { - "o": 510 - }, - { - "o": 511 - }, - { - "o": 512 - }, - { - "o": 513 - }, - { - "o": 514 - }, - { - "o": 515 - }, - { - "o": 516 - }, - { - "o": 517 - }, - { - "o": 518 - }, - { - "o": 519 - }, - { - "o": 520 - }, - { - "o": 521 - }, - { - "o": 522 - }, - { - "o": 523 - }, - { - "o": 524 - }, - { - "o": 525 - }, - { - "o": 526 - }, - { - "o": 527 - }, - { - "o": 528 - }, - { - "o": 529 - }, - { - "o": 530 - }, - { - "o": 531 - }, - { - "o": 532 - }, - { - "o": 533 - }, - { - "o": 534 - }, - { - "o": 535 - }, - { - "o": 536 - }, - { - "o": 537 - }, - { - "o": 538 - }, - { - "o": 539 - }, - { - "o": 540 - }, - { - "o": 541 - }, - { - "o": 542 - }, - { - "o": 543 - }, - { - "o": 544 - }, - { - "o": 545 - }, - { - "o": 546 - }, - { - "o": 547 - }, - { - "o": 548 - }, - { - "o": 549 - }, - { - "o": 550 - }, - { - "o": 551 - }, - { - "o": 552 - }, - { - "o": 553 - }, - { - "o": 554 - }, - { - "o": 555 - }, - { - "o": 556 - }, - { - "o": 557 - }, - { - "o": 558 - }, - { - "o": 559 - }, - { - "o": 560 - }, - { - "o": 561 - }, - { - "o": 562 - }, - { - "o": 563 - }, - { - "o": 564 - }, - { - "o": 565 - }, - { - "o": 566 - }, - { - "o": 567 - }, - { - "o": 568 - }, - { - "o": 569 - }, - { - "o": 570 - }, - { - "o": 571 - }, - { - "o": 572 - }, - { - "o": 573 - }, - { - "o": 574 - }, - { - "o": 575 - }, - { - "o": 576 - }, - { - "o": 577 - }, - { - "o": 578 - }, - { - "o": 579 - }, - { - "o": 580 - }, - { - "o": 581 - }, - { - "o": 582 - }, - { - "o": 583 - }, - { - "o": 584 - }, - { - "o": 585 - }, - { - "o": 586 - }, - { - "o": 587 - }, - { - "o": 588 - }, - { - "o": 589 - }, - { - "o": 590 - }, - { - "o": 591 - }, - { - "o": 592 - }, - { - "o": 593 - }, - { - "o": 594 - }, - { - "o": 595 - }, - { - "o": 596 - }, - { - "o": 597 - }, - { - "o": 598 - }, - { - "o": 599 - }, - { - "o": 600 - }, - { - "o": 601 - }, - { - "o": 602 - }, - { - "o": 603 - }, - { - "o": 604 - }, - { - "o": 605 - }, - { - "o": 606 - }, - { - "o": 607 - }, - { - "o": 608 - }, - { - "o": 609 - }, - { - "o": 610 - }, - { - "o": 611 - }, - { - "o": 612 - }, - { - "o": 613 - }, - { - "o": 614 - }, - { - "o": 615 - }, - { - "o": 616 - }, - { - "o": 617 - }, - { - "o": 618 - }, - { - "o": 619 - }, - { - "o": 620 - }, - { - "o": 621 - }, - { - "o": 622 - }, - { - "o": 623 - }, - { - "o": 624 - }, - { - "o": 625 - }, - { - "o": 626 - }, - { - "o": 627 - }, - { - "o": 628 - }, - { - "o": 629 - }, - { - "o": 630 - }, - { - "o": 631 - }, - { - "o": 632 - }, - { - "o": 633 - }, - { - "o": 634 - }, - { - "o": 635 - }, - { - "o": 636 - }, - { - "o": 637 - }, - { - "o": 638 - }, - { - "o": 639 - }, - { - "o": 640 - }, - { - "o": 641 - }, - { - "o": 642 - }, - { - "o": 643 - }, - { - "o": 644 - }, - { - "o": 645 - }, - { - "o": 646 - }, - { - "o": 647 - }, - { - "o": 648 - }, - { - "o": 649 - }, - { - "o": 650 - }, - { - "o": 651 - }, - { - "o": 652 - }, - { - "o": 653 - }, - { - "o": 654 - }, - { - "o": 655 - }, - { - "o": 656 - }, - { - "o": 657 - }, - { - "o": 658 - }, - { - "o": 659 - }, - { - "o": 660 - }, - { - "o": 661 - }, - { - "o": 662 - }, - { - "o": 663 - }, - { - "o": 664 - }, - { - "o": 665 - }, - { - "o": 666 - }, - { - "o": 667 - }, - { - "o": 668 - }, - { - "o": 669 - }, - { - "o": 670 - }, - { - "o": 671 - }, - { - "o": 672 - }, - { - "o": 673 - }, - { - "o": 674 - }, - { - "o": 675 - }, - { - "o": 676 - }, - { - "o": 677 - }, - { - "o": 678 - }, - { - "o": 679 - }, - { - "o": 680 - }, - { - "o": 681 - }, - { - "o": 682 - }, - { - "o": 683 - }, - { - "o": 684 - }, - { - "o": 685 - }, - { - "o": 686 - }, - { - "o": 687 - }, - { - "o": 688 - }, - { - "o": 689 - }, - { - "o": 690 - }, - { - "o": 691 - }, - { - "o": 692 - }, - { - "o": 693 - }, - { - "o": 694 - }, - { - "o": 695 - }, - { - "o": 696 - }, - { - "o": 697 - }, - { - "o": 698 - }, - { - "o": 699 - }, - { - "o": 700 - }, - { - "o": 701 - }, - { - "o": 702 - }, - { - "o": 703 - }, - { - "o": 704 - }, - { - "o": 705 - }, - { - "o": 706 - }, - { - "o": 707 - }, - { - "o": 708 - }, - { - "o": 709 - }, - { - "o": 710 - }, - { - "o": 711 - }, - { - "o": 712 - }, - { - "o": 713 - }, - { - "o": 714 - }, - { - "o": 715 - }, - { - "o": 716 - }, - { - "o": 717 - }, - { - "o": 718 - }, - { - "o": 719 - }, - { - "o": 720 - }, - { - "o": 721 - }, - { - "o": 722 - }, - { - "o": 723 - }, - { - "o": 724 - }, - { - "o": 725 - }, - { - "o": 726 - }, - { - "o": 727 - }, - { - "o": 728 - }, - { - "o": 729 - }, - { - "o": 730 - }, - { - "o": 731 - }, - { - "o": 732 - }, - { - "o": 733 - }, - { - "o": 734 - }, - { - "o": 735 - }, - { - "o": 736 - }, - { - "o": 737 - }, - { - "o": 738 - }, - { - "o": 739 - }, - { - "o": 740 - }, - { - "o": 741 - }, - { - "o": 742 - }, - { - "o": 743 - }, - { - "o": 744 - }, - { - "o": 745 - }, - { - "o": 746 - }, - { - "o": 747 - }, - { - "o": 748 - }, - { - "o": 749 - }, - { - "o": 750 - }, - { - "o": 751 - }, - { - "o": 752 - }, - { - "o": 753 - }, - { - "o": 754 - }, - { - "o": 755 - }, - { - "o": 756 - }, - { - "o": 757 - }, - { - "o": 758 - }, - { - "o": 759 - }, - { - "o": 760 - }, - { - "o": 761 - }, - { - "o": 762 - }, - { - "o": 763 - }, - { - "o": 764 - }, - { - "o": 765 - }, - { - "o": 766 - }, - { - "o": 767 - }, - { - "o": 768 - }, - { - "o": 769 - }, - { - "o": 770 - }, - { - "o": 771 - }, - { - "o": 772 - }, - { - "o": 773 - }, - { - "o": 774 - }, - { - "o": 775 - }, - { - "o": 776 - }, - { - "o": 777 - }, - { - "o": 778 - }, - { - "o": 779 - }, - { - "o": 780 - }, - { - "o": 781 - }, - { - "o": 782 - }, - { - "o": 783 - }, - { - "o": 784 - }, - { - "o": 785 - }, - { - "o": 786 - }, - { - "o": 787 - }, - { - "o": 788 - }, - { - "o": 789 - }, - { - "o": 790 - }, - { - "o": 791 - }, - { - "o": 792 - }, - { - "o": 793 - }, - { - "o": 794 - }, - { - "o": 795 - }, - { - "o": 796 - }, - { - "o": 797 - }, - { - "o": 798 - }, - { - "o": 799 - }, - { - "o": 800 - }, - { - "o": 801 - }, - { - "o": 802 - }, - { - "o": 803 - }, - { - "o": 804 - }, - { - "o": 805 - }, - { - "o": 806 - }, - { - "o": 807 - }, - { - "o": 808 - }, - { - "o": 809 - }, - { - "o": 810 - }, - { - "o": 811 - }, - { - "o": 812 - }, - { - "o": 813 - }, - { - "o": 814 - }, - { - "o": 815 - }, - { - "o": 816 - }, - { - "o": 817 - }, - { - "o": 818 - }, - { - "o": 819 - }, - { - "o": 820 - }, - { - "o": 821 - }, - { - "o": 822 - }, - { - "o": 823 - }, - { - "o": 824 - }, - { - "o": 825 - }, - { - "o": 826 - }, - { - "o": 827 - }, - { - "o": 828 - }, - { - "o": 829 - }, - { - "o": 830 - }, - { - "o": 831 - }, - { - "o": 832 - }, - { - "o": 833 - }, - { - "o": 834 - }, - { - "o": 835 - }, - { - "o": 836 - }, - { - "o": 837 - }, - { - "o": 838 - }, - { - "o": 839 - }, - { - "o": 840 - }, - { - "o": 841 - }, - { - "o": 842 - }, - { - "o": 843 - }, - { - "o": 844 - }, - { - "o": 845 - }, - { - "o": 846 - }, - { - "o": 847 - }, - { - "o": 848 - }, - { - "o": 849 - }, - { - "o": 850 - }, - { - "o": 851 - }, - { - "o": 852 - }, - { - "o": 853 - }, - { - "o": 854 - }, - { - "o": 855 - }, - { - "o": 856 - }, - { - "o": 857 - }, - { - "o": 858 - }, - { - "o": 859 - }, - { - "o": 860 - }, - { - "o": 861 - }, - { - "o": 862 - }, - { - "o": 863 - }, - { - "o": 864 - }, - { - "o": 865 - }, - { - "o": 866 - }, - { - "o": 867 - }, - { - "o": 868 - }, - { - "o": 869 - }, - { - "o": 870 - }, - { - "o": 871 - }, - { - "o": 872 - }, - { - "o": 873 - }, - { - "o": 874 - }, - { - "o": 875 - }, - { - "o": 876 - }, - { - "o": 877 - }, - { - "o": 878 - }, - { - "o": 879 - }, - { - "o": 880 - }, - { - "o": 881 - }, - { - "o": 882 - }, - { - "o": 883 - }, - { - "o": 884 - }, - { - "o": 885 - }, - { - "o": 886 - }, - { - "o": 887 - }, - { - "o": 888 - }, - { - "o": 889 - }, - { - "o": 890 - }, - { - "o": 891 - }, - { - "o": 892 - }, - { - "o": 893 - }, - { - "o": 894 - }, - { - "o": 895 - }, - { - "o": 896 - }, - { - "o": 897 - }, - { - "o": 898 - }, - { - "o": 899 - }, - { - "o": 900 - }, - { - "o": 901 - }, - { - "o": 902 - }, - { - "o": 903 - }, - { - "o": 904 - }, - { - "o": 905 - }, - { - "o": 906 - }, - { - "o": 907 - }, - { - "o": 908 - }, - { - "o": 909 - }, - { - "o": 910 - }, - { - "o": 911 - }, - { - "o": 912 - }, - { - "o": 913 - }, - { - "o": 914 - }, - { - "o": 915 - }, - { - "o": 916 - }, - { - "o": 917 - }, - { - "o": 918 - }, - { - "o": 919 - }, - { - "o": 920 - }, - { - "o": 921 - }, - { - "o": 922 - }, - { - "o": 923 - }, - { - "o": 924 - }, - { - "o": 925 - }, - { - "o": 926 - }, - { - "o": 927 - }, - { - "o": 928 - }, - { - "o": 929 - }, - { - "o": 930 - }, - { - "o": 931 - }, - { - "o": 932 - }, - { - "o": 933 - }, - { - "o": 934 - }, - { - "o": 935 - }, - { - "o": 936 - }, - { - "o": 937 - }, - { - "o": 938 - }, - { - "o": 939 - }, - { - "o": 940 - }, - { - "o": 941 - }, - { - "o": 942 - }, - { - "o": 943 - }, - { - "o": 944 - }, - { - "o": 945 - }, - { - "o": 946 - }, - { - "o": 947 - }, - { - "o": 948 - }, - { - "o": 949 - }, - { - "o": 950 - }, - { - "o": 951 - }, - { - "o": 952 - }, - { - "o": 953 - }, - { - "o": 954 - }, - { - "o": 955 - }, - { - "o": 956 - }, - { - "o": 957 - }, - { - "o": 958 - }, - { - "o": 959 - }, - { - "o": 960 - }, - { - "o": 961 - }, - { - "o": 962 - }, - { - "o": 963 - }, - { - "o": 964 - }, - { - "o": 965 - }, - { - "o": 966 - }, - { - "o": 967 - }, - { - "o": 968 - }, - { - "o": 969 - }, - { - "o": 970 - }, - { - "o": 971 - }, - { - "o": 972 - }, - { - "o": 973 - }, - { - "o": 974 - }, - { - "o": 975 - }, - { - "o": 976 - }, - { - "o": 977 - }, - { - "o": 978 - }, - { - "o": 979 - }, - { - "o": 980 - }, - { - "o": 981 - }, - { - "o": 982 - }, - { - "o": 983 - }, - { - "o": 984 - }, - { - "o": 985 - }, - { - "o": 986 - }, - { - "o": 987 - }, - { - "o": 988 - }, - { - "o": 989 - }, - { - "o": 990 - }, - { - "o": 991 - }, - { - "o": 992 - }, - { - "o": 993 - }, - { - "o": 994 - }, - { - "o": 995 - }, - { - "o": 996 - }, - { - "o": 997 - }, - { - "o": 998 - }, - { - "o": 999 - }, - { - "o": 1000 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "d1ca264c9c51c4f7146f5f9b791a05fffee44cc55eca090dcc73ec826522874f", - "content_size_bytes": 10153, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_three-segments_revision_reports", - "row_set_kind": "revision", - "rows": [ - { - "o": 0 - }, - { - "o": 1 - }, - { - "o": 2 - }, - { - "o": 3 - }, - { - "o": 4 - }, - { - "o": 5 - }, - { - "o": 6 - }, - { - "o": 7 - }, - { - "o": 8 - }, - { - "o": 9 - }, - { - "o": 10 - }, - { - "o": 11 - }, - { - "o": 12 - }, - { - "o": 13 - }, - { - "o": 14 - }, - { - "o": 15 - }, - { - "o": 16 - }, - { - "o": 17 - }, - { - "o": 18 - }, - { - "o": 19 - }, - { - "o": 20 - }, - { - "o": 21 - }, - { - "o": 22 - }, - { - "o": 23 - }, - { - "o": 24 - }, - { - "o": 25 - }, - { - "o": 26 - }, - { - "o": 27 - }, - { - "o": 28 - }, - { - "o": 29 - }, - { - "o": 30 - }, - { - "o": 31 - }, - { - "o": 32 - }, - { - "o": 33 - }, - { - "o": 34 - }, - { - "o": 35 - }, - { - "o": 36 - }, - { - "o": 37 - }, - { - "o": 38 - }, - { - "o": 39 - }, - { - "o": 40 - }, - { - "o": 41 - }, - { - "o": 42 - }, - { - "o": 43 - }, - { - "o": 44 - }, - { - "o": 45 - }, - { - "o": 46 - }, - { - "o": 47 - }, - { - "o": 48 - }, - { - "o": 49 - }, - { - "o": 50 - }, - { - "o": 51 - }, - { - "o": 52 - }, - { - "o": 53 - }, - { - "o": 54 - }, - { - "o": 55 - }, - { - "o": 56 - }, - { - "o": 57 - }, - { - "o": 58 - }, - { - "o": 59 - }, - { - "o": 60 - }, - { - "o": 61 - }, - { - "o": 62 - }, - { - "o": 63 - }, - { - "o": 64 - }, - { - "o": 65 - }, - { - "o": 66 - }, - { - "o": 67 - }, - { - "o": 68 - }, - { - "o": 69 - }, - { - "o": 70 - }, - { - "o": 71 - }, - { - "o": 72 - }, - { - "o": 73 - }, - { - "o": 74 - }, - { - "o": 75 - }, - { - "o": 76 - }, - { - "o": 77 - }, - { - "o": 78 - }, - { - "o": 79 - }, - { - "o": 80 - }, - { - "o": 81 - }, - { - "o": 82 - }, - { - "o": 83 - }, - { - "o": 84 - }, - { - "o": 85 - }, - { - "o": 86 - }, - { - "o": 87 - }, - { - "o": 88 - }, - { - "o": 89 - }, - { - "o": 90 - }, - { - "o": 91 - }, - { - "o": 92 - }, - { - "o": 93 - }, - { - "o": 94 - }, - { - "o": 95 - }, - { - "o": 96 - }, - { - "o": 97 - }, - { - "o": 98 - }, - { - "o": 99 - }, - { - "o": 100 - }, - { - "o": 101 - }, - { - "o": 102 - }, - { - "o": 103 - }, - { - "o": 104 - }, - { - "o": 105 - }, - { - "o": 106 - }, - { - "o": 107 - }, - { - "o": 108 - }, - { - "o": 109 - }, - { - "o": 110 - }, - { - "o": 111 - }, - { - "o": 112 - }, - { - "o": 113 - }, - { - "o": 114 - }, - { - "o": 115 - }, - { - "o": 116 - }, - { - "o": 117 - }, - { - "o": 118 - }, - { - "o": 119 - }, - { - "o": 120 - }, - { - "o": 121 - }, - { - "o": 122 - }, - { - "o": 123 - }, - { - "o": 124 - }, - { - "o": 125 - }, - { - "o": 126 - }, - { - "o": 127 - }, - { - "o": 128 - }, - { - "o": 129 - }, - { - "o": 130 - }, - { - "o": 131 - }, - { - "o": 132 - }, - { - "o": 133 - }, - { - "o": 134 - }, - { - "o": 135 - }, - { - "o": 136 - }, - { - "o": 137 - }, - { - "o": 138 - }, - { - "o": 139 - }, - { - "o": 140 - }, - { - "o": 141 - }, - { - "o": 142 - }, - { - "o": 143 - }, - { - "o": 144 - }, - { - "o": 145 - }, - { - "o": 146 - }, - { - "o": 147 - }, - { - "o": 148 - }, - { - "o": 149 - }, - { - "o": 150 - }, - { - "o": 151 - }, - { - "o": 152 - }, - { - "o": 153 - }, - { - "o": 154 - }, - { - "o": 155 - }, - { - "o": 156 - }, - { - "o": 157 - }, - { - "o": 158 - }, - { - "o": 159 - }, - { - "o": 160 - }, - { - "o": 161 - }, - { - "o": 162 - }, - { - "o": 163 - }, - { - "o": 164 - }, - { - "o": 165 - }, - { - "o": 166 - }, - { - "o": 167 - }, - { - "o": 168 - }, - { - "o": 169 - }, - { - "o": 170 - }, - { - "o": 171 - }, - { - "o": 172 - }, - { - "o": 173 - }, - { - "o": 174 - }, - { - "o": 175 - }, - { - "o": 176 - }, - { - "o": 177 - }, - { - "o": 178 - }, - { - "o": 179 - }, - { - "o": 180 - }, - { - "o": 181 - }, - { - "o": 182 - }, - { - "o": 183 - }, - { - "o": 184 - }, - { - "o": 185 - }, - { - "o": 186 - }, - { - "o": 187 - }, - { - "o": 188 - }, - { - "o": 189 - }, - { - "o": 190 - }, - { - "o": 191 - }, - { - "o": 192 - }, - { - "o": 193 - }, - { - "o": 194 - }, - { - "o": 195 - }, - { - "o": 196 - }, - { - "o": 197 - }, - { - "o": 198 - }, - { - "o": 199 - }, - { - "o": 200 - }, - { - "o": 201 - }, - { - "o": 202 - }, - { - "o": 203 - }, - { - "o": 204 - }, - { - "o": 205 - }, - { - "o": 206 - }, - { - "o": 207 - }, - { - "o": 208 - }, - { - "o": 209 - }, - { - "o": 210 - }, - { - "o": 211 - }, - { - "o": 212 - }, - { - "o": 213 - }, - { - "o": 214 - }, - { - "o": 215 - }, - { - "o": 216 - }, - { - "o": 217 - }, - { - "o": 218 - }, - { - "o": 219 - }, - { - "o": 220 - }, - { - "o": 221 - }, - { - "o": 222 - }, - { - "o": 223 - }, - { - "o": 224 - }, - { - "o": 225 - }, - { - "o": 226 - }, - { - "o": 227 - }, - { - "o": 228 - }, - { - "o": 229 - }, - { - "o": 230 - }, - { - "o": 231 - }, - { - "o": 232 - }, - { - "o": 233 - }, - { - "o": 234 - }, - { - "o": 235 - }, - { - "o": 236 - }, - { - "o": 237 - }, - { - "o": 238 - }, - { - "o": 239 - }, - { - "o": 240 - }, - { - "o": 241 - }, - { - "o": 242 - }, - { - "o": 243 - }, - { - "o": 244 - }, - { - "o": 245 - }, - { - "o": 246 - }, - { - "o": 247 - }, - { - "o": 248 - }, - { - "o": 249 - }, - { - "o": 250 - }, - { - "o": 251 - }, - { - "o": 252 - }, - { - "o": 253 - }, - { - "o": 254 - }, - { - "o": 255 - }, - { - "o": 256 - }, - { - "o": 257 - }, - { - "o": 258 - }, - { - "o": 259 - }, - { - "o": 260 - }, - { - "o": 261 - }, - { - "o": 262 - }, - { - "o": 263 - }, - { - "o": 264 - }, - { - "o": 265 - }, - { - "o": 266 - }, - { - "o": 267 - }, - { - "o": 268 - }, - { - "o": 269 - }, - { - "o": 270 - }, - { - "o": 271 - }, - { - "o": 272 - }, - { - "o": 273 - }, - { - "o": 274 - }, - { - "o": 275 - }, - { - "o": 276 - }, - { - "o": 277 - }, - { - "o": 278 - }, - { - "o": 279 - }, - { - "o": 280 - }, - { - "o": 281 - }, - { - "o": 282 - }, - { - "o": 283 - }, - { - "o": 284 - }, - { - "o": 285 - }, - { - "o": 286 - }, - { - "o": 287 - }, - { - "o": 288 - }, - { - "o": 289 - }, - { - "o": 290 - }, - { - "o": 291 - }, - { - "o": 292 - }, - { - "o": 293 - }, - { - "o": 294 - }, - { - "o": 295 - }, - { - "o": 296 - }, - { - "o": 297 - }, - { - "o": 298 - }, - { - "o": 299 - }, - { - "o": 300 - }, - { - "o": 301 - }, - { - "o": 302 - }, - { - "o": 303 - }, - { - "o": 304 - }, - { - "o": 305 - }, - { - "o": 306 - }, - { - "o": 307 - }, - { - "o": 308 - }, - { - "o": 309 - }, - { - "o": 310 - }, - { - "o": 311 - }, - { - "o": 312 - }, - { - "o": 313 - }, - { - "o": 314 - }, - { - "o": 315 - }, - { - "o": 316 - }, - { - "o": 317 - }, - { - "o": 318 - }, - { - "o": 319 - }, - { - "o": 320 - }, - { - "o": 321 - }, - { - "o": 322 - }, - { - "o": 323 - }, - { - "o": 324 - }, - { - "o": 325 - }, - { - "o": 326 - }, - { - "o": 327 - }, - { - "o": 328 - }, - { - "o": 329 - }, - { - "o": 330 - }, - { - "o": 331 - }, - { - "o": 332 - }, - { - "o": 333 - }, - { - "o": 334 - }, - { - "o": 335 - }, - { - "o": 336 - }, - { - "o": 337 - }, - { - "o": 338 - }, - { - "o": 339 - }, - { - "o": 340 - }, - { - "o": 341 - }, - { - "o": 342 - }, - { - "o": 343 - }, - { - "o": 344 - }, - { - "o": 345 - }, - { - "o": 346 - }, - { - "o": 347 - }, - { - "o": 348 - }, - { - "o": 349 - }, - { - "o": 350 - }, - { - "o": 351 - }, - { - "o": 352 - }, - { - "o": 353 - }, - { - "o": 354 - }, - { - "o": 355 - }, - { - "o": 356 - }, - { - "o": 357 - }, - { - "o": 358 - }, - { - "o": 359 - }, - { - "o": 360 - }, - { - "o": 361 - }, - { - "o": 362 - }, - { - "o": 363 - }, - { - "o": 364 - }, - { - "o": 365 - }, - { - "o": 366 - }, - { - "o": 367 - }, - { - "o": 368 - }, - { - "o": 369 - }, - { - "o": 370 - }, - { - "o": 371 - }, - { - "o": 372 - }, - { - "o": 373 - }, - { - "o": 374 - }, - { - "o": 375 - }, - { - "o": 376 - }, - { - "o": 377 - }, - { - "o": 378 - }, - { - "o": 379 - }, - { - "o": 380 - }, - { - "o": 381 - }, - { - "o": 382 - }, - { - "o": 383 - }, - { - "o": 384 - }, - { - "o": 385 - }, - { - "o": 386 - }, - { - "o": 387 - }, - { - "o": 388 - }, - { - "o": 389 - }, - { - "o": 390 - }, - { - "o": 391 - }, - { - "o": 392 - }, - { - "o": 393 - }, - { - "o": 394 - }, - { - "o": 395 - }, - { - "o": 396 - }, - { - "o": 397 - }, - { - "o": 398 - }, - { - "o": 399 - }, - { - "o": 400 - }, - { - "o": 401 - }, - { - "o": 402 - }, - { - "o": 403 - }, - { - "o": 404 - }, - { - "o": 405 - }, - { - "o": 406 - }, - { - "o": 407 - }, - { - "o": 408 - }, - { - "o": 409 - }, - { - "o": 410 - }, - { - "o": 411 - }, - { - "o": 412 - }, - { - "o": 413 - }, - { - "o": 414 - }, - { - "o": 415 - }, - { - "o": 416 - }, - { - "o": 417 - }, - { - "o": 418 - }, - { - "o": 419 - }, - { - "o": 420 - }, - { - "o": 421 - }, - { - "o": 422 - }, - { - "o": 423 - }, - { - "o": 424 - }, - { - "o": 425 - }, - { - "o": 426 - }, - { - "o": 427 - }, - { - "o": 428 - }, - { - "o": 429 - }, - { - "o": 430 - }, - { - "o": 431 - }, - { - "o": 432 - }, - { - "o": 433 - }, - { - "o": 434 - }, - { - "o": 435 - }, - { - "o": 436 - }, - { - "o": 437 - }, - { - "o": 438 - }, - { - "o": 439 - }, - { - "o": 440 - }, - { - "o": 441 - }, - { - "o": 442 - }, - { - "o": 443 - }, - { - "o": 444 - }, - { - "o": 445 - }, - { - "o": 446 - }, - { - "o": 447 - }, - { - "o": 448 - }, - { - "o": 449 - }, - { - "o": 450 - }, - { - "o": 451 - }, - { - "o": 452 - }, - { - "o": 453 - }, - { - "o": 454 - }, - { - "o": 455 - }, - { - "o": 456 - }, - { - "o": 457 - }, - { - "o": 458 - }, - { - "o": 459 - }, - { - "o": 460 - }, - { - "o": 461 - }, - { - "o": 462 - }, - { - "o": 463 - }, - { - "o": 464 - }, - { - "o": 465 - }, - { - "o": 466 - }, - { - "o": 467 - }, - { - "o": 468 - }, - { - "o": 469 - }, - { - "o": 470 - }, - { - "o": 471 - }, - { - "o": 472 - }, - { - "o": 473 - }, - { - "o": 474 - }, - { - "o": 475 - }, - { - "o": 476 - }, - { - "o": 477 - }, - { - "o": 478 - }, - { - "o": 479 - }, - { - "o": 480 - }, - { - "o": 481 - }, - { - "o": 482 - }, - { - "o": 483 - }, - { - "o": 484 - }, - { - "o": 485 - }, - { - "o": 486 - }, - { - "o": 487 - }, - { - "o": 488 - }, - { - "o": 489 - }, - { - "o": 490 - }, - { - "o": 491 - }, - { - "o": 492 - }, - { - "o": 493 - }, - { - "o": 494 - }, - { - "o": 495 - }, - { - "o": 496 - }, - { - "o": 497 - }, - { - "o": 498 - }, - { - "o": 499 - }, - { - "o": 500 - }, - { - "o": 501 - }, - { - "o": 502 - }, - { - "o": 503 - }, - { - "o": 504 - }, - { - "o": 505 - }, - { - "o": 506 - }, - { - "o": 507 - }, - { - "o": 508 - }, - { - "o": 509 - }, - { - "o": 510 - }, - { - "o": 511 - }, - { - "o": 512 - }, - { - "o": 513 - }, - { - "o": 514 - }, - { - "o": 515 - }, - { - "o": 516 - }, - { - "o": 517 - }, - { - "o": 518 - }, - { - "o": 519 - }, - { - "o": 520 - }, - { - "o": 521 - }, - { - "o": 522 - }, - { - "o": 523 - }, - { - "o": 524 - }, - { - "o": 525 - }, - { - "o": 526 - }, - { - "o": 527 - }, - { - "o": 528 - }, - { - "o": 529 - }, - { - "o": 530 - }, - { - "o": 531 - }, - { - "o": 532 - }, - { - "o": 533 - }, - { - "o": 534 - }, - { - "o": 535 - }, - { - "o": 536 - }, - { - "o": 537 - }, - { - "o": 538 - }, - { - "o": 539 - }, - { - "o": 540 - }, - { - "o": 541 - }, - { - "o": 542 - }, - { - "o": 543 - }, - { - "o": 544 - }, - { - "o": 545 - }, - { - "o": 546 - }, - { - "o": 547 - }, - { - "o": 548 - }, - { - "o": 549 - }, - { - "o": 550 - }, - { - "o": 551 - }, - { - "o": 552 - }, - { - "o": 553 - }, - { - "o": 554 - }, - { - "o": 555 - }, - { - "o": 556 - }, - { - "o": 557 - }, - { - "o": 558 - }, - { - "o": 559 - }, - { - "o": 560 - }, - { - "o": 561 - }, - { - "o": 562 - }, - { - "o": 563 - }, - { - "o": 564 - }, - { - "o": 565 - }, - { - "o": 566 - }, - { - "o": 567 - }, - { - "o": 568 - }, - { - "o": 569 - }, - { - "o": 570 - }, - { - "o": 571 - }, - { - "o": 572 - }, - { - "o": 573 - }, - { - "o": 574 - }, - { - "o": 575 - }, - { - "o": 576 - }, - { - "o": 577 - }, - { - "o": 578 - }, - { - "o": 579 - }, - { - "o": 580 - }, - { - "o": 581 - }, - { - "o": 582 - }, - { - "o": 583 - }, - { - "o": 584 - }, - { - "o": 585 - }, - { - "o": 586 - }, - { - "o": 587 - }, - { - "o": 588 - }, - { - "o": 589 - }, - { - "o": 590 - }, - { - "o": 591 - }, - { - "o": 592 - }, - { - "o": 593 - }, - { - "o": 594 - }, - { - "o": 595 - }, - { - "o": 596 - }, - { - "o": 597 - }, - { - "o": 598 - }, - { - "o": 599 - }, - { - "o": 600 - }, - { - "o": 601 - }, - { - "o": 602 - }, - { - "o": 603 - }, - { - "o": 604 - }, - { - "o": 605 - }, - { - "o": 606 - }, - { - "o": 607 - }, - { - "o": 608 - }, - { - "o": 609 - }, - { - "o": 610 - }, - { - "o": 611 - }, - { - "o": 612 - }, - { - "o": 613 - }, - { - "o": 614 - }, - { - "o": 615 - }, - { - "o": 616 - }, - { - "o": 617 - }, - { - "o": 618 - }, - { - "o": 619 - }, - { - "o": 620 - }, - { - "o": 621 - }, - { - "o": 622 - }, - { - "o": 623 - }, - { - "o": 624 - }, - { - "o": 625 - }, - { - "o": 626 - }, - { - "o": 627 - }, - { - "o": 628 - }, - { - "o": 629 - }, - { - "o": 630 - }, - { - "o": 631 - }, - { - "o": 632 - }, - { - "o": 633 - }, - { - "o": 634 - }, - { - "o": 635 - }, - { - "o": 636 - }, - { - "o": 637 - }, - { - "o": 638 - }, - { - "o": 639 - }, - { - "o": 640 - }, - { - "o": 641 - }, - { - "o": 642 - }, - { - "o": 643 - }, - { - "o": 644 - }, - { - "o": 645 - }, - { - "o": 646 - }, - { - "o": 647 - }, - { - "o": 648 - }, - { - "o": 649 - }, - { - "o": 650 - }, - { - "o": 651 - }, - { - "o": 652 - }, - { - "o": 653 - }, - { - "o": 654 - }, - { - "o": 655 - }, - { - "o": 656 - }, - { - "o": 657 - }, - { - "o": 658 - }, - { - "o": 659 - }, - { - "o": 660 - }, - { - "o": 661 - }, - { - "o": 662 - }, - { - "o": 663 - }, - { - "o": 664 - }, - { - "o": 665 - }, - { - "o": 666 - }, - { - "o": 667 - }, - { - "o": 668 - }, - { - "o": 669 - }, - { - "o": 670 - }, - { - "o": 671 - }, - { - "o": 672 - }, - { - "o": 673 - }, - { - "o": 674 - }, - { - "o": 675 - }, - { - "o": 676 - }, - { - "o": 677 - }, - { - "o": 678 - }, - { - "o": 679 - }, - { - "o": 680 - }, - { - "o": 681 - }, - { - "o": 682 - }, - { - "o": 683 - }, - { - "o": 684 - }, - { - "o": 685 - }, - { - "o": 686 - }, - { - "o": 687 - }, - { - "o": 688 - }, - { - "o": 689 - }, - { - "o": 690 - }, - { - "o": 691 - }, - { - "o": 692 - }, - { - "o": 693 - }, - { - "o": 694 - }, - { - "o": 695 - }, - { - "o": 696 - }, - { - "o": 697 - }, - { - "o": 698 - }, - { - "o": 699 - }, - { - "o": 700 - }, - { - "o": 701 - }, - { - "o": 702 - }, - { - "o": 703 - }, - { - "o": 704 - }, - { - "o": 705 - }, - { - "o": 706 - }, - { - "o": 707 - }, - { - "o": 708 - }, - { - "o": 709 - }, - { - "o": 710 - }, - { - "o": 711 - }, - { - "o": 712 - }, - { - "o": 713 - }, - { - "o": 714 - }, - { - "o": 715 - }, - { - "o": 716 - }, - { - "o": 717 - }, - { - "o": 718 - }, - { - "o": 719 - }, - { - "o": 720 - }, - { - "o": 721 - }, - { - "o": 722 - }, - { - "o": 723 - }, - { - "o": 724 - }, - { - "o": 725 - }, - { - "o": 726 - }, - { - "o": 727 - }, - { - "o": 728 - }, - { - "o": 729 - }, - { - "o": 730 - }, - { - "o": 731 - }, - { - "o": 732 - }, - { - "o": 733 - }, - { - "o": 734 - }, - { - "o": 735 - }, - { - "o": 736 - }, - { - "o": 737 - }, - { - "o": 738 - }, - { - "o": 739 - }, - { - "o": 740 - }, - { - "o": 741 - }, - { - "o": 742 - }, - { - "o": 743 - }, - { - "o": 744 - }, - { - "o": 745 - }, - { - "o": 746 - }, - { - "o": 747 - }, - { - "o": 748 - }, - { - "o": 749 - }, - { - "o": 750 - }, - { - "o": 751 - }, - { - "o": 752 - }, - { - "o": 753 - }, - { - "o": 754 - }, - { - "o": 755 - }, - { - "o": 756 - }, - { - "o": 757 - }, - { - "o": 758 - }, - { - "o": 759 - }, - { - "o": 760 - }, - { - "o": 761 - }, - { - "o": 762 - }, - { - "o": 763 - }, - { - "o": 764 - }, - { - "o": 765 - }, - { - "o": 766 - }, - { - "o": 767 - }, - { - "o": 768 - }, - { - "o": 769 - }, - { - "o": 770 - }, - { - "o": 771 - }, - { - "o": 772 - }, - { - "o": 773 - }, - { - "o": 774 - }, - { - "o": 775 - }, - { - "o": 776 - }, - { - "o": 777 - }, - { - "o": 778 - }, - { - "o": 779 - }, - { - "o": 780 - }, - { - "o": 781 - }, - { - "o": 782 - }, - { - "o": 783 - }, - { - "o": 784 - }, - { - "o": 785 - }, - { - "o": 786 - }, - { - "o": 787 - }, - { - "o": 788 - }, - { - "o": 789 - }, - { - "o": 790 - }, - { - "o": 791 - }, - { - "o": 792 - }, - { - "o": 793 - }, - { - "o": 794 - }, - { - "o": 795 - }, - { - "o": 796 - }, - { - "o": 797 - }, - { - "o": 798 - }, - { - "o": 799 - }, - { - "o": 800 - }, - { - "o": 801 - }, - { - "o": 802 - }, - { - "o": 803 - }, - { - "o": 804 - }, - { - "o": 805 - }, - { - "o": 806 - }, - { - "o": 807 - }, - { - "o": 808 - }, - { - "o": 809 - }, - { - "o": 810 - }, - { - "o": 811 - }, - { - "o": 812 - }, - { - "o": 813 - }, - { - "o": 814 - }, - { - "o": 815 - }, - { - "o": 816 - }, - { - "o": 817 - }, - { - "o": 818 - }, - { - "o": 819 - }, - { - "o": 820 - }, - { - "o": 821 - }, - { - "o": 822 - }, - { - "o": 823 - }, - { - "o": 824 - }, - { - "o": 825 - }, - { - "o": 826 - }, - { - "o": 827 - }, - { - "o": 828 - }, - { - "o": 829 - }, - { - "o": 830 - }, - { - "o": 831 - }, - { - "o": 832 - }, - { - "o": 833 - }, - { - "o": 834 - }, - { - "o": 835 - }, - { - "o": 836 - }, - { - "o": 837 - }, - { - "o": 838 - }, - { - "o": 839 - }, - { - "o": 840 - }, - { - "o": 841 - }, - { - "o": 842 - }, - { - "o": 843 - }, - { - "o": 844 - }, - { - "o": 845 - }, - { - "o": 846 - }, - { - "o": 847 - }, - { - "o": 848 - }, - { - "o": 849 - }, - { - "o": 850 - }, - { - "o": 851 - }, - { - "o": 852 - }, - { - "o": 853 - }, - { - "o": 854 - }, - { - "o": 855 - }, - { - "o": 856 - }, - { - "o": 857 - }, - { - "o": 858 - }, - { - "o": 859 - }, - { - "o": 860 - }, - { - "o": 861 - }, - { - "o": 862 - }, - { - "o": 863 - }, - { - "o": 864 - }, - { - "o": 865 - }, - { - "o": 866 - }, - { - "o": 867 - }, - { - "o": 868 - }, - { - "o": 869 - }, - { - "o": 870 - }, - { - "o": 871 - }, - { - "o": 872 - }, - { - "o": 873 - }, - { - "o": 874 - }, - { - "o": 875 - }, - { - "o": 876 - }, - { - "o": 877 - }, - { - "o": 878 - }, - { - "o": 879 - }, - { - "o": 880 - }, - { - "o": 881 - }, - { - "o": 882 - }, - { - "o": 883 - }, - { - "o": 884 - }, - { - "o": 885 - }, - { - "o": 886 - }, - { - "o": 887 - }, - { - "o": 888 - }, - { - "o": 889 - }, - { - "o": 890 - }, - { - "o": 891 - }, - { - "o": 892 - }, - { - "o": 893 - }, - { - "o": 894 - }, - { - "o": 895 - }, - { - "o": 896 - }, - { - "o": 897 - }, - { - "o": 898 - }, - { - "o": 899 - }, - { - "o": 900 - }, - { - "o": 901 - }, - { - "o": 902 - }, - { - "o": 903 - }, - { - "o": 904 - }, - { - "o": 905 - }, - { - "o": 906 - }, - { - "o": 907 - }, - { - "o": 908 - }, - { - "o": 909 - }, - { - "o": 910 - }, - { - "o": 911 - }, - { - "o": 912 - }, - { - "o": 913 - }, - { - "o": 914 - }, - { - "o": 915 - }, - { - "o": 916 - }, - { - "o": 917 - }, - { - "o": 918 - }, - { - "o": 919 - }, - { - "o": 920 - }, - { - "o": 921 - }, - { - "o": 922 - }, - { - "o": 923 - }, - { - "o": 924 - }, - { - "o": 925 - }, - { - "o": 926 - }, - { - "o": 927 - }, - { - "o": 928 - }, - { - "o": 929 - }, - { - "o": 930 - }, - { - "o": 931 - }, - { - "o": 932 - }, - { - "o": 933 - }, - { - "o": 934 - }, - { - "o": 935 - }, - { - "o": 936 - }, - { - "o": 937 - }, - { - "o": 938 - }, - { - "o": 939 - }, - { - "o": 940 - }, - { - "o": 941 - }, - { - "o": 942 - }, - { - "o": 943 - }, - { - "o": 944 - }, - { - "o": 945 - }, - { - "o": 946 - }, - { - "o": 947 - }, - { - "o": 948 - }, - { - "o": 949 - }, - { - "o": 950 - }, - { - "o": 951 - }, - { - "o": 952 - }, - { - "o": 953 - }, - { - "o": 954 - }, - { - "o": 955 - }, - { - "o": 956 - }, - { - "o": 957 - }, - { - "o": 958 - }, - { - "o": 959 - }, - { - "o": 960 - }, - { - "o": 961 - }, - { - "o": 962 - }, - { - "o": 963 - }, - { - "o": 964 - }, - { - "o": 965 - }, - { - "o": 966 - }, - { - "o": 967 - }, - { - "o": 968 - }, - { - "o": 969 - }, - { - "o": 970 - }, - { - "o": 971 - }, - { - "o": 972 - }, - { - "o": 973 - }, - { - "o": 974 - }, - { - "o": 975 - }, - { - "o": 976 - }, - { - "o": 977 - }, - { - "o": 978 - }, - { - "o": 979 - }, - { - "o": 980 - }, - { - "o": 981 - }, - { - "o": 982 - }, - { - "o": 983 - }, - { - "o": 984 - }, - { - "o": 985 - }, - { - "o": 986 - }, - { - "o": 987 - }, - { - "o": 988 - }, - { - "o": 989 - }, - { - "o": 990 - }, - { - "o": 991 - }, - { - "o": 992 - }, - { - "o": 993 - }, - { - "o": 994 - }, - { - "o": 995 - }, - { - "o": 996 - }, - { - "o": 997 - }, - { - "o": 998 - }, - { - "o": 999 - }, - { - "o": 1000 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe", - "content_size_bytes": 9902, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_three-segments_adjustment_inline", - "row_set_kind": "adjustment", - "rows": [ - { - "o": 0 - }, - { - "o": 1 - }, - { - "o": 2 - }, - { - "o": 3 - }, - { - "o": 4 - }, - { - "o": 5 - }, - { - "o": 6 - }, - { - "o": 7 - }, - { - "o": 8 - }, - { - "o": 9 - }, - { - "o": 10 - }, - { - "o": 11 - }, - { - "o": 12 - }, - { - "o": 13 - }, - { - "o": 14 - }, - { - "o": 15 - }, - { - "o": 16 - }, - { - "o": 17 - }, - { - "o": 18 - }, - { - "o": 19 - }, - { - "o": 20 - }, - { - "o": 21 - }, - { - "o": 22 - }, - { - "o": 23 - }, - { - "o": 24 - }, - { - "o": 25 - }, - { - "o": 26 - }, - { - "o": 27 - }, - { - "o": 28 - }, - { - "o": 29 - }, - { - "o": 30 - }, - { - "o": 31 - }, - { - "o": 32 - }, - { - "o": 33 - }, - { - "o": 34 - }, - { - "o": 35 - }, - { - "o": 36 - }, - { - "o": 37 - }, - { - "o": 38 - }, - { - "o": 39 - }, - { - "o": 40 - }, - { - "o": 41 - }, - { - "o": 42 - }, - { - "o": 43 - }, - { - "o": 44 - }, - { - "o": 45 - }, - { - "o": 46 - }, - { - "o": 47 - }, - { - "o": 48 - }, - { - "o": 49 - }, - { - "o": 50 - }, - { - "o": 51 - }, - { - "o": 52 - }, - { - "o": 53 - }, - { - "o": 54 - }, - { - "o": 55 - }, - { - "o": 56 - }, - { - "o": 57 - }, - { - "o": 58 - }, - { - "o": 59 - }, - { - "o": 60 - }, - { - "o": 61 - }, - { - "o": 62 - }, - { - "o": 63 - }, - { - "o": 64 - }, - { - "o": 65 - }, - { - "o": 66 - }, - { - "o": 67 - }, - { - "o": 68 - }, - { - "o": 69 - }, - { - "o": 70 - }, - { - "o": 71 - }, - { - "o": 72 - }, - { - "o": 73 - }, - { - "o": 74 - }, - { - "o": 75 - }, - { - "o": 76 - }, - { - "o": 77 - }, - { - "o": 78 - }, - { - "o": 79 - }, - { - "o": 80 - }, - { - "o": 81 - }, - { - "o": 82 - }, - { - "o": 83 - }, - { - "o": 84 - }, - { - "o": 85 - }, - { - "o": 86 - }, - { - "o": 87 - }, - { - "o": 88 - }, - { - "o": 89 - }, - { - "o": 90 - }, - { - "o": 91 - }, - { - "o": 92 - }, - { - "o": 93 - }, - { - "o": 94 - }, - { - "o": 95 - }, - { - "o": 96 - }, - { - "o": 97 - }, - { - "o": 98 - }, - { - "o": 99 - }, - { - "o": 100 - }, - { - "o": 101 - }, - { - "o": 102 - }, - { - "o": 103 - }, - { - "o": 104 - }, - { - "o": 105 - }, - { - "o": 106 - }, - { - "o": 107 - }, - { - "o": 108 - }, - { - "o": 109 - }, - { - "o": 110 - }, - { - "o": 111 - }, - { - "o": 112 - }, - { - "o": 113 - }, - { - "o": 114 - }, - { - "o": 115 - }, - { - "o": 116 - }, - { - "o": 117 - }, - { - "o": 118 - }, - { - "o": 119 - }, - { - "o": 120 - }, - { - "o": 121 - }, - { - "o": 122 - }, - { - "o": 123 - }, - { - "o": 124 - }, - { - "o": 125 - }, - { - "o": 126 - }, - { - "o": 127 - }, - { - "o": 128 - }, - { - "o": 129 - }, - { - "o": 130 - }, - { - "o": 131 - }, - { - "o": 132 - }, - { - "o": 133 - }, - { - "o": 134 - }, - { - "o": 135 - }, - { - "o": 136 - }, - { - "o": 137 - }, - { - "o": 138 - }, - { - "o": 139 - }, - { - "o": 140 - }, - { - "o": 141 - }, - { - "o": 142 - }, - { - "o": 143 - }, - { - "o": 144 - }, - { - "o": 145 - }, - { - "o": 146 - }, - { - "o": 147 - }, - { - "o": 148 - }, - { - "o": 149 - }, - { - "o": 150 - }, - { - "o": 151 - }, - { - "o": 152 - }, - { - "o": 153 - }, - { - "o": 154 - }, - { - "o": 155 - }, - { - "o": 156 - }, - { - "o": 157 - }, - { - "o": 158 - }, - { - "o": 159 - }, - { - "o": 160 - }, - { - "o": 161 - }, - { - "o": 162 - }, - { - "o": 163 - }, - { - "o": 164 - }, - { - "o": 165 - }, - { - "o": 166 - }, - { - "o": 167 - }, - { - "o": 168 - }, - { - "o": 169 - }, - { - "o": 170 - }, - { - "o": 171 - }, - { - "o": 172 - }, - { - "o": 173 - }, - { - "o": 174 - }, - { - "o": 175 - }, - { - "o": 176 - }, - { - "o": 177 - }, - { - "o": 178 - }, - { - "o": 179 - }, - { - "o": 180 - }, - { - "o": 181 - }, - { - "o": 182 - }, - { - "o": 183 - }, - { - "o": 184 - }, - { - "o": 185 - }, - { - "o": 186 - }, - { - "o": 187 - }, - { - "o": 188 - }, - { - "o": 189 - }, - { - "o": 190 - }, - { - "o": 191 - }, - { - "o": 192 - }, - { - "o": 193 - }, - { - "o": 194 - }, - { - "o": 195 - }, - { - "o": 196 - }, - { - "o": 197 - }, - { - "o": 198 - }, - { - "o": 199 - }, - { - "o": 200 - }, - { - "o": 201 - }, - { - "o": 202 - }, - { - "o": 203 - }, - { - "o": 204 - }, - { - "o": 205 - }, - { - "o": 206 - }, - { - "o": 207 - }, - { - "o": 208 - }, - { - "o": 209 - }, - { - "o": 210 - }, - { - "o": 211 - }, - { - "o": 212 - }, - { - "o": 213 - }, - { - "o": 214 - }, - { - "o": 215 - }, - { - "o": 216 - }, - { - "o": 217 - }, - { - "o": 218 - }, - { - "o": 219 - }, - { - "o": 220 - }, - { - "o": 221 - }, - { - "o": 222 - }, - { - "o": 223 - }, - { - "o": 224 - }, - { - "o": 225 - }, - { - "o": 226 - }, - { - "o": 227 - }, - { - "o": 228 - }, - { - "o": 229 - }, - { - "o": 230 - }, - { - "o": 231 - }, - { - "o": 232 - }, - { - "o": 233 - }, - { - "o": 234 - }, - { - "o": 235 - }, - { - "o": 236 - }, - { - "o": 237 - }, - { - "o": 238 - }, - { - "o": 239 - }, - { - "o": 240 - }, - { - "o": 241 - }, - { - "o": 242 - }, - { - "o": 243 - }, - { - "o": 244 - }, - { - "o": 245 - }, - { - "o": 246 - }, - { - "o": 247 - }, - { - "o": 248 - }, - { - "o": 249 - }, - { - "o": 250 - }, - { - "o": 251 - }, - { - "o": 252 - }, - { - "o": 253 - }, - { - "o": 254 - }, - { - "o": 255 - }, - { - "o": 256 - }, - { - "o": 257 - }, - { - "o": 258 - }, - { - "o": 259 - }, - { - "o": 260 - }, - { - "o": 261 - }, - { - "o": 262 - }, - { - "o": 263 - }, - { - "o": 264 - }, - { - "o": 265 - }, - { - "o": 266 - }, - { - "o": 267 - }, - { - "o": 268 - }, - { - "o": 269 - }, - { - "o": 270 - }, - { - "o": 271 - }, - { - "o": 272 - }, - { - "o": 273 - }, - { - "o": 274 - }, - { - "o": 275 - }, - { - "o": 276 - }, - { - "o": 277 - }, - { - "o": 278 - }, - { - "o": 279 - }, - { - "o": 280 - }, - { - "o": 281 - }, - { - "o": 282 - }, - { - "o": 283 - }, - { - "o": 284 - }, - { - "o": 285 - }, - { - "o": 286 - }, - { - "o": 287 - }, - { - "o": 288 - }, - { - "o": 289 - }, - { - "o": 290 - }, - { - "o": 291 - }, - { - "o": 292 - }, - { - "o": 293 - }, - { - "o": 294 - }, - { - "o": 295 - }, - { - "o": 296 - }, - { - "o": 297 - }, - { - "o": 298 - }, - { - "o": 299 - }, - { - "o": 300 - }, - { - "o": 301 - }, - { - "o": 302 - }, - { - "o": 303 - }, - { - "o": 304 - }, - { - "o": 305 - }, - { - "o": 306 - }, - { - "o": 307 - }, - { - "o": 308 - }, - { - "o": 309 - }, - { - "o": 310 - }, - { - "o": 311 - }, - { - "o": 312 - }, - { - "o": 313 - }, - { - "o": 314 - }, - { - "o": 315 - }, - { - "o": 316 - }, - { - "o": 317 - }, - { - "o": 318 - }, - { - "o": 319 - }, - { - "o": 320 - }, - { - "o": 321 - }, - { - "o": 322 - }, - { - "o": 323 - }, - { - "o": 324 - }, - { - "o": 325 - }, - { - "o": 326 - }, - { - "o": 327 - }, - { - "o": 328 - }, - { - "o": 329 - }, - { - "o": 330 - }, - { - "o": 331 - }, - { - "o": 332 - }, - { - "o": 333 - }, - { - "o": 334 - }, - { - "o": 335 - }, - { - "o": 336 - }, - { - "o": 337 - }, - { - "o": 338 - }, - { - "o": 339 - }, - { - "o": 340 - }, - { - "o": 341 - }, - { - "o": 342 - }, - { - "o": 343 - }, - { - "o": 344 - }, - { - "o": 345 - }, - { - "o": 346 - }, - { - "o": 347 - }, - { - "o": 348 - }, - { - "o": 349 - }, - { - "o": 350 - }, - { - "o": 351 - }, - { - "o": 352 - }, - { - "o": 353 - }, - { - "o": 354 - }, - { - "o": 355 - }, - { - "o": 356 - }, - { - "o": 357 - }, - { - "o": 358 - }, - { - "o": 359 - }, - { - "o": 360 - }, - { - "o": 361 - }, - { - "o": 362 - }, - { - "o": 363 - }, - { - "o": 364 - }, - { - "o": 365 - }, - { - "o": 366 - }, - { - "o": 367 - }, - { - "o": 368 - }, - { - "o": 369 - }, - { - "o": 370 - }, - { - "o": 371 - }, - { - "o": 372 - }, - { - "o": 373 - }, - { - "o": 374 - }, - { - "o": 375 - }, - { - "o": 376 - }, - { - "o": 377 - }, - { - "o": 378 - }, - { - "o": 379 - }, - { - "o": 380 - }, - { - "o": 381 - }, - { - "o": 382 - }, - { - "o": 383 - }, - { - "o": 384 - }, - { - "o": 385 - }, - { - "o": 386 - }, - { - "o": 387 - }, - { - "o": 388 - }, - { - "o": 389 - }, - { - "o": 390 - }, - { - "o": 391 - }, - { - "o": 392 - }, - { - "o": 393 - }, - { - "o": 394 - }, - { - "o": 395 - }, - { - "o": 396 - }, - { - "o": 397 - }, - { - "o": 398 - }, - { - "o": 399 - }, - { - "o": 400 - }, - { - "o": 401 - }, - { - "o": 402 - }, - { - "o": 403 - }, - { - "o": 404 - }, - { - "o": 405 - }, - { - "o": 406 - }, - { - "o": 407 - }, - { - "o": 408 - }, - { - "o": 409 - }, - { - "o": 410 - }, - { - "o": 411 - }, - { - "o": 412 - }, - { - "o": 413 - }, - { - "o": 414 - }, - { - "o": 415 - }, - { - "o": 416 - }, - { - "o": 417 - }, - { - "o": 418 - }, - { - "o": 419 - }, - { - "o": 420 - }, - { - "o": 421 - }, - { - "o": 422 - }, - { - "o": 423 - }, - { - "o": 424 - }, - { - "o": 425 - }, - { - "o": 426 - }, - { - "o": 427 - }, - { - "o": 428 - }, - { - "o": 429 - }, - { - "o": 430 - }, - { - "o": 431 - }, - { - "o": 432 - }, - { - "o": 433 - }, - { - "o": 434 - }, - { - "o": 435 - }, - { - "o": 436 - }, - { - "o": 437 - }, - { - "o": 438 - }, - { - "o": 439 - }, - { - "o": 440 - }, - { - "o": 441 - }, - { - "o": 442 - }, - { - "o": 443 - }, - { - "o": 444 - }, - { - "o": 445 - }, - { - "o": 446 - }, - { - "o": 447 - }, - { - "o": 448 - }, - { - "o": 449 - }, - { - "o": 450 - }, - { - "o": 451 - }, - { - "o": 452 - }, - { - "o": 453 - }, - { - "o": 454 - }, - { - "o": 455 - }, - { - "o": 456 - }, - { - "o": 457 - }, - { - "o": 458 - }, - { - "o": 459 - }, - { - "o": 460 - }, - { - "o": 461 - }, - { - "o": 462 - }, - { - "o": 463 - }, - { - "o": 464 - }, - { - "o": 465 - }, - { - "o": 466 - }, - { - "o": 467 - }, - { - "o": 468 - }, - { - "o": 469 - }, - { - "o": 470 - }, - { - "o": 471 - }, - { - "o": 472 - }, - { - "o": 473 - }, - { - "o": 474 - }, - { - "o": 475 - }, - { - "o": 476 - }, - { - "o": 477 - }, - { - "o": 478 - }, - { - "o": 479 - }, - { - "o": 480 - }, - { - "o": 481 - }, - { - "o": 482 - }, - { - "o": 483 - }, - { - "o": 484 - }, - { - "o": 485 - }, - { - "o": 486 - }, - { - "o": 487 - }, - { - "o": 488 - }, - { - "o": 489 - }, - { - "o": 490 - }, - { - "o": 491 - }, - { - "o": 492 - }, - { - "o": 493 - }, - { - "o": 494 - }, - { - "o": 495 - }, - { - "o": 496 - }, - { - "o": 497 - }, - { - "o": 498 - }, - { - "o": 499 - }, - { - "o": 500 - }, - { - "o": 501 - }, - { - "o": 502 - }, - { - "o": 503 - }, - { - "o": 504 - }, - { - "o": 505 - }, - { - "o": 506 - }, - { - "o": 507 - }, - { - "o": 508 - }, - { - "o": 509 - }, - { - "o": 510 - }, - { - "o": 511 - }, - { - "o": 512 - }, - { - "o": 513 - }, - { - "o": 514 - }, - { - "o": 515 - }, - { - "o": 516 - }, - { - "o": 517 - }, - { - "o": 518 - }, - { - "o": 519 - }, - { - "o": 520 - }, - { - "o": 521 - }, - { - "o": 522 - }, - { - "o": 523 - }, - { - "o": 524 - }, - { - "o": 525 - }, - { - "o": 526 - }, - { - "o": 527 - }, - { - "o": 528 - }, - { - "o": 529 - }, - { - "o": 530 - }, - { - "o": 531 - }, - { - "o": 532 - }, - { - "o": 533 - }, - { - "o": 534 - }, - { - "o": 535 - }, - { - "o": 536 - }, - { - "o": 537 - }, - { - "o": 538 - }, - { - "o": 539 - }, - { - "o": 540 - }, - { - "o": 541 - }, - { - "o": 542 - }, - { - "o": 543 - }, - { - "o": 544 - }, - { - "o": 545 - }, - { - "o": 546 - }, - { - "o": 547 - }, - { - "o": 548 - }, - { - "o": 549 - }, - { - "o": 550 - }, - { - "o": 551 - }, - { - "o": 552 - }, - { - "o": 553 - }, - { - "o": 554 - }, - { - "o": 555 - }, - { - "o": 556 - }, - { - "o": 557 - }, - { - "o": 558 - }, - { - "o": 559 - }, - { - "o": 560 - }, - { - "o": 561 - }, - { - "o": 562 - }, - { - "o": 563 - }, - { - "o": 564 - }, - { - "o": 565 - }, - { - "o": 566 - }, - { - "o": 567 - }, - { - "o": 568 - }, - { - "o": 569 - }, - { - "o": 570 - }, - { - "o": 571 - }, - { - "o": 572 - }, - { - "o": 573 - }, - { - "o": 574 - }, - { - "o": 575 - }, - { - "o": 576 - }, - { - "o": 577 - }, - { - "o": 578 - }, - { - "o": 579 - }, - { - "o": 580 - }, - { - "o": 581 - }, - { - "o": 582 - }, - { - "o": 583 - }, - { - "o": 584 - }, - { - "o": 585 - }, - { - "o": 586 - }, - { - "o": 587 - }, - { - "o": 588 - }, - { - "o": 589 - }, - { - "o": 590 - }, - { - "o": 591 - }, - { - "o": 592 - }, - { - "o": 593 - }, - { - "o": 594 - }, - { - "o": 595 - }, - { - "o": 596 - }, - { - "o": 597 - }, - { - "o": 598 - }, - { - "o": 599 - }, - { - "o": 600 - }, - { - "o": 601 - }, - { - "o": 602 - }, - { - "o": 603 - }, - { - "o": 604 - }, - { - "o": 605 - }, - { - "o": 606 - }, - { - "o": 607 - }, - { - "o": 608 - }, - { - "o": 609 - }, - { - "o": 610 - }, - { - "o": 611 - }, - { - "o": 612 - }, - { - "o": 613 - }, - { - "o": 614 - }, - { - "o": 615 - }, - { - "o": 616 - }, - { - "o": 617 - }, - { - "o": 618 - }, - { - "o": 619 - }, - { - "o": 620 - }, - { - "o": 621 - }, - { - "o": 622 - }, - { - "o": 623 - }, - { - "o": 624 - }, - { - "o": 625 - }, - { - "o": 626 - }, - { - "o": 627 - }, - { - "o": 628 - }, - { - "o": 629 - }, - { - "o": 630 - }, - { - "o": 631 - }, - { - "o": 632 - }, - { - "o": 633 - }, - { - "o": 634 - }, - { - "o": 635 - }, - { - "o": 636 - }, - { - "o": 637 - }, - { - "o": 638 - }, - { - "o": 639 - }, - { - "o": 640 - }, - { - "o": 641 - }, - { - "o": 642 - }, - { - "o": 643 - }, - { - "o": 644 - }, - { - "o": 645 - }, - { - "o": 646 - }, - { - "o": 647 - }, - { - "o": 648 - }, - { - "o": 649 - }, - { - "o": 650 - }, - { - "o": 651 - }, - { - "o": 652 - }, - { - "o": 653 - }, - { - "o": 654 - }, - { - "o": 655 - }, - { - "o": 656 - }, - { - "o": 657 - }, - { - "o": 658 - }, - { - "o": 659 - }, - { - "o": 660 - }, - { - "o": 661 - }, - { - "o": 662 - }, - { - "o": 663 - }, - { - "o": 664 - }, - { - "o": 665 - }, - { - "o": 666 - }, - { - "o": 667 - }, - { - "o": 668 - }, - { - "o": 669 - }, - { - "o": 670 - }, - { - "o": 671 - }, - { - "o": 672 - }, - { - "o": 673 - }, - { - "o": 674 - }, - { - "o": 675 - }, - { - "o": 676 - }, - { - "o": 677 - }, - { - "o": 678 - }, - { - "o": 679 - }, - { - "o": 680 - }, - { - "o": 681 - }, - { - "o": 682 - }, - { - "o": 683 - }, - { - "o": 684 - }, - { - "o": 685 - }, - { - "o": 686 - }, - { - "o": 687 - }, - { - "o": 688 - }, - { - "o": 689 - }, - { - "o": 690 - }, - { - "o": 691 - }, - { - "o": 692 - }, - { - "o": 693 - }, - { - "o": 694 - }, - { - "o": 695 - }, - { - "o": 696 - }, - { - "o": 697 - }, - { - "o": 698 - }, - { - "o": 699 - }, - { - "o": 700 - }, - { - "o": 701 - }, - { - "o": 702 - }, - { - "o": 703 - }, - { - "o": 704 - }, - { - "o": 705 - }, - { - "o": 706 - }, - { - "o": 707 - }, - { - "o": 708 - }, - { - "o": 709 - }, - { - "o": 710 - }, - { - "o": 711 - }, - { - "o": 712 - }, - { - "o": 713 - }, - { - "o": 714 - }, - { - "o": 715 - }, - { - "o": 716 - }, - { - "o": 717 - }, - { - "o": 718 - }, - { - "o": 719 - }, - { - "o": 720 - }, - { - "o": 721 - }, - { - "o": 722 - }, - { - "o": 723 - }, - { - "o": 724 - }, - { - "o": 725 - }, - { - "o": 726 - }, - { - "o": 727 - }, - { - "o": 728 - }, - { - "o": 729 - }, - { - "o": 730 - }, - { - "o": 731 - }, - { - "o": 732 - }, - { - "o": 733 - }, - { - "o": 734 - }, - { - "o": 735 - }, - { - "o": 736 - }, - { - "o": 737 - }, - { - "o": 738 - }, - { - "o": 739 - }, - { - "o": 740 - }, - { - "o": 741 - }, - { - "o": 742 - }, - { - "o": 743 - }, - { - "o": 744 - }, - { - "o": 745 - }, - { - "o": 746 - }, - { - "o": 747 - }, - { - "o": 748 - }, - { - "o": 749 - }, - { - "o": 750 - }, - { - "o": 751 - }, - { - "o": 752 - }, - { - "o": 753 - }, - { - "o": 754 - }, - { - "o": 755 - }, - { - "o": 756 - }, - { - "o": 757 - }, - { - "o": 758 - }, - { - "o": 759 - }, - { - "o": 760 - }, - { - "o": 761 - }, - { - "o": 762 - }, - { - "o": 763 - }, - { - "o": 764 - }, - { - "o": 765 - }, - { - "o": 766 - }, - { - "o": 767 - }, - { - "o": 768 - }, - { - "o": 769 - }, - { - "o": 770 - }, - { - "o": 771 - }, - { - "o": 772 - }, - { - "o": 773 - }, - { - "o": 774 - }, - { - "o": 775 - }, - { - "o": 776 - }, - { - "o": 777 - }, - { - "o": 778 - }, - { - "o": 779 - }, - { - "o": 780 - }, - { - "o": 781 - }, - { - "o": 782 - }, - { - "o": 783 - }, - { - "o": 784 - }, - { - "o": 785 - }, - { - "o": 786 - }, - { - "o": 787 - }, - { - "o": 788 - }, - { - "o": 789 - }, - { - "o": 790 - }, - { - "o": 791 - }, - { - "o": 792 - }, - { - "o": 793 - }, - { - "o": 794 - }, - { - "o": 795 - }, - { - "o": 796 - }, - { - "o": 797 - }, - { - "o": 798 - }, - { - "o": 799 - }, - { - "o": 800 - }, - { - "o": 801 - }, - { - "o": 802 - }, - { - "o": 803 - }, - { - "o": 804 - }, - { - "o": 805 - }, - { - "o": 806 - }, - { - "o": 807 - }, - { - "o": 808 - }, - { - "o": 809 - }, - { - "o": 810 - }, - { - "o": 811 - }, - { - "o": 812 - }, - { - "o": 813 - }, - { - "o": 814 - }, - { - "o": 815 - }, - { - "o": 816 - }, - { - "o": 817 - }, - { - "o": 818 - }, - { - "o": 819 - }, - { - "o": 820 - }, - { - "o": 821 - }, - { - "o": 822 - }, - { - "o": 823 - }, - { - "o": 824 - }, - { - "o": 825 - }, - { - "o": 826 - }, - { - "o": 827 - }, - { - "o": 828 - }, - { - "o": 829 - }, - { - "o": 830 - }, - { - "o": 831 - }, - { - "o": 832 - }, - { - "o": 833 - }, - { - "o": 834 - }, - { - "o": 835 - }, - { - "o": 836 - }, - { - "o": 837 - }, - { - "o": 838 - }, - { - "o": 839 - }, - { - "o": 840 - }, - { - "o": 841 - }, - { - "o": 842 - }, - { - "o": 843 - }, - { - "o": 844 - }, - { - "o": 845 - }, - { - "o": 846 - }, - { - "o": 847 - }, - { - "o": 848 - }, - { - "o": 849 - }, - { - "o": 850 - }, - { - "o": 851 - }, - { - "o": 852 - }, - { - "o": 853 - }, - { - "o": 854 - }, - { - "o": 855 - }, - { - "o": 856 - }, - { - "o": 857 - }, - { - "o": 858 - }, - { - "o": 859 - }, - { - "o": 860 - }, - { - "o": 861 - }, - { - "o": 862 - }, - { - "o": 863 - }, - { - "o": 864 - }, - { - "o": 865 - }, - { - "o": 866 - }, - { - "o": 867 - }, - { - "o": 868 - }, - { - "o": 869 - }, - { - "o": 870 - }, - { - "o": 871 - }, - { - "o": 872 - }, - { - "o": 873 - }, - { - "o": 874 - }, - { - "o": 875 - }, - { - "o": 876 - }, - { - "o": 877 - }, - { - "o": 878 - }, - { - "o": 879 - }, - { - "o": 880 - }, - { - "o": 881 - }, - { - "o": 882 - }, - { - "o": 883 - }, - { - "o": 884 - }, - { - "o": 885 - }, - { - "o": 886 - }, - { - "o": 887 - }, - { - "o": 888 - }, - { - "o": 889 - }, - { - "o": 890 - }, - { - "o": 891 - }, - { - "o": 892 - }, - { - "o": 893 - }, - { - "o": 894 - }, - { - "o": 895 - }, - { - "o": 896 - }, - { - "o": 897 - }, - { - "o": 898 - }, - { - "o": 899 - }, - { - "o": 900 - }, - { - "o": 901 - }, - { - "o": 902 - }, - { - "o": 903 - }, - { - "o": 904 - }, - { - "o": 905 - }, - { - "o": 906 - }, - { - "o": 907 - }, - { - "o": 908 - }, - { - "o": 909 - }, - { - "o": 910 - }, - { - "o": 911 - }, - { - "o": 912 - }, - { - "o": 913 - }, - { - "o": 914 - }, - { - "o": 915 - }, - { - "o": 916 - }, - { - "o": 917 - }, - { - "o": 918 - }, - { - "o": 919 - }, - { - "o": 920 - }, - { - "o": 921 - }, - { - "o": 922 - }, - { - "o": 923 - }, - { - "o": 924 - }, - { - "o": 925 - }, - { - "o": 926 - }, - { - "o": 927 - }, - { - "o": 928 - }, - { - "o": 929 - }, - { - "o": 930 - }, - { - "o": 931 - }, - { - "o": 932 - }, - { - "o": 933 - }, - { - "o": 934 - }, - { - "o": 935 - }, - { - "o": 936 - }, - { - "o": 937 - }, - { - "o": 938 - }, - { - "o": 939 - }, - { - "o": 940 - }, - { - "o": 941 - }, - { - "o": 942 - }, - { - "o": 943 - }, - { - "o": 944 - }, - { - "o": 945 - }, - { - "o": 946 - }, - { - "o": 947 - }, - { - "o": 948 - }, - { - "o": 949 - }, - { - "o": 950 - }, - { - "o": 951 - }, - { - "o": 952 - }, - { - "o": 953 - }, - { - "o": 954 - }, - { - "o": 955 - }, - { - "o": 956 - }, - { - "o": 957 - }, - { - "o": 958 - }, - { - "o": 959 - }, - { - "o": 960 - }, - { - "o": 961 - }, - { - "o": 962 - }, - { - "o": 963 - }, - { - "o": 964 - }, - { - "o": 965 - }, - { - "o": 966 - }, - { - "o": 967 - }, - { - "o": 968 - }, - { - "o": 969 - }, - { - "o": 970 - }, - { - "o": 971 - }, - { - "o": 972 - }, - { - "o": 973 - }, - { - "o": 974 - }, - { - "o": 975 - }, - { - "o": 976 - }, - { - "o": 977 - }, - { - "o": 978 - }, - { - "o": 979 - }, - { - "o": 980 - }, - { - "o": 981 - }, - { - "o": 982 - }, - { - "o": 983 - }, - { - "o": 984 - }, - { - "o": 985 - }, - { - "o": 986 - }, - { - "o": 987 - }, - { - "o": 988 - }, - { - "o": 989 - }, - { - "o": 990 - }, - { - "o": 991 - }, - { - "o": 992 - }, - { - "o": 993 - }, - { - "o": 994 - }, - { - "o": 995 - }, - { - "o": 996 - }, - { - "o": 997 - }, - { - "o": 998 - }, - { - "o": 999 - }, - { - "o": 1000 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe", - "content_size_bytes": 9902, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_js_three-segments_adjustment_reports", - "row_set_kind": "adjustment", - "rows": [ - { - "o": 0 - }, - { - "o": 1 - }, - { - "o": 2 - }, - { - "o": 3 - }, - { - "o": 4 - }, - { - "o": 5 - }, - { - "o": 6 - }, - { - "o": 7 - }, - { - "o": 8 - }, - { - "o": 9 - }, - { - "o": 10 - }, - { - "o": 11 - }, - { - "o": 12 - }, - { - "o": 13 - }, - { - "o": 14 - }, - { - "o": 15 - }, - { - "o": 16 - }, - { - "o": 17 - }, - { - "o": 18 - }, - { - "o": 19 - }, - { - "o": 20 - }, - { - "o": 21 - }, - { - "o": 22 - }, - { - "o": 23 - }, - { - "o": 24 - }, - { - "o": 25 - }, - { - "o": 26 - }, - { - "o": 27 - }, - { - "o": 28 - }, - { - "o": 29 - }, - { - "o": 30 - }, - { - "o": 31 - }, - { - "o": 32 - }, - { - "o": 33 - }, - { - "o": 34 - }, - { - "o": 35 - }, - { - "o": 36 - }, - { - "o": 37 - }, - { - "o": 38 - }, - { - "o": 39 - }, - { - "o": 40 - }, - { - "o": 41 - }, - { - "o": 42 - }, - { - "o": 43 - }, - { - "o": 44 - }, - { - "o": 45 - }, - { - "o": 46 - }, - { - "o": 47 - }, - { - "o": 48 - }, - { - "o": 49 - }, - { - "o": 50 - }, - { - "o": 51 - }, - { - "o": 52 - }, - { - "o": 53 - }, - { - "o": 54 - }, - { - "o": 55 - }, - { - "o": 56 - }, - { - "o": 57 - }, - { - "o": 58 - }, - { - "o": 59 - }, - { - "o": 60 - }, - { - "o": 61 - }, - { - "o": 62 - }, - { - "o": 63 - }, - { - "o": 64 - }, - { - "o": 65 - }, - { - "o": 66 - }, - { - "o": 67 - }, - { - "o": 68 - }, - { - "o": 69 - }, - { - "o": 70 - }, - { - "o": 71 - }, - { - "o": 72 - }, - { - "o": 73 - }, - { - "o": 74 - }, - { - "o": 75 - }, - { - "o": 76 - }, - { - "o": 77 - }, - { - "o": 78 - }, - { - "o": 79 - }, - { - "o": 80 - }, - { - "o": 81 - }, - { - "o": 82 - }, - { - "o": 83 - }, - { - "o": 84 - }, - { - "o": 85 - }, - { - "o": 86 - }, - { - "o": 87 - }, - { - "o": 88 - }, - { - "o": 89 - }, - { - "o": 90 - }, - { - "o": 91 - }, - { - "o": 92 - }, - { - "o": 93 - }, - { - "o": 94 - }, - { - "o": 95 - }, - { - "o": 96 - }, - { - "o": 97 - }, - { - "o": 98 - }, - { - "o": 99 - }, - { - "o": 100 - }, - { - "o": 101 - }, - { - "o": 102 - }, - { - "o": 103 - }, - { - "o": 104 - }, - { - "o": 105 - }, - { - "o": 106 - }, - { - "o": 107 - }, - { - "o": 108 - }, - { - "o": 109 - }, - { - "o": 110 - }, - { - "o": 111 - }, - { - "o": 112 - }, - { - "o": 113 - }, - { - "o": 114 - }, - { - "o": 115 - }, - { - "o": 116 - }, - { - "o": 117 - }, - { - "o": 118 - }, - { - "o": 119 - }, - { - "o": 120 - }, - { - "o": 121 - }, - { - "o": 122 - }, - { - "o": 123 - }, - { - "o": 124 - }, - { - "o": 125 - }, - { - "o": 126 - }, - { - "o": 127 - }, - { - "o": 128 - }, - { - "o": 129 - }, - { - "o": 130 - }, - { - "o": 131 - }, - { - "o": 132 - }, - { - "o": 133 - }, - { - "o": 134 - }, - { - "o": 135 - }, - { - "o": 136 - }, - { - "o": 137 - }, - { - "o": 138 - }, - { - "o": 139 - }, - { - "o": 140 - }, - { - "o": 141 - }, - { - "o": 142 - }, - { - "o": 143 - }, - { - "o": 144 - }, - { - "o": 145 - }, - { - "o": 146 - }, - { - "o": 147 - }, - { - "o": 148 - }, - { - "o": 149 - }, - { - "o": 150 - }, - { - "o": 151 - }, - { - "o": 152 - }, - { - "o": 153 - }, - { - "o": 154 - }, - { - "o": 155 - }, - { - "o": 156 - }, - { - "o": 157 - }, - { - "o": 158 - }, - { - "o": 159 - }, - { - "o": 160 - }, - { - "o": 161 - }, - { - "o": 162 - }, - { - "o": 163 - }, - { - "o": 164 - }, - { - "o": 165 - }, - { - "o": 166 - }, - { - "o": 167 - }, - { - "o": 168 - }, - { - "o": 169 - }, - { - "o": 170 - }, - { - "o": 171 - }, - { - "o": 172 - }, - { - "o": 173 - }, - { - "o": 174 - }, - { - "o": 175 - }, - { - "o": 176 - }, - { - "o": 177 - }, - { - "o": 178 - }, - { - "o": 179 - }, - { - "o": 180 - }, - { - "o": 181 - }, - { - "o": 182 - }, - { - "o": 183 - }, - { - "o": 184 - }, - { - "o": 185 - }, - { - "o": 186 - }, - { - "o": 187 - }, - { - "o": 188 - }, - { - "o": 189 - }, - { - "o": 190 - }, - { - "o": 191 - }, - { - "o": 192 - }, - { - "o": 193 - }, - { - "o": 194 - }, - { - "o": 195 - }, - { - "o": 196 - }, - { - "o": 197 - }, - { - "o": 198 - }, - { - "o": 199 - }, - { - "o": 200 - }, - { - "o": 201 - }, - { - "o": 202 - }, - { - "o": 203 - }, - { - "o": 204 - }, - { - "o": 205 - }, - { - "o": 206 - }, - { - "o": 207 - }, - { - "o": 208 - }, - { - "o": 209 - }, - { - "o": 210 - }, - { - "o": 211 - }, - { - "o": 212 - }, - { - "o": 213 - }, - { - "o": 214 - }, - { - "o": 215 - }, - { - "o": 216 - }, - { - "o": 217 - }, - { - "o": 218 - }, - { - "o": 219 - }, - { - "o": 220 - }, - { - "o": 221 - }, - { - "o": 222 - }, - { - "o": 223 - }, - { - "o": 224 - }, - { - "o": 225 - }, - { - "o": 226 - }, - { - "o": 227 - }, - { - "o": 228 - }, - { - "o": 229 - }, - { - "o": 230 - }, - { - "o": 231 - }, - { - "o": 232 - }, - { - "o": 233 - }, - { - "o": 234 - }, - { - "o": 235 - }, - { - "o": 236 - }, - { - "o": 237 - }, - { - "o": 238 - }, - { - "o": 239 - }, - { - "o": 240 - }, - { - "o": 241 - }, - { - "o": 242 - }, - { - "o": 243 - }, - { - "o": 244 - }, - { - "o": 245 - }, - { - "o": 246 - }, - { - "o": 247 - }, - { - "o": 248 - }, - { - "o": 249 - }, - { - "o": 250 - }, - { - "o": 251 - }, - { - "o": 252 - }, - { - "o": 253 - }, - { - "o": 254 - }, - { - "o": 255 - }, - { - "o": 256 - }, - { - "o": 257 - }, - { - "o": 258 - }, - { - "o": 259 - }, - { - "o": 260 - }, - { - "o": 261 - }, - { - "o": 262 - }, - { - "o": 263 - }, - { - "o": 264 - }, - { - "o": 265 - }, - { - "o": 266 - }, - { - "o": 267 - }, - { - "o": 268 - }, - { - "o": 269 - }, - { - "o": 270 - }, - { - "o": 271 - }, - { - "o": 272 - }, - { - "o": 273 - }, - { - "o": 274 - }, - { - "o": 275 - }, - { - "o": 276 - }, - { - "o": 277 - }, - { - "o": 278 - }, - { - "o": 279 - }, - { - "o": 280 - }, - { - "o": 281 - }, - { - "o": 282 - }, - { - "o": 283 - }, - { - "o": 284 - }, - { - "o": 285 - }, - { - "o": 286 - }, - { - "o": 287 - }, - { - "o": 288 - }, - { - "o": 289 - }, - { - "o": 290 - }, - { - "o": 291 - }, - { - "o": 292 - }, - { - "o": 293 - }, - { - "o": 294 - }, - { - "o": 295 - }, - { - "o": 296 - }, - { - "o": 297 - }, - { - "o": 298 - }, - { - "o": 299 - }, - { - "o": 300 - }, - { - "o": 301 - }, - { - "o": 302 - }, - { - "o": 303 - }, - { - "o": 304 - }, - { - "o": 305 - }, - { - "o": 306 - }, - { - "o": 307 - }, - { - "o": 308 - }, - { - "o": 309 - }, - { - "o": 310 - }, - { - "o": 311 - }, - { - "o": 312 - }, - { - "o": 313 - }, - { - "o": 314 - }, - { - "o": 315 - }, - { - "o": 316 - }, - { - "o": 317 - }, - { - "o": 318 - }, - { - "o": 319 - }, - { - "o": 320 - }, - { - "o": 321 - }, - { - "o": 322 - }, - { - "o": 323 - }, - { - "o": 324 - }, - { - "o": 325 - }, - { - "o": 326 - }, - { - "o": 327 - }, - { - "o": 328 - }, - { - "o": 329 - }, - { - "o": 330 - }, - { - "o": 331 - }, - { - "o": 332 - }, - { - "o": 333 - }, - { - "o": 334 - }, - { - "o": 335 - }, - { - "o": 336 - }, - { - "o": 337 - }, - { - "o": 338 - }, - { - "o": 339 - }, - { - "o": 340 - }, - { - "o": 341 - }, - { - "o": 342 - }, - { - "o": 343 - }, - { - "o": 344 - }, - { - "o": 345 - }, - { - "o": 346 - }, - { - "o": 347 - }, - { - "o": 348 - }, - { - "o": 349 - }, - { - "o": 350 - }, - { - "o": 351 - }, - { - "o": 352 - }, - { - "o": 353 - }, - { - "o": 354 - }, - { - "o": 355 - }, - { - "o": 356 - }, - { - "o": 357 - }, - { - "o": 358 - }, - { - "o": 359 - }, - { - "o": 360 - }, - { - "o": 361 - }, - { - "o": 362 - }, - { - "o": 363 - }, - { - "o": 364 - }, - { - "o": 365 - }, - { - "o": 366 - }, - { - "o": 367 - }, - { - "o": 368 - }, - { - "o": 369 - }, - { - "o": 370 - }, - { - "o": 371 - }, - { - "o": 372 - }, - { - "o": 373 - }, - { - "o": 374 - }, - { - "o": 375 - }, - { - "o": 376 - }, - { - "o": 377 - }, - { - "o": 378 - }, - { - "o": 379 - }, - { - "o": 380 - }, - { - "o": 381 - }, - { - "o": 382 - }, - { - "o": 383 - }, - { - "o": 384 - }, - { - "o": 385 - }, - { - "o": 386 - }, - { - "o": 387 - }, - { - "o": 388 - }, - { - "o": 389 - }, - { - "o": 390 - }, - { - "o": 391 - }, - { - "o": 392 - }, - { - "o": 393 - }, - { - "o": 394 - }, - { - "o": 395 - }, - { - "o": 396 - }, - { - "o": 397 - }, - { - "o": 398 - }, - { - "o": 399 - }, - { - "o": 400 - }, - { - "o": 401 - }, - { - "o": 402 - }, - { - "o": 403 - }, - { - "o": 404 - }, - { - "o": 405 - }, - { - "o": 406 - }, - { - "o": 407 - }, - { - "o": 408 - }, - { - "o": 409 - }, - { - "o": 410 - }, - { - "o": 411 - }, - { - "o": 412 - }, - { - "o": 413 - }, - { - "o": 414 - }, - { - "o": 415 - }, - { - "o": 416 - }, - { - "o": 417 - }, - { - "o": 418 - }, - { - "o": 419 - }, - { - "o": 420 - }, - { - "o": 421 - }, - { - "o": 422 - }, - { - "o": 423 - }, - { - "o": 424 - }, - { - "o": 425 - }, - { - "o": 426 - }, - { - "o": 427 - }, - { - "o": 428 - }, - { - "o": 429 - }, - { - "o": 430 - }, - { - "o": 431 - }, - { - "o": 432 - }, - { - "o": 433 - }, - { - "o": 434 - }, - { - "o": 435 - }, - { - "o": 436 - }, - { - "o": 437 - }, - { - "o": 438 - }, - { - "o": 439 - }, - { - "o": 440 - }, - { - "o": 441 - }, - { - "o": 442 - }, - { - "o": 443 - }, - { - "o": 444 - }, - { - "o": 445 - }, - { - "o": 446 - }, - { - "o": 447 - }, - { - "o": 448 - }, - { - "o": 449 - }, - { - "o": 450 - }, - { - "o": 451 - }, - { - "o": 452 - }, - { - "o": 453 - }, - { - "o": 454 - }, - { - "o": 455 - }, - { - "o": 456 - }, - { - "o": 457 - }, - { - "o": 458 - }, - { - "o": 459 - }, - { - "o": 460 - }, - { - "o": 461 - }, - { - "o": 462 - }, - { - "o": 463 - }, - { - "o": 464 - }, - { - "o": 465 - }, - { - "o": 466 - }, - { - "o": 467 - }, - { - "o": 468 - }, - { - "o": 469 - }, - { - "o": 470 - }, - { - "o": 471 - }, - { - "o": 472 - }, - { - "o": 473 - }, - { - "o": 474 - }, - { - "o": 475 - }, - { - "o": 476 - }, - { - "o": 477 - }, - { - "o": 478 - }, - { - "o": 479 - }, - { - "o": 480 - }, - { - "o": 481 - }, - { - "o": 482 - }, - { - "o": 483 - }, - { - "o": 484 - }, - { - "o": 485 - }, - { - "o": 486 - }, - { - "o": 487 - }, - { - "o": 488 - }, - { - "o": 489 - }, - { - "o": 490 - }, - { - "o": 491 - }, - { - "o": 492 - }, - { - "o": 493 - }, - { - "o": 494 - }, - { - "o": 495 - }, - { - "o": 496 - }, - { - "o": 497 - }, - { - "o": 498 - }, - { - "o": 499 - }, - { - "o": 500 - }, - { - "o": 501 - }, - { - "o": 502 - }, - { - "o": 503 - }, - { - "o": 504 - }, - { - "o": 505 - }, - { - "o": 506 - }, - { - "o": 507 - }, - { - "o": 508 - }, - { - "o": 509 - }, - { - "o": 510 - }, - { - "o": 511 - }, - { - "o": 512 - }, - { - "o": 513 - }, - { - "o": 514 - }, - { - "o": 515 - }, - { - "o": 516 - }, - { - "o": 517 - }, - { - "o": 518 - }, - { - "o": 519 - }, - { - "o": 520 - }, - { - "o": 521 - }, - { - "o": 522 - }, - { - "o": 523 - }, - { - "o": 524 - }, - { - "o": 525 - }, - { - "o": 526 - }, - { - "o": 527 - }, - { - "o": 528 - }, - { - "o": 529 - }, - { - "o": 530 - }, - { - "o": 531 - }, - { - "o": 532 - }, - { - "o": 533 - }, - { - "o": 534 - }, - { - "o": 535 - }, - { - "o": 536 - }, - { - "o": 537 - }, - { - "o": 538 - }, - { - "o": 539 - }, - { - "o": 540 - }, - { - "o": 541 - }, - { - "o": 542 - }, - { - "o": 543 - }, - { - "o": 544 - }, - { - "o": 545 - }, - { - "o": 546 - }, - { - "o": 547 - }, - { - "o": 548 - }, - { - "o": 549 - }, - { - "o": 550 - }, - { - "o": 551 - }, - { - "o": 552 - }, - { - "o": 553 - }, - { - "o": 554 - }, - { - "o": 555 - }, - { - "o": 556 - }, - { - "o": 557 - }, - { - "o": 558 - }, - { - "o": 559 - }, - { - "o": 560 - }, - { - "o": 561 - }, - { - "o": 562 - }, - { - "o": 563 - }, - { - "o": 564 - }, - { - "o": 565 - }, - { - "o": 566 - }, - { - "o": 567 - }, - { - "o": 568 - }, - { - "o": 569 - }, - { - "o": 570 - }, - { - "o": 571 - }, - { - "o": 572 - }, - { - "o": 573 - }, - { - "o": 574 - }, - { - "o": 575 - }, - { - "o": 576 - }, - { - "o": 577 - }, - { - "o": 578 - }, - { - "o": 579 - }, - { - "o": 580 - }, - { - "o": 581 - }, - { - "o": 582 - }, - { - "o": 583 - }, - { - "o": 584 - }, - { - "o": 585 - }, - { - "o": 586 - }, - { - "o": 587 - }, - { - "o": 588 - }, - { - "o": 589 - }, - { - "o": 590 - }, - { - "o": 591 - }, - { - "o": 592 - }, - { - "o": 593 - }, - { - "o": 594 - }, - { - "o": 595 - }, - { - "o": 596 - }, - { - "o": 597 - }, - { - "o": 598 - }, - { - "o": 599 - }, - { - "o": 600 - }, - { - "o": 601 - }, - { - "o": 602 - }, - { - "o": 603 - }, - { - "o": 604 - }, - { - "o": 605 - }, - { - "o": 606 - }, - { - "o": 607 - }, - { - "o": 608 - }, - { - "o": 609 - }, - { - "o": 610 - }, - { - "o": 611 - }, - { - "o": 612 - }, - { - "o": 613 - }, - { - "o": 614 - }, - { - "o": 615 - }, - { - "o": 616 - }, - { - "o": 617 - }, - { - "o": 618 - }, - { - "o": 619 - }, - { - "o": 620 - }, - { - "o": 621 - }, - { - "o": 622 - }, - { - "o": 623 - }, - { - "o": 624 - }, - { - "o": 625 - }, - { - "o": 626 - }, - { - "o": 627 - }, - { - "o": 628 - }, - { - "o": 629 - }, - { - "o": 630 - }, - { - "o": 631 - }, - { - "o": 632 - }, - { - "o": 633 - }, - { - "o": 634 - }, - { - "o": 635 - }, - { - "o": 636 - }, - { - "o": 637 - }, - { - "o": 638 - }, - { - "o": 639 - }, - { - "o": 640 - }, - { - "o": 641 - }, - { - "o": 642 - }, - { - "o": 643 - }, - { - "o": 644 - }, - { - "o": 645 - }, - { - "o": 646 - }, - { - "o": 647 - }, - { - "o": 648 - }, - { - "o": 649 - }, - { - "o": 650 - }, - { - "o": 651 - }, - { - "o": 652 - }, - { - "o": 653 - }, - { - "o": 654 - }, - { - "o": 655 - }, - { - "o": 656 - }, - { - "o": 657 - }, - { - "o": 658 - }, - { - "o": 659 - }, - { - "o": 660 - }, - { - "o": 661 - }, - { - "o": 662 - }, - { - "o": 663 - }, - { - "o": 664 - }, - { - "o": 665 - }, - { - "o": 666 - }, - { - "o": 667 - }, - { - "o": 668 - }, - { - "o": 669 - }, - { - "o": 670 - }, - { - "o": 671 - }, - { - "o": 672 - }, - { - "o": 673 - }, - { - "o": 674 - }, - { - "o": 675 - }, - { - "o": 676 - }, - { - "o": 677 - }, - { - "o": 678 - }, - { - "o": 679 - }, - { - "o": 680 - }, - { - "o": 681 - }, - { - "o": 682 - }, - { - "o": 683 - }, - { - "o": 684 - }, - { - "o": 685 - }, - { - "o": 686 - }, - { - "o": 687 - }, - { - "o": 688 - }, - { - "o": 689 - }, - { - "o": 690 - }, - { - "o": 691 - }, - { - "o": 692 - }, - { - "o": 693 - }, - { - "o": 694 - }, - { - "o": 695 - }, - { - "o": 696 - }, - { - "o": 697 - }, - { - "o": 698 - }, - { - "o": 699 - }, - { - "o": 700 - }, - { - "o": 701 - }, - { - "o": 702 - }, - { - "o": 703 - }, - { - "o": 704 - }, - { - "o": 705 - }, - { - "o": 706 - }, - { - "o": 707 - }, - { - "o": 708 - }, - { - "o": 709 - }, - { - "o": 710 - }, - { - "o": 711 - }, - { - "o": 712 - }, - { - "o": 713 - }, - { - "o": 714 - }, - { - "o": 715 - }, - { - "o": 716 - }, - { - "o": 717 - }, - { - "o": 718 - }, - { - "o": 719 - }, - { - "o": 720 - }, - { - "o": 721 - }, - { - "o": 722 - }, - { - "o": 723 - }, - { - "o": 724 - }, - { - "o": 725 - }, - { - "o": 726 - }, - { - "o": 727 - }, - { - "o": 728 - }, - { - "o": 729 - }, - { - "o": 730 - }, - { - "o": 731 - }, - { - "o": 732 - }, - { - "o": 733 - }, - { - "o": 734 - }, - { - "o": 735 - }, - { - "o": 736 - }, - { - "o": 737 - }, - { - "o": 738 - }, - { - "o": 739 - }, - { - "o": 740 - }, - { - "o": 741 - }, - { - "o": 742 - }, - { - "o": 743 - }, - { - "o": 744 - }, - { - "o": 745 - }, - { - "o": 746 - }, - { - "o": 747 - }, - { - "o": 748 - }, - { - "o": 749 - }, - { - "o": 750 - }, - { - "o": 751 - }, - { - "o": 752 - }, - { - "o": 753 - }, - { - "o": 754 - }, - { - "o": 755 - }, - { - "o": 756 - }, - { - "o": 757 - }, - { - "o": 758 - }, - { - "o": 759 - }, - { - "o": 760 - }, - { - "o": 761 - }, - { - "o": 762 - }, - { - "o": 763 - }, - { - "o": 764 - }, - { - "o": 765 - }, - { - "o": 766 - }, - { - "o": 767 - }, - { - "o": 768 - }, - { - "o": 769 - }, - { - "o": 770 - }, - { - "o": 771 - }, - { - "o": 772 - }, - { - "o": 773 - }, - { - "o": 774 - }, - { - "o": 775 - }, - { - "o": 776 - }, - { - "o": 777 - }, - { - "o": 778 - }, - { - "o": 779 - }, - { - "o": 780 - }, - { - "o": 781 - }, - { - "o": 782 - }, - { - "o": 783 - }, - { - "o": 784 - }, - { - "o": 785 - }, - { - "o": 786 - }, - { - "o": 787 - }, - { - "o": 788 - }, - { - "o": 789 - }, - { - "o": 790 - }, - { - "o": 791 - }, - { - "o": 792 - }, - { - "o": 793 - }, - { - "o": 794 - }, - { - "o": 795 - }, - { - "o": 796 - }, - { - "o": 797 - }, - { - "o": 798 - }, - { - "o": 799 - }, - { - "o": 800 - }, - { - "o": 801 - }, - { - "o": 802 - }, - { - "o": 803 - }, - { - "o": 804 - }, - { - "o": 805 - }, - { - "o": 806 - }, - { - "o": 807 - }, - { - "o": 808 - }, - { - "o": 809 - }, - { - "o": 810 - }, - { - "o": 811 - }, - { - "o": 812 - }, - { - "o": 813 - }, - { - "o": 814 - }, - { - "o": 815 - }, - { - "o": 816 - }, - { - "o": 817 - }, - { - "o": 818 - }, - { - "o": 819 - }, - { - "o": 820 - }, - { - "o": 821 - }, - { - "o": 822 - }, - { - "o": 823 - }, - { - "o": 824 - }, - { - "o": 825 - }, - { - "o": 826 - }, - { - "o": 827 - }, - { - "o": 828 - }, - { - "o": 829 - }, - { - "o": 830 - }, - { - "o": 831 - }, - { - "o": 832 - }, - { - "o": 833 - }, - { - "o": 834 - }, - { - "o": 835 - }, - { - "o": 836 - }, - { - "o": 837 - }, - { - "o": 838 - }, - { - "o": 839 - }, - { - "o": 840 - }, - { - "o": 841 - }, - { - "o": 842 - }, - { - "o": 843 - }, - { - "o": 844 - }, - { - "o": 845 - }, - { - "o": 846 - }, - { - "o": 847 - }, - { - "o": 848 - }, - { - "o": 849 - }, - { - "o": 850 - }, - { - "o": 851 - }, - { - "o": 852 - }, - { - "o": 853 - }, - { - "o": 854 - }, - { - "o": 855 - }, - { - "o": 856 - }, - { - "o": 857 - }, - { - "o": 858 - }, - { - "o": 859 - }, - { - "o": 860 - }, - { - "o": 861 - }, - { - "o": 862 - }, - { - "o": 863 - }, - { - "o": 864 - }, - { - "o": 865 - }, - { - "o": 866 - }, - { - "o": 867 - }, - { - "o": 868 - }, - { - "o": 869 - }, - { - "o": 870 - }, - { - "o": 871 - }, - { - "o": 872 - }, - { - "o": 873 - }, - { - "o": 874 - }, - { - "o": 875 - }, - { - "o": 876 - }, - { - "o": 877 - }, - { - "o": 878 - }, - { - "o": 879 - }, - { - "o": 880 - }, - { - "o": 881 - }, - { - "o": 882 - }, - { - "o": 883 - }, - { - "o": 884 - }, - { - "o": 885 - }, - { - "o": 886 - }, - { - "o": 887 - }, - { - "o": 888 - }, - { - "o": 889 - }, - { - "o": 890 - }, - { - "o": 891 - }, - { - "o": 892 - }, - { - "o": 893 - }, - { - "o": 894 - }, - { - "o": 895 - }, - { - "o": 896 - }, - { - "o": 897 - }, - { - "o": 898 - }, - { - "o": 899 - }, - { - "o": 900 - }, - { - "o": 901 - }, - { - "o": 902 - }, - { - "o": 903 - }, - { - "o": 904 - }, - { - "o": 905 - }, - { - "o": 906 - }, - { - "o": 907 - }, - { - "o": 908 - }, - { - "o": 909 - }, - { - "o": 910 - }, - { - "o": 911 - }, - { - "o": 912 - }, - { - "o": 913 - }, - { - "o": 914 - }, - { - "o": 915 - }, - { - "o": 916 - }, - { - "o": 917 - }, - { - "o": 918 - }, - { - "o": 919 - }, - { - "o": 920 - }, - { - "o": 921 - }, - { - "o": 922 - }, - { - "o": 923 - }, - { - "o": 924 - }, - { - "o": 925 - }, - { - "o": 926 - }, - { - "o": 927 - }, - { - "o": 928 - }, - { - "o": 929 - }, - { - "o": 930 - }, - { - "o": 931 - }, - { - "o": 932 - }, - { - "o": 933 - }, - { - "o": 934 - }, - { - "o": 935 - }, - { - "o": 936 - }, - { - "o": 937 - }, - { - "o": 938 - }, - { - "o": 939 - }, - { - "o": 940 - }, - { - "o": 941 - }, - { - "o": 942 - }, - { - "o": 943 - }, - { - "o": 944 - }, - { - "o": 945 - }, - { - "o": 946 - }, - { - "o": 947 - }, - { - "o": 948 - }, - { - "o": 949 - }, - { - "o": 950 - }, - { - "o": 951 - }, - { - "o": 952 - }, - { - "o": 953 - }, - { - "o": 954 - }, - { - "o": 955 - }, - { - "o": 956 - }, - { - "o": 957 - }, - { - "o": 958 - }, - { - "o": 959 - }, - { - "o": 960 - }, - { - "o": 961 - }, - { - "o": 962 - }, - { - "o": 963 - }, - { - "o": 964 - }, - { - "o": 965 - }, - { - "o": 966 - }, - { - "o": 967 - }, - { - "o": 968 - }, - { - "o": 969 - }, - { - "o": 970 - }, - { - "o": 971 - }, - { - "o": 972 - }, - { - "o": 973 - }, - { - "o": 974 - }, - { - "o": 975 - }, - { - "o": 976 - }, - { - "o": 977 - }, - { - "o": 978 - }, - { - "o": 979 - }, - { - "o": 980 - }, - { - "o": 981 - }, - { - "o": 982 - }, - { - "o": 983 - }, - { - "o": 984 - }, - { - "o": 985 - }, - { - "o": 986 - }, - { - "o": 987 - }, - { - "o": 988 - }, - { - "o": 989 - }, - { - "o": 990 - }, - { - "o": 991 - }, - { - "o": 992 - }, - { - "o": 993 - }, - { - "o": 994 - }, - { - "o": 995 - }, - { - "o": 996 - }, - { - "o": 997 - }, - { - "o": 998 - }, - { - "o": 999 - }, - { - "o": 1000 - } - ] - } + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"69403a2a705dcce0c564849c26c55a899f0c1158dd12a56ce370b004069fd5fa","content_size_bytes":344,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_single-row_revision_inline","row_set_kind":"revision","rows":[{"date":"2026-10-01","impressions":1200,"media_buy_id":"mb_1","package_id":"pkg_1","spend":"42.17"}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"b5367a164b48f0c0e31537bda570038687db83416988840fc2bacd96300ad3e8","content_size_bytes":345,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_single-row_revision_reports","row_set_kind":"revision","rows":[{"date":"2026-10-01","impressions":1200,"media_buy_id":"mb_1","package_id":"pkg_1","spend":"42.17"}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187","content_size_bytes":101,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_single-row_adjustment_inline","row_set_kind":"adjustment","rows":[{"date":"2026-10-01","impressions":1200,"media_buy_id":"mb_1","package_id":"pkg_1","spend":"42.17"}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187","content_size_bytes":101,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_single-row_adjustment_reports","row_set_kind":"adjustment","rows":[{"date":"2026-10-01","impressions":1200,"media_buy_id":"mb_1","package_id":"pkg_1","spend":"42.17"}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"39d5233af55a871656ea98bc512f3c6bc3446ff30a97747f26cd117913af83af","content_size_bytes":467,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_unicode-and-numbers_revision_inline","row_set_kind":"revision","rows":[{"a":2,"control":"\u0007\n\"\\","name":"caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e","sep":"\u2028","z":1},{"float":0.30000000000000004,"max_int":9007199254740991,"n":0,"neg":-12.5,"nested":{"a":[],"z":[1,{"a":true,"b":null}]},"tiny":1e-07}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"20391f076cea7702c50fa4ce949705afca0529fd3de4787ac66dbbafe448b6cd","content_size_bytes":468,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_unicode-and-numbers_revision_reports","row_set_kind":"revision","rows":[{"a":2,"control":"\u0007\n\"\\","name":"caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e","sep":"\u2028","z":1},{"float":0.30000000000000004,"max_int":9007199254740991,"n":0,"neg":-12.5,"nested":{"a":[],"z":[1,{"a":true,"b":null}]},"tiny":1e-07}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095","content_size_bytes":215,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_unicode-and-numbers_adjustment_inline","row_set_kind":"adjustment","rows":[{"a":2,"control":"\u0007\n\"\\","name":"caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e","sep":"\u2028","z":1},{"float":0.30000000000000004,"max_int":9007199254740991,"n":0,"neg":-12.5,"nested":{"a":[],"z":[1,{"a":true,"b":null}]},"tiny":1e-07}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095","content_size_bytes":215,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_unicode-and-numbers_adjustment_reports","row_set_kind":"adjustment","rows":[{"a":2,"control":"\u0007\n\"\\","name":"caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e","sep":"\u2028","z":1},{"float":0.30000000000000004,"max_int":9007199254740991,"n":0,"neg":-12.5,"nested":{"a":[],"z":[1,{"a":true,"b":null}]},"tiny":1e-07}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"de89268832ff75b1fe6f77a61de3c174ed49289172bde20d537b10c4b067fbc0","content_size_bytes":10152,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_three-segments_revision_inline","row_set_kind":"revision","rows":[{"o":0},{"o":1},{"o":2},{"o":3},{"o":4},{"o":5},{"o":6},{"o":7},{"o":8},{"o":9},{"o":10},{"o":11},{"o":12},{"o":13},{"o":14},{"o":15},{"o":16},{"o":17},{"o":18},{"o":19},{"o":20},{"o":21},{"o":22},{"o":23},{"o":24},{"o":25},{"o":26},{"o":27},{"o":28},{"o":29},{"o":30},{"o":31},{"o":32},{"o":33},{"o":34},{"o":35},{"o":36},{"o":37},{"o":38},{"o":39},{"o":40},{"o":41},{"o":42},{"o":43},{"o":44},{"o":45},{"o":46},{"o":47},{"o":48},{"o":49},{"o":50},{"o":51},{"o":52},{"o":53},{"o":54},{"o":55},{"o":56},{"o":57},{"o":58},{"o":59},{"o":60},{"o":61},{"o":62},{"o":63},{"o":64},{"o":65},{"o":66},{"o":67},{"o":68},{"o":69},{"o":70},{"o":71},{"o":72},{"o":73},{"o":74},{"o":75},{"o":76},{"o":77},{"o":78},{"o":79},{"o":80},{"o":81},{"o":82},{"o":83},{"o":84},{"o":85},{"o":86},{"o":87},{"o":88},{"o":89},{"o":90},{"o":91},{"o":92},{"o":93},{"o":94},{"o":95},{"o":96},{"o":97},{"o":98},{"o":99},{"o":100},{"o":101},{"o":102},{"o":103},{"o":104},{"o":105},{"o":106},{"o":107},{"o":108},{"o":109},{"o":110},{"o":111},{"o":112},{"o":113},{"o":114},{"o":115},{"o":116},{"o":117},{"o":118},{"o":119},{"o":120},{"o":121},{"o":122},{"o":123},{"o":124},{"o":125},{"o":126},{"o":127},{"o":128},{"o":129},{"o":130},{"o":131},{"o":132},{"o":133},{"o":134},{"o":135},{"o":136},{"o":137},{"o":138},{"o":139},{"o":140},{"o":141},{"o":142},{"o":143},{"o":144},{"o":145},{"o":146},{"o":147},{"o":148},{"o":149},{"o":150},{"o":151},{"o":152},{"o":153},{"o":154},{"o":155},{"o":156},{"o":157},{"o":158},{"o":159},{"o":160},{"o":161},{"o":162},{"o":163},{"o":164},{"o":165},{"o":166},{"o":167},{"o":168},{"o":169},{"o":170},{"o":171},{"o":172},{"o":173},{"o":174},{"o":175},{"o":176},{"o":177},{"o":178},{"o":179},{"o":180},{"o":181},{"o":182},{"o":183},{"o":184},{"o":185},{"o":186},{"o":187},{"o":188},{"o":189},{"o":190},{"o":191},{"o":192},{"o":193},{"o":194},{"o":195},{"o":196},{"o":197},{"o":198},{"o":199},{"o":200},{"o":201},{"o":202},{"o":203},{"o":204},{"o":205},{"o":206},{"o":207},{"o":208},{"o":209},{"o":210},{"o":211},{"o":212},{"o":213},{"o":214},{"o":215},{"o":216},{"o":217},{"o":218},{"o":219},{"o":220},{"o":221},{"o":222},{"o":223},{"o":224},{"o":225},{"o":226},{"o":227},{"o":228},{"o":229},{"o":230},{"o":231},{"o":232},{"o":233},{"o":234},{"o":235},{"o":236},{"o":237},{"o":238},{"o":239},{"o":240},{"o":241},{"o":242},{"o":243},{"o":244},{"o":245},{"o":246},{"o":247},{"o":248},{"o":249},{"o":250},{"o":251},{"o":252},{"o":253},{"o":254},{"o":255},{"o":256},{"o":257},{"o":258},{"o":259},{"o":260},{"o":261},{"o":262},{"o":263},{"o":264},{"o":265},{"o":266},{"o":267},{"o":268},{"o":269},{"o":270},{"o":271},{"o":272},{"o":273},{"o":274},{"o":275},{"o":276},{"o":277},{"o":278},{"o":279},{"o":280},{"o":281},{"o":282},{"o":283},{"o":284},{"o":285},{"o":286},{"o":287},{"o":288},{"o":289},{"o":290},{"o":291},{"o":292},{"o":293},{"o":294},{"o":295},{"o":296},{"o":297},{"o":298},{"o":299},{"o":300},{"o":301},{"o":302},{"o":303},{"o":304},{"o":305},{"o":306},{"o":307},{"o":308},{"o":309},{"o":310},{"o":311},{"o":312},{"o":313},{"o":314},{"o":315},{"o":316},{"o":317},{"o":318},{"o":319},{"o":320},{"o":321},{"o":322},{"o":323},{"o":324},{"o":325},{"o":326},{"o":327},{"o":328},{"o":329},{"o":330},{"o":331},{"o":332},{"o":333},{"o":334},{"o":335},{"o":336},{"o":337},{"o":338},{"o":339},{"o":340},{"o":341},{"o":342},{"o":343},{"o":344},{"o":345},{"o":346},{"o":347},{"o":348},{"o":349},{"o":350},{"o":351},{"o":352},{"o":353},{"o":354},{"o":355},{"o":356},{"o":357},{"o":358},{"o":359},{"o":360},{"o":361},{"o":362},{"o":363},{"o":364},{"o":365},{"o":366},{"o":367},{"o":368},{"o":369},{"o":370},{"o":371},{"o":372},{"o":373},{"o":374},{"o":375},{"o":376},{"o":377},{"o":378},{"o":379},{"o":380},{"o":381},{"o":382},{"o":383},{"o":384},{"o":385},{"o":386},{"o":387},{"o":388},{"o":389},{"o":390},{"o":391},{"o":392},{"o":393},{"o":394},{"o":395},{"o":396},{"o":397},{"o":398},{"o":399},{"o":400},{"o":401},{"o":402},{"o":403},{"o":404},{"o":405},{"o":406},{"o":407},{"o":408},{"o":409},{"o":410},{"o":411},{"o":412},{"o":413},{"o":414},{"o":415},{"o":416},{"o":417},{"o":418},{"o":419},{"o":420},{"o":421},{"o":422},{"o":423},{"o":424},{"o":425},{"o":426},{"o":427},{"o":428},{"o":429},{"o":430},{"o":431},{"o":432},{"o":433},{"o":434},{"o":435},{"o":436},{"o":437},{"o":438},{"o":439},{"o":440},{"o":441},{"o":442},{"o":443},{"o":444},{"o":445},{"o":446},{"o":447},{"o":448},{"o":449},{"o":450},{"o":451},{"o":452},{"o":453},{"o":454},{"o":455},{"o":456},{"o":457},{"o":458},{"o":459},{"o":460},{"o":461},{"o":462},{"o":463},{"o":464},{"o":465},{"o":466},{"o":467},{"o":468},{"o":469},{"o":470},{"o":471},{"o":472},{"o":473},{"o":474},{"o":475},{"o":476},{"o":477},{"o":478},{"o":479},{"o":480},{"o":481},{"o":482},{"o":483},{"o":484},{"o":485},{"o":486},{"o":487},{"o":488},{"o":489},{"o":490},{"o":491},{"o":492},{"o":493},{"o":494},{"o":495},{"o":496},{"o":497},{"o":498},{"o":499},{"o":500},{"o":501},{"o":502},{"o":503},{"o":504},{"o":505},{"o":506},{"o":507},{"o":508},{"o":509},{"o":510},{"o":511},{"o":512},{"o":513},{"o":514},{"o":515},{"o":516},{"o":517},{"o":518},{"o":519},{"o":520},{"o":521},{"o":522},{"o":523},{"o":524},{"o":525},{"o":526},{"o":527},{"o":528},{"o":529},{"o":530},{"o":531},{"o":532},{"o":533},{"o":534},{"o":535},{"o":536},{"o":537},{"o":538},{"o":539},{"o":540},{"o":541},{"o":542},{"o":543},{"o":544},{"o":545},{"o":546},{"o":547},{"o":548},{"o":549},{"o":550},{"o":551},{"o":552},{"o":553},{"o":554},{"o":555},{"o":556},{"o":557},{"o":558},{"o":559},{"o":560},{"o":561},{"o":562},{"o":563},{"o":564},{"o":565},{"o":566},{"o":567},{"o":568},{"o":569},{"o":570},{"o":571},{"o":572},{"o":573},{"o":574},{"o":575},{"o":576},{"o":577},{"o":578},{"o":579},{"o":580},{"o":581},{"o":582},{"o":583},{"o":584},{"o":585},{"o":586},{"o":587},{"o":588},{"o":589},{"o":590},{"o":591},{"o":592},{"o":593},{"o":594},{"o":595},{"o":596},{"o":597},{"o":598},{"o":599},{"o":600},{"o":601},{"o":602},{"o":603},{"o":604},{"o":605},{"o":606},{"o":607},{"o":608},{"o":609},{"o":610},{"o":611},{"o":612},{"o":613},{"o":614},{"o":615},{"o":616},{"o":617},{"o":618},{"o":619},{"o":620},{"o":621},{"o":622},{"o":623},{"o":624},{"o":625},{"o":626},{"o":627},{"o":628},{"o":629},{"o":630},{"o":631},{"o":632},{"o":633},{"o":634},{"o":635},{"o":636},{"o":637},{"o":638},{"o":639},{"o":640},{"o":641},{"o":642},{"o":643},{"o":644},{"o":645},{"o":646},{"o":647},{"o":648},{"o":649},{"o":650},{"o":651},{"o":652},{"o":653},{"o":654},{"o":655},{"o":656},{"o":657},{"o":658},{"o":659},{"o":660},{"o":661},{"o":662},{"o":663},{"o":664},{"o":665},{"o":666},{"o":667},{"o":668},{"o":669},{"o":670},{"o":671},{"o":672},{"o":673},{"o":674},{"o":675},{"o":676},{"o":677},{"o":678},{"o":679},{"o":680},{"o":681},{"o":682},{"o":683},{"o":684},{"o":685},{"o":686},{"o":687},{"o":688},{"o":689},{"o":690},{"o":691},{"o":692},{"o":693},{"o":694},{"o":695},{"o":696},{"o":697},{"o":698},{"o":699},{"o":700},{"o":701},{"o":702},{"o":703},{"o":704},{"o":705},{"o":706},{"o":707},{"o":708},{"o":709},{"o":710},{"o":711},{"o":712},{"o":713},{"o":714},{"o":715},{"o":716},{"o":717},{"o":718},{"o":719},{"o":720},{"o":721},{"o":722},{"o":723},{"o":724},{"o":725},{"o":726},{"o":727},{"o":728},{"o":729},{"o":730},{"o":731},{"o":732},{"o":733},{"o":734},{"o":735},{"o":736},{"o":737},{"o":738},{"o":739},{"o":740},{"o":741},{"o":742},{"o":743},{"o":744},{"o":745},{"o":746},{"o":747},{"o":748},{"o":749},{"o":750},{"o":751},{"o":752},{"o":753},{"o":754},{"o":755},{"o":756},{"o":757},{"o":758},{"o":759},{"o":760},{"o":761},{"o":762},{"o":763},{"o":764},{"o":765},{"o":766},{"o":767},{"o":768},{"o":769},{"o":770},{"o":771},{"o":772},{"o":773},{"o":774},{"o":775},{"o":776},{"o":777},{"o":778},{"o":779},{"o":780},{"o":781},{"o":782},{"o":783},{"o":784},{"o":785},{"o":786},{"o":787},{"o":788},{"o":789},{"o":790},{"o":791},{"o":792},{"o":793},{"o":794},{"o":795},{"o":796},{"o":797},{"o":798},{"o":799},{"o":800},{"o":801},{"o":802},{"o":803},{"o":804},{"o":805},{"o":806},{"o":807},{"o":808},{"o":809},{"o":810},{"o":811},{"o":812},{"o":813},{"o":814},{"o":815},{"o":816},{"o":817},{"o":818},{"o":819},{"o":820},{"o":821},{"o":822},{"o":823},{"o":824},{"o":825},{"o":826},{"o":827},{"o":828},{"o":829},{"o":830},{"o":831},{"o":832},{"o":833},{"o":834},{"o":835},{"o":836},{"o":837},{"o":838},{"o":839},{"o":840},{"o":841},{"o":842},{"o":843},{"o":844},{"o":845},{"o":846},{"o":847},{"o":848},{"o":849},{"o":850},{"o":851},{"o":852},{"o":853},{"o":854},{"o":855},{"o":856},{"o":857},{"o":858},{"o":859},{"o":860},{"o":861},{"o":862},{"o":863},{"o":864},{"o":865},{"o":866},{"o":867},{"o":868},{"o":869},{"o":870},{"o":871},{"o":872},{"o":873},{"o":874},{"o":875},{"o":876},{"o":877},{"o":878},{"o":879},{"o":880},{"o":881},{"o":882},{"o":883},{"o":884},{"o":885},{"o":886},{"o":887},{"o":888},{"o":889},{"o":890},{"o":891},{"o":892},{"o":893},{"o":894},{"o":895},{"o":896},{"o":897},{"o":898},{"o":899},{"o":900},{"o":901},{"o":902},{"o":903},{"o":904},{"o":905},{"o":906},{"o":907},{"o":908},{"o":909},{"o":910},{"o":911},{"o":912},{"o":913},{"o":914},{"o":915},{"o":916},{"o":917},{"o":918},{"o":919},{"o":920},{"o":921},{"o":922},{"o":923},{"o":924},{"o":925},{"o":926},{"o":927},{"o":928},{"o":929},{"o":930},{"o":931},{"o":932},{"o":933},{"o":934},{"o":935},{"o":936},{"o":937},{"o":938},{"o":939},{"o":940},{"o":941},{"o":942},{"o":943},{"o":944},{"o":945},{"o":946},{"o":947},{"o":948},{"o":949},{"o":950},{"o":951},{"o":952},{"o":953},{"o":954},{"o":955},{"o":956},{"o":957},{"o":958},{"o":959},{"o":960},{"o":961},{"o":962},{"o":963},{"o":964},{"o":965},{"o":966},{"o":967},{"o":968},{"o":969},{"o":970},{"o":971},{"o":972},{"o":973},{"o":974},{"o":975},{"o":976},{"o":977},{"o":978},{"o":979},{"o":980},{"o":981},{"o":982},{"o":983},{"o":984},{"o":985},{"o":986},{"o":987},{"o":988},{"o":989},{"o":990},{"o":991},{"o":992},{"o":993},{"o":994},{"o":995},{"o":996},{"o":997},{"o":998},{"o":999},{"o":1000}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"d1ca264c9c51c4f7146f5f9b791a05fffee44cc55eca090dcc73ec826522874f","content_size_bytes":10153,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_three-segments_revision_reports","row_set_kind":"revision","rows":[{"o":0},{"o":1},{"o":2},{"o":3},{"o":4},{"o":5},{"o":6},{"o":7},{"o":8},{"o":9},{"o":10},{"o":11},{"o":12},{"o":13},{"o":14},{"o":15},{"o":16},{"o":17},{"o":18},{"o":19},{"o":20},{"o":21},{"o":22},{"o":23},{"o":24},{"o":25},{"o":26},{"o":27},{"o":28},{"o":29},{"o":30},{"o":31},{"o":32},{"o":33},{"o":34},{"o":35},{"o":36},{"o":37},{"o":38},{"o":39},{"o":40},{"o":41},{"o":42},{"o":43},{"o":44},{"o":45},{"o":46},{"o":47},{"o":48},{"o":49},{"o":50},{"o":51},{"o":52},{"o":53},{"o":54},{"o":55},{"o":56},{"o":57},{"o":58},{"o":59},{"o":60},{"o":61},{"o":62},{"o":63},{"o":64},{"o":65},{"o":66},{"o":67},{"o":68},{"o":69},{"o":70},{"o":71},{"o":72},{"o":73},{"o":74},{"o":75},{"o":76},{"o":77},{"o":78},{"o":79},{"o":80},{"o":81},{"o":82},{"o":83},{"o":84},{"o":85},{"o":86},{"o":87},{"o":88},{"o":89},{"o":90},{"o":91},{"o":92},{"o":93},{"o":94},{"o":95},{"o":96},{"o":97},{"o":98},{"o":99},{"o":100},{"o":101},{"o":102},{"o":103},{"o":104},{"o":105},{"o":106},{"o":107},{"o":108},{"o":109},{"o":110},{"o":111},{"o":112},{"o":113},{"o":114},{"o":115},{"o":116},{"o":117},{"o":118},{"o":119},{"o":120},{"o":121},{"o":122},{"o":123},{"o":124},{"o":125},{"o":126},{"o":127},{"o":128},{"o":129},{"o":130},{"o":131},{"o":132},{"o":133},{"o":134},{"o":135},{"o":136},{"o":137},{"o":138},{"o":139},{"o":140},{"o":141},{"o":142},{"o":143},{"o":144},{"o":145},{"o":146},{"o":147},{"o":148},{"o":149},{"o":150},{"o":151},{"o":152},{"o":153},{"o":154},{"o":155},{"o":156},{"o":157},{"o":158},{"o":159},{"o":160},{"o":161},{"o":162},{"o":163},{"o":164},{"o":165},{"o":166},{"o":167},{"o":168},{"o":169},{"o":170},{"o":171},{"o":172},{"o":173},{"o":174},{"o":175},{"o":176},{"o":177},{"o":178},{"o":179},{"o":180},{"o":181},{"o":182},{"o":183},{"o":184},{"o":185},{"o":186},{"o":187},{"o":188},{"o":189},{"o":190},{"o":191},{"o":192},{"o":193},{"o":194},{"o":195},{"o":196},{"o":197},{"o":198},{"o":199},{"o":200},{"o":201},{"o":202},{"o":203},{"o":204},{"o":205},{"o":206},{"o":207},{"o":208},{"o":209},{"o":210},{"o":211},{"o":212},{"o":213},{"o":214},{"o":215},{"o":216},{"o":217},{"o":218},{"o":219},{"o":220},{"o":221},{"o":222},{"o":223},{"o":224},{"o":225},{"o":226},{"o":227},{"o":228},{"o":229},{"o":230},{"o":231},{"o":232},{"o":233},{"o":234},{"o":235},{"o":236},{"o":237},{"o":238},{"o":239},{"o":240},{"o":241},{"o":242},{"o":243},{"o":244},{"o":245},{"o":246},{"o":247},{"o":248},{"o":249},{"o":250},{"o":251},{"o":252},{"o":253},{"o":254},{"o":255},{"o":256},{"o":257},{"o":258},{"o":259},{"o":260},{"o":261},{"o":262},{"o":263},{"o":264},{"o":265},{"o":266},{"o":267},{"o":268},{"o":269},{"o":270},{"o":271},{"o":272},{"o":273},{"o":274},{"o":275},{"o":276},{"o":277},{"o":278},{"o":279},{"o":280},{"o":281},{"o":282},{"o":283},{"o":284},{"o":285},{"o":286},{"o":287},{"o":288},{"o":289},{"o":290},{"o":291},{"o":292},{"o":293},{"o":294},{"o":295},{"o":296},{"o":297},{"o":298},{"o":299},{"o":300},{"o":301},{"o":302},{"o":303},{"o":304},{"o":305},{"o":306},{"o":307},{"o":308},{"o":309},{"o":310},{"o":311},{"o":312},{"o":313},{"o":314},{"o":315},{"o":316},{"o":317},{"o":318},{"o":319},{"o":320},{"o":321},{"o":322},{"o":323},{"o":324},{"o":325},{"o":326},{"o":327},{"o":328},{"o":329},{"o":330},{"o":331},{"o":332},{"o":333},{"o":334},{"o":335},{"o":336},{"o":337},{"o":338},{"o":339},{"o":340},{"o":341},{"o":342},{"o":343},{"o":344},{"o":345},{"o":346},{"o":347},{"o":348},{"o":349},{"o":350},{"o":351},{"o":352},{"o":353},{"o":354},{"o":355},{"o":356},{"o":357},{"o":358},{"o":359},{"o":360},{"o":361},{"o":362},{"o":363},{"o":364},{"o":365},{"o":366},{"o":367},{"o":368},{"o":369},{"o":370},{"o":371},{"o":372},{"o":373},{"o":374},{"o":375},{"o":376},{"o":377},{"o":378},{"o":379},{"o":380},{"o":381},{"o":382},{"o":383},{"o":384},{"o":385},{"o":386},{"o":387},{"o":388},{"o":389},{"o":390},{"o":391},{"o":392},{"o":393},{"o":394},{"o":395},{"o":396},{"o":397},{"o":398},{"o":399},{"o":400},{"o":401},{"o":402},{"o":403},{"o":404},{"o":405},{"o":406},{"o":407},{"o":408},{"o":409},{"o":410},{"o":411},{"o":412},{"o":413},{"o":414},{"o":415},{"o":416},{"o":417},{"o":418},{"o":419},{"o":420},{"o":421},{"o":422},{"o":423},{"o":424},{"o":425},{"o":426},{"o":427},{"o":428},{"o":429},{"o":430},{"o":431},{"o":432},{"o":433},{"o":434},{"o":435},{"o":436},{"o":437},{"o":438},{"o":439},{"o":440},{"o":441},{"o":442},{"o":443},{"o":444},{"o":445},{"o":446},{"o":447},{"o":448},{"o":449},{"o":450},{"o":451},{"o":452},{"o":453},{"o":454},{"o":455},{"o":456},{"o":457},{"o":458},{"o":459},{"o":460},{"o":461},{"o":462},{"o":463},{"o":464},{"o":465},{"o":466},{"o":467},{"o":468},{"o":469},{"o":470},{"o":471},{"o":472},{"o":473},{"o":474},{"o":475},{"o":476},{"o":477},{"o":478},{"o":479},{"o":480},{"o":481},{"o":482},{"o":483},{"o":484},{"o":485},{"o":486},{"o":487},{"o":488},{"o":489},{"o":490},{"o":491},{"o":492},{"o":493},{"o":494},{"o":495},{"o":496},{"o":497},{"o":498},{"o":499},{"o":500},{"o":501},{"o":502},{"o":503},{"o":504},{"o":505},{"o":506},{"o":507},{"o":508},{"o":509},{"o":510},{"o":511},{"o":512},{"o":513},{"o":514},{"o":515},{"o":516},{"o":517},{"o":518},{"o":519},{"o":520},{"o":521},{"o":522},{"o":523},{"o":524},{"o":525},{"o":526},{"o":527},{"o":528},{"o":529},{"o":530},{"o":531},{"o":532},{"o":533},{"o":534},{"o":535},{"o":536},{"o":537},{"o":538},{"o":539},{"o":540},{"o":541},{"o":542},{"o":543},{"o":544},{"o":545},{"o":546},{"o":547},{"o":548},{"o":549},{"o":550},{"o":551},{"o":552},{"o":553},{"o":554},{"o":555},{"o":556},{"o":557},{"o":558},{"o":559},{"o":560},{"o":561},{"o":562},{"o":563},{"o":564},{"o":565},{"o":566},{"o":567},{"o":568},{"o":569},{"o":570},{"o":571},{"o":572},{"o":573},{"o":574},{"o":575},{"o":576},{"o":577},{"o":578},{"o":579},{"o":580},{"o":581},{"o":582},{"o":583},{"o":584},{"o":585},{"o":586},{"o":587},{"o":588},{"o":589},{"o":590},{"o":591},{"o":592},{"o":593},{"o":594},{"o":595},{"o":596},{"o":597},{"o":598},{"o":599},{"o":600},{"o":601},{"o":602},{"o":603},{"o":604},{"o":605},{"o":606},{"o":607},{"o":608},{"o":609},{"o":610},{"o":611},{"o":612},{"o":613},{"o":614},{"o":615},{"o":616},{"o":617},{"o":618},{"o":619},{"o":620},{"o":621},{"o":622},{"o":623},{"o":624},{"o":625},{"o":626},{"o":627},{"o":628},{"o":629},{"o":630},{"o":631},{"o":632},{"o":633},{"o":634},{"o":635},{"o":636},{"o":637},{"o":638},{"o":639},{"o":640},{"o":641},{"o":642},{"o":643},{"o":644},{"o":645},{"o":646},{"o":647},{"o":648},{"o":649},{"o":650},{"o":651},{"o":652},{"o":653},{"o":654},{"o":655},{"o":656},{"o":657},{"o":658},{"o":659},{"o":660},{"o":661},{"o":662},{"o":663},{"o":664},{"o":665},{"o":666},{"o":667},{"o":668},{"o":669},{"o":670},{"o":671},{"o":672},{"o":673},{"o":674},{"o":675},{"o":676},{"o":677},{"o":678},{"o":679},{"o":680},{"o":681},{"o":682},{"o":683},{"o":684},{"o":685},{"o":686},{"o":687},{"o":688},{"o":689},{"o":690},{"o":691},{"o":692},{"o":693},{"o":694},{"o":695},{"o":696},{"o":697},{"o":698},{"o":699},{"o":700},{"o":701},{"o":702},{"o":703},{"o":704},{"o":705},{"o":706},{"o":707},{"o":708},{"o":709},{"o":710},{"o":711},{"o":712},{"o":713},{"o":714},{"o":715},{"o":716},{"o":717},{"o":718},{"o":719},{"o":720},{"o":721},{"o":722},{"o":723},{"o":724},{"o":725},{"o":726},{"o":727},{"o":728},{"o":729},{"o":730},{"o":731},{"o":732},{"o":733},{"o":734},{"o":735},{"o":736},{"o":737},{"o":738},{"o":739},{"o":740},{"o":741},{"o":742},{"o":743},{"o":744},{"o":745},{"o":746},{"o":747},{"o":748},{"o":749},{"o":750},{"o":751},{"o":752},{"o":753},{"o":754},{"o":755},{"o":756},{"o":757},{"o":758},{"o":759},{"o":760},{"o":761},{"o":762},{"o":763},{"o":764},{"o":765},{"o":766},{"o":767},{"o":768},{"o":769},{"o":770},{"o":771},{"o":772},{"o":773},{"o":774},{"o":775},{"o":776},{"o":777},{"o":778},{"o":779},{"o":780},{"o":781},{"o":782},{"o":783},{"o":784},{"o":785},{"o":786},{"o":787},{"o":788},{"o":789},{"o":790},{"o":791},{"o":792},{"o":793},{"o":794},{"o":795},{"o":796},{"o":797},{"o":798},{"o":799},{"o":800},{"o":801},{"o":802},{"o":803},{"o":804},{"o":805},{"o":806},{"o":807},{"o":808},{"o":809},{"o":810},{"o":811},{"o":812},{"o":813},{"o":814},{"o":815},{"o":816},{"o":817},{"o":818},{"o":819},{"o":820},{"o":821},{"o":822},{"o":823},{"o":824},{"o":825},{"o":826},{"o":827},{"o":828},{"o":829},{"o":830},{"o":831},{"o":832},{"o":833},{"o":834},{"o":835},{"o":836},{"o":837},{"o":838},{"o":839},{"o":840},{"o":841},{"o":842},{"o":843},{"o":844},{"o":845},{"o":846},{"o":847},{"o":848},{"o":849},{"o":850},{"o":851},{"o":852},{"o":853},{"o":854},{"o":855},{"o":856},{"o":857},{"o":858},{"o":859},{"o":860},{"o":861},{"o":862},{"o":863},{"o":864},{"o":865},{"o":866},{"o":867},{"o":868},{"o":869},{"o":870},{"o":871},{"o":872},{"o":873},{"o":874},{"o":875},{"o":876},{"o":877},{"o":878},{"o":879},{"o":880},{"o":881},{"o":882},{"o":883},{"o":884},{"o":885},{"o":886},{"o":887},{"o":888},{"o":889},{"o":890},{"o":891},{"o":892},{"o":893},{"o":894},{"o":895},{"o":896},{"o":897},{"o":898},{"o":899},{"o":900},{"o":901},{"o":902},{"o":903},{"o":904},{"o":905},{"o":906},{"o":907},{"o":908},{"o":909},{"o":910},{"o":911},{"o":912},{"o":913},{"o":914},{"o":915},{"o":916},{"o":917},{"o":918},{"o":919},{"o":920},{"o":921},{"o":922},{"o":923},{"o":924},{"o":925},{"o":926},{"o":927},{"o":928},{"o":929},{"o":930},{"o":931},{"o":932},{"o":933},{"o":934},{"o":935},{"o":936},{"o":937},{"o":938},{"o":939},{"o":940},{"o":941},{"o":942},{"o":943},{"o":944},{"o":945},{"o":946},{"o":947},{"o":948},{"o":949},{"o":950},{"o":951},{"o":952},{"o":953},{"o":954},{"o":955},{"o":956},{"o":957},{"o":958},{"o":959},{"o":960},{"o":961},{"o":962},{"o":963},{"o":964},{"o":965},{"o":966},{"o":967},{"o":968},{"o":969},{"o":970},{"o":971},{"o":972},{"o":973},{"o":974},{"o":975},{"o":976},{"o":977},{"o":978},{"o":979},{"o":980},{"o":981},{"o":982},{"o":983},{"o":984},{"o":985},{"o":986},{"o":987},{"o":988},{"o":989},{"o":990},{"o":991},{"o":992},{"o":993},{"o":994},{"o":995},{"o":996},{"o":997},{"o":998},{"o":999},{"o":1000}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe","content_size_bytes":9902,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_three-segments_adjustment_inline","row_set_kind":"adjustment","rows":[{"o":0},{"o":1},{"o":2},{"o":3},{"o":4},{"o":5},{"o":6},{"o":7},{"o":8},{"o":9},{"o":10},{"o":11},{"o":12},{"o":13},{"o":14},{"o":15},{"o":16},{"o":17},{"o":18},{"o":19},{"o":20},{"o":21},{"o":22},{"o":23},{"o":24},{"o":25},{"o":26},{"o":27},{"o":28},{"o":29},{"o":30},{"o":31},{"o":32},{"o":33},{"o":34},{"o":35},{"o":36},{"o":37},{"o":38},{"o":39},{"o":40},{"o":41},{"o":42},{"o":43},{"o":44},{"o":45},{"o":46},{"o":47},{"o":48},{"o":49},{"o":50},{"o":51},{"o":52},{"o":53},{"o":54},{"o":55},{"o":56},{"o":57},{"o":58},{"o":59},{"o":60},{"o":61},{"o":62},{"o":63},{"o":64},{"o":65},{"o":66},{"o":67},{"o":68},{"o":69},{"o":70},{"o":71},{"o":72},{"o":73},{"o":74},{"o":75},{"o":76},{"o":77},{"o":78},{"o":79},{"o":80},{"o":81},{"o":82},{"o":83},{"o":84},{"o":85},{"o":86},{"o":87},{"o":88},{"o":89},{"o":90},{"o":91},{"o":92},{"o":93},{"o":94},{"o":95},{"o":96},{"o":97},{"o":98},{"o":99},{"o":100},{"o":101},{"o":102},{"o":103},{"o":104},{"o":105},{"o":106},{"o":107},{"o":108},{"o":109},{"o":110},{"o":111},{"o":112},{"o":113},{"o":114},{"o":115},{"o":116},{"o":117},{"o":118},{"o":119},{"o":120},{"o":121},{"o":122},{"o":123},{"o":124},{"o":125},{"o":126},{"o":127},{"o":128},{"o":129},{"o":130},{"o":131},{"o":132},{"o":133},{"o":134},{"o":135},{"o":136},{"o":137},{"o":138},{"o":139},{"o":140},{"o":141},{"o":142},{"o":143},{"o":144},{"o":145},{"o":146},{"o":147},{"o":148},{"o":149},{"o":150},{"o":151},{"o":152},{"o":153},{"o":154},{"o":155},{"o":156},{"o":157},{"o":158},{"o":159},{"o":160},{"o":161},{"o":162},{"o":163},{"o":164},{"o":165},{"o":166},{"o":167},{"o":168},{"o":169},{"o":170},{"o":171},{"o":172},{"o":173},{"o":174},{"o":175},{"o":176},{"o":177},{"o":178},{"o":179},{"o":180},{"o":181},{"o":182},{"o":183},{"o":184},{"o":185},{"o":186},{"o":187},{"o":188},{"o":189},{"o":190},{"o":191},{"o":192},{"o":193},{"o":194},{"o":195},{"o":196},{"o":197},{"o":198},{"o":199},{"o":200},{"o":201},{"o":202},{"o":203},{"o":204},{"o":205},{"o":206},{"o":207},{"o":208},{"o":209},{"o":210},{"o":211},{"o":212},{"o":213},{"o":214},{"o":215},{"o":216},{"o":217},{"o":218},{"o":219},{"o":220},{"o":221},{"o":222},{"o":223},{"o":224},{"o":225},{"o":226},{"o":227},{"o":228},{"o":229},{"o":230},{"o":231},{"o":232},{"o":233},{"o":234},{"o":235},{"o":236},{"o":237},{"o":238},{"o":239},{"o":240},{"o":241},{"o":242},{"o":243},{"o":244},{"o":245},{"o":246},{"o":247},{"o":248},{"o":249},{"o":250},{"o":251},{"o":252},{"o":253},{"o":254},{"o":255},{"o":256},{"o":257},{"o":258},{"o":259},{"o":260},{"o":261},{"o":262},{"o":263},{"o":264},{"o":265},{"o":266},{"o":267},{"o":268},{"o":269},{"o":270},{"o":271},{"o":272},{"o":273},{"o":274},{"o":275},{"o":276},{"o":277},{"o":278},{"o":279},{"o":280},{"o":281},{"o":282},{"o":283},{"o":284},{"o":285},{"o":286},{"o":287},{"o":288},{"o":289},{"o":290},{"o":291},{"o":292},{"o":293},{"o":294},{"o":295},{"o":296},{"o":297},{"o":298},{"o":299},{"o":300},{"o":301},{"o":302},{"o":303},{"o":304},{"o":305},{"o":306},{"o":307},{"o":308},{"o":309},{"o":310},{"o":311},{"o":312},{"o":313},{"o":314},{"o":315},{"o":316},{"o":317},{"o":318},{"o":319},{"o":320},{"o":321},{"o":322},{"o":323},{"o":324},{"o":325},{"o":326},{"o":327},{"o":328},{"o":329},{"o":330},{"o":331},{"o":332},{"o":333},{"o":334},{"o":335},{"o":336},{"o":337},{"o":338},{"o":339},{"o":340},{"o":341},{"o":342},{"o":343},{"o":344},{"o":345},{"o":346},{"o":347},{"o":348},{"o":349},{"o":350},{"o":351},{"o":352},{"o":353},{"o":354},{"o":355},{"o":356},{"o":357},{"o":358},{"o":359},{"o":360},{"o":361},{"o":362},{"o":363},{"o":364},{"o":365},{"o":366},{"o":367},{"o":368},{"o":369},{"o":370},{"o":371},{"o":372},{"o":373},{"o":374},{"o":375},{"o":376},{"o":377},{"o":378},{"o":379},{"o":380},{"o":381},{"o":382},{"o":383},{"o":384},{"o":385},{"o":386},{"o":387},{"o":388},{"o":389},{"o":390},{"o":391},{"o":392},{"o":393},{"o":394},{"o":395},{"o":396},{"o":397},{"o":398},{"o":399},{"o":400},{"o":401},{"o":402},{"o":403},{"o":404},{"o":405},{"o":406},{"o":407},{"o":408},{"o":409},{"o":410},{"o":411},{"o":412},{"o":413},{"o":414},{"o":415},{"o":416},{"o":417},{"o":418},{"o":419},{"o":420},{"o":421},{"o":422},{"o":423},{"o":424},{"o":425},{"o":426},{"o":427},{"o":428},{"o":429},{"o":430},{"o":431},{"o":432},{"o":433},{"o":434},{"o":435},{"o":436},{"o":437},{"o":438},{"o":439},{"o":440},{"o":441},{"o":442},{"o":443},{"o":444},{"o":445},{"o":446},{"o":447},{"o":448},{"o":449},{"o":450},{"o":451},{"o":452},{"o":453},{"o":454},{"o":455},{"o":456},{"o":457},{"o":458},{"o":459},{"o":460},{"o":461},{"o":462},{"o":463},{"o":464},{"o":465},{"o":466},{"o":467},{"o":468},{"o":469},{"o":470},{"o":471},{"o":472},{"o":473},{"o":474},{"o":475},{"o":476},{"o":477},{"o":478},{"o":479},{"o":480},{"o":481},{"o":482},{"o":483},{"o":484},{"o":485},{"o":486},{"o":487},{"o":488},{"o":489},{"o":490},{"o":491},{"o":492},{"o":493},{"o":494},{"o":495},{"o":496},{"o":497},{"o":498},{"o":499},{"o":500},{"o":501},{"o":502},{"o":503},{"o":504},{"o":505},{"o":506},{"o":507},{"o":508},{"o":509},{"o":510},{"o":511},{"o":512},{"o":513},{"o":514},{"o":515},{"o":516},{"o":517},{"o":518},{"o":519},{"o":520},{"o":521},{"o":522},{"o":523},{"o":524},{"o":525},{"o":526},{"o":527},{"o":528},{"o":529},{"o":530},{"o":531},{"o":532},{"o":533},{"o":534},{"o":535},{"o":536},{"o":537},{"o":538},{"o":539},{"o":540},{"o":541},{"o":542},{"o":543},{"o":544},{"o":545},{"o":546},{"o":547},{"o":548},{"o":549},{"o":550},{"o":551},{"o":552},{"o":553},{"o":554},{"o":555},{"o":556},{"o":557},{"o":558},{"o":559},{"o":560},{"o":561},{"o":562},{"o":563},{"o":564},{"o":565},{"o":566},{"o":567},{"o":568},{"o":569},{"o":570},{"o":571},{"o":572},{"o":573},{"o":574},{"o":575},{"o":576},{"o":577},{"o":578},{"o":579},{"o":580},{"o":581},{"o":582},{"o":583},{"o":584},{"o":585},{"o":586},{"o":587},{"o":588},{"o":589},{"o":590},{"o":591},{"o":592},{"o":593},{"o":594},{"o":595},{"o":596},{"o":597},{"o":598},{"o":599},{"o":600},{"o":601},{"o":602},{"o":603},{"o":604},{"o":605},{"o":606},{"o":607},{"o":608},{"o":609},{"o":610},{"o":611},{"o":612},{"o":613},{"o":614},{"o":615},{"o":616},{"o":617},{"o":618},{"o":619},{"o":620},{"o":621},{"o":622},{"o":623},{"o":624},{"o":625},{"o":626},{"o":627},{"o":628},{"o":629},{"o":630},{"o":631},{"o":632},{"o":633},{"o":634},{"o":635},{"o":636},{"o":637},{"o":638},{"o":639},{"o":640},{"o":641},{"o":642},{"o":643},{"o":644},{"o":645},{"o":646},{"o":647},{"o":648},{"o":649},{"o":650},{"o":651},{"o":652},{"o":653},{"o":654},{"o":655},{"o":656},{"o":657},{"o":658},{"o":659},{"o":660},{"o":661},{"o":662},{"o":663},{"o":664},{"o":665},{"o":666},{"o":667},{"o":668},{"o":669},{"o":670},{"o":671},{"o":672},{"o":673},{"o":674},{"o":675},{"o":676},{"o":677},{"o":678},{"o":679},{"o":680},{"o":681},{"o":682},{"o":683},{"o":684},{"o":685},{"o":686},{"o":687},{"o":688},{"o":689},{"o":690},{"o":691},{"o":692},{"o":693},{"o":694},{"o":695},{"o":696},{"o":697},{"o":698},{"o":699},{"o":700},{"o":701},{"o":702},{"o":703},{"o":704},{"o":705},{"o":706},{"o":707},{"o":708},{"o":709},{"o":710},{"o":711},{"o":712},{"o":713},{"o":714},{"o":715},{"o":716},{"o":717},{"o":718},{"o":719},{"o":720},{"o":721},{"o":722},{"o":723},{"o":724},{"o":725},{"o":726},{"o":727},{"o":728},{"o":729},{"o":730},{"o":731},{"o":732},{"o":733},{"o":734},{"o":735},{"o":736},{"o":737},{"o":738},{"o":739},{"o":740},{"o":741},{"o":742},{"o":743},{"o":744},{"o":745},{"o":746},{"o":747},{"o":748},{"o":749},{"o":750},{"o":751},{"o":752},{"o":753},{"o":754},{"o":755},{"o":756},{"o":757},{"o":758},{"o":759},{"o":760},{"o":761},{"o":762},{"o":763},{"o":764},{"o":765},{"o":766},{"o":767},{"o":768},{"o":769},{"o":770},{"o":771},{"o":772},{"o":773},{"o":774},{"o":775},{"o":776},{"o":777},{"o":778},{"o":779},{"o":780},{"o":781},{"o":782},{"o":783},{"o":784},{"o":785},{"o":786},{"o":787},{"o":788},{"o":789},{"o":790},{"o":791},{"o":792},{"o":793},{"o":794},{"o":795},{"o":796},{"o":797},{"o":798},{"o":799},{"o":800},{"o":801},{"o":802},{"o":803},{"o":804},{"o":805},{"o":806},{"o":807},{"o":808},{"o":809},{"o":810},{"o":811},{"o":812},{"o":813},{"o":814},{"o":815},{"o":816},{"o":817},{"o":818},{"o":819},{"o":820},{"o":821},{"o":822},{"o":823},{"o":824},{"o":825},{"o":826},{"o":827},{"o":828},{"o":829},{"o":830},{"o":831},{"o":832},{"o":833},{"o":834},{"o":835},{"o":836},{"o":837},{"o":838},{"o":839},{"o":840},{"o":841},{"o":842},{"o":843},{"o":844},{"o":845},{"o":846},{"o":847},{"o":848},{"o":849},{"o":850},{"o":851},{"o":852},{"o":853},{"o":854},{"o":855},{"o":856},{"o":857},{"o":858},{"o":859},{"o":860},{"o":861},{"o":862},{"o":863},{"o":864},{"o":865},{"o":866},{"o":867},{"o":868},{"o":869},{"o":870},{"o":871},{"o":872},{"o":873},{"o":874},{"o":875},{"o":876},{"o":877},{"o":878},{"o":879},{"o":880},{"o":881},{"o":882},{"o":883},{"o":884},{"o":885},{"o":886},{"o":887},{"o":888},{"o":889},{"o":890},{"o":891},{"o":892},{"o":893},{"o":894},{"o":895},{"o":896},{"o":897},{"o":898},{"o":899},{"o":900},{"o":901},{"o":902},{"o":903},{"o":904},{"o":905},{"o":906},{"o":907},{"o":908},{"o":909},{"o":910},{"o":911},{"o":912},{"o":913},{"o":914},{"o":915},{"o":916},{"o":917},{"o":918},{"o":919},{"o":920},{"o":921},{"o":922},{"o":923},{"o":924},{"o":925},{"o":926},{"o":927},{"o":928},{"o":929},{"o":930},{"o":931},{"o":932},{"o":933},{"o":934},{"o":935},{"o":936},{"o":937},{"o":938},{"o":939},{"o":940},{"o":941},{"o":942},{"o":943},{"o":944},{"o":945},{"o":946},{"o":947},{"o":948},{"o":949},{"o":950},{"o":951},{"o":952},{"o":953},{"o":954},{"o":955},{"o":956},{"o":957},{"o":958},{"o":959},{"o":960},{"o":961},{"o":962},{"o":963},{"o":964},{"o":965},{"o":966},{"o":967},{"o":968},{"o":969},{"o":970},{"o":971},{"o":972},{"o":973},{"o":974},{"o":975},{"o":976},{"o":977},{"o":978},{"o":979},{"o":980},{"o":981},{"o":982},{"o":983},{"o":984},{"o":985},{"o":986},{"o":987},{"o":988},{"o":989},{"o":990},{"o":991},{"o":992},{"o":993},{"o":994},{"o":995},{"o":996},{"o":997},{"o":998},{"o":999},{"o":1000}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe","content_size_bytes":9902,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_js_three-segments_adjustment_reports","row_set_kind":"adjustment","rows":[{"o":0},{"o":1},{"o":2},{"o":3},{"o":4},{"o":5},{"o":6},{"o":7},{"o":8},{"o":9},{"o":10},{"o":11},{"o":12},{"o":13},{"o":14},{"o":15},{"o":16},{"o":17},{"o":18},{"o":19},{"o":20},{"o":21},{"o":22},{"o":23},{"o":24},{"o":25},{"o":26},{"o":27},{"o":28},{"o":29},{"o":30},{"o":31},{"o":32},{"o":33},{"o":34},{"o":35},{"o":36},{"o":37},{"o":38},{"o":39},{"o":40},{"o":41},{"o":42},{"o":43},{"o":44},{"o":45},{"o":46},{"o":47},{"o":48},{"o":49},{"o":50},{"o":51},{"o":52},{"o":53},{"o":54},{"o":55},{"o":56},{"o":57},{"o":58},{"o":59},{"o":60},{"o":61},{"o":62},{"o":63},{"o":64},{"o":65},{"o":66},{"o":67},{"o":68},{"o":69},{"o":70},{"o":71},{"o":72},{"o":73},{"o":74},{"o":75},{"o":76},{"o":77},{"o":78},{"o":79},{"o":80},{"o":81},{"o":82},{"o":83},{"o":84},{"o":85},{"o":86},{"o":87},{"o":88},{"o":89},{"o":90},{"o":91},{"o":92},{"o":93},{"o":94},{"o":95},{"o":96},{"o":97},{"o":98},{"o":99},{"o":100},{"o":101},{"o":102},{"o":103},{"o":104},{"o":105},{"o":106},{"o":107},{"o":108},{"o":109},{"o":110},{"o":111},{"o":112},{"o":113},{"o":114},{"o":115},{"o":116},{"o":117},{"o":118},{"o":119},{"o":120},{"o":121},{"o":122},{"o":123},{"o":124},{"o":125},{"o":126},{"o":127},{"o":128},{"o":129},{"o":130},{"o":131},{"o":132},{"o":133},{"o":134},{"o":135},{"o":136},{"o":137},{"o":138},{"o":139},{"o":140},{"o":141},{"o":142},{"o":143},{"o":144},{"o":145},{"o":146},{"o":147},{"o":148},{"o":149},{"o":150},{"o":151},{"o":152},{"o":153},{"o":154},{"o":155},{"o":156},{"o":157},{"o":158},{"o":159},{"o":160},{"o":161},{"o":162},{"o":163},{"o":164},{"o":165},{"o":166},{"o":167},{"o":168},{"o":169},{"o":170},{"o":171},{"o":172},{"o":173},{"o":174},{"o":175},{"o":176},{"o":177},{"o":178},{"o":179},{"o":180},{"o":181},{"o":182},{"o":183},{"o":184},{"o":185},{"o":186},{"o":187},{"o":188},{"o":189},{"o":190},{"o":191},{"o":192},{"o":193},{"o":194},{"o":195},{"o":196},{"o":197},{"o":198},{"o":199},{"o":200},{"o":201},{"o":202},{"o":203},{"o":204},{"o":205},{"o":206},{"o":207},{"o":208},{"o":209},{"o":210},{"o":211},{"o":212},{"o":213},{"o":214},{"o":215},{"o":216},{"o":217},{"o":218},{"o":219},{"o":220},{"o":221},{"o":222},{"o":223},{"o":224},{"o":225},{"o":226},{"o":227},{"o":228},{"o":229},{"o":230},{"o":231},{"o":232},{"o":233},{"o":234},{"o":235},{"o":236},{"o":237},{"o":238},{"o":239},{"o":240},{"o":241},{"o":242},{"o":243},{"o":244},{"o":245},{"o":246},{"o":247},{"o":248},{"o":249},{"o":250},{"o":251},{"o":252},{"o":253},{"o":254},{"o":255},{"o":256},{"o":257},{"o":258},{"o":259},{"o":260},{"o":261},{"o":262},{"o":263},{"o":264},{"o":265},{"o":266},{"o":267},{"o":268},{"o":269},{"o":270},{"o":271},{"o":272},{"o":273},{"o":274},{"o":275},{"o":276},{"o":277},{"o":278},{"o":279},{"o":280},{"o":281},{"o":282},{"o":283},{"o":284},{"o":285},{"o":286},{"o":287},{"o":288},{"o":289},{"o":290},{"o":291},{"o":292},{"o":293},{"o":294},{"o":295},{"o":296},{"o":297},{"o":298},{"o":299},{"o":300},{"o":301},{"o":302},{"o":303},{"o":304},{"o":305},{"o":306},{"o":307},{"o":308},{"o":309},{"o":310},{"o":311},{"o":312},{"o":313},{"o":314},{"o":315},{"o":316},{"o":317},{"o":318},{"o":319},{"o":320},{"o":321},{"o":322},{"o":323},{"o":324},{"o":325},{"o":326},{"o":327},{"o":328},{"o":329},{"o":330},{"o":331},{"o":332},{"o":333},{"o":334},{"o":335},{"o":336},{"o":337},{"o":338},{"o":339},{"o":340},{"o":341},{"o":342},{"o":343},{"o":344},{"o":345},{"o":346},{"o":347},{"o":348},{"o":349},{"o":350},{"o":351},{"o":352},{"o":353},{"o":354},{"o":355},{"o":356},{"o":357},{"o":358},{"o":359},{"o":360},{"o":361},{"o":362},{"o":363},{"o":364},{"o":365},{"o":366},{"o":367},{"o":368},{"o":369},{"o":370},{"o":371},{"o":372},{"o":373},{"o":374},{"o":375},{"o":376},{"o":377},{"o":378},{"o":379},{"o":380},{"o":381},{"o":382},{"o":383},{"o":384},{"o":385},{"o":386},{"o":387},{"o":388},{"o":389},{"o":390},{"o":391},{"o":392},{"o":393},{"o":394},{"o":395},{"o":396},{"o":397},{"o":398},{"o":399},{"o":400},{"o":401},{"o":402},{"o":403},{"o":404},{"o":405},{"o":406},{"o":407},{"o":408},{"o":409},{"o":410},{"o":411},{"o":412},{"o":413},{"o":414},{"o":415},{"o":416},{"o":417},{"o":418},{"o":419},{"o":420},{"o":421},{"o":422},{"o":423},{"o":424},{"o":425},{"o":426},{"o":427},{"o":428},{"o":429},{"o":430},{"o":431},{"o":432},{"o":433},{"o":434},{"o":435},{"o":436},{"o":437},{"o":438},{"o":439},{"o":440},{"o":441},{"o":442},{"o":443},{"o":444},{"o":445},{"o":446},{"o":447},{"o":448},{"o":449},{"o":450},{"o":451},{"o":452},{"o":453},{"o":454},{"o":455},{"o":456},{"o":457},{"o":458},{"o":459},{"o":460},{"o":461},{"o":462},{"o":463},{"o":464},{"o":465},{"o":466},{"o":467},{"o":468},{"o":469},{"o":470},{"o":471},{"o":472},{"o":473},{"o":474},{"o":475},{"o":476},{"o":477},{"o":478},{"o":479},{"o":480},{"o":481},{"o":482},{"o":483},{"o":484},{"o":485},{"o":486},{"o":487},{"o":488},{"o":489},{"o":490},{"o":491},{"o":492},{"o":493},{"o":494},{"o":495},{"o":496},{"o":497},{"o":498},{"o":499},{"o":500},{"o":501},{"o":502},{"o":503},{"o":504},{"o":505},{"o":506},{"o":507},{"o":508},{"o":509},{"o":510},{"o":511},{"o":512},{"o":513},{"o":514},{"o":515},{"o":516},{"o":517},{"o":518},{"o":519},{"o":520},{"o":521},{"o":522},{"o":523},{"o":524},{"o":525},{"o":526},{"o":527},{"o":528},{"o":529},{"o":530},{"o":531},{"o":532},{"o":533},{"o":534},{"o":535},{"o":536},{"o":537},{"o":538},{"o":539},{"o":540},{"o":541},{"o":542},{"o":543},{"o":544},{"o":545},{"o":546},{"o":547},{"o":548},{"o":549},{"o":550},{"o":551},{"o":552},{"o":553},{"o":554},{"o":555},{"o":556},{"o":557},{"o":558},{"o":559},{"o":560},{"o":561},{"o":562},{"o":563},{"o":564},{"o":565},{"o":566},{"o":567},{"o":568},{"o":569},{"o":570},{"o":571},{"o":572},{"o":573},{"o":574},{"o":575},{"o":576},{"o":577},{"o":578},{"o":579},{"o":580},{"o":581},{"o":582},{"o":583},{"o":584},{"o":585},{"o":586},{"o":587},{"o":588},{"o":589},{"o":590},{"o":591},{"o":592},{"o":593},{"o":594},{"o":595},{"o":596},{"o":597},{"o":598},{"o":599},{"o":600},{"o":601},{"o":602},{"o":603},{"o":604},{"o":605},{"o":606},{"o":607},{"o":608},{"o":609},{"o":610},{"o":611},{"o":612},{"o":613},{"o":614},{"o":615},{"o":616},{"o":617},{"o":618},{"o":619},{"o":620},{"o":621},{"o":622},{"o":623},{"o":624},{"o":625},{"o":626},{"o":627},{"o":628},{"o":629},{"o":630},{"o":631},{"o":632},{"o":633},{"o":634},{"o":635},{"o":636},{"o":637},{"o":638},{"o":639},{"o":640},{"o":641},{"o":642},{"o":643},{"o":644},{"o":645},{"o":646},{"o":647},{"o":648},{"o":649},{"o":650},{"o":651},{"o":652},{"o":653},{"o":654},{"o":655},{"o":656},{"o":657},{"o":658},{"o":659},{"o":660},{"o":661},{"o":662},{"o":663},{"o":664},{"o":665},{"o":666},{"o":667},{"o":668},{"o":669},{"o":670},{"o":671},{"o":672},{"o":673},{"o":674},{"o":675},{"o":676},{"o":677},{"o":678},{"o":679},{"o":680},{"o":681},{"o":682},{"o":683},{"o":684},{"o":685},{"o":686},{"o":687},{"o":688},{"o":689},{"o":690},{"o":691},{"o":692},{"o":693},{"o":694},{"o":695},{"o":696},{"o":697},{"o":698},{"o":699},{"o":700},{"o":701},{"o":702},{"o":703},{"o":704},{"o":705},{"o":706},{"o":707},{"o":708},{"o":709},{"o":710},{"o":711},{"o":712},{"o":713},{"o":714},{"o":715},{"o":716},{"o":717},{"o":718},{"o":719},{"o":720},{"o":721},{"o":722},{"o":723},{"o":724},{"o":725},{"o":726},{"o":727},{"o":728},{"o":729},{"o":730},{"o":731},{"o":732},{"o":733},{"o":734},{"o":735},{"o":736},{"o":737},{"o":738},{"o":739},{"o":740},{"o":741},{"o":742},{"o":743},{"o":744},{"o":745},{"o":746},{"o":747},{"o":748},{"o":749},{"o":750},{"o":751},{"o":752},{"o":753},{"o":754},{"o":755},{"o":756},{"o":757},{"o":758},{"o":759},{"o":760},{"o":761},{"o":762},{"o":763},{"o":764},{"o":765},{"o":766},{"o":767},{"o":768},{"o":769},{"o":770},{"o":771},{"o":772},{"o":773},{"o":774},{"o":775},{"o":776},{"o":777},{"o":778},{"o":779},{"o":780},{"o":781},{"o":782},{"o":783},{"o":784},{"o":785},{"o":786},{"o":787},{"o":788},{"o":789},{"o":790},{"o":791},{"o":792},{"o":793},{"o":794},{"o":795},{"o":796},{"o":797},{"o":798},{"o":799},{"o":800},{"o":801},{"o":802},{"o":803},{"o":804},{"o":805},{"o":806},{"o":807},{"o":808},{"o":809},{"o":810},{"o":811},{"o":812},{"o":813},{"o":814},{"o":815},{"o":816},{"o":817},{"o":818},{"o":819},{"o":820},{"o":821},{"o":822},{"o":823},{"o":824},{"o":825},{"o":826},{"o":827},{"o":828},{"o":829},{"o":830},{"o":831},{"o":832},{"o":833},{"o":834},{"o":835},{"o":836},{"o":837},{"o":838},{"o":839},{"o":840},{"o":841},{"o":842},{"o":843},{"o":844},{"o":845},{"o":846},{"o":847},{"o":848},{"o":849},{"o":850},{"o":851},{"o":852},{"o":853},{"o":854},{"o":855},{"o":856},{"o":857},{"o":858},{"o":859},{"o":860},{"o":861},{"o":862},{"o":863},{"o":864},{"o":865},{"o":866},{"o":867},{"o":868},{"o":869},{"o":870},{"o":871},{"o":872},{"o":873},{"o":874},{"o":875},{"o":876},{"o":877},{"o":878},{"o":879},{"o":880},{"o":881},{"o":882},{"o":883},{"o":884},{"o":885},{"o":886},{"o":887},{"o":888},{"o":889},{"o":890},{"o":891},{"o":892},{"o":893},{"o":894},{"o":895},{"o":896},{"o":897},{"o":898},{"o":899},{"o":900},{"o":901},{"o":902},{"o":903},{"o":904},{"o":905},{"o":906},{"o":907},{"o":908},{"o":909},{"o":910},{"o":911},{"o":912},{"o":913},{"o":914},{"o":915},{"o":916},{"o":917},{"o":918},{"o":919},{"o":920},{"o":921},{"o":922},{"o":923},{"o":924},{"o":925},{"o":926},{"o":927},{"o":928},{"o":929},{"o":930},{"o":931},{"o":932},{"o":933},{"o":934},{"o":935},{"o":936},{"o":937},{"o":938},{"o":939},{"o":940},{"o":941},{"o":942},{"o":943},{"o":944},{"o":945},{"o":946},{"o":947},{"o":948},{"o":949},{"o":950},{"o":951},{"o":952},{"o":953},{"o":954},{"o":955},{"o":956},{"o":957},{"o":958},{"o":959},{"o":960},{"o":961},{"o":962},{"o":963},{"o":964},{"o":965},{"o":966},{"o":967},{"o":968},{"o":969},{"o":970},{"o":971},{"o":972},{"o":973},{"o":974},{"o":975},{"o":976},{"o":977},{"o":978},{"o":979},{"o":980},{"o":981},{"o":982},{"o":983},{"o":984},{"o":985},{"o":986},{"o":987},{"o":988},{"o":989},{"o":990},{"o":991},{"o":992},{"o":993},{"o":994},{"o":995},{"o":996},{"o":997},{"o":998},{"o":999},{"o":1000}]} ], "state": { "chunk_bodies": [ - { - "account_id": "acct_interop", - "body_hex": "7b226f223a307d0a7b226f223a317d0a7b226f223a327d0a7b226f223a337d0a7b226f223a347d0a7b226f223a357d0a7b226f223a367d0a7b226f223a377d0a7b226f223a387d0a7b226f223a397d0a7b226f223a31307d0a7b226f223a31317d0a7b226f223a31327d0a7b226f223a31337d0a7b226f223a31347d0a7b226f223a31357d0a7b226f223a31367d0a7b226f223a31377d0a7b226f223a31387d0a7b226f223a31397d0a7b226f223a32307d0a7b226f223a32317d0a7b226f223a32327d0a7b226f223a32337d0a7b226f223a32347d0a7b226f223a32357d0a7b226f223a32367d0a7b226f223a32377d0a7b226f223a32387d0a7b226f223a32397d0a7b226f223a33307d0a7b226f223a33317d0a7b226f223a33327d0a7b226f223a33337d0a7b226f223a33347d0a7b226f223a33357d0a7b226f223a33367d0a7b226f223a33377d0a7b226f223a33387d0a7b226f223a33397d0a7b226f223a34307d0a7b226f223a34317d0a7b226f223a34327d0a7b226f223a34337d0a7b226f223a34347d0a7b226f223a34357d0a7b226f223a34367d0a7b226f223a34377d0a7b226f223a34387d0a7b226f223a34397d0a7b226f223a35307d0a7b226f223a35317d0a7b226f223a35327d0a7b226f223a35337d0a7b226f223a35347d0a7b226f223a35357d0a7b226f223a35367d0a7b226f223a35377d0a7b226f223a35387d0a7b226f223a35397d0a7b226f223a36307d0a7b226f223a36317d0a7b226f223a36327d0a7b226f223a36337d0a7b226f223a36347d0a7b226f223a36357d0a7b226f223a36367d0a7b226f223a36377d0a7b226f223a36387d0a7b226f223a36397d0a7b226f223a37307d0a7b226f223a37317d0a7b226f223a37327d0a7b226f223a37337d0a7b226f223a37347d0a7b226f223a37357d0a7b226f223a37367d0a7b226f223a37377d0a7b226f223a37387d0a7b226f223a37397d0a7b226f223a38307d0a7b226f223a38317d0a7b226f223a38327d0a7b226f223a38337d0a7b226f223a38347d0a7b226f223a38357d0a7b226f223a38367d0a7b226f223a38377d0a7b226f223a38387d0a7b226f223a38397d0a7b226f223a39307d0a7b226f223a39317d0a7b226f223a39327d0a7b226f223a39337d0a7b226f223a39347d0a7b226f223a39357d0a7b226f223a39367d0a7b226f223a39377d0a7b226f223a39387d0a7b226f223a39397d0a7b226f223a3130307d0a7b226f223a3130317d0a7b226f223a3130327d0a7b226f223a3130337d0a7b226f223a3130347d0a7b226f223a3130357d0a7b226f223a3130367d0a7b226f223a3130377d0a7b226f223a3130387d0a7b226f223a3130397d0a7b226f223a3131307d0a7b226f223a3131317d0a7b226f223a3131327d0a7b226f223a3131337d0a7b226f223a3131347d0a7b226f223a3131357d0a7b226f223a3131367d0a7b226f223a3131377d0a7b226f223a3131387d0a7b226f223a3131397d0a7b226f223a3132307d0a7b226f223a3132317d0a7b226f223a3132327d0a7b226f223a3132337d0a7b226f223a3132347d0a7b226f223a3132357d0a7b226f223a3132367d0a7b226f223a3132377d0a7b226f223a3132387d0a7b226f223a3132397d0a7b226f223a3133307d0a7b226f223a3133317d0a7b226f223a3133327d0a7b226f223a3133337d0a7b226f223a3133347d0a7b226f223a3133357d0a7b226f223a3133367d0a7b226f223a3133377d0a7b226f223a3133387d0a7b226f223a3133397d0a7b226f223a3134307d0a7b226f223a3134317d0a7b226f223a3134327d0a7b226f223a3134337d0a7b226f223a3134347d0a7b226f223a3134357d0a7b226f223a3134367d0a7b226f223a3134377d0a7b226f223a3134387d0a7b226f223a3134397d0a7b226f223a3135307d0a7b226f223a3135317d0a7b226f223a3135327d0a7b226f223a3135337d0a7b226f223a3135347d0a7b226f223a3135357d0a7b226f223a3135367d0a7b226f223a3135377d0a7b226f223a3135387d0a7b226f223a3135397d0a7b226f223a3136307d0a7b226f223a3136317d0a7b226f223a3136327d0a7b226f223a3136337d0a7b226f223a3136347d0a7b226f223a3136357d0a7b226f223a3136367d0a7b226f223a3136377d0a7b226f223a3136387d0a7b226f223a3136397d0a7b226f223a3137307d0a7b226f223a3137317d0a7b226f223a3137327d0a7b226f223a3137337d0a7b226f223a3137347d0a7b226f223a3137357d0a7b226f223a3137367d0a7b226f223a3137377d0a7b226f223a3137387d0a7b226f223a3137397d0a7b226f223a3138307d0a7b226f223a3138317d0a7b226f223a3138327d0a7b226f223a3138337d0a7b226f223a3138347d0a7b226f223a3138357d0a7b226f223a3138367d0a7b226f223a3138377d0a7b226f223a3138387d0a7b226f223a3138397d0a7b226f223a3139307d0a7b226f223a3139317d0a7b226f223a3139327d0a7b226f223a3139337d0a7b226f223a3139347d0a7b226f223a3139357d0a7b226f223a3139367d0a7b226f223a3139377d0a7b226f223a3139387d0a7b226f223a3139397d0a7b226f223a3230307d0a7b226f223a3230317d0a7b226f223a3230327d0a7b226f223a3230337d0a7b226f223a3230347d0a7b226f223a3230357d0a7b226f223a3230367d0a7b226f223a3230377d0a7b226f223a3230387d0a7b226f223a3230397d0a7b226f223a3231307d0a7b226f223a3231317d0a7b226f223a3231327d0a7b226f223a3231337d0a7b226f223a3231347d0a7b226f223a3231357d0a7b226f223a3231367d0a7b226f223a3231377d0a7b226f223a3231387d0a7b226f223a3231397d0a7b226f223a3232307d0a7b226f223a3232317d0a7b226f223a3232327d0a7b226f223a3232337d0a7b226f223a3232347d0a7b226f223a3232357d0a7b226f223a3232367d0a7b226f223a3232377d0a7b226f223a3232387d0a7b226f223a3232397d0a7b226f223a3233307d0a7b226f223a3233317d0a7b226f223a3233327d0a7b226f223a3233337d0a7b226f223a3233347d0a7b226f223a3233357d0a7b226f223a3233367d0a7b226f223a3233377d0a7b226f223a3233387d0a7b226f223a3233397d0a7b226f223a3234307d0a7b226f223a3234317d0a7b226f223a3234327d0a7b226f223a3234337d0a7b226f223a3234347d0a7b226f223a3234357d0a7b226f223a3234367d0a7b226f223a3234377d0a7b226f223a3234387d0a7b226f223a3234397d0a7b226f223a3235307d0a7b226f223a3235317d0a7b226f223a3235327d0a7b226f223a3235337d0a7b226f223a3235347d0a7b226f223a3235357d0a7b226f223a3235367d0a7b226f223a3235377d0a7b226f223a3235387d0a7b226f223a3235397d0a7b226f223a3236307d0a7b226f223a3236317d0a7b226f223a3236327d0a7b226f223a3236337d0a7b226f223a3236347d0a7b226f223a3236357d0a7b226f223a3236367d0a7b226f223a3236377d0a7b226f223a3236387d0a7b226f223a3236397d0a7b226f223a3237307d0a7b226f223a3237317d0a7b226f223a3237327d0a7b226f223a3237337d0a7b226f223a3237347d0a7b226f223a3237357d0a7b226f223a3237367d0a7b226f223a3237377d0a7b226f223a3237387d0a7b226f223a3237397d0a7b226f223a3238307d0a7b226f223a3238317d0a7b226f223a3238327d0a7b226f223a3238337d0a7b226f223a3238347d0a7b226f223a3238357d0a7b226f223a3238367d0a7b226f223a3238377d0a7b226f223a3238387d0a7b226f223a3238397d0a7b226f223a3239307d0a7b226f223a3239317d0a7b226f223a3239327d0a7b226f223a3239337d0a7b226f223a3239347d0a7b226f223a3239357d0a7b226f223a3239367d0a7b226f223a3239377d0a7b226f223a3239387d0a7b226f223a3239397d0a7b226f223a3330307d0a7b226f223a3330317d0a7b226f223a3330327d0a7b226f223a3330337d0a7b226f223a3330347d0a7b226f223a3330357d0a7b226f223a3330367d0a7b226f223a3330377d0a7b226f223a3330387d0a7b226f223a3330397d0a7b226f223a3331307d0a7b226f223a3331317d0a7b226f223a3331327d0a7b226f223a3331337d0a7b226f223a3331347d0a7b226f223a3331357d0a7b226f223a3331367d0a7b226f223a3331377d0a7b226f223a3331387d0a7b226f223a3331397d0a7b226f223a3332307d0a7b226f223a3332317d0a7b226f223a3332327d0a7b226f223a3332337d0a7b226f223a3332347d0a7b226f223a3332357d0a7b226f223a3332367d0a7b226f223a3332377d0a7b226f223a3332387d0a7b226f223a3332397d0a7b226f223a3333307d0a7b226f223a3333317d0a7b226f223a3333327d0a7b226f223a3333337d0a7b226f223a3333347d0a7b226f223a3333357d0a7b226f223a3333367d0a7b226f223a3333377d0a7b226f223a3333387d0a7b226f223a3333397d0a7b226f223a3334307d0a7b226f223a3334317d0a7b226f223a3334327d0a7b226f223a3334337d0a7b226f223a3334347d0a7b226f223a3334357d0a7b226f223a3334367d0a7b226f223a3334377d0a7b226f223a3334387d0a7b226f223a3334397d0a7b226f223a3335307d0a7b226f223a3335317d0a7b226f223a3335327d0a7b226f223a3335337d0a7b226f223a3335347d0a7b226f223a3335357d0a7b226f223a3335367d0a7b226f223a3335377d0a7b226f223a3335387d0a7b226f223a3335397d0a7b226f223a3336307d0a7b226f223a3336317d0a7b226f223a3336327d0a7b226f223a3336337d0a7b226f223a3336347d0a7b226f223a3336357d0a7b226f223a3336367d0a7b226f223a3336377d0a7b226f223a3336387d0a7b226f223a3336397d0a7b226f223a3337307d0a7b226f223a3337317d0a7b226f223a3337327d0a7b226f223a3337337d0a7b226f223a3337347d0a7b226f223a3337357d0a7b226f223a3337367d0a7b226f223a3337377d0a7b226f223a3337387d0a7b226f223a3337397d0a7b226f223a3338307d0a7b226f223a3338317d0a7b226f223a3338327d0a7b226f223a3338337d0a7b226f223a3338347d0a7b226f223a3338357d0a7b226f223a3338367d0a7b226f223a3338377d0a7b226f223a3338387d0a7b226f223a3338397d0a7b226f223a3339307d0a7b226f223a3339317d0a7b226f223a3339327d0a7b226f223a3339337d0a7b226f223a3339347d0a7b226f223a3339357d0a7b226f223a3339367d0a7b226f223a3339377d0a7b226f223a3339387d0a7b226f223a3339397d0a7b226f223a3430307d0a7b226f223a3430317d0a7b226f223a3430327d0a7b226f223a3430337d0a7b226f223a3430347d0a7b226f223a3430357d0a7b226f223a3430367d0a7b226f223a3430377d0a7b226f223a3430387d0a7b226f223a3430397d0a7b226f223a3431307d0a7b226f223a3431317d0a7b226f223a3431327d0a7b226f223a3431337d0a7b226f223a3431347d0a7b226f223a3431357d0a7b226f223a3431367d0a7b226f223a3431377d0a7b226f223a3431387d0a7b226f223a3431397d0a7b226f223a3432307d0a7b226f223a3432317d0a7b226f223a3432327d0a7b226f223a3432337d0a7b226f223a3432347d0a7b226f223a3432357d0a7b226f223a3432367d0a7b226f223a3432377d0a7b226f223a3432387d0a7b226f223a3432397d0a7b226f223a3433307d0a7b226f223a3433317d0a7b226f223a3433327d0a7b226f223a3433337d0a7b226f223a3433347d0a7b226f223a3433357d0a7b226f223a3433367d0a7b226f223a3433377d0a7b226f223a3433387d0a7b226f223a3433397d0a7b226f223a3434307d0a7b226f223a3434317d0a7b226f223a3434327d0a7b226f223a3434337d0a7b226f223a3434347d0a7b226f223a3434357d0a7b226f223a3434367d0a7b226f223a3434377d0a7b226f223a3434387d0a7b226f223a3434397d0a7b226f223a3435307d0a7b226f223a3435317d0a7b226f223a3435327d0a7b226f223a3435337d0a7b226f223a3435347d0a7b226f223a3435357d0a7b226f223a3435367d0a7b226f223a3435377d0a7b226f223a3435387d0a7b226f223a3435397d0a7b226f223a3436307d0a7b226f223a3436317d0a7b226f223a3436327d0a7b226f223a3436337d0a7b226f223a3436347d0a7b226f223a3436357d0a7b226f223a3436367d0a7b226f223a3436377d0a7b226f223a3436387d0a7b226f223a3436397d0a7b226f223a3437307d0a7b226f223a3437317d0a7b226f223a3437327d0a7b226f223a3437337d0a7b226f223a3437347d0a7b226f223a3437357d0a7b226f223a3437367d0a7b226f223a3437377d0a7b226f223a3437387d0a7b226f223a3437397d0a7b226f223a3438307d0a7b226f223a3438317d0a7b226f223a3438327d0a7b226f223a3438337d0a7b226f223a3438347d0a7b226f223a3438357d0a7b226f223a3438367d0a7b226f223a3438377d0a7b226f223a3438387d0a7b226f223a3438397d0a7b226f223a3439307d0a7b226f223a3439317d0a7b226f223a3439327d0a7b226f223a3439337d0a7b226f223a3439347d0a7b226f223a3439357d0a7b226f223a3439367d0a7b226f223a3439377d0a7b226f223a3439387d0a7b226f223a3439397d0a7b226f223a3530307d0a7b226f223a3530317d0a7b226f223a3530327d0a7b226f223a3530337d0a7b226f223a3530347d0a7b226f223a3530357d0a7b226f223a3530367d0a7b226f223a3530377d0a7b226f223a3530387d0a7b226f223a3530397d0a7b226f223a3531307d0a7b226f223a3531317d0a7b226f223a3531327d0a7b226f223a3531337d0a7b226f223a3531347d0a7b226f223a3531357d0a7b226f223a3531367d0a7b226f223a3531377d0a7b226f223a3531387d0a7b226f223a3531397d0a7b226f223a3532307d0a7b226f223a3532317d0a7b226f223a3532327d0a7b226f223a3532337d0a7b226f223a3532347d0a7b226f223a3532357d0a7b226f223a3532367d0a7b226f223a3532377d0a7b226f223a3532387d0a7b226f223a3532397d0a7b226f223a3533307d0a7b226f223a3533317d0a7b226f223a3533327d0a7b226f223a3533337d0a7b226f223a3533347d0a7b226f223a3533357d0a7b226f223a3533367d0a7b226f223a3533377d0a7b226f223a3533387d0a7b226f223a3533397d0a7b226f223a3534307d0a7b226f223a3534317d0a7b226f223a3534327d0a7b226f223a3534337d0a7b226f223a3534347d0a7b226f223a3534357d0a7b226f223a3534367d0a7b226f223a3534377d0a7b226f223a3534387d0a7b226f223a3534397d0a7b226f223a3535307d0a7b226f223a3535317d0a7b226f223a3535327d0a7b226f223a3535337d0a7b226f223a3535347d0a7b226f223a3535357d0a7b226f223a3535367d0a7b226f223a3535377d0a7b226f223a3535387d0a7b226f223a3535397d0a7b226f223a3536307d0a7b226f223a3536317d0a7b226f223a3536327d0a7b226f223a3536337d0a7b226f223a3536347d0a7b226f223a3536357d0a7b226f223a3536367d0a7b226f223a3536377d0a7b226f223a3536387d0a7b226f223a3536397d0a7b226f223a3537307d0a7b226f223a3537317d0a7b226f223a3537327d0a7b226f223a3537337d0a7b226f223a3537347d0a7b226f223a3537357d0a7b226f223a3537367d0a7b226f223a3537377d0a7b226f223a3537387d0a7b226f223a3537397d0a7b226f223a3538307d0a7b226f223a3538317d0a7b226f223a3538327d0a7b226f223a3538337d0a7b226f223a3538347d0a7b226f223a3538357d0a7b226f223a3538367d0a7b226f223a3538377d0a7b226f223a3538387d0a7b226f223a3538397d0a7b226f223a3539307d0a7b226f223a3539317d0a7b226f223a3539327d0a7b226f223a3539337d0a7b226f223a3539347d0a7b226f223a3539357d0a7b226f223a3539367d0a7b226f223a3539377d0a7b226f223a3539387d0a7b226f223a3539397d0a7b226f223a3630307d0a7b226f223a3630317d0a7b226f223a3630327d0a7b226f223a3630337d0a7b226f223a3630347d0a7b226f223a3630357d0a7b226f223a3630367d0a7b226f223a3630377d0a7b226f223a3630387d0a7b226f223a3630397d0a7b226f223a3631307d0a7b226f223a3631317d0a7b226f223a3631327d0a7b226f223a3631337d0a7b226f223a3631347d0a7b226f223a3631357d0a7b226f223a3631367d0a7b226f223a3631377d0a7b226f223a3631387d0a7b226f223a3631397d0a7b226f223a3632307d0a7b226f223a3632317d0a7b226f223a3632327d0a7b226f223a3632337d0a7b226f223a3632347d0a7b226f223a3632357d0a7b226f223a3632367d0a7b226f223a3632377d0a7b226f223a3632387d0a7b226f223a3632397d0a7b226f223a3633307d0a7b226f223a3633317d0a7b226f223a3633327d0a7b226f223a3633337d0a7b226f223a3633347d0a7b226f223a3633357d0a7b226f223a3633367d0a7b226f223a3633377d0a7b226f223a3633387d0a7b226f223a3633397d0a7b226f223a3634307d0a7b226f223a3634317d0a7b226f223a3634327d0a7b226f223a3634337d0a7b226f223a3634347d0a7b226f223a3634357d0a7b226f223a3634367d0a7b226f223a3634377d0a7b226f223a3634387d0a7b226f223a3634397d0a7b226f223a3635307d0a7b226f223a3635317d0a7b226f223a3635327d0a7b226f223a3635337d0a7b226f223a3635347d0a7b226f223a3635357d0a7b226f223a3635367d0a7b226f223a3635377d0a7b226f223a3635387d0a7b226f223a3635397d0a7b226f223a3636307d0a7b226f223a3636317d0a7b226f223a3636327d0a7b226f223a3636337d0a7b226f223a3636347d0a7b226f223a3636357d0a7b226f223a3636367d0a7b226f223a3636377d0a7b226f223a3636387d0a7b226f223a3636397d0a7b226f223a3637307d0a7b226f223a3637317d0a7b226f223a3637327d0a7b226f223a3637337d0a7b226f223a3637347d0a7b226f223a3637357d0a7b226f223a3637367d0a7b226f223a3637377d0a7b226f223a3637387d0a7b226f223a3637397d0a7b226f223a3638307d0a7b226f223a3638317d0a7b226f223a3638327d0a7b226f223a3638337d0a7b226f223a3638347d0a7b226f223a3638357d0a7b226f223a3638367d0a7b226f223a3638377d0a7b226f223a3638387d0a7b226f223a3638397d0a7b226f223a3639307d0a7b226f223a3639317d0a7b226f223a3639327d0a7b226f223a3639337d0a7b226f223a3639347d0a7b226f223a3639357d0a7b226f223a3639367d0a7b226f223a3639377d0a7b226f223a3639387d0a7b226f223a3639397d0a7b226f223a3730307d0a7b226f223a3730317d0a7b226f223a3730327d0a7b226f223a3730337d0a7b226f223a3730347d0a7b226f223a3730357d0a7b226f223a3730367d0a7b226f223a3730377d0a7b226f223a3730387d0a7b226f223a3730397d0a7b226f223a3731307d0a7b226f223a3731317d0a7b226f223a3731327d0a7b226f223a3731337d0a7b226f223a3731347d0a7b226f223a3731357d0a7b226f223a3731367d0a7b226f223a3731377d0a7b226f223a3731387d0a7b226f223a3731397d0a7b226f223a3732307d0a7b226f223a3732317d0a7b226f223a3732327d0a7b226f223a3732337d0a7b226f223a3732347d0a7b226f223a3732357d0a7b226f223a3732367d0a7b226f223a3732377d0a7b226f223a3732387d0a7b226f223a3732397d0a7b226f223a3733307d0a7b226f223a3733317d0a7b226f223a3733327d0a7b226f223a3733337d0a7b226f223a3733347d0a7b226f223a3733357d0a7b226f223a3733367d0a7b226f223a3733377d0a7b226f223a3733387d0a7b226f223a3733397d0a7b226f223a3734307d0a7b226f223a3734317d0a7b226f223a3734327d0a7b226f223a3734337d0a7b226f223a3734347d0a7b226f223a3734357d0a7b226f223a3734367d0a7b226f223a3734377d0a7b226f223a3734387d0a7b226f223a3734397d0a7b226f223a3735307d0a7b226f223a3735317d0a7b226f223a3735327d0a7b226f223a3735337d0a7b226f223a3735347d0a7b226f223a3735357d0a7b226f223a3735367d0a7b226f223a3735377d0a7b226f223a3735387d0a7b226f223a3735397d0a7b226f223a3736307d0a7b226f223a3736317d0a7b226f223a3736327d0a7b226f223a3736337d0a7b226f223a3736347d0a7b226f223a3736357d0a7b226f223a3736367d0a7b226f223a3736377d0a7b226f223a3736387d0a7b226f223a3736397d0a7b226f223a3737307d0a7b226f223a3737317d0a7b226f223a3737327d0a7b226f223a3737337d0a7b226f223a3737347d0a7b226f223a3737357d0a7b226f223a3737367d0a7b226f223a3737377d0a7b226f223a3737387d0a7b226f223a3737397d0a7b226f223a3738307d0a7b226f223a3738317d0a7b226f223a3738327d0a7b226f223a3738337d0a7b226f223a3738347d0a7b226f223a3738357d0a7b226f223a3738367d0a7b226f223a3738377d0a7b226f223a3738387d0a7b226f223a3738397d0a7b226f223a3739307d0a7b226f223a3739317d0a7b226f223a3739327d0a7b226f223a3739337d0a7b226f223a3739347d0a7b226f223a3739357d0a7b226f223a3739367d0a7b226f223a3739377d0a7b226f223a3739387d0a7b226f223a3739397d0a7b226f223a3830307d0a7b226f223a3830317d0a7b226f223a3830327d0a7b226f223a3830337d0a7b226f223a3830347d0a7b226f223a3830357d0a7b226f223a3830367d0a7b226f223a3830377d0a7b226f223a3830387d0a7b226f223a3830397d0a7b226f223a3831307d0a7b226f223a3831317d0a7b226f223a3831327d0a7b226f223a3831337d0a7b226f223a3831347d0a7b226f223a3831357d0a7b226f223a3831367d0a7b226f223a3831377d0a7b226f223a3831387d0a7b226f223a3831397d0a7b226f223a3832307d0a7b226f223a3832317d0a7b226f223a3832327d0a7b226f223a3832337d0a7b226f223a3832347d0a7b226f223a3832357d0a7b226f223a3832367d0a7b226f223a3832377d0a7b226f223a3832387d0a7b226f223a3832397d0a7b226f223a3833307d0a7b226f223a3833317d0a7b226f223a3833327d0a7b226f223a3833337d0a7b226f223a3833347d0a7b226f223a3833357d0a7b226f223a3833367d0a7b226f223a3833377d0a7b226f223a3833387d0a7b226f223a3833397d0a7b226f223a3834307d0a7b226f223a3834317d0a7b226f223a3834327d0a7b226f223a3834337d0a7b226f223a3834347d0a7b226f223a3834357d0a7b226f223a3834367d0a7b226f223a3834377d0a7b226f223a3834387d0a7b226f223a3834397d0a7b226f223a3835307d0a7b226f223a3835317d0a7b226f223a3835327d0a7b226f223a3835337d0a7b226f223a3835347d0a7b226f223a3835357d0a7b226f223a3835367d0a7b226f223a3835377d0a7b226f223a3835387d0a7b226f223a3835397d0a7b226f223a3836307d0a7b226f223a3836317d0a7b226f223a3836327d0a7b226f223a3836337d0a7b226f223a3836347d0a7b226f223a3836357d0a7b226f223a3836367d0a7b226f223a3836377d0a7b226f223a3836387d0a7b226f223a3836397d0a7b226f223a3837307d0a7b226f223a3837317d0a7b226f223a3837327d0a7b226f223a3837337d0a7b226f223a3837347d0a7b226f223a3837357d0a7b226f223a3837367d0a7b226f223a3837377d0a7b226f223a3837387d0a7b226f223a3837397d0a7b226f223a3838307d0a7b226f223a3838317d0a7b226f223a3838327d0a7b226f223a3838337d0a7b226f223a3838347d0a7b226f223a3838357d0a7b226f223a3838367d0a7b226f223a3838377d0a7b226f223a3838387d0a7b226f223a3838397d0a7b226f223a3839307d0a7b226f223a3839317d0a7b226f223a3839327d0a7b226f223a3839337d0a7b226f223a3839347d0a7b226f223a3839357d0a7b226f223a3839367d0a7b226f223a3839377d0a7b226f223a3839387d0a7b226f223a3839397d0a7b226f223a3930307d0a7b226f223a3930317d0a7b226f223a3930327d0a7b226f223a3930337d0a7b226f223a3930347d0a7b226f223a3930357d0a7b226f223a3930367d0a7b226f223a3930377d0a7b226f223a3930387d0a7b226f223a3930397d0a7b226f223a3931307d0a7b226f223a3931317d0a7b226f223a3931327d0a7b226f223a3931337d0a7b226f223a3931347d0a7b226f223a3931357d0a7b226f223a3931367d0a7b226f223a3931377d0a7b226f223a3931387d0a7b226f223a3931397d0a7b226f223a3932307d0a7b226f223a3932317d0a7b226f223a3932327d0a7b226f223a3932337d0a7b226f223a3932347d0a7b226f223a3932357d0a7b226f223a3932367d0a7b226f223a3932377d0a7b226f223a3932387d0a7b226f223a3932397d0a7b226f223a3933307d0a7b226f223a3933317d0a7b226f223a3933327d0a7b226f223a3933337d0a7b226f223a3933347d0a7b226f223a3933357d0a7b226f223a3933367d0a7b226f223a3933377d0a7b226f223a3933387d0a7b226f223a3933397d0a7b226f223a3934307d0a7b226f223a3934317d0a7b226f223a3934327d0a7b226f223a3934337d0a7b226f223a3934347d0a7b226f223a3934357d0a7b226f223a3934367d0a7b226f223a3934377d0a7b226f223a3934387d0a7b226f223a3934397d0a7b226f223a3935307d0a7b226f223a3935317d0a7b226f223a3935327d0a7b226f223a3935337d0a7b226f223a3935347d0a7b226f223a3935357d0a7b226f223a3935367d0a7b226f223a3935377d0a7b226f223a3935387d0a7b226f223a3935397d0a7b226f223a3936307d0a7b226f223a3936317d0a7b226f223a3936327d0a7b226f223a3936337d0a7b226f223a3936347d0a7b226f223a3936357d0a7b226f223a3936367d0a7b226f223a3936377d0a7b226f223a3936387d0a7b226f223a3936397d0a7b226f223a3937307d0a7b226f223a3937317d0a7b226f223a3937327d0a7b226f223a3937337d0a7b226f223a3937347d0a7b226f223a3937357d0a7b226f223a3937367d0a7b226f223a3937377d0a7b226f223a3937387d0a7b226f223a3937397d0a7b226f223a3938307d0a7b226f223a3938317d0a7b226f223a3938327d0a7b226f223a3938337d0a7b226f223a3938347d0a7b226f223a3938357d0a7b226f223a3938367d0a7b226f223a3938377d0a7b226f223a3938387d0a7b226f223a3938397d0a7b226f223a3939307d0a7b226f223a3939317d0a7b226f223a3939327d0a7b226f223a3939337d0a7b226f223a3939347d0a7b226f223a3939357d0a7b226f223a3939367d0a7b226f223a3939377d0a7b226f223a3939387d0a7b226f223a3939397d0a7b226f223a313030307d0a", - "obligation_id": "robl_interop", - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277" - }, - { - "account_id": "acct_interop", - "body_hex": "7b2261223a322c22636f6e74726f6c223a225c75303030375c6e5c225c5c222c226e616d65223a22636166c3a920f09f988020e697a5e69cace8aa9e222c22736570223a22e280a8222c227a223a317d0a7b22666c6f6174223a302e33303030303030303030303030303030342c226d61785f696e74223a393030373139393235343734303939312c226e223a302c226e6567223a2d31322e352c226e6573746564223a7b2261223a5b5d2c227a223a5b312c7b2261223a747275652c2262223a6e756c6c7d5d7d2c2274696e79223a31652d377d0a", - "obligation_id": "robl_interop", - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - }, - { - "account_id": "acct_interop", - "body_hex": "7b2264617465223a22323032362d31302d3031222c22696d7072657373696f6e73223a313230302c226d656469615f6275795f6964223a226d625f31222c227061636b6167655f6964223a22706b675f31222c227370656e64223a2234322e3137227d0a", - "obligation_id": "robl_interop", - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } + {"account_id":"acct_interop","body_hex":"7b226f223a307d0a7b226f223a317d0a7b226f223a327d0a7b226f223a337d0a7b226f223a347d0a7b226f223a357d0a7b226f223a367d0a7b226f223a377d0a7b226f223a387d0a7b226f223a397d0a7b226f223a31307d0a7b226f223a31317d0a7b226f223a31327d0a7b226f223a31337d0a7b226f223a31347d0a7b226f223a31357d0a7b226f223a31367d0a7b226f223a31377d0a7b226f223a31387d0a7b226f223a31397d0a7b226f223a32307d0a7b226f223a32317d0a7b226f223a32327d0a7b226f223a32337d0a7b226f223a32347d0a7b226f223a32357d0a7b226f223a32367d0a7b226f223a32377d0a7b226f223a32387d0a7b226f223a32397d0a7b226f223a33307d0a7b226f223a33317d0a7b226f223a33327d0a7b226f223a33337d0a7b226f223a33347d0a7b226f223a33357d0a7b226f223a33367d0a7b226f223a33377d0a7b226f223a33387d0a7b226f223a33397d0a7b226f223a34307d0a7b226f223a34317d0a7b226f223a34327d0a7b226f223a34337d0a7b226f223a34347d0a7b226f223a34357d0a7b226f223a34367d0a7b226f223a34377d0a7b226f223a34387d0a7b226f223a34397d0a7b226f223a35307d0a7b226f223a35317d0a7b226f223a35327d0a7b226f223a35337d0a7b226f223a35347d0a7b226f223a35357d0a7b226f223a35367d0a7b226f223a35377d0a7b226f223a35387d0a7b226f223a35397d0a7b226f223a36307d0a7b226f223a36317d0a7b226f223a36327d0a7b226f223a36337d0a7b226f223a36347d0a7b226f223a36357d0a7b226f223a36367d0a7b226f223a36377d0a7b226f223a36387d0a7b226f223a36397d0a7b226f223a37307d0a7b226f223a37317d0a7b226f223a37327d0a7b226f223a37337d0a7b226f223a37347d0a7b226f223a37357d0a7b226f223a37367d0a7b226f223a37377d0a7b226f223a37387d0a7b226f223a37397d0a7b226f223a38307d0a7b226f223a38317d0a7b226f223a38327d0a7b226f223a38337d0a7b226f223a38347d0a7b226f223a38357d0a7b226f223a38367d0a7b226f223a38377d0a7b226f223a38387d0a7b226f223a38397d0a7b226f223a39307d0a7b226f223a39317d0a7b226f223a39327d0a7b226f223a39337d0a7b226f223a39347d0a7b226f223a39357d0a7b226f223a39367d0a7b226f223a39377d0a7b226f223a39387d0a7b226f223a39397d0a7b226f223a3130307d0a7b226f223a3130317d0a7b226f223a3130327d0a7b226f223a3130337d0a7b226f223a3130347d0a7b226f223a3130357d0a7b226f223a3130367d0a7b226f223a3130377d0a7b226f223a3130387d0a7b226f223a3130397d0a7b226f223a3131307d0a7b226f223a3131317d0a7b226f223a3131327d0a7b226f223a3131337d0a7b226f223a3131347d0a7b226f223a3131357d0a7b226f223a3131367d0a7b226f223a3131377d0a7b226f223a3131387d0a7b226f223a3131397d0a7b226f223a3132307d0a7b226f223a3132317d0a7b226f223a3132327d0a7b226f223a3132337d0a7b226f223a3132347d0a7b226f223a3132357d0a7b226f223a3132367d0a7b226f223a3132377d0a7b226f223a3132387d0a7b226f223a3132397d0a7b226f223a3133307d0a7b226f223a3133317d0a7b226f223a3133327d0a7b226f223a3133337d0a7b226f223a3133347d0a7b226f223a3133357d0a7b226f223a3133367d0a7b226f223a3133377d0a7b226f223a3133387d0a7b226f223a3133397d0a7b226f223a3134307d0a7b226f223a3134317d0a7b226f223a3134327d0a7b226f223a3134337d0a7b226f223a3134347d0a7b226f223a3134357d0a7b226f223a3134367d0a7b226f223a3134377d0a7b226f223a3134387d0a7b226f223a3134397d0a7b226f223a3135307d0a7b226f223a3135317d0a7b226f223a3135327d0a7b226f223a3135337d0a7b226f223a3135347d0a7b226f223a3135357d0a7b226f223a3135367d0a7b226f223a3135377d0a7b226f223a3135387d0a7b226f223a3135397d0a7b226f223a3136307d0a7b226f223a3136317d0a7b226f223a3136327d0a7b226f223a3136337d0a7b226f223a3136347d0a7b226f223a3136357d0a7b226f223a3136367d0a7b226f223a3136377d0a7b226f223a3136387d0a7b226f223a3136397d0a7b226f223a3137307d0a7b226f223a3137317d0a7b226f223a3137327d0a7b226f223a3137337d0a7b226f223a3137347d0a7b226f223a3137357d0a7b226f223a3137367d0a7b226f223a3137377d0a7b226f223a3137387d0a7b226f223a3137397d0a7b226f223a3138307d0a7b226f223a3138317d0a7b226f223a3138327d0a7b226f223a3138337d0a7b226f223a3138347d0a7b226f223a3138357d0a7b226f223a3138367d0a7b226f223a3138377d0a7b226f223a3138387d0a7b226f223a3138397d0a7b226f223a3139307d0a7b226f223a3139317d0a7b226f223a3139327d0a7b226f223a3139337d0a7b226f223a3139347d0a7b226f223a3139357d0a7b226f223a3139367d0a7b226f223a3139377d0a7b226f223a3139387d0a7b226f223a3139397d0a7b226f223a3230307d0a7b226f223a3230317d0a7b226f223a3230327d0a7b226f223a3230337d0a7b226f223a3230347d0a7b226f223a3230357d0a7b226f223a3230367d0a7b226f223a3230377d0a7b226f223a3230387d0a7b226f223a3230397d0a7b226f223a3231307d0a7b226f223a3231317d0a7b226f223a3231327d0a7b226f223a3231337d0a7b226f223a3231347d0a7b226f223a3231357d0a7b226f223a3231367d0a7b226f223a3231377d0a7b226f223a3231387d0a7b226f223a3231397d0a7b226f223a3232307d0a7b226f223a3232317d0a7b226f223a3232327d0a7b226f223a3232337d0a7b226f223a3232347d0a7b226f223a3232357d0a7b226f223a3232367d0a7b226f223a3232377d0a7b226f223a3232387d0a7b226f223a3232397d0a7b226f223a3233307d0a7b226f223a3233317d0a7b226f223a3233327d0a7b226f223a3233337d0a7b226f223a3233347d0a7b226f223a3233357d0a7b226f223a3233367d0a7b226f223a3233377d0a7b226f223a3233387d0a7b226f223a3233397d0a7b226f223a3234307d0a7b226f223a3234317d0a7b226f223a3234327d0a7b226f223a3234337d0a7b226f223a3234347d0a7b226f223a3234357d0a7b226f223a3234367d0a7b226f223a3234377d0a7b226f223a3234387d0a7b226f223a3234397d0a7b226f223a3235307d0a7b226f223a3235317d0a7b226f223a3235327d0a7b226f223a3235337d0a7b226f223a3235347d0a7b226f223a3235357d0a7b226f223a3235367d0a7b226f223a3235377d0a7b226f223a3235387d0a7b226f223a3235397d0a7b226f223a3236307d0a7b226f223a3236317d0a7b226f223a3236327d0a7b226f223a3236337d0a7b226f223a3236347d0a7b226f223a3236357d0a7b226f223a3236367d0a7b226f223a3236377d0a7b226f223a3236387d0a7b226f223a3236397d0a7b226f223a3237307d0a7b226f223a3237317d0a7b226f223a3237327d0a7b226f223a3237337d0a7b226f223a3237347d0a7b226f223a3237357d0a7b226f223a3237367d0a7b226f223a3237377d0a7b226f223a3237387d0a7b226f223a3237397d0a7b226f223a3238307d0a7b226f223a3238317d0a7b226f223a3238327d0a7b226f223a3238337d0a7b226f223a3238347d0a7b226f223a3238357d0a7b226f223a3238367d0a7b226f223a3238377d0a7b226f223a3238387d0a7b226f223a3238397d0a7b226f223a3239307d0a7b226f223a3239317d0a7b226f223a3239327d0a7b226f223a3239337d0a7b226f223a3239347d0a7b226f223a3239357d0a7b226f223a3239367d0a7b226f223a3239377d0a7b226f223a3239387d0a7b226f223a3239397d0a7b226f223a3330307d0a7b226f223a3330317d0a7b226f223a3330327d0a7b226f223a3330337d0a7b226f223a3330347d0a7b226f223a3330357d0a7b226f223a3330367d0a7b226f223a3330377d0a7b226f223a3330387d0a7b226f223a3330397d0a7b226f223a3331307d0a7b226f223a3331317d0a7b226f223a3331327d0a7b226f223a3331337d0a7b226f223a3331347d0a7b226f223a3331357d0a7b226f223a3331367d0a7b226f223a3331377d0a7b226f223a3331387d0a7b226f223a3331397d0a7b226f223a3332307d0a7b226f223a3332317d0a7b226f223a3332327d0a7b226f223a3332337d0a7b226f223a3332347d0a7b226f223a3332357d0a7b226f223a3332367d0a7b226f223a3332377d0a7b226f223a3332387d0a7b226f223a3332397d0a7b226f223a3333307d0a7b226f223a3333317d0a7b226f223a3333327d0a7b226f223a3333337d0a7b226f223a3333347d0a7b226f223a3333357d0a7b226f223a3333367d0a7b226f223a3333377d0a7b226f223a3333387d0a7b226f223a3333397d0a7b226f223a3334307d0a7b226f223a3334317d0a7b226f223a3334327d0a7b226f223a3334337d0a7b226f223a3334347d0a7b226f223a3334357d0a7b226f223a3334367d0a7b226f223a3334377d0a7b226f223a3334387d0a7b226f223a3334397d0a7b226f223a3335307d0a7b226f223a3335317d0a7b226f223a3335327d0a7b226f223a3335337d0a7b226f223a3335347d0a7b226f223a3335357d0a7b226f223a3335367d0a7b226f223a3335377d0a7b226f223a3335387d0a7b226f223a3335397d0a7b226f223a3336307d0a7b226f223a3336317d0a7b226f223a3336327d0a7b226f223a3336337d0a7b226f223a3336347d0a7b226f223a3336357d0a7b226f223a3336367d0a7b226f223a3336377d0a7b226f223a3336387d0a7b226f223a3336397d0a7b226f223a3337307d0a7b226f223a3337317d0a7b226f223a3337327d0a7b226f223a3337337d0a7b226f223a3337347d0a7b226f223a3337357d0a7b226f223a3337367d0a7b226f223a3337377d0a7b226f223a3337387d0a7b226f223a3337397d0a7b226f223a3338307d0a7b226f223a3338317d0a7b226f223a3338327d0a7b226f223a3338337d0a7b226f223a3338347d0a7b226f223a3338357d0a7b226f223a3338367d0a7b226f223a3338377d0a7b226f223a3338387d0a7b226f223a3338397d0a7b226f223a3339307d0a7b226f223a3339317d0a7b226f223a3339327d0a7b226f223a3339337d0a7b226f223a3339347d0a7b226f223a3339357d0a7b226f223a3339367d0a7b226f223a3339377d0a7b226f223a3339387d0a7b226f223a3339397d0a7b226f223a3430307d0a7b226f223a3430317d0a7b226f223a3430327d0a7b226f223a3430337d0a7b226f223a3430347d0a7b226f223a3430357d0a7b226f223a3430367d0a7b226f223a3430377d0a7b226f223a3430387d0a7b226f223a3430397d0a7b226f223a3431307d0a7b226f223a3431317d0a7b226f223a3431327d0a7b226f223a3431337d0a7b226f223a3431347d0a7b226f223a3431357d0a7b226f223a3431367d0a7b226f223a3431377d0a7b226f223a3431387d0a7b226f223a3431397d0a7b226f223a3432307d0a7b226f223a3432317d0a7b226f223a3432327d0a7b226f223a3432337d0a7b226f223a3432347d0a7b226f223a3432357d0a7b226f223a3432367d0a7b226f223a3432377d0a7b226f223a3432387d0a7b226f223a3432397d0a7b226f223a3433307d0a7b226f223a3433317d0a7b226f223a3433327d0a7b226f223a3433337d0a7b226f223a3433347d0a7b226f223a3433357d0a7b226f223a3433367d0a7b226f223a3433377d0a7b226f223a3433387d0a7b226f223a3433397d0a7b226f223a3434307d0a7b226f223a3434317d0a7b226f223a3434327d0a7b226f223a3434337d0a7b226f223a3434347d0a7b226f223a3434357d0a7b226f223a3434367d0a7b226f223a3434377d0a7b226f223a3434387d0a7b226f223a3434397d0a7b226f223a3435307d0a7b226f223a3435317d0a7b226f223a3435327d0a7b226f223a3435337d0a7b226f223a3435347d0a7b226f223a3435357d0a7b226f223a3435367d0a7b226f223a3435377d0a7b226f223a3435387d0a7b226f223a3435397d0a7b226f223a3436307d0a7b226f223a3436317d0a7b226f223a3436327d0a7b226f223a3436337d0a7b226f223a3436347d0a7b226f223a3436357d0a7b226f223a3436367d0a7b226f223a3436377d0a7b226f223a3436387d0a7b226f223a3436397d0a7b226f223a3437307d0a7b226f223a3437317d0a7b226f223a3437327d0a7b226f223a3437337d0a7b226f223a3437347d0a7b226f223a3437357d0a7b226f223a3437367d0a7b226f223a3437377d0a7b226f223a3437387d0a7b226f223a3437397d0a7b226f223a3438307d0a7b226f223a3438317d0a7b226f223a3438327d0a7b226f223a3438337d0a7b226f223a3438347d0a7b226f223a3438357d0a7b226f223a3438367d0a7b226f223a3438377d0a7b226f223a3438387d0a7b226f223a3438397d0a7b226f223a3439307d0a7b226f223a3439317d0a7b226f223a3439327d0a7b226f223a3439337d0a7b226f223a3439347d0a7b226f223a3439357d0a7b226f223a3439367d0a7b226f223a3439377d0a7b226f223a3439387d0a7b226f223a3439397d0a7b226f223a3530307d0a7b226f223a3530317d0a7b226f223a3530327d0a7b226f223a3530337d0a7b226f223a3530347d0a7b226f223a3530357d0a7b226f223a3530367d0a7b226f223a3530377d0a7b226f223a3530387d0a7b226f223a3530397d0a7b226f223a3531307d0a7b226f223a3531317d0a7b226f223a3531327d0a7b226f223a3531337d0a7b226f223a3531347d0a7b226f223a3531357d0a7b226f223a3531367d0a7b226f223a3531377d0a7b226f223a3531387d0a7b226f223a3531397d0a7b226f223a3532307d0a7b226f223a3532317d0a7b226f223a3532327d0a7b226f223a3532337d0a7b226f223a3532347d0a7b226f223a3532357d0a7b226f223a3532367d0a7b226f223a3532377d0a7b226f223a3532387d0a7b226f223a3532397d0a7b226f223a3533307d0a7b226f223a3533317d0a7b226f223a3533327d0a7b226f223a3533337d0a7b226f223a3533347d0a7b226f223a3533357d0a7b226f223a3533367d0a7b226f223a3533377d0a7b226f223a3533387d0a7b226f223a3533397d0a7b226f223a3534307d0a7b226f223a3534317d0a7b226f223a3534327d0a7b226f223a3534337d0a7b226f223a3534347d0a7b226f223a3534357d0a7b226f223a3534367d0a7b226f223a3534377d0a7b226f223a3534387d0a7b226f223a3534397d0a7b226f223a3535307d0a7b226f223a3535317d0a7b226f223a3535327d0a7b226f223a3535337d0a7b226f223a3535347d0a7b226f223a3535357d0a7b226f223a3535367d0a7b226f223a3535377d0a7b226f223a3535387d0a7b226f223a3535397d0a7b226f223a3536307d0a7b226f223a3536317d0a7b226f223a3536327d0a7b226f223a3536337d0a7b226f223a3536347d0a7b226f223a3536357d0a7b226f223a3536367d0a7b226f223a3536377d0a7b226f223a3536387d0a7b226f223a3536397d0a7b226f223a3537307d0a7b226f223a3537317d0a7b226f223a3537327d0a7b226f223a3537337d0a7b226f223a3537347d0a7b226f223a3537357d0a7b226f223a3537367d0a7b226f223a3537377d0a7b226f223a3537387d0a7b226f223a3537397d0a7b226f223a3538307d0a7b226f223a3538317d0a7b226f223a3538327d0a7b226f223a3538337d0a7b226f223a3538347d0a7b226f223a3538357d0a7b226f223a3538367d0a7b226f223a3538377d0a7b226f223a3538387d0a7b226f223a3538397d0a7b226f223a3539307d0a7b226f223a3539317d0a7b226f223a3539327d0a7b226f223a3539337d0a7b226f223a3539347d0a7b226f223a3539357d0a7b226f223a3539367d0a7b226f223a3539377d0a7b226f223a3539387d0a7b226f223a3539397d0a7b226f223a3630307d0a7b226f223a3630317d0a7b226f223a3630327d0a7b226f223a3630337d0a7b226f223a3630347d0a7b226f223a3630357d0a7b226f223a3630367d0a7b226f223a3630377d0a7b226f223a3630387d0a7b226f223a3630397d0a7b226f223a3631307d0a7b226f223a3631317d0a7b226f223a3631327d0a7b226f223a3631337d0a7b226f223a3631347d0a7b226f223a3631357d0a7b226f223a3631367d0a7b226f223a3631377d0a7b226f223a3631387d0a7b226f223a3631397d0a7b226f223a3632307d0a7b226f223a3632317d0a7b226f223a3632327d0a7b226f223a3632337d0a7b226f223a3632347d0a7b226f223a3632357d0a7b226f223a3632367d0a7b226f223a3632377d0a7b226f223a3632387d0a7b226f223a3632397d0a7b226f223a3633307d0a7b226f223a3633317d0a7b226f223a3633327d0a7b226f223a3633337d0a7b226f223a3633347d0a7b226f223a3633357d0a7b226f223a3633367d0a7b226f223a3633377d0a7b226f223a3633387d0a7b226f223a3633397d0a7b226f223a3634307d0a7b226f223a3634317d0a7b226f223a3634327d0a7b226f223a3634337d0a7b226f223a3634347d0a7b226f223a3634357d0a7b226f223a3634367d0a7b226f223a3634377d0a7b226f223a3634387d0a7b226f223a3634397d0a7b226f223a3635307d0a7b226f223a3635317d0a7b226f223a3635327d0a7b226f223a3635337d0a7b226f223a3635347d0a7b226f223a3635357d0a7b226f223a3635367d0a7b226f223a3635377d0a7b226f223a3635387d0a7b226f223a3635397d0a7b226f223a3636307d0a7b226f223a3636317d0a7b226f223a3636327d0a7b226f223a3636337d0a7b226f223a3636347d0a7b226f223a3636357d0a7b226f223a3636367d0a7b226f223a3636377d0a7b226f223a3636387d0a7b226f223a3636397d0a7b226f223a3637307d0a7b226f223a3637317d0a7b226f223a3637327d0a7b226f223a3637337d0a7b226f223a3637347d0a7b226f223a3637357d0a7b226f223a3637367d0a7b226f223a3637377d0a7b226f223a3637387d0a7b226f223a3637397d0a7b226f223a3638307d0a7b226f223a3638317d0a7b226f223a3638327d0a7b226f223a3638337d0a7b226f223a3638347d0a7b226f223a3638357d0a7b226f223a3638367d0a7b226f223a3638377d0a7b226f223a3638387d0a7b226f223a3638397d0a7b226f223a3639307d0a7b226f223a3639317d0a7b226f223a3639327d0a7b226f223a3639337d0a7b226f223a3639347d0a7b226f223a3639357d0a7b226f223a3639367d0a7b226f223a3639377d0a7b226f223a3639387d0a7b226f223a3639397d0a7b226f223a3730307d0a7b226f223a3730317d0a7b226f223a3730327d0a7b226f223a3730337d0a7b226f223a3730347d0a7b226f223a3730357d0a7b226f223a3730367d0a7b226f223a3730377d0a7b226f223a3730387d0a7b226f223a3730397d0a7b226f223a3731307d0a7b226f223a3731317d0a7b226f223a3731327d0a7b226f223a3731337d0a7b226f223a3731347d0a7b226f223a3731357d0a7b226f223a3731367d0a7b226f223a3731377d0a7b226f223a3731387d0a7b226f223a3731397d0a7b226f223a3732307d0a7b226f223a3732317d0a7b226f223a3732327d0a7b226f223a3732337d0a7b226f223a3732347d0a7b226f223a3732357d0a7b226f223a3732367d0a7b226f223a3732377d0a7b226f223a3732387d0a7b226f223a3732397d0a7b226f223a3733307d0a7b226f223a3733317d0a7b226f223a3733327d0a7b226f223a3733337d0a7b226f223a3733347d0a7b226f223a3733357d0a7b226f223a3733367d0a7b226f223a3733377d0a7b226f223a3733387d0a7b226f223a3733397d0a7b226f223a3734307d0a7b226f223a3734317d0a7b226f223a3734327d0a7b226f223a3734337d0a7b226f223a3734347d0a7b226f223a3734357d0a7b226f223a3734367d0a7b226f223a3734377d0a7b226f223a3734387d0a7b226f223a3734397d0a7b226f223a3735307d0a7b226f223a3735317d0a7b226f223a3735327d0a7b226f223a3735337d0a7b226f223a3735347d0a7b226f223a3735357d0a7b226f223a3735367d0a7b226f223a3735377d0a7b226f223a3735387d0a7b226f223a3735397d0a7b226f223a3736307d0a7b226f223a3736317d0a7b226f223a3736327d0a7b226f223a3736337d0a7b226f223a3736347d0a7b226f223a3736357d0a7b226f223a3736367d0a7b226f223a3736377d0a7b226f223a3736387d0a7b226f223a3736397d0a7b226f223a3737307d0a7b226f223a3737317d0a7b226f223a3737327d0a7b226f223a3737337d0a7b226f223a3737347d0a7b226f223a3737357d0a7b226f223a3737367d0a7b226f223a3737377d0a7b226f223a3737387d0a7b226f223a3737397d0a7b226f223a3738307d0a7b226f223a3738317d0a7b226f223a3738327d0a7b226f223a3738337d0a7b226f223a3738347d0a7b226f223a3738357d0a7b226f223a3738367d0a7b226f223a3738377d0a7b226f223a3738387d0a7b226f223a3738397d0a7b226f223a3739307d0a7b226f223a3739317d0a7b226f223a3739327d0a7b226f223a3739337d0a7b226f223a3739347d0a7b226f223a3739357d0a7b226f223a3739367d0a7b226f223a3739377d0a7b226f223a3739387d0a7b226f223a3739397d0a7b226f223a3830307d0a7b226f223a3830317d0a7b226f223a3830327d0a7b226f223a3830337d0a7b226f223a3830347d0a7b226f223a3830357d0a7b226f223a3830367d0a7b226f223a3830377d0a7b226f223a3830387d0a7b226f223a3830397d0a7b226f223a3831307d0a7b226f223a3831317d0a7b226f223a3831327d0a7b226f223a3831337d0a7b226f223a3831347d0a7b226f223a3831357d0a7b226f223a3831367d0a7b226f223a3831377d0a7b226f223a3831387d0a7b226f223a3831397d0a7b226f223a3832307d0a7b226f223a3832317d0a7b226f223a3832327d0a7b226f223a3832337d0a7b226f223a3832347d0a7b226f223a3832357d0a7b226f223a3832367d0a7b226f223a3832377d0a7b226f223a3832387d0a7b226f223a3832397d0a7b226f223a3833307d0a7b226f223a3833317d0a7b226f223a3833327d0a7b226f223a3833337d0a7b226f223a3833347d0a7b226f223a3833357d0a7b226f223a3833367d0a7b226f223a3833377d0a7b226f223a3833387d0a7b226f223a3833397d0a7b226f223a3834307d0a7b226f223a3834317d0a7b226f223a3834327d0a7b226f223a3834337d0a7b226f223a3834347d0a7b226f223a3834357d0a7b226f223a3834367d0a7b226f223a3834377d0a7b226f223a3834387d0a7b226f223a3834397d0a7b226f223a3835307d0a7b226f223a3835317d0a7b226f223a3835327d0a7b226f223a3835337d0a7b226f223a3835347d0a7b226f223a3835357d0a7b226f223a3835367d0a7b226f223a3835377d0a7b226f223a3835387d0a7b226f223a3835397d0a7b226f223a3836307d0a7b226f223a3836317d0a7b226f223a3836327d0a7b226f223a3836337d0a7b226f223a3836347d0a7b226f223a3836357d0a7b226f223a3836367d0a7b226f223a3836377d0a7b226f223a3836387d0a7b226f223a3836397d0a7b226f223a3837307d0a7b226f223a3837317d0a7b226f223a3837327d0a7b226f223a3837337d0a7b226f223a3837347d0a7b226f223a3837357d0a7b226f223a3837367d0a7b226f223a3837377d0a7b226f223a3837387d0a7b226f223a3837397d0a7b226f223a3838307d0a7b226f223a3838317d0a7b226f223a3838327d0a7b226f223a3838337d0a7b226f223a3838347d0a7b226f223a3838357d0a7b226f223a3838367d0a7b226f223a3838377d0a7b226f223a3838387d0a7b226f223a3838397d0a7b226f223a3839307d0a7b226f223a3839317d0a7b226f223a3839327d0a7b226f223a3839337d0a7b226f223a3839347d0a7b226f223a3839357d0a7b226f223a3839367d0a7b226f223a3839377d0a7b226f223a3839387d0a7b226f223a3839397d0a7b226f223a3930307d0a7b226f223a3930317d0a7b226f223a3930327d0a7b226f223a3930337d0a7b226f223a3930347d0a7b226f223a3930357d0a7b226f223a3930367d0a7b226f223a3930377d0a7b226f223a3930387d0a7b226f223a3930397d0a7b226f223a3931307d0a7b226f223a3931317d0a7b226f223a3931327d0a7b226f223a3931337d0a7b226f223a3931347d0a7b226f223a3931357d0a7b226f223a3931367d0a7b226f223a3931377d0a7b226f223a3931387d0a7b226f223a3931397d0a7b226f223a3932307d0a7b226f223a3932317d0a7b226f223a3932327d0a7b226f223a3932337d0a7b226f223a3932347d0a7b226f223a3932357d0a7b226f223a3932367d0a7b226f223a3932377d0a7b226f223a3932387d0a7b226f223a3932397d0a7b226f223a3933307d0a7b226f223a3933317d0a7b226f223a3933327d0a7b226f223a3933337d0a7b226f223a3933347d0a7b226f223a3933357d0a7b226f223a3933367d0a7b226f223a3933377d0a7b226f223a3933387d0a7b226f223a3933397d0a7b226f223a3934307d0a7b226f223a3934317d0a7b226f223a3934327d0a7b226f223a3934337d0a7b226f223a3934347d0a7b226f223a3934357d0a7b226f223a3934367d0a7b226f223a3934377d0a7b226f223a3934387d0a7b226f223a3934397d0a7b226f223a3935307d0a7b226f223a3935317d0a7b226f223a3935327d0a7b226f223a3935337d0a7b226f223a3935347d0a7b226f223a3935357d0a7b226f223a3935367d0a7b226f223a3935377d0a7b226f223a3935387d0a7b226f223a3935397d0a7b226f223a3936307d0a7b226f223a3936317d0a7b226f223a3936327d0a7b226f223a3936337d0a7b226f223a3936347d0a7b226f223a3936357d0a7b226f223a3936367d0a7b226f223a3936377d0a7b226f223a3936387d0a7b226f223a3936397d0a7b226f223a3937307d0a7b226f223a3937317d0a7b226f223a3937327d0a7b226f223a3937337d0a7b226f223a3937347d0a7b226f223a3937357d0a7b226f223a3937367d0a7b226f223a3937377d0a7b226f223a3937387d0a7b226f223a3937397d0a7b226f223a3938307d0a7b226f223a3938317d0a7b226f223a3938327d0a7b226f223a3938337d0a7b226f223a3938347d0a7b226f223a3938357d0a7b226f223a3938367d0a7b226f223a3938377d0a7b226f223a3938387d0a7b226f223a3938397d0a7b226f223a3939307d0a7b226f223a3939317d0a7b226f223a3939327d0a7b226f223a3939337d0a7b226f223a3939347d0a7b226f223a3939357d0a7b226f223a3939367d0a7b226f223a3939377d0a7b226f223a3939387d0a7b226f223a3939397d0a7b226f223a313030307d0a","obligation_id":"robl_interop","sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277"}, + {"account_id":"acct_interop","body_hex":"7b2261223a322c22636f6e74726f6c223a225c75303030375c6e5c225c5c222c226e616d65223a22636166c3a920f09f988020e697a5e69cace8aa9e222c22736570223a22e280a8222c227a223a317d0a7b22666c6f6174223a302e33303030303030303030303030303030342c226d61785f696e74223a393030373139393235343734303939312c226e223a302c226e6567223a2d31322e352c226e6573746564223a7b2261223a5b5d2c227a223a5b312c7b2261223a747275652c2262223a6e756c6c7d5d7d2c2274696e79223a31652d377d0a","obligation_id":"robl_interop","sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}, + {"account_id":"acct_interop","body_hex":"7b2264617465223a22323032362d31302d3031222c22696d7072657373696f6e73223a313230302c226d656469615f6275795f6964223a226d625f31222c227061636b6167655f6964223a22706b675f31222c227370656e64223a2234322e3137227d0a","obligation_id":"robl_interop","sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"} ], "objects": { - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/261f3a1eb893ee2ab06dcba59c4c226e643150f9151fd3136aa79daac7c2a33a/49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe/000000.jsonl.gz": "H4sIAAAAAAAAA03azbEcJxSA0b3CUAQD3B9wUg5ApdxdrnocZtW777rkdwYa+s/vf3//8/n768//z/HznD/P9fOMn2f+POvn2T/P/fM8tyN4i+Mmx22OGx23Om523O644XHL85an/9Zbnrc8b3ne8rzlecvzluctr1tet7z8M9zyuuV1y+uW1y2vW163HLcctxy3HP6FbzluOW45bjluOW45bzlvOW85bzn9z7vlvOW85bzlvOW65brluuW65brl8ndxy3XLdct1y33Lfct9y33Lfct9y+1P7pb7lvuW9y3vW963vG953/K+5X3L21/zLe9bPrd8bvnc8rnlc8vnls8tn1s+oDwpqHxY+cDyoeWDy4eXDzAfYj7IfMz44mjGA/lEPpLP5EP5VD6WXA4wx3zmzWBzwDnoHHgOPgegg9CB6GB0rPfDYgamg9MB6iB1oDpYHbAOWgeuI96vlxnEDmQHswPaQe3AdnA7wB3kjnw/kWbAO+gd+A5+B8CD4IHwYHhAPOr9DpvB8QB5kDxQHiwPmAfNA+fB8+j3Y28G0oPpAfWgemA9uB5gD7IH2mO/FcUMugfeg+8B+CB8ID4YH5APysd5y9ZbtyxcnE/OJ+eT88n55HxyPjmfnM/xFkczOJ+cT84n55PzyfnkfL719y3AXyuwGW8NfovwW4XfMvzW4bcQcz45n5zP9ZZ5MzifnE/OJ+eT88n55HxyPjmf8fYSZnA+OZ+cT84n55PzyfnkfHI+821YzOB8cj45n5xPzifnk/PJ+eR81tsVmcH55HxyPjmfnE/OJ+eT88n57Lf1MoPzyfnkfHI+OZ+cT84n55Pzud/+zgzOJ+eT88n55HxyPjmfnE/O53mbyLeLtI3kfHG+OF+cL84X54vzxfnifI23VTWD88X54nxxvjhfnC/OF+eL8zXfftgMzhfni/PF+eJ8cb7ejvttud+e+2vTbcbbdr9999t4v53323pzvjhfnC/OV7ydvRmcL84X54vzxfnifHG+OF+cr3yvD2ZwvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDiP9479XrLfW/Z7zf56zzbjvWm/V+33rv1etjkPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dzfqdo7Vnvnau9g7Z2sfR2tmfEO197p2jte4zw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+S8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L83rn6O8g/Z2kv6P0d5b+DtO/TtPNeOfp70Cd8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDhvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/O+92cvauzd3f2Ls/e7dm7Pnv3Z18XaGa8KzTOm/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+c73dX/i7L3235uy5/9+XvwvzdmL8r8687czM435xvzjfnm/PN+eZ8c74535xvzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/n530d8z6Ped/HvA9k3hcy7xOZ943M+0jmfSXz9ZnM13cyn7+//gMn4sd0rSYAAA==", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/705459c9b7547dedbf7cabf978d9ea55759f2358d3e062257529da157ffbea9e/628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095/000000.jsonl.gz": "H4sIAAAAAAAAA1WOwQ7BQBRF975C7vqRN1Vp5v2KERk1RFJTYZqgadK/YCGsJIi/8CcWYukThJ27Omd1bgkLiQhp7sMizyAwBTMnxhsYA4K3MwdBasf3S/N93NXN5/b03N9e1wMISzeH4FGfQdhAVNUoMc5yGyDc7vD/YsLMrgZTHyCaOVFaR904iVlrRfAQJng3gbRU1O5+eRncCFJ+P/b6v0JP0U/DonCEIcQXWVb1K0KY+jVEuVZSNT7kPqqA1gAAAA==", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/7f7820795b5d933eebf3dce8962b626072b809c407fed39552dad3cf51cc2b1a/5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187/000000.jsonl.gz": "H4sIAAAAAAAAAyXKQQqAIBBG4X3H+NcZMxIFXkYsB5GwJGsR0d3DWr6Pd8O7Q2CgSQ+KSRGjRUx5l1LithYY1kQtkvjo7HReNnoYpMnWMbt5cUF+y0v4sGRZa/e64xFP8wJBEPOlZAAAAA==", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/c5866668a6a52204b0b829245568ead9e74a674d056c8ae7949d8f140f50ee40/b5367a164b48f0c0e31537bda570038687db83416988840fc2bacd96300ad3e8/000000.jsonl.gz": "H4sIAAAAAAAAAyXKQQqAIBBG4X3H+NcZMxIFXkYsB5GwJGsR0d3DWr6Pd8O7Q2CgSQ+KSRGjRUx5l1LithYY1kQtkvjo7HReNnoYpMnWMbt5cUF+y0v4sGRZa/e64xFP8wJBEPOlZAAAAA==", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/e0d86fc58a94fda3c36f8454d332c4988cd985c83f5ce0cf7d90fc57ce9f7de7/d1ca264c9c51c4f7146f5f9b791a05fffee44cc55eca090dcc73ec826522874f/000000.jsonl.gz": "H4sIAAAAAAAAA03azbEcJxSA0b3CUAQD3B9wUg5ApdxdrnocZtW777rkdwYa+s/vf3//8/n768//z/HznD/P9fOMn2f+POvn2T/P/fM8tyN4i+Mmx22OGx23Om523O644XHL85an/9Zbnrc8b3ne8rzlecvzluctr1tet7z8M9zyuuV1y+uW1y2vW163HLcctxy3HP6FbzluOW45bjluOW45bzlvOW85bzn9z7vlvOW85bzlvOW65brluuW65brl8ndxy3XLdct1y33Lfct9y33Lfct9y+1P7pb7lvuW9y3vW963vG953/K+5X3L21/zLe9bPrd8bvnc8rnlc8vnls8tn1s+oDwpqHxY+cDyoeWDy4eXDzAfYj7IfMz44mjGA/lEPpLP5EP5VD6WXA4wx3zmzWBzwDnoHHgOPgegg9CB6GB0rPfDYgamg9MB6iB1oDpYHbAOWgeuI96vlxnEDmQHswPaQe3AdnA7wB3kjnw/kWbAO+gd+A5+B8CD4IHwYHhAPOr9DpvB8QB5kDxQHiwPmAfNA+fB8+j3Y28G0oPpAfWgemA9uB5gD7IH2mO/FcUMugfeg+8B+CB8ID4YH5APysd5y9ZbtyxcnE/OJ+eT88n55HxyPjmfnM/xFkczOJ+cT84n55PzyfnkfL719y3AXyuwGW8NfovwW4XfMvzW4bcQcz45n5zP9ZZ5MzifnE/OJ+eT88n55HxyPjmf8fYSZnA+OZ+cT84n55PzyfnkfHI+821YzOB8cj45n5xPzifnk/PJ+eR81tsVmcH55HxyPjmfnE/OJ+eT88n57Lf1MoPzyfnkfHI+OZ+cT84n55Pzud/+zgzOJ+eT88n55HxyPjmfnE/O53mbyLeLtI3kfHG+OF+cL84X54vzxfnifI23VTWD88X54nxxvjhfnC/OF+eL8zXfftgMzhfni/PF+eJ8cb7ejvttud+e+2vTbcbbdr9999t4v53323pzvjhfnC/OV7ydvRmcL84X54vzxfnifHG+OF+cr3yvD2ZwvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDiP9479XrLfW/Z7zf56zzbjvWm/V+33rv1etjkPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dzfqdo7Vnvnau9g7Z2sfR2tmfEO197p2jte4zw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+S8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L83rn6O8g/Z2kv6P0d5b+DtO/TtPNeOfp70Cd8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDhvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/O+92cvauzd3f2Ls/e7dm7Pnv3Z18XaGa8KzTOm/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+c73dX/i7L3235uy5/9+XvwvzdmL8r8687czM435xvzjfnm/PN+eZ8c74535xvzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/n530d8z6Ped/HvA9k3hcy7xOZ943M+0jmfSXz9ZnM13cyn7+//gMn4sd0rSYAAA==", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/ff289a3d78dbb114f4d8a549013c3e2aef591b1b1ee0bee597d6f466428bf7dc/20391f076cea7702c50fa4ce949705afca0529fd3de4787ac66dbbafe448b6cd/000000.jsonl.gz": "H4sIAAAAAAAAA1WOwQ7BQBRF975C7vqRN1Vp5v2KERk1RFJTYZqgadK/YCGsJIi/8CcWYukThJ27Omd1bgkLiQhp7sMizyAwBTMnxhsYA4K3MwdBasf3S/N93NXN5/b03N9e1wMISzeH4FGfQdhAVNUoMc5yGyDc7vD/YsLMrgZTHyCaOVFaR904iVlrRfAQJng3gbRU1O5+eRncCFJ+P/b6v0JP0U/DonCEIcQXWVb1K0KY+jVEuVZSNT7kPqqA1gAAAA==" + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/261f3a1eb893ee2ab06dcba59c4c226e643150f9151fd3136aa79daac7c2a33a/49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe/000000.jsonl.gz":"H4sIAAAAAAAAA03azbEcJxSA0b3CUAQD3B9wUg5ApdxdrnocZtW777rkdwYa+s/vf3//8/n768//z/HznD/P9fOMn2f+POvn2T/P/fM8tyN4i+Mmx22OGx23Om523O644XHL85an/9Zbnrc8b3ne8rzlecvzluctr1tet7z8M9zyuuV1y+uW1y2vW163HLcctxy3HP6FbzluOW45bjluOW45bzlvOW85bzn9z7vlvOW85bzlvOW65brluuW65brl8ndxy3XLdct1y33Lfct9y33Lfct9y+1P7pb7lvuW9y3vW963vG953/K+5X3L21/zLe9bPrd8bvnc8rnlc8vnls8tn1s+oDwpqHxY+cDyoeWDy4eXDzAfYj7IfMz44mjGA/lEPpLP5EP5VD6WXA4wx3zmzWBzwDnoHHgOPgegg9CB6GB0rPfDYgamg9MB6iB1oDpYHbAOWgeuI96vlxnEDmQHswPaQe3AdnA7wB3kjnw/kWbAO+gd+A5+B8CD4IHwYHhAPOr9DpvB8QB5kDxQHiwPmAfNA+fB8+j3Y28G0oPpAfWgemA9uB5gD7IH2mO/FcUMugfeg+8B+CB8ID4YH5APysd5y9ZbtyxcnE/OJ+eT88n55HxyPjmfnM/xFkczOJ+cT84n55PzyfnkfL719y3AXyuwGW8NfovwW4XfMvzW4bcQcz45n5zP9ZZ5MzifnE/OJ+eT88n55HxyPjmf8fYSZnA+OZ+cT84n55PzyfnkfHI+821YzOB8cj45n5xPzifnk/PJ+eR81tsVmcH55HxyPjmfnE/OJ+eT88n57Lf1MoPzyfnkfHI+OZ+cT84n55Pzud/+zgzOJ+eT88n55HxyPjmfnE/O53mbyLeLtI3kfHG+OF+cL84X54vzxfnifI23VTWD88X54nxxvjhfnC/OF+eL8zXfftgMzhfni/PF+eJ8cb7ejvttud+e+2vTbcbbdr9999t4v53323pzvjhfnC/OV7ydvRmcL84X54vzxfnifHG+OF+cr3yvD2ZwvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDiP9479XrLfW/Z7zf56zzbjvWm/V+33rv1etjkPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dzfqdo7Vnvnau9g7Z2sfR2tmfEO197p2jte4zw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+S8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L83rn6O8g/Z2kv6P0d5b+DtO/TtPNeOfp70Cd8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDhvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/O+92cvauzd3f2Ls/e7dm7Pnv3Z18XaGa8KzTOm/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+c73dX/i7L3235uy5/9+XvwvzdmL8r8687czM435xvzjfnm/PN+eZ8c74535xvzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/n530d8z6Ped/HvA9k3hcy7xOZ943M+0jmfSXz9ZnM13cyn7+//gMn4sd0rSYAAA==", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/705459c9b7547dedbf7cabf978d9ea55759f2358d3e062257529da157ffbea9e/628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095/000000.jsonl.gz":"H4sIAAAAAAAAA1WOwQ7BQBRF975C7vqRN1Vp5v2KERk1RFJTYZqgadK/YCGsJIi/8CcWYukThJ27Omd1bgkLiQhp7sMizyAwBTMnxhsYA4K3MwdBasf3S/N93NXN5/b03N9e1wMISzeH4FGfQdhAVNUoMc5yGyDc7vD/YsLMrgZTHyCaOVFaR904iVlrRfAQJng3gbRU1O5+eRncCFJ+P/b6v0JP0U/DonCEIcQXWVb1K0KY+jVEuVZSNT7kPqqA1gAAAA==", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/7f7820795b5d933eebf3dce8962b626072b809c407fed39552dad3cf51cc2b1a/5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187/000000.jsonl.gz":"H4sIAAAAAAAAAyXKQQqAIBBG4X3H+NcZMxIFXkYsB5GwJGsR0d3DWr6Pd8O7Q2CgSQ+KSRGjRUx5l1LithYY1kQtkvjo7HReNnoYpMnWMbt5cUF+y0v4sGRZa/e64xFP8wJBEPOlZAAAAA==", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/c5866668a6a52204b0b829245568ead9e74a674d056c8ae7949d8f140f50ee40/b5367a164b48f0c0e31537bda570038687db83416988840fc2bacd96300ad3e8/000000.jsonl.gz":"H4sIAAAAAAAAAyXKQQqAIBBG4X3H+NcZMxIFXkYsB5GwJGsR0d3DWr6Pd8O7Q2CgSQ+KSRGjRUx5l1LithYY1kQtkvjo7HReNnoYpMnWMbt5cUF+y0v4sGRZa/e64xFP8wJBEPOlZAAAAA==", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/e0d86fc58a94fda3c36f8454d332c4988cd985c83f5ce0cf7d90fc57ce9f7de7/d1ca264c9c51c4f7146f5f9b791a05fffee44cc55eca090dcc73ec826522874f/000000.jsonl.gz":"H4sIAAAAAAAAA03azbEcJxSA0b3CUAQD3B9wUg5ApdxdrnocZtW777rkdwYa+s/vf3//8/n768//z/HznD/P9fOMn2f+POvn2T/P/fM8tyN4i+Mmx22OGx23Om523O644XHL85an/9Zbnrc8b3ne8rzlecvzluctr1tet7z8M9zyuuV1y+uW1y2vW163HLcctxy3HP6FbzluOW45bjluOW45bzlvOW85bzn9z7vlvOW85bzlvOW65brluuW65brl8ndxy3XLdct1y33Lfct9y33Lfct9y+1P7pb7lvuW9y3vW963vG953/K+5X3L21/zLe9bPrd8bvnc8rnlc8vnls8tn1s+oDwpqHxY+cDyoeWDy4eXDzAfYj7IfMz44mjGA/lEPpLP5EP5VD6WXA4wx3zmzWBzwDnoHHgOPgegg9CB6GB0rPfDYgamg9MB6iB1oDpYHbAOWgeuI96vlxnEDmQHswPaQe3AdnA7wB3kjnw/kWbAO+gd+A5+B8CD4IHwYHhAPOr9DpvB8QB5kDxQHiwPmAfNA+fB8+j3Y28G0oPpAfWgemA9uB5gD7IH2mO/FcUMugfeg+8B+CB8ID4YH5APysd5y9ZbtyxcnE/OJ+eT88n55HxyPjmfnM/xFkczOJ+cT84n55PzyfnkfL719y3AXyuwGW8NfovwW4XfMvzW4bcQcz45n5zP9ZZ5MzifnE/OJ+eT88n55HxyPjmf8fYSZnA+OZ+cT84n55PzyfnkfHI+821YzOB8cj45n5xPzifnk/PJ+eR81tsVmcH55HxyPjmfnE/OJ+eT88n57Lf1MoPzyfnkfHI+OZ+cT84n55Pzud/+zgzOJ+eT88n55HxyPjmfnE/O53mbyLeLtI3kfHG+OF+cL84X54vzxfnifI23VTWD88X54nxxvjhfnC/OF+eL8zXfftgMzhfni/PF+eJ8cb7ejvttud+e+2vTbcbbdr9999t4v53323pzvjhfnC/OV7ydvRmcL84X54vzxfnifHG+OF+cr3yvD2ZwvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/OF+eL88X54nxxvjhfnC/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDiP9479XrLfW/Z7zf56zzbjvWm/V+33rv1etjkPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz4Dw4D86D8+A8OA/Og/PgPDgPzoPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dzfqdo7Vnvnau9g7Z2sfR2tmfEO197p2jte4zw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+Q8OU/Ok/PkPDlPzpPz5Dw5T86T8+S8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L83rn6O8g/Z2kv6P0d5b+DtO/TtPNeOfp70Cd8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDgvzovz4rw4L86L8+K8OC/Oi/PivDhvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b86b8+a8OW/O+92cvauzd3f2Ls/e7dm7Pnv3Z18XaGa8KzTOm/PmvDlvzpvz5rw5b86b8+a8OW/Om/PmvDlvzpvz5rw5b84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+c73dX/i7L3235uy5/9+XvwvzdmL8r8687czM435xvzjfnm/PN+eZ8c74535xvzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/n530d8z6Ped/HvA9k3hcy7xOZ943M+0jmfSXz9ZnM13cyn7+//gMn4sd0rSYAAA==", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/ff289a3d78dbb114f4d8a549013c3e2aef591b1b1ee0bee597d6f466428bf7dc/20391f076cea7702c50fa4ce949705afca0529fd3de4787ac66dbbafe448b6cd/000000.jsonl.gz":"H4sIAAAAAAAAA1WOwQ7BQBRF975C7vqRN1Vp5v2KERk1RFJTYZqgadK/YCGsJIi/8CcWYukThJ27Omd1bgkLiQhp7sMizyAwBTMnxhsYA4K3MwdBasf3S/N93NXN5/b03N9e1wMISzeH4FGfQdhAVNUoMc5yGyDc7vD/YsLMrgZTHyCaOVFaR904iVlrRfAQJng3gbRU1O5+eRncCFJ+P/b6v0JP0U/DonCEIcQXWVb1K0KY+jVEuVZSNT7kPqqA1gAAAA==" }, "row_chunks": [ - { - "account_id": "acct_interop", - "canonical_size_bytes": 100, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 1, - "row_set_id": "rrev_js_single-row_adjustment_inline", - "segments": [ - { - "byte_count": 100, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 1, - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } - ], - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 100, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/7f7820795b5d933eebf3dce8962b626072b809c407fed39552dad3cf51cc2b1a/5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "4d6f55b54163962ef467b8cd2ae7751f75abf8d64d347f44197c4d504ecd6091", - "row_count": 1, - "row_set_id": "rrev_js_single-row_adjustment_reports", - "segments": [ - { - "byte_count": 100, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 1, - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } - ], - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336", - "size_bytes": 106 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 100, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 1, - "row_set_id": "rrev_js_single-row_revision_inline", - "segments": [ - { - "byte_count": 100, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 1, - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } - ], - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 100, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/c5866668a6a52204b0b829245568ead9e74a674d056c8ae7949d8f140f50ee40/b5367a164b48f0c0e31537bda570038687db83416988840fc2bacd96300ad3e8/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "4d6f55b54163962ef467b8cd2ae7751f75abf8d64d347f44197c4d504ecd6091", - "row_count": 1, - "row_set_id": "rrev_js_single-row_revision_reports", - "segments": [ - { - "byte_count": 100, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 1, - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } - ], - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336", - "size_bytes": 106 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 9901, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 1001, - "row_set_id": "rrev_js_three-segments_adjustment_inline", - "segments": [ - { - "byte_count": 4890, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 500, - "sha256": "6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502" - }, - { - "byte_count": 5000, - "byte_offset": 4890, - "first_ordinal": 500, - "row_count": 500, - "sha256": "18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9" - }, - { - "byte_count": 11, - "byte_offset": 9890, - "first_ordinal": 1000, - "row_count": 1, - "sha256": "34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f" - } - ], - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 9901, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/261f3a1eb893ee2ab06dcba59c4c226e643150f9151fd3136aa79daac7c2a33a/49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "aad958a59f3b31b3ec2e54f6bb02f0ac70e19f19a349816a6d943b24d9c5b484", - "row_count": 1001, - "row_set_id": "rrev_js_three-segments_adjustment_reports", - "segments": [ - { - "byte_count": 4890, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 500, - "sha256": "6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502" - }, - { - "byte_count": 5000, - "byte_offset": 4890, - "first_ordinal": 500, - "row_count": 500, - "sha256": "18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9" - }, - { - "byte_count": 11, - "byte_offset": 9890, - "first_ordinal": 1000, - "row_count": 1, - "sha256": "34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f" - } - ], - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277", - "size_bytes": 1987 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 9901, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 1001, - "row_set_id": "rrev_js_three-segments_revision_inline", - "segments": [ - { - "byte_count": 4890, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 500, - "sha256": "6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502" - }, - { - "byte_count": 5000, - "byte_offset": 4890, - "first_ordinal": 500, - "row_count": 500, - "sha256": "18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9" - }, - { - "byte_count": 11, - "byte_offset": 9890, - "first_ordinal": 1000, - "row_count": 1, - "sha256": "34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f" - } - ], - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 9901, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/e0d86fc58a94fda3c36f8454d332c4988cd985c83f5ce0cf7d90fc57ce9f7de7/d1ca264c9c51c4f7146f5f9b791a05fffee44cc55eca090dcc73ec826522874f/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "aad958a59f3b31b3ec2e54f6bb02f0ac70e19f19a349816a6d943b24d9c5b484", - "row_count": 1001, - "row_set_id": "rrev_js_three-segments_revision_reports", - "segments": [ - { - "byte_count": 4890, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 500, - "sha256": "6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502" - }, - { - "byte_count": 5000, - "byte_offset": 4890, - "first_ordinal": 500, - "row_count": 500, - "sha256": "18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9" - }, - { - "byte_count": 11, - "byte_offset": 9890, - "first_ordinal": 1000, - "row_count": 1, - "sha256": "34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f" - } - ], - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277", - "size_bytes": 1987 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 214, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 2, - "row_set_id": "rrev_js_unicode-and-numbers_adjustment_inline", - "segments": [ - { - "byte_count": 214, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 2, - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - } - ], - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 214, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/705459c9b7547dedbf7cabf978d9ea55759f2358d3e062257529da157ffbea9e/628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "28a23f11b0bd66046049d747ccf769ccea2179aa7b8815f1d225d6594bb140e5", - "row_count": 2, - "row_set_id": "rrev_js_unicode-and-numbers_adjustment_reports", - "segments": [ - { - "byte_count": 214, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 2, - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - } - ], - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023", - "size_bytes": 196 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 214, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 2, - "row_set_id": "rrev_js_unicode-and-numbers_revision_inline", - "segments": [ - { - "byte_count": 214, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 2, - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - } - ], - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 214, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/ff289a3d78dbb114f4d8a549013c3e2aef591b1b1ee0bee597d6f466428bf7dc/20391f076cea7702c50fa4ce949705afca0529fd3de4787ac66dbbafe448b6cd/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "28a23f11b0bd66046049d747ccf769ccea2179aa7b8815f1d225d6594bb140e5", - "row_count": 2, - "row_set_id": "rrev_js_unicode-and-numbers_revision_reports", - "segments": [ - { - "byte_count": 214, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 2, - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - } - ], - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023", - "size_bytes": 196 - } + {"account_id":"acct_interop","canonical_size_bytes":100,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":1,"row_set_id":"rrev_js_single-row_adjustment_inline","segments":[{"byte_count":100,"byte_offset":0,"first_ordinal":0,"row_count":1,"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"}],"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":100,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/7f7820795b5d933eebf3dce8962b626072b809c407fed39552dad3cf51cc2b1a/5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"4d6f55b54163962ef467b8cd2ae7751f75abf8d64d347f44197c4d504ecd6091","row_count":1,"row_set_id":"rrev_js_single-row_adjustment_reports","segments":[{"byte_count":100,"byte_offset":0,"first_ordinal":0,"row_count":1,"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"}],"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336","size_bytes":106}, + {"account_id":"acct_interop","canonical_size_bytes":100,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":1,"row_set_id":"rrev_js_single-row_revision_inline","segments":[{"byte_count":100,"byte_offset":0,"first_ordinal":0,"row_count":1,"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"}],"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":100,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/c5866668a6a52204b0b829245568ead9e74a674d056c8ae7949d8f140f50ee40/b5367a164b48f0c0e31537bda570038687db83416988840fc2bacd96300ad3e8/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"4d6f55b54163962ef467b8cd2ae7751f75abf8d64d347f44197c4d504ecd6091","row_count":1,"row_set_id":"rrev_js_single-row_revision_reports","segments":[{"byte_count":100,"byte_offset":0,"first_ordinal":0,"row_count":1,"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"}],"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336","size_bytes":106}, + {"account_id":"acct_interop","canonical_size_bytes":9901,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":1001,"row_set_id":"rrev_js_three-segments_adjustment_inline","segments":[{"byte_count":4890,"byte_offset":0,"first_ordinal":0,"row_count":500,"sha256":"6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502"},{"byte_count":5000,"byte_offset":4890,"first_ordinal":500,"row_count":500,"sha256":"18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9"},{"byte_count":11,"byte_offset":9890,"first_ordinal":1000,"row_count":1,"sha256":"34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f"}],"sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":9901,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/261f3a1eb893ee2ab06dcba59c4c226e643150f9151fd3136aa79daac7c2a33a/49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"aad958a59f3b31b3ec2e54f6bb02f0ac70e19f19a349816a6d943b24d9c5b484","row_count":1001,"row_set_id":"rrev_js_three-segments_adjustment_reports","segments":[{"byte_count":4890,"byte_offset":0,"first_ordinal":0,"row_count":500,"sha256":"6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502"},{"byte_count":5000,"byte_offset":4890,"first_ordinal":500,"row_count":500,"sha256":"18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9"},{"byte_count":11,"byte_offset":9890,"first_ordinal":1000,"row_count":1,"sha256":"34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f"}],"sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277","size_bytes":1987}, + {"account_id":"acct_interop","canonical_size_bytes":9901,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":1001,"row_set_id":"rrev_js_three-segments_revision_inline","segments":[{"byte_count":4890,"byte_offset":0,"first_ordinal":0,"row_count":500,"sha256":"6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502"},{"byte_count":5000,"byte_offset":4890,"first_ordinal":500,"row_count":500,"sha256":"18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9"},{"byte_count":11,"byte_offset":9890,"first_ordinal":1000,"row_count":1,"sha256":"34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f"}],"sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":9901,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/e0d86fc58a94fda3c36f8454d332c4988cd985c83f5ce0cf7d90fc57ce9f7de7/d1ca264c9c51c4f7146f5f9b791a05fffee44cc55eca090dcc73ec826522874f/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"aad958a59f3b31b3ec2e54f6bb02f0ac70e19f19a349816a6d943b24d9c5b484","row_count":1001,"row_set_id":"rrev_js_three-segments_revision_reports","segments":[{"byte_count":4890,"byte_offset":0,"first_ordinal":0,"row_count":500,"sha256":"6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502"},{"byte_count":5000,"byte_offset":4890,"first_ordinal":500,"row_count":500,"sha256":"18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9"},{"byte_count":11,"byte_offset":9890,"first_ordinal":1000,"row_count":1,"sha256":"34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f"}],"sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277","size_bytes":1987}, + {"account_id":"acct_interop","canonical_size_bytes":214,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":2,"row_set_id":"rrev_js_unicode-and-numbers_adjustment_inline","segments":[{"byte_count":214,"byte_offset":0,"first_ordinal":0,"row_count":2,"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}],"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":214,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/705459c9b7547dedbf7cabf978d9ea55759f2358d3e062257529da157ffbea9e/628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"28a23f11b0bd66046049d747ccf769ccea2179aa7b8815f1d225d6594bb140e5","row_count":2,"row_set_id":"rrev_js_unicode-and-numbers_adjustment_reports","segments":[{"byte_count":214,"byte_offset":0,"first_ordinal":0,"row_count":2,"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}],"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023","size_bytes":196}, + {"account_id":"acct_interop","canonical_size_bytes":214,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":2,"row_set_id":"rrev_js_unicode-and-numbers_revision_inline","segments":[{"byte_count":214,"byte_offset":0,"first_ordinal":0,"row_count":2,"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}],"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":214,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/ff289a3d78dbb114f4d8a549013c3e2aef591b1b1ee0bee597d6f466428bf7dc/20391f076cea7702c50fa4ce949705afca0529fd3de4787ac66dbbafe448b6cd/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"28a23f11b0bd66046049d747ccf769ccea2179aa7b8815f1d225d6594bb140e5","row_count":2,"row_set_id":"rrev_js_unicode-and-numbers_revision_reports","segments":[{"byte_count":214,"byte_offset":0,"first_ordinal":0,"row_count":2,"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}],"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023","size_bytes":196} ], "row_sets": [ - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187", - "content_size_bytes": 101, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 1, - "row_manifest_sha256": "8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a", - "row_set_id": "rrev_js_single-row_adjustment_inline", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187", - "content_size_bytes": 101, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 1, - "row_manifest_sha256": "8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a", - "row_set_id": "rrev_js_single-row_adjustment_reports", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "69403a2a705dcce0c564849c26c55a899f0c1158dd12a56ce370b004069fd5fa", - "content_size_bytes": 344, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 1, - "row_manifest_sha256": "8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a", - "row_set_id": "rrev_js_single-row_revision_inline", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "b5367a164b48f0c0e31537bda570038687db83416988840fc2bacd96300ad3e8", - "content_size_bytes": 345, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 1, - "row_manifest_sha256": "8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a", - "row_set_id": "rrev_js_single-row_revision_reports", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe", - "content_size_bytes": 9902, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 1001, - "row_manifest_sha256": "c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927", - "row_set_id": "rrev_js_three-segments_adjustment_inline", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe", - "content_size_bytes": 9902, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 1001, - "row_manifest_sha256": "c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927", - "row_set_id": "rrev_js_three-segments_adjustment_reports", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "de89268832ff75b1fe6f77a61de3c174ed49289172bde20d537b10c4b067fbc0", - "content_size_bytes": 10152, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 1001, - "row_manifest_sha256": "c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927", - "row_set_id": "rrev_js_three-segments_revision_inline", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "d1ca264c9c51c4f7146f5f9b791a05fffee44cc55eca090dcc73ec826522874f", - "content_size_bytes": 10153, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 1001, - "row_manifest_sha256": "c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927", - "row_set_id": "rrev_js_three-segments_revision_reports", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095", - "content_size_bytes": 215, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 2, - "row_manifest_sha256": "a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f", - "row_set_id": "rrev_js_unicode-and-numbers_adjustment_inline", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095", - "content_size_bytes": 215, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 2, - "row_manifest_sha256": "a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f", - "row_set_id": "rrev_js_unicode-and-numbers_adjustment_reports", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "39d5233af55a871656ea98bc512f3c6bc3446ff30a97747f26cd117913af83af", - "content_size_bytes": 467, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 2, - "row_manifest_sha256": "a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f", - "row_set_id": "rrev_js_unicode-and-numbers_revision_inline", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "20391f076cea7702c50fa4ce949705afca0529fd3de4787ac66dbbafe448b6cd", - "content_size_bytes": 468, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 2, - "row_manifest_sha256": "a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f", - "row_set_id": "rrev_js_unicode-and-numbers_revision_reports", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - } + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187","content_size_bytes":101,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":1,"row_manifest_sha256":"8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a","row_set_id":"rrev_js_single-row_adjustment_inline","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187","content_size_bytes":101,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":1,"row_manifest_sha256":"8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a","row_set_id":"rrev_js_single-row_adjustment_reports","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"69403a2a705dcce0c564849c26c55a899f0c1158dd12a56ce370b004069fd5fa","content_size_bytes":344,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":1,"row_manifest_sha256":"8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a","row_set_id":"rrev_js_single-row_revision_inline","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"b5367a164b48f0c0e31537bda570038687db83416988840fc2bacd96300ad3e8","content_size_bytes":345,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":1,"row_manifest_sha256":"8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a","row_set_id":"rrev_js_single-row_revision_reports","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe","content_size_bytes":9902,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":1001,"row_manifest_sha256":"c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927","row_set_id":"rrev_js_three-segments_adjustment_inline","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe","content_size_bytes":9902,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":1001,"row_manifest_sha256":"c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927","row_set_id":"rrev_js_three-segments_adjustment_reports","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"de89268832ff75b1fe6f77a61de3c174ed49289172bde20d537b10c4b067fbc0","content_size_bytes":10152,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":1001,"row_manifest_sha256":"c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927","row_set_id":"rrev_js_three-segments_revision_inline","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"d1ca264c9c51c4f7146f5f9b791a05fffee44cc55eca090dcc73ec826522874f","content_size_bytes":10153,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":1001,"row_manifest_sha256":"c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927","row_set_id":"rrev_js_three-segments_revision_reports","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095","content_size_bytes":215,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":2,"row_manifest_sha256":"a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f","row_set_id":"rrev_js_unicode-and-numbers_adjustment_inline","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095","content_size_bytes":215,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":2,"row_manifest_sha256":"a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f","row_set_id":"rrev_js_unicode-and-numbers_adjustment_reports","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"39d5233af55a871656ea98bc512f3c6bc3446ff30a97747f26cd117913af83af","content_size_bytes":467,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":2,"row_manifest_sha256":"a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f","row_set_id":"rrev_js_unicode-and-numbers_revision_inline","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"20391f076cea7702c50fa4ce949705afca0529fd3de4787ac66dbbafe448b6cd","content_size_bytes":468,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":2,"row_manifest_sha256":"a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f","row_set_id":"rrev_js_unicode-and-numbers_revision_reports","row_set_kind":"revision","rows_shared_from_row_set_id":null} ] } } diff --git a/tests/fixtures/reporting-row-storage-interop-v1/python-written.json b/tests/fixtures/reporting-row-storage-interop-v1/python-written.json index 9a83b1400..465adcf34 100644 --- a/tests/fixtures/reporting-row-storage-interop-v1/python-written.json +++ b/tests/fixtures/reporting-row-storage-interop-v1/python-written.json @@ -1,12926 +1,59 @@ { "expected": [ - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "f04cd965936bf4821f54148bcaa037a7a9b4f0315e7d9cead0b6e90baed2f818", - "content_size_bytes": 344, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_single-row_revision_inline", - "row_set_kind": "revision", - "rows": [ - { - "date": "2026-10-01", - "impressions": 1200, - "media_buy_id": "mb_1", - "package_id": "pkg_1", - "spend": "42.17" - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "b7f659d4967acd5e1d55aaee8fd97a1e18d197a610b6c994c2bc16cc718302a8", - "content_size_bytes": 345, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_single-row_revision_reports", - "row_set_kind": "revision", - "rows": [ - { - "date": "2026-10-01", - "impressions": 1200, - "media_buy_id": "mb_1", - "package_id": "pkg_1", - "spend": "42.17" - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187", - "content_size_bytes": 101, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_single-row_adjustment_inline", - "row_set_kind": "adjustment", - "rows": [ - { - "date": "2026-10-01", - "impressions": 1200, - "media_buy_id": "mb_1", - "package_id": "pkg_1", - "spend": "42.17" - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187", - "content_size_bytes": 101, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_single-row_adjustment_reports", - "row_set_kind": "adjustment", - "rows": [ - { - "date": "2026-10-01", - "impressions": 1200, - "media_buy_id": "mb_1", - "package_id": "pkg_1", - "spend": "42.17" - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "795bc2f0c2979e4e3178d994692f994f34220bca84af68db33705ac6fd52efdd", - "content_size_bytes": 467, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_unicode-and-numbers_revision_inline", - "row_set_kind": "revision", - "rows": [ - { - "a": 2, - "control": "\u0007\n\"\\", - "name": "caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e", - "sep": "\u2028", - "z": 1 - }, - { - "float": 0.30000000000000004, - "max_int": 9007199254740991, - "n": 0, - "neg": -12.5, - "nested": { - "a": [], - "z": [ - 1, - { - "a": true, - "b": null - } - ] - }, - "tiny": 1e-07 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "9da4eb545db09a88341b27b05d81247eae4bfeea407c4bdc4a57f16f3b497869", - "content_size_bytes": 468, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_unicode-and-numbers_revision_reports", - "row_set_kind": "revision", - "rows": [ - { - "a": 2, - "control": "\u0007\n\"\\", - "name": "caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e", - "sep": "\u2028", - "z": 1 - }, - { - "float": 0.30000000000000004, - "max_int": 9007199254740991, - "n": 0, - "neg": -12.5, - "nested": { - "a": [], - "z": [ - 1, - { - "a": true, - "b": null - } - ] - }, - "tiny": 1e-07 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095", - "content_size_bytes": 215, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_unicode-and-numbers_adjustment_inline", - "row_set_kind": "adjustment", - "rows": [ - { - "a": 2, - "control": "\u0007\n\"\\", - "name": "caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e", - "sep": "\u2028", - "z": 1 - }, - { - "float": 0.30000000000000004, - "max_int": 9007199254740991, - "n": 0, - "neg": -12.5, - "nested": { - "a": [], - "z": [ - 1, - { - "a": true, - "b": null - } - ] - }, - "tiny": 1e-07 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095", - "content_size_bytes": 215, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_unicode-and-numbers_adjustment_reports", - "row_set_kind": "adjustment", - "rows": [ - { - "a": 2, - "control": "\u0007\n\"\\", - "name": "caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e", - "sep": "\u2028", - "z": 1 - }, - { - "float": 0.30000000000000004, - "max_int": 9007199254740991, - "n": 0, - "neg": -12.5, - "nested": { - "a": [], - "z": [ - 1, - { - "a": true, - "b": null - } - ] - }, - "tiny": 1e-07 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "cd76d91d96b99808a64c0a9348d81d82584153af22ea74d1138970381705f57d", - "content_size_bytes": 10152, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_three-segments_revision_inline", - "row_set_kind": "revision", - "rows": [ - { - "o": 0 - }, - { - "o": 1 - }, - { - "o": 2 - }, - { - "o": 3 - }, - { - "o": 4 - }, - { - "o": 5 - }, - { - "o": 6 - }, - { - "o": 7 - }, - { - "o": 8 - }, - { - "o": 9 - }, - { - "o": 10 - }, - { - "o": 11 - }, - { - "o": 12 - }, - { - "o": 13 - }, - { - "o": 14 - }, - { - "o": 15 - }, - { - "o": 16 - }, - { - "o": 17 - }, - { - "o": 18 - }, - { - "o": 19 - }, - { - "o": 20 - }, - { - "o": 21 - }, - { - "o": 22 - }, - { - "o": 23 - }, - { - "o": 24 - }, - { - "o": 25 - }, - { - "o": 26 - }, - { - "o": 27 - }, - { - "o": 28 - }, - { - "o": 29 - }, - { - "o": 30 - }, - { - "o": 31 - }, - { - "o": 32 - }, - { - "o": 33 - }, - { - "o": 34 - }, - { - "o": 35 - }, - { - "o": 36 - }, - { - "o": 37 - }, - { - "o": 38 - }, - { - "o": 39 - }, - { - "o": 40 - }, - { - "o": 41 - }, - { - "o": 42 - }, - { - "o": 43 - }, - { - "o": 44 - }, - { - "o": 45 - }, - { - "o": 46 - }, - { - "o": 47 - }, - { - "o": 48 - }, - { - "o": 49 - }, - { - "o": 50 - }, - { - "o": 51 - }, - { - "o": 52 - }, - { - "o": 53 - }, - { - "o": 54 - }, - { - "o": 55 - }, - { - "o": 56 - }, - { - "o": 57 - }, - { - "o": 58 - }, - { - "o": 59 - }, - { - "o": 60 - }, - { - "o": 61 - }, - { - "o": 62 - }, - { - "o": 63 - }, - { - "o": 64 - }, - { - "o": 65 - }, - { - "o": 66 - }, - { - "o": 67 - }, - { - "o": 68 - }, - { - "o": 69 - }, - { - "o": 70 - }, - { - "o": 71 - }, - { - "o": 72 - }, - { - "o": 73 - }, - { - "o": 74 - }, - { - "o": 75 - }, - { - "o": 76 - }, - { - "o": 77 - }, - { - "o": 78 - }, - { - "o": 79 - }, - { - "o": 80 - }, - { - "o": 81 - }, - { - "o": 82 - }, - { - "o": 83 - }, - { - "o": 84 - }, - { - "o": 85 - }, - { - "o": 86 - }, - { - "o": 87 - }, - { - "o": 88 - }, - { - "o": 89 - }, - { - "o": 90 - }, - { - "o": 91 - }, - { - "o": 92 - }, - { - "o": 93 - }, - { - "o": 94 - }, - { - "o": 95 - }, - { - "o": 96 - }, - { - "o": 97 - }, - { - "o": 98 - }, - { - "o": 99 - }, - { - "o": 100 - }, - { - "o": 101 - }, - { - "o": 102 - }, - { - "o": 103 - }, - { - "o": 104 - }, - { - "o": 105 - }, - { - "o": 106 - }, - { - "o": 107 - }, - { - "o": 108 - }, - { - "o": 109 - }, - { - "o": 110 - }, - { - "o": 111 - }, - { - "o": 112 - }, - { - "o": 113 - }, - { - "o": 114 - }, - { - "o": 115 - }, - { - "o": 116 - }, - { - "o": 117 - }, - { - "o": 118 - }, - { - "o": 119 - }, - { - "o": 120 - }, - { - "o": 121 - }, - { - "o": 122 - }, - { - "o": 123 - }, - { - "o": 124 - }, - { - "o": 125 - }, - { - "o": 126 - }, - { - "o": 127 - }, - { - "o": 128 - }, - { - "o": 129 - }, - { - "o": 130 - }, - { - "o": 131 - }, - { - "o": 132 - }, - { - "o": 133 - }, - { - "o": 134 - }, - { - "o": 135 - }, - { - "o": 136 - }, - { - "o": 137 - }, - { - "o": 138 - }, - { - "o": 139 - }, - { - "o": 140 - }, - { - "o": 141 - }, - { - "o": 142 - }, - { - "o": 143 - }, - { - "o": 144 - }, - { - "o": 145 - }, - { - "o": 146 - }, - { - "o": 147 - }, - { - "o": 148 - }, - { - "o": 149 - }, - { - "o": 150 - }, - { - "o": 151 - }, - { - "o": 152 - }, - { - "o": 153 - }, - { - "o": 154 - }, - { - "o": 155 - }, - { - "o": 156 - }, - { - "o": 157 - }, - { - "o": 158 - }, - { - "o": 159 - }, - { - "o": 160 - }, - { - "o": 161 - }, - { - "o": 162 - }, - { - "o": 163 - }, - { - "o": 164 - }, - { - "o": 165 - }, - { - "o": 166 - }, - { - "o": 167 - }, - { - "o": 168 - }, - { - "o": 169 - }, - { - "o": 170 - }, - { - "o": 171 - }, - { - "o": 172 - }, - { - "o": 173 - }, - { - "o": 174 - }, - { - "o": 175 - }, - { - "o": 176 - }, - { - "o": 177 - }, - { - "o": 178 - }, - { - "o": 179 - }, - { - "o": 180 - }, - { - "o": 181 - }, - { - "o": 182 - }, - { - "o": 183 - }, - { - "o": 184 - }, - { - "o": 185 - }, - { - "o": 186 - }, - { - "o": 187 - }, - { - "o": 188 - }, - { - "o": 189 - }, - { - "o": 190 - }, - { - "o": 191 - }, - { - "o": 192 - }, - { - "o": 193 - }, - { - "o": 194 - }, - { - "o": 195 - }, - { - "o": 196 - }, - { - "o": 197 - }, - { - "o": 198 - }, - { - "o": 199 - }, - { - "o": 200 - }, - { - "o": 201 - }, - { - "o": 202 - }, - { - "o": 203 - }, - { - "o": 204 - }, - { - "o": 205 - }, - { - "o": 206 - }, - { - "o": 207 - }, - { - "o": 208 - }, - { - "o": 209 - }, - { - "o": 210 - }, - { - "o": 211 - }, - { - "o": 212 - }, - { - "o": 213 - }, - { - "o": 214 - }, - { - "o": 215 - }, - { - "o": 216 - }, - { - "o": 217 - }, - { - "o": 218 - }, - { - "o": 219 - }, - { - "o": 220 - }, - { - "o": 221 - }, - { - "o": 222 - }, - { - "o": 223 - }, - { - "o": 224 - }, - { - "o": 225 - }, - { - "o": 226 - }, - { - "o": 227 - }, - { - "o": 228 - }, - { - "o": 229 - }, - { - "o": 230 - }, - { - "o": 231 - }, - { - "o": 232 - }, - { - "o": 233 - }, - { - "o": 234 - }, - { - "o": 235 - }, - { - "o": 236 - }, - { - "o": 237 - }, - { - "o": 238 - }, - { - "o": 239 - }, - { - "o": 240 - }, - { - "o": 241 - }, - { - "o": 242 - }, - { - "o": 243 - }, - { - "o": 244 - }, - { - "o": 245 - }, - { - "o": 246 - }, - { - "o": 247 - }, - { - "o": 248 - }, - { - "o": 249 - }, - { - "o": 250 - }, - { - "o": 251 - }, - { - "o": 252 - }, - { - "o": 253 - }, - { - "o": 254 - }, - { - "o": 255 - }, - { - "o": 256 - }, - { - "o": 257 - }, - { - "o": 258 - }, - { - "o": 259 - }, - { - "o": 260 - }, - { - "o": 261 - }, - { - "o": 262 - }, - { - "o": 263 - }, - { - "o": 264 - }, - { - "o": 265 - }, - { - "o": 266 - }, - { - "o": 267 - }, - { - "o": 268 - }, - { - "o": 269 - }, - { - "o": 270 - }, - { - "o": 271 - }, - { - "o": 272 - }, - { - "o": 273 - }, - { - "o": 274 - }, - { - "o": 275 - }, - { - "o": 276 - }, - { - "o": 277 - }, - { - "o": 278 - }, - { - "o": 279 - }, - { - "o": 280 - }, - { - "o": 281 - }, - { - "o": 282 - }, - { - "o": 283 - }, - { - "o": 284 - }, - { - "o": 285 - }, - { - "o": 286 - }, - { - "o": 287 - }, - { - "o": 288 - }, - { - "o": 289 - }, - { - "o": 290 - }, - { - "o": 291 - }, - { - "o": 292 - }, - { - "o": 293 - }, - { - "o": 294 - }, - { - "o": 295 - }, - { - "o": 296 - }, - { - "o": 297 - }, - { - "o": 298 - }, - { - "o": 299 - }, - { - "o": 300 - }, - { - "o": 301 - }, - { - "o": 302 - }, - { - "o": 303 - }, - { - "o": 304 - }, - { - "o": 305 - }, - { - "o": 306 - }, - { - "o": 307 - }, - { - "o": 308 - }, - { - "o": 309 - }, - { - "o": 310 - }, - { - "o": 311 - }, - { - "o": 312 - }, - { - "o": 313 - }, - { - "o": 314 - }, - { - "o": 315 - }, - { - "o": 316 - }, - { - "o": 317 - }, - { - "o": 318 - }, - { - "o": 319 - }, - { - "o": 320 - }, - { - "o": 321 - }, - { - "o": 322 - }, - { - "o": 323 - }, - { - "o": 324 - }, - { - "o": 325 - }, - { - "o": 326 - }, - { - "o": 327 - }, - { - "o": 328 - }, - { - "o": 329 - }, - { - "o": 330 - }, - { - "o": 331 - }, - { - "o": 332 - }, - { - "o": 333 - }, - { - "o": 334 - }, - { - "o": 335 - }, - { - "o": 336 - }, - { - "o": 337 - }, - { - "o": 338 - }, - { - "o": 339 - }, - { - "o": 340 - }, - { - "o": 341 - }, - { - "o": 342 - }, - { - "o": 343 - }, - { - "o": 344 - }, - { - "o": 345 - }, - { - "o": 346 - }, - { - "o": 347 - }, - { - "o": 348 - }, - { - "o": 349 - }, - { - "o": 350 - }, - { - "o": 351 - }, - { - "o": 352 - }, - { - "o": 353 - }, - { - "o": 354 - }, - { - "o": 355 - }, - { - "o": 356 - }, - { - "o": 357 - }, - { - "o": 358 - }, - { - "o": 359 - }, - { - "o": 360 - }, - { - "o": 361 - }, - { - "o": 362 - }, - { - "o": 363 - }, - { - "o": 364 - }, - { - "o": 365 - }, - { - "o": 366 - }, - { - "o": 367 - }, - { - "o": 368 - }, - { - "o": 369 - }, - { - "o": 370 - }, - { - "o": 371 - }, - { - "o": 372 - }, - { - "o": 373 - }, - { - "o": 374 - }, - { - "o": 375 - }, - { - "o": 376 - }, - { - "o": 377 - }, - { - "o": 378 - }, - { - "o": 379 - }, - { - "o": 380 - }, - { - "o": 381 - }, - { - "o": 382 - }, - { - "o": 383 - }, - { - "o": 384 - }, - { - "o": 385 - }, - { - "o": 386 - }, - { - "o": 387 - }, - { - "o": 388 - }, - { - "o": 389 - }, - { - "o": 390 - }, - { - "o": 391 - }, - { - "o": 392 - }, - { - "o": 393 - }, - { - "o": 394 - }, - { - "o": 395 - }, - { - "o": 396 - }, - { - "o": 397 - }, - { - "o": 398 - }, - { - "o": 399 - }, - { - "o": 400 - }, - { - "o": 401 - }, - { - "o": 402 - }, - { - "o": 403 - }, - { - "o": 404 - }, - { - "o": 405 - }, - { - "o": 406 - }, - { - "o": 407 - }, - { - "o": 408 - }, - { - "o": 409 - }, - { - "o": 410 - }, - { - "o": 411 - }, - { - "o": 412 - }, - { - "o": 413 - }, - { - "o": 414 - }, - { - "o": 415 - }, - { - "o": 416 - }, - { - "o": 417 - }, - { - "o": 418 - }, - { - "o": 419 - }, - { - "o": 420 - }, - { - "o": 421 - }, - { - "o": 422 - }, - { - "o": 423 - }, - { - "o": 424 - }, - { - "o": 425 - }, - { - "o": 426 - }, - { - "o": 427 - }, - { - "o": 428 - }, - { - "o": 429 - }, - { - "o": 430 - }, - { - "o": 431 - }, - { - "o": 432 - }, - { - "o": 433 - }, - { - "o": 434 - }, - { - "o": 435 - }, - { - "o": 436 - }, - { - "o": 437 - }, - { - "o": 438 - }, - { - "o": 439 - }, - { - "o": 440 - }, - { - "o": 441 - }, - { - "o": 442 - }, - { - "o": 443 - }, - { - "o": 444 - }, - { - "o": 445 - }, - { - "o": 446 - }, - { - "o": 447 - }, - { - "o": 448 - }, - { - "o": 449 - }, - { - "o": 450 - }, - { - "o": 451 - }, - { - "o": 452 - }, - { - "o": 453 - }, - { - "o": 454 - }, - { - "o": 455 - }, - { - "o": 456 - }, - { - "o": 457 - }, - { - "o": 458 - }, - { - "o": 459 - }, - { - "o": 460 - }, - { - "o": 461 - }, - { - "o": 462 - }, - { - "o": 463 - }, - { - "o": 464 - }, - { - "o": 465 - }, - { - "o": 466 - }, - { - "o": 467 - }, - { - "o": 468 - }, - { - "o": 469 - }, - { - "o": 470 - }, - { - "o": 471 - }, - { - "o": 472 - }, - { - "o": 473 - }, - { - "o": 474 - }, - { - "o": 475 - }, - { - "o": 476 - }, - { - "o": 477 - }, - { - "o": 478 - }, - { - "o": 479 - }, - { - "o": 480 - }, - { - "o": 481 - }, - { - "o": 482 - }, - { - "o": 483 - }, - { - "o": 484 - }, - { - "o": 485 - }, - { - "o": 486 - }, - { - "o": 487 - }, - { - "o": 488 - }, - { - "o": 489 - }, - { - "o": 490 - }, - { - "o": 491 - }, - { - "o": 492 - }, - { - "o": 493 - }, - { - "o": 494 - }, - { - "o": 495 - }, - { - "o": 496 - }, - { - "o": 497 - }, - { - "o": 498 - }, - { - "o": 499 - }, - { - "o": 500 - }, - { - "o": 501 - }, - { - "o": 502 - }, - { - "o": 503 - }, - { - "o": 504 - }, - { - "o": 505 - }, - { - "o": 506 - }, - { - "o": 507 - }, - { - "o": 508 - }, - { - "o": 509 - }, - { - "o": 510 - }, - { - "o": 511 - }, - { - "o": 512 - }, - { - "o": 513 - }, - { - "o": 514 - }, - { - "o": 515 - }, - { - "o": 516 - }, - { - "o": 517 - }, - { - "o": 518 - }, - { - "o": 519 - }, - { - "o": 520 - }, - { - "o": 521 - }, - { - "o": 522 - }, - { - "o": 523 - }, - { - "o": 524 - }, - { - "o": 525 - }, - { - "o": 526 - }, - { - "o": 527 - }, - { - "o": 528 - }, - { - "o": 529 - }, - { - "o": 530 - }, - { - "o": 531 - }, - { - "o": 532 - }, - { - "o": 533 - }, - { - "o": 534 - }, - { - "o": 535 - }, - { - "o": 536 - }, - { - "o": 537 - }, - { - "o": 538 - }, - { - "o": 539 - }, - { - "o": 540 - }, - { - "o": 541 - }, - { - "o": 542 - }, - { - "o": 543 - }, - { - "o": 544 - }, - { - "o": 545 - }, - { - "o": 546 - }, - { - "o": 547 - }, - { - "o": 548 - }, - { - "o": 549 - }, - { - "o": 550 - }, - { - "o": 551 - }, - { - "o": 552 - }, - { - "o": 553 - }, - { - "o": 554 - }, - { - "o": 555 - }, - { - "o": 556 - }, - { - "o": 557 - }, - { - "o": 558 - }, - { - "o": 559 - }, - { - "o": 560 - }, - { - "o": 561 - }, - { - "o": 562 - }, - { - "o": 563 - }, - { - "o": 564 - }, - { - "o": 565 - }, - { - "o": 566 - }, - { - "o": 567 - }, - { - "o": 568 - }, - { - "o": 569 - }, - { - "o": 570 - }, - { - "o": 571 - }, - { - "o": 572 - }, - { - "o": 573 - }, - { - "o": 574 - }, - { - "o": 575 - }, - { - "o": 576 - }, - { - "o": 577 - }, - { - "o": 578 - }, - { - "o": 579 - }, - { - "o": 580 - }, - { - "o": 581 - }, - { - "o": 582 - }, - { - "o": 583 - }, - { - "o": 584 - }, - { - "o": 585 - }, - { - "o": 586 - }, - { - "o": 587 - }, - { - "o": 588 - }, - { - "o": 589 - }, - { - "o": 590 - }, - { - "o": 591 - }, - { - "o": 592 - }, - { - "o": 593 - }, - { - "o": 594 - }, - { - "o": 595 - }, - { - "o": 596 - }, - { - "o": 597 - }, - { - "o": 598 - }, - { - "o": 599 - }, - { - "o": 600 - }, - { - "o": 601 - }, - { - "o": 602 - }, - { - "o": 603 - }, - { - "o": 604 - }, - { - "o": 605 - }, - { - "o": 606 - }, - { - "o": 607 - }, - { - "o": 608 - }, - { - "o": 609 - }, - { - "o": 610 - }, - { - "o": 611 - }, - { - "o": 612 - }, - { - "o": 613 - }, - { - "o": 614 - }, - { - "o": 615 - }, - { - "o": 616 - }, - { - "o": 617 - }, - { - "o": 618 - }, - { - "o": 619 - }, - { - "o": 620 - }, - { - "o": 621 - }, - { - "o": 622 - }, - { - "o": 623 - }, - { - "o": 624 - }, - { - "o": 625 - }, - { - "o": 626 - }, - { - "o": 627 - }, - { - "o": 628 - }, - { - "o": 629 - }, - { - "o": 630 - }, - { - "o": 631 - }, - { - "o": 632 - }, - { - "o": 633 - }, - { - "o": 634 - }, - { - "o": 635 - }, - { - "o": 636 - }, - { - "o": 637 - }, - { - "o": 638 - }, - { - "o": 639 - }, - { - "o": 640 - }, - { - "o": 641 - }, - { - "o": 642 - }, - { - "o": 643 - }, - { - "o": 644 - }, - { - "o": 645 - }, - { - "o": 646 - }, - { - "o": 647 - }, - { - "o": 648 - }, - { - "o": 649 - }, - { - "o": 650 - }, - { - "o": 651 - }, - { - "o": 652 - }, - { - "o": 653 - }, - { - "o": 654 - }, - { - "o": 655 - }, - { - "o": 656 - }, - { - "o": 657 - }, - { - "o": 658 - }, - { - "o": 659 - }, - { - "o": 660 - }, - { - "o": 661 - }, - { - "o": 662 - }, - { - "o": 663 - }, - { - "o": 664 - }, - { - "o": 665 - }, - { - "o": 666 - }, - { - "o": 667 - }, - { - "o": 668 - }, - { - "o": 669 - }, - { - "o": 670 - }, - { - "o": 671 - }, - { - "o": 672 - }, - { - "o": 673 - }, - { - "o": 674 - }, - { - "o": 675 - }, - { - "o": 676 - }, - { - "o": 677 - }, - { - "o": 678 - }, - { - "o": 679 - }, - { - "o": 680 - }, - { - "o": 681 - }, - { - "o": 682 - }, - { - "o": 683 - }, - { - "o": 684 - }, - { - "o": 685 - }, - { - "o": 686 - }, - { - "o": 687 - }, - { - "o": 688 - }, - { - "o": 689 - }, - { - "o": 690 - }, - { - "o": 691 - }, - { - "o": 692 - }, - { - "o": 693 - }, - { - "o": 694 - }, - { - "o": 695 - }, - { - "o": 696 - }, - { - "o": 697 - }, - { - "o": 698 - }, - { - "o": 699 - }, - { - "o": 700 - }, - { - "o": 701 - }, - { - "o": 702 - }, - { - "o": 703 - }, - { - "o": 704 - }, - { - "o": 705 - }, - { - "o": 706 - }, - { - "o": 707 - }, - { - "o": 708 - }, - { - "o": 709 - }, - { - "o": 710 - }, - { - "o": 711 - }, - { - "o": 712 - }, - { - "o": 713 - }, - { - "o": 714 - }, - { - "o": 715 - }, - { - "o": 716 - }, - { - "o": 717 - }, - { - "o": 718 - }, - { - "o": 719 - }, - { - "o": 720 - }, - { - "o": 721 - }, - { - "o": 722 - }, - { - "o": 723 - }, - { - "o": 724 - }, - { - "o": 725 - }, - { - "o": 726 - }, - { - "o": 727 - }, - { - "o": 728 - }, - { - "o": 729 - }, - { - "o": 730 - }, - { - "o": 731 - }, - { - "o": 732 - }, - { - "o": 733 - }, - { - "o": 734 - }, - { - "o": 735 - }, - { - "o": 736 - }, - { - "o": 737 - }, - { - "o": 738 - }, - { - "o": 739 - }, - { - "o": 740 - }, - { - "o": 741 - }, - { - "o": 742 - }, - { - "o": 743 - }, - { - "o": 744 - }, - { - "o": 745 - }, - { - "o": 746 - }, - { - "o": 747 - }, - { - "o": 748 - }, - { - "o": 749 - }, - { - "o": 750 - }, - { - "o": 751 - }, - { - "o": 752 - }, - { - "o": 753 - }, - { - "o": 754 - }, - { - "o": 755 - }, - { - "o": 756 - }, - { - "o": 757 - }, - { - "o": 758 - }, - { - "o": 759 - }, - { - "o": 760 - }, - { - "o": 761 - }, - { - "o": 762 - }, - { - "o": 763 - }, - { - "o": 764 - }, - { - "o": 765 - }, - { - "o": 766 - }, - { - "o": 767 - }, - { - "o": 768 - }, - { - "o": 769 - }, - { - "o": 770 - }, - { - "o": 771 - }, - { - "o": 772 - }, - { - "o": 773 - }, - { - "o": 774 - }, - { - "o": 775 - }, - { - "o": 776 - }, - { - "o": 777 - }, - { - "o": 778 - }, - { - "o": 779 - }, - { - "o": 780 - }, - { - "o": 781 - }, - { - "o": 782 - }, - { - "o": 783 - }, - { - "o": 784 - }, - { - "o": 785 - }, - { - "o": 786 - }, - { - "o": 787 - }, - { - "o": 788 - }, - { - "o": 789 - }, - { - "o": 790 - }, - { - "o": 791 - }, - { - "o": 792 - }, - { - "o": 793 - }, - { - "o": 794 - }, - { - "o": 795 - }, - { - "o": 796 - }, - { - "o": 797 - }, - { - "o": 798 - }, - { - "o": 799 - }, - { - "o": 800 - }, - { - "o": 801 - }, - { - "o": 802 - }, - { - "o": 803 - }, - { - "o": 804 - }, - { - "o": 805 - }, - { - "o": 806 - }, - { - "o": 807 - }, - { - "o": 808 - }, - { - "o": 809 - }, - { - "o": 810 - }, - { - "o": 811 - }, - { - "o": 812 - }, - { - "o": 813 - }, - { - "o": 814 - }, - { - "o": 815 - }, - { - "o": 816 - }, - { - "o": 817 - }, - { - "o": 818 - }, - { - "o": 819 - }, - { - "o": 820 - }, - { - "o": 821 - }, - { - "o": 822 - }, - { - "o": 823 - }, - { - "o": 824 - }, - { - "o": 825 - }, - { - "o": 826 - }, - { - "o": 827 - }, - { - "o": 828 - }, - { - "o": 829 - }, - { - "o": 830 - }, - { - "o": 831 - }, - { - "o": 832 - }, - { - "o": 833 - }, - { - "o": 834 - }, - { - "o": 835 - }, - { - "o": 836 - }, - { - "o": 837 - }, - { - "o": 838 - }, - { - "o": 839 - }, - { - "o": 840 - }, - { - "o": 841 - }, - { - "o": 842 - }, - { - "o": 843 - }, - { - "o": 844 - }, - { - "o": 845 - }, - { - "o": 846 - }, - { - "o": 847 - }, - { - "o": 848 - }, - { - "o": 849 - }, - { - "o": 850 - }, - { - "o": 851 - }, - { - "o": 852 - }, - { - "o": 853 - }, - { - "o": 854 - }, - { - "o": 855 - }, - { - "o": 856 - }, - { - "o": 857 - }, - { - "o": 858 - }, - { - "o": 859 - }, - { - "o": 860 - }, - { - "o": 861 - }, - { - "o": 862 - }, - { - "o": 863 - }, - { - "o": 864 - }, - { - "o": 865 - }, - { - "o": 866 - }, - { - "o": 867 - }, - { - "o": 868 - }, - { - "o": 869 - }, - { - "o": 870 - }, - { - "o": 871 - }, - { - "o": 872 - }, - { - "o": 873 - }, - { - "o": 874 - }, - { - "o": 875 - }, - { - "o": 876 - }, - { - "o": 877 - }, - { - "o": 878 - }, - { - "o": 879 - }, - { - "o": 880 - }, - { - "o": 881 - }, - { - "o": 882 - }, - { - "o": 883 - }, - { - "o": 884 - }, - { - "o": 885 - }, - { - "o": 886 - }, - { - "o": 887 - }, - { - "o": 888 - }, - { - "o": 889 - }, - { - "o": 890 - }, - { - "o": 891 - }, - { - "o": 892 - }, - { - "o": 893 - }, - { - "o": 894 - }, - { - "o": 895 - }, - { - "o": 896 - }, - { - "o": 897 - }, - { - "o": 898 - }, - { - "o": 899 - }, - { - "o": 900 - }, - { - "o": 901 - }, - { - "o": 902 - }, - { - "o": 903 - }, - { - "o": 904 - }, - { - "o": 905 - }, - { - "o": 906 - }, - { - "o": 907 - }, - { - "o": 908 - }, - { - "o": 909 - }, - { - "o": 910 - }, - { - "o": 911 - }, - { - "o": 912 - }, - { - "o": 913 - }, - { - "o": 914 - }, - { - "o": 915 - }, - { - "o": 916 - }, - { - "o": 917 - }, - { - "o": 918 - }, - { - "o": 919 - }, - { - "o": 920 - }, - { - "o": 921 - }, - { - "o": 922 - }, - { - "o": 923 - }, - { - "o": 924 - }, - { - "o": 925 - }, - { - "o": 926 - }, - { - "o": 927 - }, - { - "o": 928 - }, - { - "o": 929 - }, - { - "o": 930 - }, - { - "o": 931 - }, - { - "o": 932 - }, - { - "o": 933 - }, - { - "o": 934 - }, - { - "o": 935 - }, - { - "o": 936 - }, - { - "o": 937 - }, - { - "o": 938 - }, - { - "o": 939 - }, - { - "o": 940 - }, - { - "o": 941 - }, - { - "o": 942 - }, - { - "o": 943 - }, - { - "o": 944 - }, - { - "o": 945 - }, - { - "o": 946 - }, - { - "o": 947 - }, - { - "o": 948 - }, - { - "o": 949 - }, - { - "o": 950 - }, - { - "o": 951 - }, - { - "o": 952 - }, - { - "o": 953 - }, - { - "o": 954 - }, - { - "o": 955 - }, - { - "o": 956 - }, - { - "o": 957 - }, - { - "o": 958 - }, - { - "o": 959 - }, - { - "o": 960 - }, - { - "o": 961 - }, - { - "o": 962 - }, - { - "o": 963 - }, - { - "o": 964 - }, - { - "o": 965 - }, - { - "o": 966 - }, - { - "o": 967 - }, - { - "o": 968 - }, - { - "o": 969 - }, - { - "o": 970 - }, - { - "o": 971 - }, - { - "o": 972 - }, - { - "o": 973 - }, - { - "o": 974 - }, - { - "o": 975 - }, - { - "o": 976 - }, - { - "o": 977 - }, - { - "o": 978 - }, - { - "o": 979 - }, - { - "o": 980 - }, - { - "o": 981 - }, - { - "o": 982 - }, - { - "o": 983 - }, - { - "o": 984 - }, - { - "o": 985 - }, - { - "o": 986 - }, - { - "o": 987 - }, - { - "o": 988 - }, - { - "o": 989 - }, - { - "o": 990 - }, - { - "o": 991 - }, - { - "o": 992 - }, - { - "o": 993 - }, - { - "o": 994 - }, - { - "o": 995 - }, - { - "o": 996 - }, - { - "o": 997 - }, - { - "o": 998 - }, - { - "o": 999 - }, - { - "o": 1000 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "a87c08703c1ac542c704086ada8372ba78925a4f1e6f008d8556a92b9d9c7f2b", - "content_size_bytes": 10153, - "control_totals": [ - { - "name": "impressions", - "value": "1200", - "value_type": "integer" - }, - { - "name": "spend", - "unit": "USD", - "value": "42.17", - "value_type": "decimal" - } - ], - "digest_profile": "revision_envelope_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_three-segments_revision_reports", - "row_set_kind": "revision", - "rows": [ - { - "o": 0 - }, - { - "o": 1 - }, - { - "o": 2 - }, - { - "o": 3 - }, - { - "o": 4 - }, - { - "o": 5 - }, - { - "o": 6 - }, - { - "o": 7 - }, - { - "o": 8 - }, - { - "o": 9 - }, - { - "o": 10 - }, - { - "o": 11 - }, - { - "o": 12 - }, - { - "o": 13 - }, - { - "o": 14 - }, - { - "o": 15 - }, - { - "o": 16 - }, - { - "o": 17 - }, - { - "o": 18 - }, - { - "o": 19 - }, - { - "o": 20 - }, - { - "o": 21 - }, - { - "o": 22 - }, - { - "o": 23 - }, - { - "o": 24 - }, - { - "o": 25 - }, - { - "o": 26 - }, - { - "o": 27 - }, - { - "o": 28 - }, - { - "o": 29 - }, - { - "o": 30 - }, - { - "o": 31 - }, - { - "o": 32 - }, - { - "o": 33 - }, - { - "o": 34 - }, - { - "o": 35 - }, - { - "o": 36 - }, - { - "o": 37 - }, - { - "o": 38 - }, - { - "o": 39 - }, - { - "o": 40 - }, - { - "o": 41 - }, - { - "o": 42 - }, - { - "o": 43 - }, - { - "o": 44 - }, - { - "o": 45 - }, - { - "o": 46 - }, - { - "o": 47 - }, - { - "o": 48 - }, - { - "o": 49 - }, - { - "o": 50 - }, - { - "o": 51 - }, - { - "o": 52 - }, - { - "o": 53 - }, - { - "o": 54 - }, - { - "o": 55 - }, - { - "o": 56 - }, - { - "o": 57 - }, - { - "o": 58 - }, - { - "o": 59 - }, - { - "o": 60 - }, - { - "o": 61 - }, - { - "o": 62 - }, - { - "o": 63 - }, - { - "o": 64 - }, - { - "o": 65 - }, - { - "o": 66 - }, - { - "o": 67 - }, - { - "o": 68 - }, - { - "o": 69 - }, - { - "o": 70 - }, - { - "o": 71 - }, - { - "o": 72 - }, - { - "o": 73 - }, - { - "o": 74 - }, - { - "o": 75 - }, - { - "o": 76 - }, - { - "o": 77 - }, - { - "o": 78 - }, - { - "o": 79 - }, - { - "o": 80 - }, - { - "o": 81 - }, - { - "o": 82 - }, - { - "o": 83 - }, - { - "o": 84 - }, - { - "o": 85 - }, - { - "o": 86 - }, - { - "o": 87 - }, - { - "o": 88 - }, - { - "o": 89 - }, - { - "o": 90 - }, - { - "o": 91 - }, - { - "o": 92 - }, - { - "o": 93 - }, - { - "o": 94 - }, - { - "o": 95 - }, - { - "o": 96 - }, - { - "o": 97 - }, - { - "o": 98 - }, - { - "o": 99 - }, - { - "o": 100 - }, - { - "o": 101 - }, - { - "o": 102 - }, - { - "o": 103 - }, - { - "o": 104 - }, - { - "o": 105 - }, - { - "o": 106 - }, - { - "o": 107 - }, - { - "o": 108 - }, - { - "o": 109 - }, - { - "o": 110 - }, - { - "o": 111 - }, - { - "o": 112 - }, - { - "o": 113 - }, - { - "o": 114 - }, - { - "o": 115 - }, - { - "o": 116 - }, - { - "o": 117 - }, - { - "o": 118 - }, - { - "o": 119 - }, - { - "o": 120 - }, - { - "o": 121 - }, - { - "o": 122 - }, - { - "o": 123 - }, - { - "o": 124 - }, - { - "o": 125 - }, - { - "o": 126 - }, - { - "o": 127 - }, - { - "o": 128 - }, - { - "o": 129 - }, - { - "o": 130 - }, - { - "o": 131 - }, - { - "o": 132 - }, - { - "o": 133 - }, - { - "o": 134 - }, - { - "o": 135 - }, - { - "o": 136 - }, - { - "o": 137 - }, - { - "o": 138 - }, - { - "o": 139 - }, - { - "o": 140 - }, - { - "o": 141 - }, - { - "o": 142 - }, - { - "o": 143 - }, - { - "o": 144 - }, - { - "o": 145 - }, - { - "o": 146 - }, - { - "o": 147 - }, - { - "o": 148 - }, - { - "o": 149 - }, - { - "o": 150 - }, - { - "o": 151 - }, - { - "o": 152 - }, - { - "o": 153 - }, - { - "o": 154 - }, - { - "o": 155 - }, - { - "o": 156 - }, - { - "o": 157 - }, - { - "o": 158 - }, - { - "o": 159 - }, - { - "o": 160 - }, - { - "o": 161 - }, - { - "o": 162 - }, - { - "o": 163 - }, - { - "o": 164 - }, - { - "o": 165 - }, - { - "o": 166 - }, - { - "o": 167 - }, - { - "o": 168 - }, - { - "o": 169 - }, - { - "o": 170 - }, - { - "o": 171 - }, - { - "o": 172 - }, - { - "o": 173 - }, - { - "o": 174 - }, - { - "o": 175 - }, - { - "o": 176 - }, - { - "o": 177 - }, - { - "o": 178 - }, - { - "o": 179 - }, - { - "o": 180 - }, - { - "o": 181 - }, - { - "o": 182 - }, - { - "o": 183 - }, - { - "o": 184 - }, - { - "o": 185 - }, - { - "o": 186 - }, - { - "o": 187 - }, - { - "o": 188 - }, - { - "o": 189 - }, - { - "o": 190 - }, - { - "o": 191 - }, - { - "o": 192 - }, - { - "o": 193 - }, - { - "o": 194 - }, - { - "o": 195 - }, - { - "o": 196 - }, - { - "o": 197 - }, - { - "o": 198 - }, - { - "o": 199 - }, - { - "o": 200 - }, - { - "o": 201 - }, - { - "o": 202 - }, - { - "o": 203 - }, - { - "o": 204 - }, - { - "o": 205 - }, - { - "o": 206 - }, - { - "o": 207 - }, - { - "o": 208 - }, - { - "o": 209 - }, - { - "o": 210 - }, - { - "o": 211 - }, - { - "o": 212 - }, - { - "o": 213 - }, - { - "o": 214 - }, - { - "o": 215 - }, - { - "o": 216 - }, - { - "o": 217 - }, - { - "o": 218 - }, - { - "o": 219 - }, - { - "o": 220 - }, - { - "o": 221 - }, - { - "o": 222 - }, - { - "o": 223 - }, - { - "o": 224 - }, - { - "o": 225 - }, - { - "o": 226 - }, - { - "o": 227 - }, - { - "o": 228 - }, - { - "o": 229 - }, - { - "o": 230 - }, - { - "o": 231 - }, - { - "o": 232 - }, - { - "o": 233 - }, - { - "o": 234 - }, - { - "o": 235 - }, - { - "o": 236 - }, - { - "o": 237 - }, - { - "o": 238 - }, - { - "o": 239 - }, - { - "o": 240 - }, - { - "o": 241 - }, - { - "o": 242 - }, - { - "o": 243 - }, - { - "o": 244 - }, - { - "o": 245 - }, - { - "o": 246 - }, - { - "o": 247 - }, - { - "o": 248 - }, - { - "o": 249 - }, - { - "o": 250 - }, - { - "o": 251 - }, - { - "o": 252 - }, - { - "o": 253 - }, - { - "o": 254 - }, - { - "o": 255 - }, - { - "o": 256 - }, - { - "o": 257 - }, - { - "o": 258 - }, - { - "o": 259 - }, - { - "o": 260 - }, - { - "o": 261 - }, - { - "o": 262 - }, - { - "o": 263 - }, - { - "o": 264 - }, - { - "o": 265 - }, - { - "o": 266 - }, - { - "o": 267 - }, - { - "o": 268 - }, - { - "o": 269 - }, - { - "o": 270 - }, - { - "o": 271 - }, - { - "o": 272 - }, - { - "o": 273 - }, - { - "o": 274 - }, - { - "o": 275 - }, - { - "o": 276 - }, - { - "o": 277 - }, - { - "o": 278 - }, - { - "o": 279 - }, - { - "o": 280 - }, - { - "o": 281 - }, - { - "o": 282 - }, - { - "o": 283 - }, - { - "o": 284 - }, - { - "o": 285 - }, - { - "o": 286 - }, - { - "o": 287 - }, - { - "o": 288 - }, - { - "o": 289 - }, - { - "o": 290 - }, - { - "o": 291 - }, - { - "o": 292 - }, - { - "o": 293 - }, - { - "o": 294 - }, - { - "o": 295 - }, - { - "o": 296 - }, - { - "o": 297 - }, - { - "o": 298 - }, - { - "o": 299 - }, - { - "o": 300 - }, - { - "o": 301 - }, - { - "o": 302 - }, - { - "o": 303 - }, - { - "o": 304 - }, - { - "o": 305 - }, - { - "o": 306 - }, - { - "o": 307 - }, - { - "o": 308 - }, - { - "o": 309 - }, - { - "o": 310 - }, - { - "o": 311 - }, - { - "o": 312 - }, - { - "o": 313 - }, - { - "o": 314 - }, - { - "o": 315 - }, - { - "o": 316 - }, - { - "o": 317 - }, - { - "o": 318 - }, - { - "o": 319 - }, - { - "o": 320 - }, - { - "o": 321 - }, - { - "o": 322 - }, - { - "o": 323 - }, - { - "o": 324 - }, - { - "o": 325 - }, - { - "o": 326 - }, - { - "o": 327 - }, - { - "o": 328 - }, - { - "o": 329 - }, - { - "o": 330 - }, - { - "o": 331 - }, - { - "o": 332 - }, - { - "o": 333 - }, - { - "o": 334 - }, - { - "o": 335 - }, - { - "o": 336 - }, - { - "o": 337 - }, - { - "o": 338 - }, - { - "o": 339 - }, - { - "o": 340 - }, - { - "o": 341 - }, - { - "o": 342 - }, - { - "o": 343 - }, - { - "o": 344 - }, - { - "o": 345 - }, - { - "o": 346 - }, - { - "o": 347 - }, - { - "o": 348 - }, - { - "o": 349 - }, - { - "o": 350 - }, - { - "o": 351 - }, - { - "o": 352 - }, - { - "o": 353 - }, - { - "o": 354 - }, - { - "o": 355 - }, - { - "o": 356 - }, - { - "o": 357 - }, - { - "o": 358 - }, - { - "o": 359 - }, - { - "o": 360 - }, - { - "o": 361 - }, - { - "o": 362 - }, - { - "o": 363 - }, - { - "o": 364 - }, - { - "o": 365 - }, - { - "o": 366 - }, - { - "o": 367 - }, - { - "o": 368 - }, - { - "o": 369 - }, - { - "o": 370 - }, - { - "o": 371 - }, - { - "o": 372 - }, - { - "o": 373 - }, - { - "o": 374 - }, - { - "o": 375 - }, - { - "o": 376 - }, - { - "o": 377 - }, - { - "o": 378 - }, - { - "o": 379 - }, - { - "o": 380 - }, - { - "o": 381 - }, - { - "o": 382 - }, - { - "o": 383 - }, - { - "o": 384 - }, - { - "o": 385 - }, - { - "o": 386 - }, - { - "o": 387 - }, - { - "o": 388 - }, - { - "o": 389 - }, - { - "o": 390 - }, - { - "o": 391 - }, - { - "o": 392 - }, - { - "o": 393 - }, - { - "o": 394 - }, - { - "o": 395 - }, - { - "o": 396 - }, - { - "o": 397 - }, - { - "o": 398 - }, - { - "o": 399 - }, - { - "o": 400 - }, - { - "o": 401 - }, - { - "o": 402 - }, - { - "o": 403 - }, - { - "o": 404 - }, - { - "o": 405 - }, - { - "o": 406 - }, - { - "o": 407 - }, - { - "o": 408 - }, - { - "o": 409 - }, - { - "o": 410 - }, - { - "o": 411 - }, - { - "o": 412 - }, - { - "o": 413 - }, - { - "o": 414 - }, - { - "o": 415 - }, - { - "o": 416 - }, - { - "o": 417 - }, - { - "o": 418 - }, - { - "o": 419 - }, - { - "o": 420 - }, - { - "o": 421 - }, - { - "o": 422 - }, - { - "o": 423 - }, - { - "o": 424 - }, - { - "o": 425 - }, - { - "o": 426 - }, - { - "o": 427 - }, - { - "o": 428 - }, - { - "o": 429 - }, - { - "o": 430 - }, - { - "o": 431 - }, - { - "o": 432 - }, - { - "o": 433 - }, - { - "o": 434 - }, - { - "o": 435 - }, - { - "o": 436 - }, - { - "o": 437 - }, - { - "o": 438 - }, - { - "o": 439 - }, - { - "o": 440 - }, - { - "o": 441 - }, - { - "o": 442 - }, - { - "o": 443 - }, - { - "o": 444 - }, - { - "o": 445 - }, - { - "o": 446 - }, - { - "o": 447 - }, - { - "o": 448 - }, - { - "o": 449 - }, - { - "o": 450 - }, - { - "o": 451 - }, - { - "o": 452 - }, - { - "o": 453 - }, - { - "o": 454 - }, - { - "o": 455 - }, - { - "o": 456 - }, - { - "o": 457 - }, - { - "o": 458 - }, - { - "o": 459 - }, - { - "o": 460 - }, - { - "o": 461 - }, - { - "o": 462 - }, - { - "o": 463 - }, - { - "o": 464 - }, - { - "o": 465 - }, - { - "o": 466 - }, - { - "o": 467 - }, - { - "o": 468 - }, - { - "o": 469 - }, - { - "o": 470 - }, - { - "o": 471 - }, - { - "o": 472 - }, - { - "o": 473 - }, - { - "o": 474 - }, - { - "o": 475 - }, - { - "o": 476 - }, - { - "o": 477 - }, - { - "o": 478 - }, - { - "o": 479 - }, - { - "o": 480 - }, - { - "o": 481 - }, - { - "o": 482 - }, - { - "o": 483 - }, - { - "o": 484 - }, - { - "o": 485 - }, - { - "o": 486 - }, - { - "o": 487 - }, - { - "o": 488 - }, - { - "o": 489 - }, - { - "o": 490 - }, - { - "o": 491 - }, - { - "o": 492 - }, - { - "o": 493 - }, - { - "o": 494 - }, - { - "o": 495 - }, - { - "o": 496 - }, - { - "o": 497 - }, - { - "o": 498 - }, - { - "o": 499 - }, - { - "o": 500 - }, - { - "o": 501 - }, - { - "o": 502 - }, - { - "o": 503 - }, - { - "o": 504 - }, - { - "o": 505 - }, - { - "o": 506 - }, - { - "o": 507 - }, - { - "o": 508 - }, - { - "o": 509 - }, - { - "o": 510 - }, - { - "o": 511 - }, - { - "o": 512 - }, - { - "o": 513 - }, - { - "o": 514 - }, - { - "o": 515 - }, - { - "o": 516 - }, - { - "o": 517 - }, - { - "o": 518 - }, - { - "o": 519 - }, - { - "o": 520 - }, - { - "o": 521 - }, - { - "o": 522 - }, - { - "o": 523 - }, - { - "o": 524 - }, - { - "o": 525 - }, - { - "o": 526 - }, - { - "o": 527 - }, - { - "o": 528 - }, - { - "o": 529 - }, - { - "o": 530 - }, - { - "o": 531 - }, - { - "o": 532 - }, - { - "o": 533 - }, - { - "o": 534 - }, - { - "o": 535 - }, - { - "o": 536 - }, - { - "o": 537 - }, - { - "o": 538 - }, - { - "o": 539 - }, - { - "o": 540 - }, - { - "o": 541 - }, - { - "o": 542 - }, - { - "o": 543 - }, - { - "o": 544 - }, - { - "o": 545 - }, - { - "o": 546 - }, - { - "o": 547 - }, - { - "o": 548 - }, - { - "o": 549 - }, - { - "o": 550 - }, - { - "o": 551 - }, - { - "o": 552 - }, - { - "o": 553 - }, - { - "o": 554 - }, - { - "o": 555 - }, - { - "o": 556 - }, - { - "o": 557 - }, - { - "o": 558 - }, - { - "o": 559 - }, - { - "o": 560 - }, - { - "o": 561 - }, - { - "o": 562 - }, - { - "o": 563 - }, - { - "o": 564 - }, - { - "o": 565 - }, - { - "o": 566 - }, - { - "o": 567 - }, - { - "o": 568 - }, - { - "o": 569 - }, - { - "o": 570 - }, - { - "o": 571 - }, - { - "o": 572 - }, - { - "o": 573 - }, - { - "o": 574 - }, - { - "o": 575 - }, - { - "o": 576 - }, - { - "o": 577 - }, - { - "o": 578 - }, - { - "o": 579 - }, - { - "o": 580 - }, - { - "o": 581 - }, - { - "o": 582 - }, - { - "o": 583 - }, - { - "o": 584 - }, - { - "o": 585 - }, - { - "o": 586 - }, - { - "o": 587 - }, - { - "o": 588 - }, - { - "o": 589 - }, - { - "o": 590 - }, - { - "o": 591 - }, - { - "o": 592 - }, - { - "o": 593 - }, - { - "o": 594 - }, - { - "o": 595 - }, - { - "o": 596 - }, - { - "o": 597 - }, - { - "o": 598 - }, - { - "o": 599 - }, - { - "o": 600 - }, - { - "o": 601 - }, - { - "o": 602 - }, - { - "o": 603 - }, - { - "o": 604 - }, - { - "o": 605 - }, - { - "o": 606 - }, - { - "o": 607 - }, - { - "o": 608 - }, - { - "o": 609 - }, - { - "o": 610 - }, - { - "o": 611 - }, - { - "o": 612 - }, - { - "o": 613 - }, - { - "o": 614 - }, - { - "o": 615 - }, - { - "o": 616 - }, - { - "o": 617 - }, - { - "o": 618 - }, - { - "o": 619 - }, - { - "o": 620 - }, - { - "o": 621 - }, - { - "o": 622 - }, - { - "o": 623 - }, - { - "o": 624 - }, - { - "o": 625 - }, - { - "o": 626 - }, - { - "o": 627 - }, - { - "o": 628 - }, - { - "o": 629 - }, - { - "o": 630 - }, - { - "o": 631 - }, - { - "o": 632 - }, - { - "o": 633 - }, - { - "o": 634 - }, - { - "o": 635 - }, - { - "o": 636 - }, - { - "o": 637 - }, - { - "o": 638 - }, - { - "o": 639 - }, - { - "o": 640 - }, - { - "o": 641 - }, - { - "o": 642 - }, - { - "o": 643 - }, - { - "o": 644 - }, - { - "o": 645 - }, - { - "o": 646 - }, - { - "o": 647 - }, - { - "o": 648 - }, - { - "o": 649 - }, - { - "o": 650 - }, - { - "o": 651 - }, - { - "o": 652 - }, - { - "o": 653 - }, - { - "o": 654 - }, - { - "o": 655 - }, - { - "o": 656 - }, - { - "o": 657 - }, - { - "o": 658 - }, - { - "o": 659 - }, - { - "o": 660 - }, - { - "o": 661 - }, - { - "o": 662 - }, - { - "o": 663 - }, - { - "o": 664 - }, - { - "o": 665 - }, - { - "o": 666 - }, - { - "o": 667 - }, - { - "o": 668 - }, - { - "o": 669 - }, - { - "o": 670 - }, - { - "o": 671 - }, - { - "o": 672 - }, - { - "o": 673 - }, - { - "o": 674 - }, - { - "o": 675 - }, - { - "o": 676 - }, - { - "o": 677 - }, - { - "o": 678 - }, - { - "o": 679 - }, - { - "o": 680 - }, - { - "o": 681 - }, - { - "o": 682 - }, - { - "o": 683 - }, - { - "o": 684 - }, - { - "o": 685 - }, - { - "o": 686 - }, - { - "o": 687 - }, - { - "o": 688 - }, - { - "o": 689 - }, - { - "o": 690 - }, - { - "o": 691 - }, - { - "o": 692 - }, - { - "o": 693 - }, - { - "o": 694 - }, - { - "o": 695 - }, - { - "o": 696 - }, - { - "o": 697 - }, - { - "o": 698 - }, - { - "o": 699 - }, - { - "o": 700 - }, - { - "o": 701 - }, - { - "o": 702 - }, - { - "o": 703 - }, - { - "o": 704 - }, - { - "o": 705 - }, - { - "o": 706 - }, - { - "o": 707 - }, - { - "o": 708 - }, - { - "o": 709 - }, - { - "o": 710 - }, - { - "o": 711 - }, - { - "o": 712 - }, - { - "o": 713 - }, - { - "o": 714 - }, - { - "o": 715 - }, - { - "o": 716 - }, - { - "o": 717 - }, - { - "o": 718 - }, - { - "o": 719 - }, - { - "o": 720 - }, - { - "o": 721 - }, - { - "o": 722 - }, - { - "o": 723 - }, - { - "o": 724 - }, - { - "o": 725 - }, - { - "o": 726 - }, - { - "o": 727 - }, - { - "o": 728 - }, - { - "o": 729 - }, - { - "o": 730 - }, - { - "o": 731 - }, - { - "o": 732 - }, - { - "o": 733 - }, - { - "o": 734 - }, - { - "o": 735 - }, - { - "o": 736 - }, - { - "o": 737 - }, - { - "o": 738 - }, - { - "o": 739 - }, - { - "o": 740 - }, - { - "o": 741 - }, - { - "o": 742 - }, - { - "o": 743 - }, - { - "o": 744 - }, - { - "o": 745 - }, - { - "o": 746 - }, - { - "o": 747 - }, - { - "o": 748 - }, - { - "o": 749 - }, - { - "o": 750 - }, - { - "o": 751 - }, - { - "o": 752 - }, - { - "o": 753 - }, - { - "o": 754 - }, - { - "o": 755 - }, - { - "o": 756 - }, - { - "o": 757 - }, - { - "o": 758 - }, - { - "o": 759 - }, - { - "o": 760 - }, - { - "o": 761 - }, - { - "o": 762 - }, - { - "o": 763 - }, - { - "o": 764 - }, - { - "o": 765 - }, - { - "o": 766 - }, - { - "o": 767 - }, - { - "o": 768 - }, - { - "o": 769 - }, - { - "o": 770 - }, - { - "o": 771 - }, - { - "o": 772 - }, - { - "o": 773 - }, - { - "o": 774 - }, - { - "o": 775 - }, - { - "o": 776 - }, - { - "o": 777 - }, - { - "o": 778 - }, - { - "o": 779 - }, - { - "o": 780 - }, - { - "o": 781 - }, - { - "o": 782 - }, - { - "o": 783 - }, - { - "o": 784 - }, - { - "o": 785 - }, - { - "o": 786 - }, - { - "o": 787 - }, - { - "o": 788 - }, - { - "o": 789 - }, - { - "o": 790 - }, - { - "o": 791 - }, - { - "o": 792 - }, - { - "o": 793 - }, - { - "o": 794 - }, - { - "o": 795 - }, - { - "o": 796 - }, - { - "o": 797 - }, - { - "o": 798 - }, - { - "o": 799 - }, - { - "o": 800 - }, - { - "o": 801 - }, - { - "o": 802 - }, - { - "o": 803 - }, - { - "o": 804 - }, - { - "o": 805 - }, - { - "o": 806 - }, - { - "o": 807 - }, - { - "o": 808 - }, - { - "o": 809 - }, - { - "o": 810 - }, - { - "o": 811 - }, - { - "o": 812 - }, - { - "o": 813 - }, - { - "o": 814 - }, - { - "o": 815 - }, - { - "o": 816 - }, - { - "o": 817 - }, - { - "o": 818 - }, - { - "o": 819 - }, - { - "o": 820 - }, - { - "o": 821 - }, - { - "o": 822 - }, - { - "o": 823 - }, - { - "o": 824 - }, - { - "o": 825 - }, - { - "o": 826 - }, - { - "o": 827 - }, - { - "o": 828 - }, - { - "o": 829 - }, - { - "o": 830 - }, - { - "o": 831 - }, - { - "o": 832 - }, - { - "o": 833 - }, - { - "o": 834 - }, - { - "o": 835 - }, - { - "o": 836 - }, - { - "o": 837 - }, - { - "o": 838 - }, - { - "o": 839 - }, - { - "o": 840 - }, - { - "o": 841 - }, - { - "o": 842 - }, - { - "o": 843 - }, - { - "o": 844 - }, - { - "o": 845 - }, - { - "o": 846 - }, - { - "o": 847 - }, - { - "o": 848 - }, - { - "o": 849 - }, - { - "o": 850 - }, - { - "o": 851 - }, - { - "o": 852 - }, - { - "o": 853 - }, - { - "o": 854 - }, - { - "o": 855 - }, - { - "o": 856 - }, - { - "o": 857 - }, - { - "o": 858 - }, - { - "o": 859 - }, - { - "o": 860 - }, - { - "o": 861 - }, - { - "o": 862 - }, - { - "o": 863 - }, - { - "o": 864 - }, - { - "o": 865 - }, - { - "o": 866 - }, - { - "o": 867 - }, - { - "o": 868 - }, - { - "o": 869 - }, - { - "o": 870 - }, - { - "o": 871 - }, - { - "o": 872 - }, - { - "o": 873 - }, - { - "o": 874 - }, - { - "o": 875 - }, - { - "o": 876 - }, - { - "o": 877 - }, - { - "o": 878 - }, - { - "o": 879 - }, - { - "o": 880 - }, - { - "o": 881 - }, - { - "o": 882 - }, - { - "o": 883 - }, - { - "o": 884 - }, - { - "o": 885 - }, - { - "o": 886 - }, - { - "o": 887 - }, - { - "o": 888 - }, - { - "o": 889 - }, - { - "o": 890 - }, - { - "o": 891 - }, - { - "o": 892 - }, - { - "o": 893 - }, - { - "o": 894 - }, - { - "o": 895 - }, - { - "o": 896 - }, - { - "o": 897 - }, - { - "o": 898 - }, - { - "o": 899 - }, - { - "o": 900 - }, - { - "o": 901 - }, - { - "o": 902 - }, - { - "o": 903 - }, - { - "o": 904 - }, - { - "o": 905 - }, - { - "o": 906 - }, - { - "o": 907 - }, - { - "o": 908 - }, - { - "o": 909 - }, - { - "o": 910 - }, - { - "o": 911 - }, - { - "o": 912 - }, - { - "o": 913 - }, - { - "o": 914 - }, - { - "o": 915 - }, - { - "o": 916 - }, - { - "o": 917 - }, - { - "o": 918 - }, - { - "o": 919 - }, - { - "o": 920 - }, - { - "o": 921 - }, - { - "o": 922 - }, - { - "o": 923 - }, - { - "o": 924 - }, - { - "o": 925 - }, - { - "o": 926 - }, - { - "o": 927 - }, - { - "o": 928 - }, - { - "o": 929 - }, - { - "o": 930 - }, - { - "o": 931 - }, - { - "o": 932 - }, - { - "o": 933 - }, - { - "o": 934 - }, - { - "o": 935 - }, - { - "o": 936 - }, - { - "o": 937 - }, - { - "o": 938 - }, - { - "o": 939 - }, - { - "o": 940 - }, - { - "o": 941 - }, - { - "o": 942 - }, - { - "o": 943 - }, - { - "o": 944 - }, - { - "o": 945 - }, - { - "o": 946 - }, - { - "o": 947 - }, - { - "o": 948 - }, - { - "o": 949 - }, - { - "o": 950 - }, - { - "o": 951 - }, - { - "o": 952 - }, - { - "o": 953 - }, - { - "o": 954 - }, - { - "o": 955 - }, - { - "o": 956 - }, - { - "o": 957 - }, - { - "o": 958 - }, - { - "o": 959 - }, - { - "o": 960 - }, - { - "o": 961 - }, - { - "o": 962 - }, - { - "o": 963 - }, - { - "o": 964 - }, - { - "o": 965 - }, - { - "o": 966 - }, - { - "o": 967 - }, - { - "o": 968 - }, - { - "o": 969 - }, - { - "o": 970 - }, - { - "o": 971 - }, - { - "o": 972 - }, - { - "o": 973 - }, - { - "o": 974 - }, - { - "o": 975 - }, - { - "o": 976 - }, - { - "o": 977 - }, - { - "o": 978 - }, - { - "o": 979 - }, - { - "o": 980 - }, - { - "o": 981 - }, - { - "o": 982 - }, - { - "o": 983 - }, - { - "o": 984 - }, - { - "o": 985 - }, - { - "o": 986 - }, - { - "o": 987 - }, - { - "o": 988 - }, - { - "o": 989 - }, - { - "o": 990 - }, - { - "o": 991 - }, - { - "o": 992 - }, - { - "o": 993 - }, - { - "o": 994 - }, - { - "o": 995 - }, - { - "o": 996 - }, - { - "o": 997 - }, - { - "o": 998 - }, - { - "o": 999 - }, - { - "o": 1000 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "inline", - "content_sha256": "49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe", - "content_size_bytes": 9902, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_three-segments_adjustment_inline", - "row_set_kind": "adjustment", - "rows": [ - { - "o": 0 - }, - { - "o": 1 - }, - { - "o": 2 - }, - { - "o": 3 - }, - { - "o": 4 - }, - { - "o": 5 - }, - { - "o": 6 - }, - { - "o": 7 - }, - { - "o": 8 - }, - { - "o": 9 - }, - { - "o": 10 - }, - { - "o": 11 - }, - { - "o": 12 - }, - { - "o": 13 - }, - { - "o": 14 - }, - { - "o": 15 - }, - { - "o": 16 - }, - { - "o": 17 - }, - { - "o": 18 - }, - { - "o": 19 - }, - { - "o": 20 - }, - { - "o": 21 - }, - { - "o": 22 - }, - { - "o": 23 - }, - { - "o": 24 - }, - { - "o": 25 - }, - { - "o": 26 - }, - { - "o": 27 - }, - { - "o": 28 - }, - { - "o": 29 - }, - { - "o": 30 - }, - { - "o": 31 - }, - { - "o": 32 - }, - { - "o": 33 - }, - { - "o": 34 - }, - { - "o": 35 - }, - { - "o": 36 - }, - { - "o": 37 - }, - { - "o": 38 - }, - { - "o": 39 - }, - { - "o": 40 - }, - { - "o": 41 - }, - { - "o": 42 - }, - { - "o": 43 - }, - { - "o": 44 - }, - { - "o": 45 - }, - { - "o": 46 - }, - { - "o": 47 - }, - { - "o": 48 - }, - { - "o": 49 - }, - { - "o": 50 - }, - { - "o": 51 - }, - { - "o": 52 - }, - { - "o": 53 - }, - { - "o": 54 - }, - { - "o": 55 - }, - { - "o": 56 - }, - { - "o": 57 - }, - { - "o": 58 - }, - { - "o": 59 - }, - { - "o": 60 - }, - { - "o": 61 - }, - { - "o": 62 - }, - { - "o": 63 - }, - { - "o": 64 - }, - { - "o": 65 - }, - { - "o": 66 - }, - { - "o": 67 - }, - { - "o": 68 - }, - { - "o": 69 - }, - { - "o": 70 - }, - { - "o": 71 - }, - { - "o": 72 - }, - { - "o": 73 - }, - { - "o": 74 - }, - { - "o": 75 - }, - { - "o": 76 - }, - { - "o": 77 - }, - { - "o": 78 - }, - { - "o": 79 - }, - { - "o": 80 - }, - { - "o": 81 - }, - { - "o": 82 - }, - { - "o": 83 - }, - { - "o": 84 - }, - { - "o": 85 - }, - { - "o": 86 - }, - { - "o": 87 - }, - { - "o": 88 - }, - { - "o": 89 - }, - { - "o": 90 - }, - { - "o": 91 - }, - { - "o": 92 - }, - { - "o": 93 - }, - { - "o": 94 - }, - { - "o": 95 - }, - { - "o": 96 - }, - { - "o": 97 - }, - { - "o": 98 - }, - { - "o": 99 - }, - { - "o": 100 - }, - { - "o": 101 - }, - { - "o": 102 - }, - { - "o": 103 - }, - { - "o": 104 - }, - { - "o": 105 - }, - { - "o": 106 - }, - { - "o": 107 - }, - { - "o": 108 - }, - { - "o": 109 - }, - { - "o": 110 - }, - { - "o": 111 - }, - { - "o": 112 - }, - { - "o": 113 - }, - { - "o": 114 - }, - { - "o": 115 - }, - { - "o": 116 - }, - { - "o": 117 - }, - { - "o": 118 - }, - { - "o": 119 - }, - { - "o": 120 - }, - { - "o": 121 - }, - { - "o": 122 - }, - { - "o": 123 - }, - { - "o": 124 - }, - { - "o": 125 - }, - { - "o": 126 - }, - { - "o": 127 - }, - { - "o": 128 - }, - { - "o": 129 - }, - { - "o": 130 - }, - { - "o": 131 - }, - { - "o": 132 - }, - { - "o": 133 - }, - { - "o": 134 - }, - { - "o": 135 - }, - { - "o": 136 - }, - { - "o": 137 - }, - { - "o": 138 - }, - { - "o": 139 - }, - { - "o": 140 - }, - { - "o": 141 - }, - { - "o": 142 - }, - { - "o": 143 - }, - { - "o": 144 - }, - { - "o": 145 - }, - { - "o": 146 - }, - { - "o": 147 - }, - { - "o": 148 - }, - { - "o": 149 - }, - { - "o": 150 - }, - { - "o": 151 - }, - { - "o": 152 - }, - { - "o": 153 - }, - { - "o": 154 - }, - { - "o": 155 - }, - { - "o": 156 - }, - { - "o": 157 - }, - { - "o": 158 - }, - { - "o": 159 - }, - { - "o": 160 - }, - { - "o": 161 - }, - { - "o": 162 - }, - { - "o": 163 - }, - { - "o": 164 - }, - { - "o": 165 - }, - { - "o": 166 - }, - { - "o": 167 - }, - { - "o": 168 - }, - { - "o": 169 - }, - { - "o": 170 - }, - { - "o": 171 - }, - { - "o": 172 - }, - { - "o": 173 - }, - { - "o": 174 - }, - { - "o": 175 - }, - { - "o": 176 - }, - { - "o": 177 - }, - { - "o": 178 - }, - { - "o": 179 - }, - { - "o": 180 - }, - { - "o": 181 - }, - { - "o": 182 - }, - { - "o": 183 - }, - { - "o": 184 - }, - { - "o": 185 - }, - { - "o": 186 - }, - { - "o": 187 - }, - { - "o": 188 - }, - { - "o": 189 - }, - { - "o": 190 - }, - { - "o": 191 - }, - { - "o": 192 - }, - { - "o": 193 - }, - { - "o": 194 - }, - { - "o": 195 - }, - { - "o": 196 - }, - { - "o": 197 - }, - { - "o": 198 - }, - { - "o": 199 - }, - { - "o": 200 - }, - { - "o": 201 - }, - { - "o": 202 - }, - { - "o": 203 - }, - { - "o": 204 - }, - { - "o": 205 - }, - { - "o": 206 - }, - { - "o": 207 - }, - { - "o": 208 - }, - { - "o": 209 - }, - { - "o": 210 - }, - { - "o": 211 - }, - { - "o": 212 - }, - { - "o": 213 - }, - { - "o": 214 - }, - { - "o": 215 - }, - { - "o": 216 - }, - { - "o": 217 - }, - { - "o": 218 - }, - { - "o": 219 - }, - { - "o": 220 - }, - { - "o": 221 - }, - { - "o": 222 - }, - { - "o": 223 - }, - { - "o": 224 - }, - { - "o": 225 - }, - { - "o": 226 - }, - { - "o": 227 - }, - { - "o": 228 - }, - { - "o": 229 - }, - { - "o": 230 - }, - { - "o": 231 - }, - { - "o": 232 - }, - { - "o": 233 - }, - { - "o": 234 - }, - { - "o": 235 - }, - { - "o": 236 - }, - { - "o": 237 - }, - { - "o": 238 - }, - { - "o": 239 - }, - { - "o": 240 - }, - { - "o": 241 - }, - { - "o": 242 - }, - { - "o": 243 - }, - { - "o": 244 - }, - { - "o": 245 - }, - { - "o": 246 - }, - { - "o": 247 - }, - { - "o": 248 - }, - { - "o": 249 - }, - { - "o": 250 - }, - { - "o": 251 - }, - { - "o": 252 - }, - { - "o": 253 - }, - { - "o": 254 - }, - { - "o": 255 - }, - { - "o": 256 - }, - { - "o": 257 - }, - { - "o": 258 - }, - { - "o": 259 - }, - { - "o": 260 - }, - { - "o": 261 - }, - { - "o": 262 - }, - { - "o": 263 - }, - { - "o": 264 - }, - { - "o": 265 - }, - { - "o": 266 - }, - { - "o": 267 - }, - { - "o": 268 - }, - { - "o": 269 - }, - { - "o": 270 - }, - { - "o": 271 - }, - { - "o": 272 - }, - { - "o": 273 - }, - { - "o": 274 - }, - { - "o": 275 - }, - { - "o": 276 - }, - { - "o": 277 - }, - { - "o": 278 - }, - { - "o": 279 - }, - { - "o": 280 - }, - { - "o": 281 - }, - { - "o": 282 - }, - { - "o": 283 - }, - { - "o": 284 - }, - { - "o": 285 - }, - { - "o": 286 - }, - { - "o": 287 - }, - { - "o": 288 - }, - { - "o": 289 - }, - { - "o": 290 - }, - { - "o": 291 - }, - { - "o": 292 - }, - { - "o": 293 - }, - { - "o": 294 - }, - { - "o": 295 - }, - { - "o": 296 - }, - { - "o": 297 - }, - { - "o": 298 - }, - { - "o": 299 - }, - { - "o": 300 - }, - { - "o": 301 - }, - { - "o": 302 - }, - { - "o": 303 - }, - { - "o": 304 - }, - { - "o": 305 - }, - { - "o": 306 - }, - { - "o": 307 - }, - { - "o": 308 - }, - { - "o": 309 - }, - { - "o": 310 - }, - { - "o": 311 - }, - { - "o": 312 - }, - { - "o": 313 - }, - { - "o": 314 - }, - { - "o": 315 - }, - { - "o": 316 - }, - { - "o": 317 - }, - { - "o": 318 - }, - { - "o": 319 - }, - { - "o": 320 - }, - { - "o": 321 - }, - { - "o": 322 - }, - { - "o": 323 - }, - { - "o": 324 - }, - { - "o": 325 - }, - { - "o": 326 - }, - { - "o": 327 - }, - { - "o": 328 - }, - { - "o": 329 - }, - { - "o": 330 - }, - { - "o": 331 - }, - { - "o": 332 - }, - { - "o": 333 - }, - { - "o": 334 - }, - { - "o": 335 - }, - { - "o": 336 - }, - { - "o": 337 - }, - { - "o": 338 - }, - { - "o": 339 - }, - { - "o": 340 - }, - { - "o": 341 - }, - { - "o": 342 - }, - { - "o": 343 - }, - { - "o": 344 - }, - { - "o": 345 - }, - { - "o": 346 - }, - { - "o": 347 - }, - { - "o": 348 - }, - { - "o": 349 - }, - { - "o": 350 - }, - { - "o": 351 - }, - { - "o": 352 - }, - { - "o": 353 - }, - { - "o": 354 - }, - { - "o": 355 - }, - { - "o": 356 - }, - { - "o": 357 - }, - { - "o": 358 - }, - { - "o": 359 - }, - { - "o": 360 - }, - { - "o": 361 - }, - { - "o": 362 - }, - { - "o": 363 - }, - { - "o": 364 - }, - { - "o": 365 - }, - { - "o": 366 - }, - { - "o": 367 - }, - { - "o": 368 - }, - { - "o": 369 - }, - { - "o": 370 - }, - { - "o": 371 - }, - { - "o": 372 - }, - { - "o": 373 - }, - { - "o": 374 - }, - { - "o": 375 - }, - { - "o": 376 - }, - { - "o": 377 - }, - { - "o": 378 - }, - { - "o": 379 - }, - { - "o": 380 - }, - { - "o": 381 - }, - { - "o": 382 - }, - { - "o": 383 - }, - { - "o": 384 - }, - { - "o": 385 - }, - { - "o": 386 - }, - { - "o": 387 - }, - { - "o": 388 - }, - { - "o": 389 - }, - { - "o": 390 - }, - { - "o": 391 - }, - { - "o": 392 - }, - { - "o": 393 - }, - { - "o": 394 - }, - { - "o": 395 - }, - { - "o": 396 - }, - { - "o": 397 - }, - { - "o": 398 - }, - { - "o": 399 - }, - { - "o": 400 - }, - { - "o": 401 - }, - { - "o": 402 - }, - { - "o": 403 - }, - { - "o": 404 - }, - { - "o": 405 - }, - { - "o": 406 - }, - { - "o": 407 - }, - { - "o": 408 - }, - { - "o": 409 - }, - { - "o": 410 - }, - { - "o": 411 - }, - { - "o": 412 - }, - { - "o": 413 - }, - { - "o": 414 - }, - { - "o": 415 - }, - { - "o": 416 - }, - { - "o": 417 - }, - { - "o": 418 - }, - { - "o": 419 - }, - { - "o": 420 - }, - { - "o": 421 - }, - { - "o": 422 - }, - { - "o": 423 - }, - { - "o": 424 - }, - { - "o": 425 - }, - { - "o": 426 - }, - { - "o": 427 - }, - { - "o": 428 - }, - { - "o": 429 - }, - { - "o": 430 - }, - { - "o": 431 - }, - { - "o": 432 - }, - { - "o": 433 - }, - { - "o": 434 - }, - { - "o": 435 - }, - { - "o": 436 - }, - { - "o": 437 - }, - { - "o": 438 - }, - { - "o": 439 - }, - { - "o": 440 - }, - { - "o": 441 - }, - { - "o": 442 - }, - { - "o": 443 - }, - { - "o": 444 - }, - { - "o": 445 - }, - { - "o": 446 - }, - { - "o": 447 - }, - { - "o": 448 - }, - { - "o": 449 - }, - { - "o": 450 - }, - { - "o": 451 - }, - { - "o": 452 - }, - { - "o": 453 - }, - { - "o": 454 - }, - { - "o": 455 - }, - { - "o": 456 - }, - { - "o": 457 - }, - { - "o": 458 - }, - { - "o": 459 - }, - { - "o": 460 - }, - { - "o": 461 - }, - { - "o": 462 - }, - { - "o": 463 - }, - { - "o": 464 - }, - { - "o": 465 - }, - { - "o": 466 - }, - { - "o": 467 - }, - { - "o": 468 - }, - { - "o": 469 - }, - { - "o": 470 - }, - { - "o": 471 - }, - { - "o": 472 - }, - { - "o": 473 - }, - { - "o": 474 - }, - { - "o": 475 - }, - { - "o": 476 - }, - { - "o": 477 - }, - { - "o": 478 - }, - { - "o": 479 - }, - { - "o": 480 - }, - { - "o": 481 - }, - { - "o": 482 - }, - { - "o": 483 - }, - { - "o": 484 - }, - { - "o": 485 - }, - { - "o": 486 - }, - { - "o": 487 - }, - { - "o": 488 - }, - { - "o": 489 - }, - { - "o": 490 - }, - { - "o": 491 - }, - { - "o": 492 - }, - { - "o": 493 - }, - { - "o": 494 - }, - { - "o": 495 - }, - { - "o": 496 - }, - { - "o": 497 - }, - { - "o": 498 - }, - { - "o": 499 - }, - { - "o": 500 - }, - { - "o": 501 - }, - { - "o": 502 - }, - { - "o": 503 - }, - { - "o": 504 - }, - { - "o": 505 - }, - { - "o": 506 - }, - { - "o": 507 - }, - { - "o": 508 - }, - { - "o": 509 - }, - { - "o": 510 - }, - { - "o": 511 - }, - { - "o": 512 - }, - { - "o": 513 - }, - { - "o": 514 - }, - { - "o": 515 - }, - { - "o": 516 - }, - { - "o": 517 - }, - { - "o": 518 - }, - { - "o": 519 - }, - { - "o": 520 - }, - { - "o": 521 - }, - { - "o": 522 - }, - { - "o": 523 - }, - { - "o": 524 - }, - { - "o": 525 - }, - { - "o": 526 - }, - { - "o": 527 - }, - { - "o": 528 - }, - { - "o": 529 - }, - { - "o": 530 - }, - { - "o": 531 - }, - { - "o": 532 - }, - { - "o": 533 - }, - { - "o": 534 - }, - { - "o": 535 - }, - { - "o": 536 - }, - { - "o": 537 - }, - { - "o": 538 - }, - { - "o": 539 - }, - { - "o": 540 - }, - { - "o": 541 - }, - { - "o": 542 - }, - { - "o": 543 - }, - { - "o": 544 - }, - { - "o": 545 - }, - { - "o": 546 - }, - { - "o": 547 - }, - { - "o": 548 - }, - { - "o": 549 - }, - { - "o": 550 - }, - { - "o": 551 - }, - { - "o": 552 - }, - { - "o": 553 - }, - { - "o": 554 - }, - { - "o": 555 - }, - { - "o": 556 - }, - { - "o": 557 - }, - { - "o": 558 - }, - { - "o": 559 - }, - { - "o": 560 - }, - { - "o": 561 - }, - { - "o": 562 - }, - { - "o": 563 - }, - { - "o": 564 - }, - { - "o": 565 - }, - { - "o": 566 - }, - { - "o": 567 - }, - { - "o": 568 - }, - { - "o": 569 - }, - { - "o": 570 - }, - { - "o": 571 - }, - { - "o": 572 - }, - { - "o": 573 - }, - { - "o": 574 - }, - { - "o": 575 - }, - { - "o": 576 - }, - { - "o": 577 - }, - { - "o": 578 - }, - { - "o": 579 - }, - { - "o": 580 - }, - { - "o": 581 - }, - { - "o": 582 - }, - { - "o": 583 - }, - { - "o": 584 - }, - { - "o": 585 - }, - { - "o": 586 - }, - { - "o": 587 - }, - { - "o": 588 - }, - { - "o": 589 - }, - { - "o": 590 - }, - { - "o": 591 - }, - { - "o": 592 - }, - { - "o": 593 - }, - { - "o": 594 - }, - { - "o": 595 - }, - { - "o": 596 - }, - { - "o": 597 - }, - { - "o": 598 - }, - { - "o": 599 - }, - { - "o": 600 - }, - { - "o": 601 - }, - { - "o": 602 - }, - { - "o": 603 - }, - { - "o": 604 - }, - { - "o": 605 - }, - { - "o": 606 - }, - { - "o": 607 - }, - { - "o": 608 - }, - { - "o": 609 - }, - { - "o": 610 - }, - { - "o": 611 - }, - { - "o": 612 - }, - { - "o": 613 - }, - { - "o": 614 - }, - { - "o": 615 - }, - { - "o": 616 - }, - { - "o": 617 - }, - { - "o": 618 - }, - { - "o": 619 - }, - { - "o": 620 - }, - { - "o": 621 - }, - { - "o": 622 - }, - { - "o": 623 - }, - { - "o": 624 - }, - { - "o": 625 - }, - { - "o": 626 - }, - { - "o": 627 - }, - { - "o": 628 - }, - { - "o": 629 - }, - { - "o": 630 - }, - { - "o": 631 - }, - { - "o": 632 - }, - { - "o": 633 - }, - { - "o": 634 - }, - { - "o": 635 - }, - { - "o": 636 - }, - { - "o": 637 - }, - { - "o": 638 - }, - { - "o": 639 - }, - { - "o": 640 - }, - { - "o": 641 - }, - { - "o": 642 - }, - { - "o": 643 - }, - { - "o": 644 - }, - { - "o": 645 - }, - { - "o": 646 - }, - { - "o": 647 - }, - { - "o": 648 - }, - { - "o": 649 - }, - { - "o": 650 - }, - { - "o": 651 - }, - { - "o": 652 - }, - { - "o": 653 - }, - { - "o": 654 - }, - { - "o": 655 - }, - { - "o": 656 - }, - { - "o": 657 - }, - { - "o": 658 - }, - { - "o": 659 - }, - { - "o": 660 - }, - { - "o": 661 - }, - { - "o": 662 - }, - { - "o": 663 - }, - { - "o": 664 - }, - { - "o": 665 - }, - { - "o": 666 - }, - { - "o": 667 - }, - { - "o": 668 - }, - { - "o": 669 - }, - { - "o": 670 - }, - { - "o": 671 - }, - { - "o": 672 - }, - { - "o": 673 - }, - { - "o": 674 - }, - { - "o": 675 - }, - { - "o": 676 - }, - { - "o": 677 - }, - { - "o": 678 - }, - { - "o": 679 - }, - { - "o": 680 - }, - { - "o": 681 - }, - { - "o": 682 - }, - { - "o": 683 - }, - { - "o": 684 - }, - { - "o": 685 - }, - { - "o": 686 - }, - { - "o": 687 - }, - { - "o": 688 - }, - { - "o": 689 - }, - { - "o": 690 - }, - { - "o": 691 - }, - { - "o": 692 - }, - { - "o": 693 - }, - { - "o": 694 - }, - { - "o": 695 - }, - { - "o": 696 - }, - { - "o": 697 - }, - { - "o": 698 - }, - { - "o": 699 - }, - { - "o": 700 - }, - { - "o": 701 - }, - { - "o": 702 - }, - { - "o": 703 - }, - { - "o": 704 - }, - { - "o": 705 - }, - { - "o": 706 - }, - { - "o": 707 - }, - { - "o": 708 - }, - { - "o": 709 - }, - { - "o": 710 - }, - { - "o": 711 - }, - { - "o": 712 - }, - { - "o": 713 - }, - { - "o": 714 - }, - { - "o": 715 - }, - { - "o": 716 - }, - { - "o": 717 - }, - { - "o": 718 - }, - { - "o": 719 - }, - { - "o": 720 - }, - { - "o": 721 - }, - { - "o": 722 - }, - { - "o": 723 - }, - { - "o": 724 - }, - { - "o": 725 - }, - { - "o": 726 - }, - { - "o": 727 - }, - { - "o": 728 - }, - { - "o": 729 - }, - { - "o": 730 - }, - { - "o": 731 - }, - { - "o": 732 - }, - { - "o": 733 - }, - { - "o": 734 - }, - { - "o": 735 - }, - { - "o": 736 - }, - { - "o": 737 - }, - { - "o": 738 - }, - { - "o": 739 - }, - { - "o": 740 - }, - { - "o": 741 - }, - { - "o": 742 - }, - { - "o": 743 - }, - { - "o": 744 - }, - { - "o": 745 - }, - { - "o": 746 - }, - { - "o": 747 - }, - { - "o": 748 - }, - { - "o": 749 - }, - { - "o": 750 - }, - { - "o": 751 - }, - { - "o": 752 - }, - { - "o": 753 - }, - { - "o": 754 - }, - { - "o": 755 - }, - { - "o": 756 - }, - { - "o": 757 - }, - { - "o": 758 - }, - { - "o": 759 - }, - { - "o": 760 - }, - { - "o": 761 - }, - { - "o": 762 - }, - { - "o": 763 - }, - { - "o": 764 - }, - { - "o": 765 - }, - { - "o": 766 - }, - { - "o": 767 - }, - { - "o": 768 - }, - { - "o": 769 - }, - { - "o": 770 - }, - { - "o": 771 - }, - { - "o": 772 - }, - { - "o": 773 - }, - { - "o": 774 - }, - { - "o": 775 - }, - { - "o": 776 - }, - { - "o": 777 - }, - { - "o": 778 - }, - { - "o": 779 - }, - { - "o": 780 - }, - { - "o": 781 - }, - { - "o": 782 - }, - { - "o": 783 - }, - { - "o": 784 - }, - { - "o": 785 - }, - { - "o": 786 - }, - { - "o": 787 - }, - { - "o": 788 - }, - { - "o": 789 - }, - { - "o": 790 - }, - { - "o": 791 - }, - { - "o": 792 - }, - { - "o": 793 - }, - { - "o": 794 - }, - { - "o": 795 - }, - { - "o": 796 - }, - { - "o": 797 - }, - { - "o": 798 - }, - { - "o": 799 - }, - { - "o": 800 - }, - { - "o": 801 - }, - { - "o": 802 - }, - { - "o": 803 - }, - { - "o": 804 - }, - { - "o": 805 - }, - { - "o": 806 - }, - { - "o": 807 - }, - { - "o": 808 - }, - { - "o": 809 - }, - { - "o": 810 - }, - { - "o": 811 - }, - { - "o": 812 - }, - { - "o": 813 - }, - { - "o": 814 - }, - { - "o": 815 - }, - { - "o": 816 - }, - { - "o": 817 - }, - { - "o": 818 - }, - { - "o": 819 - }, - { - "o": 820 - }, - { - "o": 821 - }, - { - "o": 822 - }, - { - "o": 823 - }, - { - "o": 824 - }, - { - "o": 825 - }, - { - "o": 826 - }, - { - "o": 827 - }, - { - "o": 828 - }, - { - "o": 829 - }, - { - "o": 830 - }, - { - "o": 831 - }, - { - "o": 832 - }, - { - "o": 833 - }, - { - "o": 834 - }, - { - "o": 835 - }, - { - "o": 836 - }, - { - "o": 837 - }, - { - "o": 838 - }, - { - "o": 839 - }, - { - "o": 840 - }, - { - "o": 841 - }, - { - "o": 842 - }, - { - "o": 843 - }, - { - "o": 844 - }, - { - "o": 845 - }, - { - "o": 846 - }, - { - "o": 847 - }, - { - "o": 848 - }, - { - "o": 849 - }, - { - "o": 850 - }, - { - "o": 851 - }, - { - "o": 852 - }, - { - "o": 853 - }, - { - "o": 854 - }, - { - "o": 855 - }, - { - "o": 856 - }, - { - "o": 857 - }, - { - "o": 858 - }, - { - "o": 859 - }, - { - "o": 860 - }, - { - "o": 861 - }, - { - "o": 862 - }, - { - "o": 863 - }, - { - "o": 864 - }, - { - "o": 865 - }, - { - "o": 866 - }, - { - "o": 867 - }, - { - "o": 868 - }, - { - "o": 869 - }, - { - "o": 870 - }, - { - "o": 871 - }, - { - "o": 872 - }, - { - "o": 873 - }, - { - "o": 874 - }, - { - "o": 875 - }, - { - "o": 876 - }, - { - "o": 877 - }, - { - "o": 878 - }, - { - "o": 879 - }, - { - "o": 880 - }, - { - "o": 881 - }, - { - "o": 882 - }, - { - "o": 883 - }, - { - "o": 884 - }, - { - "o": 885 - }, - { - "o": 886 - }, - { - "o": 887 - }, - { - "o": 888 - }, - { - "o": 889 - }, - { - "o": 890 - }, - { - "o": 891 - }, - { - "o": 892 - }, - { - "o": 893 - }, - { - "o": 894 - }, - { - "o": 895 - }, - { - "o": 896 - }, - { - "o": 897 - }, - { - "o": 898 - }, - { - "o": 899 - }, - { - "o": 900 - }, - { - "o": 901 - }, - { - "o": 902 - }, - { - "o": 903 - }, - { - "o": 904 - }, - { - "o": 905 - }, - { - "o": 906 - }, - { - "o": 907 - }, - { - "o": 908 - }, - { - "o": 909 - }, - { - "o": 910 - }, - { - "o": 911 - }, - { - "o": 912 - }, - { - "o": 913 - }, - { - "o": 914 - }, - { - "o": 915 - }, - { - "o": 916 - }, - { - "o": 917 - }, - { - "o": 918 - }, - { - "o": 919 - }, - { - "o": 920 - }, - { - "o": 921 - }, - { - "o": 922 - }, - { - "o": 923 - }, - { - "o": 924 - }, - { - "o": 925 - }, - { - "o": 926 - }, - { - "o": 927 - }, - { - "o": 928 - }, - { - "o": 929 - }, - { - "o": 930 - }, - { - "o": 931 - }, - { - "o": 932 - }, - { - "o": 933 - }, - { - "o": 934 - }, - { - "o": 935 - }, - { - "o": 936 - }, - { - "o": 937 - }, - { - "o": 938 - }, - { - "o": 939 - }, - { - "o": 940 - }, - { - "o": 941 - }, - { - "o": 942 - }, - { - "o": 943 - }, - { - "o": 944 - }, - { - "o": 945 - }, - { - "o": 946 - }, - { - "o": 947 - }, - { - "o": 948 - }, - { - "o": 949 - }, - { - "o": 950 - }, - { - "o": 951 - }, - { - "o": 952 - }, - { - "o": 953 - }, - { - "o": 954 - }, - { - "o": 955 - }, - { - "o": 956 - }, - { - "o": 957 - }, - { - "o": 958 - }, - { - "o": 959 - }, - { - "o": 960 - }, - { - "o": 961 - }, - { - "o": 962 - }, - { - "o": 963 - }, - { - "o": 964 - }, - { - "o": 965 - }, - { - "o": 966 - }, - { - "o": 967 - }, - { - "o": 968 - }, - { - "o": 969 - }, - { - "o": 970 - }, - { - "o": 971 - }, - { - "o": 972 - }, - { - "o": 973 - }, - { - "o": 974 - }, - { - "o": 975 - }, - { - "o": 976 - }, - { - "o": 977 - }, - { - "o": 978 - }, - { - "o": 979 - }, - { - "o": 980 - }, - { - "o": 981 - }, - { - "o": 982 - }, - { - "o": 983 - }, - { - "o": 984 - }, - { - "o": 985 - }, - { - "o": 986 - }, - { - "o": 987 - }, - { - "o": 988 - }, - { - "o": 989 - }, - { - "o": 990 - }, - { - "o": 991 - }, - { - "o": 992 - }, - { - "o": 993 - }, - { - "o": 994 - }, - { - "o": 995 - }, - { - "o": 996 - }, - { - "o": 997 - }, - { - "o": 998 - }, - { - "o": 999 - }, - { - "o": 1000 - } - ] - }, - { - "account_id": "acct_interop", - "binding_id": "reports", - "content_sha256": "49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe", - "content_size_bytes": 9902, - "control_totals": null, - "digest_profile": "rows_v1", - "obligation_id": "robl_interop", - "row_set_id": "rrev_py_three-segments_adjustment_reports", - "row_set_kind": "adjustment", - "rows": [ - { - "o": 0 - }, - { - "o": 1 - }, - { - "o": 2 - }, - { - "o": 3 - }, - { - "o": 4 - }, - { - "o": 5 - }, - { - "o": 6 - }, - { - "o": 7 - }, - { - "o": 8 - }, - { - "o": 9 - }, - { - "o": 10 - }, - { - "o": 11 - }, - { - "o": 12 - }, - { - "o": 13 - }, - { - "o": 14 - }, - { - "o": 15 - }, - { - "o": 16 - }, - { - "o": 17 - }, - { - "o": 18 - }, - { - "o": 19 - }, - { - "o": 20 - }, - { - "o": 21 - }, - { - "o": 22 - }, - { - "o": 23 - }, - { - "o": 24 - }, - { - "o": 25 - }, - { - "o": 26 - }, - { - "o": 27 - }, - { - "o": 28 - }, - { - "o": 29 - }, - { - "o": 30 - }, - { - "o": 31 - }, - { - "o": 32 - }, - { - "o": 33 - }, - { - "o": 34 - }, - { - "o": 35 - }, - { - "o": 36 - }, - { - "o": 37 - }, - { - "o": 38 - }, - { - "o": 39 - }, - { - "o": 40 - }, - { - "o": 41 - }, - { - "o": 42 - }, - { - "o": 43 - }, - { - "o": 44 - }, - { - "o": 45 - }, - { - "o": 46 - }, - { - "o": 47 - }, - { - "o": 48 - }, - { - "o": 49 - }, - { - "o": 50 - }, - { - "o": 51 - }, - { - "o": 52 - }, - { - "o": 53 - }, - { - "o": 54 - }, - { - "o": 55 - }, - { - "o": 56 - }, - { - "o": 57 - }, - { - "o": 58 - }, - { - "o": 59 - }, - { - "o": 60 - }, - { - "o": 61 - }, - { - "o": 62 - }, - { - "o": 63 - }, - { - "o": 64 - }, - { - "o": 65 - }, - { - "o": 66 - }, - { - "o": 67 - }, - { - "o": 68 - }, - { - "o": 69 - }, - { - "o": 70 - }, - { - "o": 71 - }, - { - "o": 72 - }, - { - "o": 73 - }, - { - "o": 74 - }, - { - "o": 75 - }, - { - "o": 76 - }, - { - "o": 77 - }, - { - "o": 78 - }, - { - "o": 79 - }, - { - "o": 80 - }, - { - "o": 81 - }, - { - "o": 82 - }, - { - "o": 83 - }, - { - "o": 84 - }, - { - "o": 85 - }, - { - "o": 86 - }, - { - "o": 87 - }, - { - "o": 88 - }, - { - "o": 89 - }, - { - "o": 90 - }, - { - "o": 91 - }, - { - "o": 92 - }, - { - "o": 93 - }, - { - "o": 94 - }, - { - "o": 95 - }, - { - "o": 96 - }, - { - "o": 97 - }, - { - "o": 98 - }, - { - "o": 99 - }, - { - "o": 100 - }, - { - "o": 101 - }, - { - "o": 102 - }, - { - "o": 103 - }, - { - "o": 104 - }, - { - "o": 105 - }, - { - "o": 106 - }, - { - "o": 107 - }, - { - "o": 108 - }, - { - "o": 109 - }, - { - "o": 110 - }, - { - "o": 111 - }, - { - "o": 112 - }, - { - "o": 113 - }, - { - "o": 114 - }, - { - "o": 115 - }, - { - "o": 116 - }, - { - "o": 117 - }, - { - "o": 118 - }, - { - "o": 119 - }, - { - "o": 120 - }, - { - "o": 121 - }, - { - "o": 122 - }, - { - "o": 123 - }, - { - "o": 124 - }, - { - "o": 125 - }, - { - "o": 126 - }, - { - "o": 127 - }, - { - "o": 128 - }, - { - "o": 129 - }, - { - "o": 130 - }, - { - "o": 131 - }, - { - "o": 132 - }, - { - "o": 133 - }, - { - "o": 134 - }, - { - "o": 135 - }, - { - "o": 136 - }, - { - "o": 137 - }, - { - "o": 138 - }, - { - "o": 139 - }, - { - "o": 140 - }, - { - "o": 141 - }, - { - "o": 142 - }, - { - "o": 143 - }, - { - "o": 144 - }, - { - "o": 145 - }, - { - "o": 146 - }, - { - "o": 147 - }, - { - "o": 148 - }, - { - "o": 149 - }, - { - "o": 150 - }, - { - "o": 151 - }, - { - "o": 152 - }, - { - "o": 153 - }, - { - "o": 154 - }, - { - "o": 155 - }, - { - "o": 156 - }, - { - "o": 157 - }, - { - "o": 158 - }, - { - "o": 159 - }, - { - "o": 160 - }, - { - "o": 161 - }, - { - "o": 162 - }, - { - "o": 163 - }, - { - "o": 164 - }, - { - "o": 165 - }, - { - "o": 166 - }, - { - "o": 167 - }, - { - "o": 168 - }, - { - "o": 169 - }, - { - "o": 170 - }, - { - "o": 171 - }, - { - "o": 172 - }, - { - "o": 173 - }, - { - "o": 174 - }, - { - "o": 175 - }, - { - "o": 176 - }, - { - "o": 177 - }, - { - "o": 178 - }, - { - "o": 179 - }, - { - "o": 180 - }, - { - "o": 181 - }, - { - "o": 182 - }, - { - "o": 183 - }, - { - "o": 184 - }, - { - "o": 185 - }, - { - "o": 186 - }, - { - "o": 187 - }, - { - "o": 188 - }, - { - "o": 189 - }, - { - "o": 190 - }, - { - "o": 191 - }, - { - "o": 192 - }, - { - "o": 193 - }, - { - "o": 194 - }, - { - "o": 195 - }, - { - "o": 196 - }, - { - "o": 197 - }, - { - "o": 198 - }, - { - "o": 199 - }, - { - "o": 200 - }, - { - "o": 201 - }, - { - "o": 202 - }, - { - "o": 203 - }, - { - "o": 204 - }, - { - "o": 205 - }, - { - "o": 206 - }, - { - "o": 207 - }, - { - "o": 208 - }, - { - "o": 209 - }, - { - "o": 210 - }, - { - "o": 211 - }, - { - "o": 212 - }, - { - "o": 213 - }, - { - "o": 214 - }, - { - "o": 215 - }, - { - "o": 216 - }, - { - "o": 217 - }, - { - "o": 218 - }, - { - "o": 219 - }, - { - "o": 220 - }, - { - "o": 221 - }, - { - "o": 222 - }, - { - "o": 223 - }, - { - "o": 224 - }, - { - "o": 225 - }, - { - "o": 226 - }, - { - "o": 227 - }, - { - "o": 228 - }, - { - "o": 229 - }, - { - "o": 230 - }, - { - "o": 231 - }, - { - "o": 232 - }, - { - "o": 233 - }, - { - "o": 234 - }, - { - "o": 235 - }, - { - "o": 236 - }, - { - "o": 237 - }, - { - "o": 238 - }, - { - "o": 239 - }, - { - "o": 240 - }, - { - "o": 241 - }, - { - "o": 242 - }, - { - "o": 243 - }, - { - "o": 244 - }, - { - "o": 245 - }, - { - "o": 246 - }, - { - "o": 247 - }, - { - "o": 248 - }, - { - "o": 249 - }, - { - "o": 250 - }, - { - "o": 251 - }, - { - "o": 252 - }, - { - "o": 253 - }, - { - "o": 254 - }, - { - "o": 255 - }, - { - "o": 256 - }, - { - "o": 257 - }, - { - "o": 258 - }, - { - "o": 259 - }, - { - "o": 260 - }, - { - "o": 261 - }, - { - "o": 262 - }, - { - "o": 263 - }, - { - "o": 264 - }, - { - "o": 265 - }, - { - "o": 266 - }, - { - "o": 267 - }, - { - "o": 268 - }, - { - "o": 269 - }, - { - "o": 270 - }, - { - "o": 271 - }, - { - "o": 272 - }, - { - "o": 273 - }, - { - "o": 274 - }, - { - "o": 275 - }, - { - "o": 276 - }, - { - "o": 277 - }, - { - "o": 278 - }, - { - "o": 279 - }, - { - "o": 280 - }, - { - "o": 281 - }, - { - "o": 282 - }, - { - "o": 283 - }, - { - "o": 284 - }, - { - "o": 285 - }, - { - "o": 286 - }, - { - "o": 287 - }, - { - "o": 288 - }, - { - "o": 289 - }, - { - "o": 290 - }, - { - "o": 291 - }, - { - "o": 292 - }, - { - "o": 293 - }, - { - "o": 294 - }, - { - "o": 295 - }, - { - "o": 296 - }, - { - "o": 297 - }, - { - "o": 298 - }, - { - "o": 299 - }, - { - "o": 300 - }, - { - "o": 301 - }, - { - "o": 302 - }, - { - "o": 303 - }, - { - "o": 304 - }, - { - "o": 305 - }, - { - "o": 306 - }, - { - "o": 307 - }, - { - "o": 308 - }, - { - "o": 309 - }, - { - "o": 310 - }, - { - "o": 311 - }, - { - "o": 312 - }, - { - "o": 313 - }, - { - "o": 314 - }, - { - "o": 315 - }, - { - "o": 316 - }, - { - "o": 317 - }, - { - "o": 318 - }, - { - "o": 319 - }, - { - "o": 320 - }, - { - "o": 321 - }, - { - "o": 322 - }, - { - "o": 323 - }, - { - "o": 324 - }, - { - "o": 325 - }, - { - "o": 326 - }, - { - "o": 327 - }, - { - "o": 328 - }, - { - "o": 329 - }, - { - "o": 330 - }, - { - "o": 331 - }, - { - "o": 332 - }, - { - "o": 333 - }, - { - "o": 334 - }, - { - "o": 335 - }, - { - "o": 336 - }, - { - "o": 337 - }, - { - "o": 338 - }, - { - "o": 339 - }, - { - "o": 340 - }, - { - "o": 341 - }, - { - "o": 342 - }, - { - "o": 343 - }, - { - "o": 344 - }, - { - "o": 345 - }, - { - "o": 346 - }, - { - "o": 347 - }, - { - "o": 348 - }, - { - "o": 349 - }, - { - "o": 350 - }, - { - "o": 351 - }, - { - "o": 352 - }, - { - "o": 353 - }, - { - "o": 354 - }, - { - "o": 355 - }, - { - "o": 356 - }, - { - "o": 357 - }, - { - "o": 358 - }, - { - "o": 359 - }, - { - "o": 360 - }, - { - "o": 361 - }, - { - "o": 362 - }, - { - "o": 363 - }, - { - "o": 364 - }, - { - "o": 365 - }, - { - "o": 366 - }, - { - "o": 367 - }, - { - "o": 368 - }, - { - "o": 369 - }, - { - "o": 370 - }, - { - "o": 371 - }, - { - "o": 372 - }, - { - "o": 373 - }, - { - "o": 374 - }, - { - "o": 375 - }, - { - "o": 376 - }, - { - "o": 377 - }, - { - "o": 378 - }, - { - "o": 379 - }, - { - "o": 380 - }, - { - "o": 381 - }, - { - "o": 382 - }, - { - "o": 383 - }, - { - "o": 384 - }, - { - "o": 385 - }, - { - "o": 386 - }, - { - "o": 387 - }, - { - "o": 388 - }, - { - "o": 389 - }, - { - "o": 390 - }, - { - "o": 391 - }, - { - "o": 392 - }, - { - "o": 393 - }, - { - "o": 394 - }, - { - "o": 395 - }, - { - "o": 396 - }, - { - "o": 397 - }, - { - "o": 398 - }, - { - "o": 399 - }, - { - "o": 400 - }, - { - "o": 401 - }, - { - "o": 402 - }, - { - "o": 403 - }, - { - "o": 404 - }, - { - "o": 405 - }, - { - "o": 406 - }, - { - "o": 407 - }, - { - "o": 408 - }, - { - "o": 409 - }, - { - "o": 410 - }, - { - "o": 411 - }, - { - "o": 412 - }, - { - "o": 413 - }, - { - "o": 414 - }, - { - "o": 415 - }, - { - "o": 416 - }, - { - "o": 417 - }, - { - "o": 418 - }, - { - "o": 419 - }, - { - "o": 420 - }, - { - "o": 421 - }, - { - "o": 422 - }, - { - "o": 423 - }, - { - "o": 424 - }, - { - "o": 425 - }, - { - "o": 426 - }, - { - "o": 427 - }, - { - "o": 428 - }, - { - "o": 429 - }, - { - "o": 430 - }, - { - "o": 431 - }, - { - "o": 432 - }, - { - "o": 433 - }, - { - "o": 434 - }, - { - "o": 435 - }, - { - "o": 436 - }, - { - "o": 437 - }, - { - "o": 438 - }, - { - "o": 439 - }, - { - "o": 440 - }, - { - "o": 441 - }, - { - "o": 442 - }, - { - "o": 443 - }, - { - "o": 444 - }, - { - "o": 445 - }, - { - "o": 446 - }, - { - "o": 447 - }, - { - "o": 448 - }, - { - "o": 449 - }, - { - "o": 450 - }, - { - "o": 451 - }, - { - "o": 452 - }, - { - "o": 453 - }, - { - "o": 454 - }, - { - "o": 455 - }, - { - "o": 456 - }, - { - "o": 457 - }, - { - "o": 458 - }, - { - "o": 459 - }, - { - "o": 460 - }, - { - "o": 461 - }, - { - "o": 462 - }, - { - "o": 463 - }, - { - "o": 464 - }, - { - "o": 465 - }, - { - "o": 466 - }, - { - "o": 467 - }, - { - "o": 468 - }, - { - "o": 469 - }, - { - "o": 470 - }, - { - "o": 471 - }, - { - "o": 472 - }, - { - "o": 473 - }, - { - "o": 474 - }, - { - "o": 475 - }, - { - "o": 476 - }, - { - "o": 477 - }, - { - "o": 478 - }, - { - "o": 479 - }, - { - "o": 480 - }, - { - "o": 481 - }, - { - "o": 482 - }, - { - "o": 483 - }, - { - "o": 484 - }, - { - "o": 485 - }, - { - "o": 486 - }, - { - "o": 487 - }, - { - "o": 488 - }, - { - "o": 489 - }, - { - "o": 490 - }, - { - "o": 491 - }, - { - "o": 492 - }, - { - "o": 493 - }, - { - "o": 494 - }, - { - "o": 495 - }, - { - "o": 496 - }, - { - "o": 497 - }, - { - "o": 498 - }, - { - "o": 499 - }, - { - "o": 500 - }, - { - "o": 501 - }, - { - "o": 502 - }, - { - "o": 503 - }, - { - "o": 504 - }, - { - "o": 505 - }, - { - "o": 506 - }, - { - "o": 507 - }, - { - "o": 508 - }, - { - "o": 509 - }, - { - "o": 510 - }, - { - "o": 511 - }, - { - "o": 512 - }, - { - "o": 513 - }, - { - "o": 514 - }, - { - "o": 515 - }, - { - "o": 516 - }, - { - "o": 517 - }, - { - "o": 518 - }, - { - "o": 519 - }, - { - "o": 520 - }, - { - "o": 521 - }, - { - "o": 522 - }, - { - "o": 523 - }, - { - "o": 524 - }, - { - "o": 525 - }, - { - "o": 526 - }, - { - "o": 527 - }, - { - "o": 528 - }, - { - "o": 529 - }, - { - "o": 530 - }, - { - "o": 531 - }, - { - "o": 532 - }, - { - "o": 533 - }, - { - "o": 534 - }, - { - "o": 535 - }, - { - "o": 536 - }, - { - "o": 537 - }, - { - "o": 538 - }, - { - "o": 539 - }, - { - "o": 540 - }, - { - "o": 541 - }, - { - "o": 542 - }, - { - "o": 543 - }, - { - "o": 544 - }, - { - "o": 545 - }, - { - "o": 546 - }, - { - "o": 547 - }, - { - "o": 548 - }, - { - "o": 549 - }, - { - "o": 550 - }, - { - "o": 551 - }, - { - "o": 552 - }, - { - "o": 553 - }, - { - "o": 554 - }, - { - "o": 555 - }, - { - "o": 556 - }, - { - "o": 557 - }, - { - "o": 558 - }, - { - "o": 559 - }, - { - "o": 560 - }, - { - "o": 561 - }, - { - "o": 562 - }, - { - "o": 563 - }, - { - "o": 564 - }, - { - "o": 565 - }, - { - "o": 566 - }, - { - "o": 567 - }, - { - "o": 568 - }, - { - "o": 569 - }, - { - "o": 570 - }, - { - "o": 571 - }, - { - "o": 572 - }, - { - "o": 573 - }, - { - "o": 574 - }, - { - "o": 575 - }, - { - "o": 576 - }, - { - "o": 577 - }, - { - "o": 578 - }, - { - "o": 579 - }, - { - "o": 580 - }, - { - "o": 581 - }, - { - "o": 582 - }, - { - "o": 583 - }, - { - "o": 584 - }, - { - "o": 585 - }, - { - "o": 586 - }, - { - "o": 587 - }, - { - "o": 588 - }, - { - "o": 589 - }, - { - "o": 590 - }, - { - "o": 591 - }, - { - "o": 592 - }, - { - "o": 593 - }, - { - "o": 594 - }, - { - "o": 595 - }, - { - "o": 596 - }, - { - "o": 597 - }, - { - "o": 598 - }, - { - "o": 599 - }, - { - "o": 600 - }, - { - "o": 601 - }, - { - "o": 602 - }, - { - "o": 603 - }, - { - "o": 604 - }, - { - "o": 605 - }, - { - "o": 606 - }, - { - "o": 607 - }, - { - "o": 608 - }, - { - "o": 609 - }, - { - "o": 610 - }, - { - "o": 611 - }, - { - "o": 612 - }, - { - "o": 613 - }, - { - "o": 614 - }, - { - "o": 615 - }, - { - "o": 616 - }, - { - "o": 617 - }, - { - "o": 618 - }, - { - "o": 619 - }, - { - "o": 620 - }, - { - "o": 621 - }, - { - "o": 622 - }, - { - "o": 623 - }, - { - "o": 624 - }, - { - "o": 625 - }, - { - "o": 626 - }, - { - "o": 627 - }, - { - "o": 628 - }, - { - "o": 629 - }, - { - "o": 630 - }, - { - "o": 631 - }, - { - "o": 632 - }, - { - "o": 633 - }, - { - "o": 634 - }, - { - "o": 635 - }, - { - "o": 636 - }, - { - "o": 637 - }, - { - "o": 638 - }, - { - "o": 639 - }, - { - "o": 640 - }, - { - "o": 641 - }, - { - "o": 642 - }, - { - "o": 643 - }, - { - "o": 644 - }, - { - "o": 645 - }, - { - "o": 646 - }, - { - "o": 647 - }, - { - "o": 648 - }, - { - "o": 649 - }, - { - "o": 650 - }, - { - "o": 651 - }, - { - "o": 652 - }, - { - "o": 653 - }, - { - "o": 654 - }, - { - "o": 655 - }, - { - "o": 656 - }, - { - "o": 657 - }, - { - "o": 658 - }, - { - "o": 659 - }, - { - "o": 660 - }, - { - "o": 661 - }, - { - "o": 662 - }, - { - "o": 663 - }, - { - "o": 664 - }, - { - "o": 665 - }, - { - "o": 666 - }, - { - "o": 667 - }, - { - "o": 668 - }, - { - "o": 669 - }, - { - "o": 670 - }, - { - "o": 671 - }, - { - "o": 672 - }, - { - "o": 673 - }, - { - "o": 674 - }, - { - "o": 675 - }, - { - "o": 676 - }, - { - "o": 677 - }, - { - "o": 678 - }, - { - "o": 679 - }, - { - "o": 680 - }, - { - "o": 681 - }, - { - "o": 682 - }, - { - "o": 683 - }, - { - "o": 684 - }, - { - "o": 685 - }, - { - "o": 686 - }, - { - "o": 687 - }, - { - "o": 688 - }, - { - "o": 689 - }, - { - "o": 690 - }, - { - "o": 691 - }, - { - "o": 692 - }, - { - "o": 693 - }, - { - "o": 694 - }, - { - "o": 695 - }, - { - "o": 696 - }, - { - "o": 697 - }, - { - "o": 698 - }, - { - "o": 699 - }, - { - "o": 700 - }, - { - "o": 701 - }, - { - "o": 702 - }, - { - "o": 703 - }, - { - "o": 704 - }, - { - "o": 705 - }, - { - "o": 706 - }, - { - "o": 707 - }, - { - "o": 708 - }, - { - "o": 709 - }, - { - "o": 710 - }, - { - "o": 711 - }, - { - "o": 712 - }, - { - "o": 713 - }, - { - "o": 714 - }, - { - "o": 715 - }, - { - "o": 716 - }, - { - "o": 717 - }, - { - "o": 718 - }, - { - "o": 719 - }, - { - "o": 720 - }, - { - "o": 721 - }, - { - "o": 722 - }, - { - "o": 723 - }, - { - "o": 724 - }, - { - "o": 725 - }, - { - "o": 726 - }, - { - "o": 727 - }, - { - "o": 728 - }, - { - "o": 729 - }, - { - "o": 730 - }, - { - "o": 731 - }, - { - "o": 732 - }, - { - "o": 733 - }, - { - "o": 734 - }, - { - "o": 735 - }, - { - "o": 736 - }, - { - "o": 737 - }, - { - "o": 738 - }, - { - "o": 739 - }, - { - "o": 740 - }, - { - "o": 741 - }, - { - "o": 742 - }, - { - "o": 743 - }, - { - "o": 744 - }, - { - "o": 745 - }, - { - "o": 746 - }, - { - "o": 747 - }, - { - "o": 748 - }, - { - "o": 749 - }, - { - "o": 750 - }, - { - "o": 751 - }, - { - "o": 752 - }, - { - "o": 753 - }, - { - "o": 754 - }, - { - "o": 755 - }, - { - "o": 756 - }, - { - "o": 757 - }, - { - "o": 758 - }, - { - "o": 759 - }, - { - "o": 760 - }, - { - "o": 761 - }, - { - "o": 762 - }, - { - "o": 763 - }, - { - "o": 764 - }, - { - "o": 765 - }, - { - "o": 766 - }, - { - "o": 767 - }, - { - "o": 768 - }, - { - "o": 769 - }, - { - "o": 770 - }, - { - "o": 771 - }, - { - "o": 772 - }, - { - "o": 773 - }, - { - "o": 774 - }, - { - "o": 775 - }, - { - "o": 776 - }, - { - "o": 777 - }, - { - "o": 778 - }, - { - "o": 779 - }, - { - "o": 780 - }, - { - "o": 781 - }, - { - "o": 782 - }, - { - "o": 783 - }, - { - "o": 784 - }, - { - "o": 785 - }, - { - "o": 786 - }, - { - "o": 787 - }, - { - "o": 788 - }, - { - "o": 789 - }, - { - "o": 790 - }, - { - "o": 791 - }, - { - "o": 792 - }, - { - "o": 793 - }, - { - "o": 794 - }, - { - "o": 795 - }, - { - "o": 796 - }, - { - "o": 797 - }, - { - "o": 798 - }, - { - "o": 799 - }, - { - "o": 800 - }, - { - "o": 801 - }, - { - "o": 802 - }, - { - "o": 803 - }, - { - "o": 804 - }, - { - "o": 805 - }, - { - "o": 806 - }, - { - "o": 807 - }, - { - "o": 808 - }, - { - "o": 809 - }, - { - "o": 810 - }, - { - "o": 811 - }, - { - "o": 812 - }, - { - "o": 813 - }, - { - "o": 814 - }, - { - "o": 815 - }, - { - "o": 816 - }, - { - "o": 817 - }, - { - "o": 818 - }, - { - "o": 819 - }, - { - "o": 820 - }, - { - "o": 821 - }, - { - "o": 822 - }, - { - "o": 823 - }, - { - "o": 824 - }, - { - "o": 825 - }, - { - "o": 826 - }, - { - "o": 827 - }, - { - "o": 828 - }, - { - "o": 829 - }, - { - "o": 830 - }, - { - "o": 831 - }, - { - "o": 832 - }, - { - "o": 833 - }, - { - "o": 834 - }, - { - "o": 835 - }, - { - "o": 836 - }, - { - "o": 837 - }, - { - "o": 838 - }, - { - "o": 839 - }, - { - "o": 840 - }, - { - "o": 841 - }, - { - "o": 842 - }, - { - "o": 843 - }, - { - "o": 844 - }, - { - "o": 845 - }, - { - "o": 846 - }, - { - "o": 847 - }, - { - "o": 848 - }, - { - "o": 849 - }, - { - "o": 850 - }, - { - "o": 851 - }, - { - "o": 852 - }, - { - "o": 853 - }, - { - "o": 854 - }, - { - "o": 855 - }, - { - "o": 856 - }, - { - "o": 857 - }, - { - "o": 858 - }, - { - "o": 859 - }, - { - "o": 860 - }, - { - "o": 861 - }, - { - "o": 862 - }, - { - "o": 863 - }, - { - "o": 864 - }, - { - "o": 865 - }, - { - "o": 866 - }, - { - "o": 867 - }, - { - "o": 868 - }, - { - "o": 869 - }, - { - "o": 870 - }, - { - "o": 871 - }, - { - "o": 872 - }, - { - "o": 873 - }, - { - "o": 874 - }, - { - "o": 875 - }, - { - "o": 876 - }, - { - "o": 877 - }, - { - "o": 878 - }, - { - "o": 879 - }, - { - "o": 880 - }, - { - "o": 881 - }, - { - "o": 882 - }, - { - "o": 883 - }, - { - "o": 884 - }, - { - "o": 885 - }, - { - "o": 886 - }, - { - "o": 887 - }, - { - "o": 888 - }, - { - "o": 889 - }, - { - "o": 890 - }, - { - "o": 891 - }, - { - "o": 892 - }, - { - "o": 893 - }, - { - "o": 894 - }, - { - "o": 895 - }, - { - "o": 896 - }, - { - "o": 897 - }, - { - "o": 898 - }, - { - "o": 899 - }, - { - "o": 900 - }, - { - "o": 901 - }, - { - "o": 902 - }, - { - "o": 903 - }, - { - "o": 904 - }, - { - "o": 905 - }, - { - "o": 906 - }, - { - "o": 907 - }, - { - "o": 908 - }, - { - "o": 909 - }, - { - "o": 910 - }, - { - "o": 911 - }, - { - "o": 912 - }, - { - "o": 913 - }, - { - "o": 914 - }, - { - "o": 915 - }, - { - "o": 916 - }, - { - "o": 917 - }, - { - "o": 918 - }, - { - "o": 919 - }, - { - "o": 920 - }, - { - "o": 921 - }, - { - "o": 922 - }, - { - "o": 923 - }, - { - "o": 924 - }, - { - "o": 925 - }, - { - "o": 926 - }, - { - "o": 927 - }, - { - "o": 928 - }, - { - "o": 929 - }, - { - "o": 930 - }, - { - "o": 931 - }, - { - "o": 932 - }, - { - "o": 933 - }, - { - "o": 934 - }, - { - "o": 935 - }, - { - "o": 936 - }, - { - "o": 937 - }, - { - "o": 938 - }, - { - "o": 939 - }, - { - "o": 940 - }, - { - "o": 941 - }, - { - "o": 942 - }, - { - "o": 943 - }, - { - "o": 944 - }, - { - "o": 945 - }, - { - "o": 946 - }, - { - "o": 947 - }, - { - "o": 948 - }, - { - "o": 949 - }, - { - "o": 950 - }, - { - "o": 951 - }, - { - "o": 952 - }, - { - "o": 953 - }, - { - "o": 954 - }, - { - "o": 955 - }, - { - "o": 956 - }, - { - "o": 957 - }, - { - "o": 958 - }, - { - "o": 959 - }, - { - "o": 960 - }, - { - "o": 961 - }, - { - "o": 962 - }, - { - "o": 963 - }, - { - "o": 964 - }, - { - "o": 965 - }, - { - "o": 966 - }, - { - "o": 967 - }, - { - "o": 968 - }, - { - "o": 969 - }, - { - "o": 970 - }, - { - "o": 971 - }, - { - "o": 972 - }, - { - "o": 973 - }, - { - "o": 974 - }, - { - "o": 975 - }, - { - "o": 976 - }, - { - "o": 977 - }, - { - "o": 978 - }, - { - "o": 979 - }, - { - "o": 980 - }, - { - "o": 981 - }, - { - "o": 982 - }, - { - "o": 983 - }, - { - "o": 984 - }, - { - "o": 985 - }, - { - "o": 986 - }, - { - "o": 987 - }, - { - "o": 988 - }, - { - "o": 989 - }, - { - "o": 990 - }, - { - "o": 991 - }, - { - "o": 992 - }, - { - "o": 993 - }, - { - "o": 994 - }, - { - "o": 995 - }, - { - "o": 996 - }, - { - "o": 997 - }, - { - "o": 998 - }, - { - "o": 999 - }, - { - "o": 1000 - } - ] - } + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"f04cd965936bf4821f54148bcaa037a7a9b4f0315e7d9cead0b6e90baed2f818","content_size_bytes":344,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_single-row_revision_inline","row_set_kind":"revision","rows":[{"date":"2026-10-01","impressions":1200,"media_buy_id":"mb_1","package_id":"pkg_1","spend":"42.17"}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"b7f659d4967acd5e1d55aaee8fd97a1e18d197a610b6c994c2bc16cc718302a8","content_size_bytes":345,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_single-row_revision_reports","row_set_kind":"revision","rows":[{"date":"2026-10-01","impressions":1200,"media_buy_id":"mb_1","package_id":"pkg_1","spend":"42.17"}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187","content_size_bytes":101,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_single-row_adjustment_inline","row_set_kind":"adjustment","rows":[{"date":"2026-10-01","impressions":1200,"media_buy_id":"mb_1","package_id":"pkg_1","spend":"42.17"}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187","content_size_bytes":101,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_single-row_adjustment_reports","row_set_kind":"adjustment","rows":[{"date":"2026-10-01","impressions":1200,"media_buy_id":"mb_1","package_id":"pkg_1","spend":"42.17"}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"795bc2f0c2979e4e3178d994692f994f34220bca84af68db33705ac6fd52efdd","content_size_bytes":467,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_unicode-and-numbers_revision_inline","row_set_kind":"revision","rows":[{"a":2,"control":"\u0007\n\"\\","name":"caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e","sep":"\u2028","z":1},{"float":0.30000000000000004,"max_int":9007199254740991,"n":0,"neg":-12.5,"nested":{"a":[],"z":[1,{"a":true,"b":null}]},"tiny":1e-07}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"9da4eb545db09a88341b27b05d81247eae4bfeea407c4bdc4a57f16f3b497869","content_size_bytes":468,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_unicode-and-numbers_revision_reports","row_set_kind":"revision","rows":[{"a":2,"control":"\u0007\n\"\\","name":"caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e","sep":"\u2028","z":1},{"float":0.30000000000000004,"max_int":9007199254740991,"n":0,"neg":-12.5,"nested":{"a":[],"z":[1,{"a":true,"b":null}]},"tiny":1e-07}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095","content_size_bytes":215,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_unicode-and-numbers_adjustment_inline","row_set_kind":"adjustment","rows":[{"a":2,"control":"\u0007\n\"\\","name":"caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e","sep":"\u2028","z":1},{"float":0.30000000000000004,"max_int":9007199254740991,"n":0,"neg":-12.5,"nested":{"a":[],"z":[1,{"a":true,"b":null}]},"tiny":1e-07}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095","content_size_bytes":215,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_unicode-and-numbers_adjustment_reports","row_set_kind":"adjustment","rows":[{"a":2,"control":"\u0007\n\"\\","name":"caf\u00e9 \ud83d\ude00 \u65e5\u672c\u8a9e","sep":"\u2028","z":1},{"float":0.30000000000000004,"max_int":9007199254740991,"n":0,"neg":-12.5,"nested":{"a":[],"z":[1,{"a":true,"b":null}]},"tiny":1e-07}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"cd76d91d96b99808a64c0a9348d81d82584153af22ea74d1138970381705f57d","content_size_bytes":10152,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_three-segments_revision_inline","row_set_kind":"revision","rows":[{"o":0},{"o":1},{"o":2},{"o":3},{"o":4},{"o":5},{"o":6},{"o":7},{"o":8},{"o":9},{"o":10},{"o":11},{"o":12},{"o":13},{"o":14},{"o":15},{"o":16},{"o":17},{"o":18},{"o":19},{"o":20},{"o":21},{"o":22},{"o":23},{"o":24},{"o":25},{"o":26},{"o":27},{"o":28},{"o":29},{"o":30},{"o":31},{"o":32},{"o":33},{"o":34},{"o":35},{"o":36},{"o":37},{"o":38},{"o":39},{"o":40},{"o":41},{"o":42},{"o":43},{"o":44},{"o":45},{"o":46},{"o":47},{"o":48},{"o":49},{"o":50},{"o":51},{"o":52},{"o":53},{"o":54},{"o":55},{"o":56},{"o":57},{"o":58},{"o":59},{"o":60},{"o":61},{"o":62},{"o":63},{"o":64},{"o":65},{"o":66},{"o":67},{"o":68},{"o":69},{"o":70},{"o":71},{"o":72},{"o":73},{"o":74},{"o":75},{"o":76},{"o":77},{"o":78},{"o":79},{"o":80},{"o":81},{"o":82},{"o":83},{"o":84},{"o":85},{"o":86},{"o":87},{"o":88},{"o":89},{"o":90},{"o":91},{"o":92},{"o":93},{"o":94},{"o":95},{"o":96},{"o":97},{"o":98},{"o":99},{"o":100},{"o":101},{"o":102},{"o":103},{"o":104},{"o":105},{"o":106},{"o":107},{"o":108},{"o":109},{"o":110},{"o":111},{"o":112},{"o":113},{"o":114},{"o":115},{"o":116},{"o":117},{"o":118},{"o":119},{"o":120},{"o":121},{"o":122},{"o":123},{"o":124},{"o":125},{"o":126},{"o":127},{"o":128},{"o":129},{"o":130},{"o":131},{"o":132},{"o":133},{"o":134},{"o":135},{"o":136},{"o":137},{"o":138},{"o":139},{"o":140},{"o":141},{"o":142},{"o":143},{"o":144},{"o":145},{"o":146},{"o":147},{"o":148},{"o":149},{"o":150},{"o":151},{"o":152},{"o":153},{"o":154},{"o":155},{"o":156},{"o":157},{"o":158},{"o":159},{"o":160},{"o":161},{"o":162},{"o":163},{"o":164},{"o":165},{"o":166},{"o":167},{"o":168},{"o":169},{"o":170},{"o":171},{"o":172},{"o":173},{"o":174},{"o":175},{"o":176},{"o":177},{"o":178},{"o":179},{"o":180},{"o":181},{"o":182},{"o":183},{"o":184},{"o":185},{"o":186},{"o":187},{"o":188},{"o":189},{"o":190},{"o":191},{"o":192},{"o":193},{"o":194},{"o":195},{"o":196},{"o":197},{"o":198},{"o":199},{"o":200},{"o":201},{"o":202},{"o":203},{"o":204},{"o":205},{"o":206},{"o":207},{"o":208},{"o":209},{"o":210},{"o":211},{"o":212},{"o":213},{"o":214},{"o":215},{"o":216},{"o":217},{"o":218},{"o":219},{"o":220},{"o":221},{"o":222},{"o":223},{"o":224},{"o":225},{"o":226},{"o":227},{"o":228},{"o":229},{"o":230},{"o":231},{"o":232},{"o":233},{"o":234},{"o":235},{"o":236},{"o":237},{"o":238},{"o":239},{"o":240},{"o":241},{"o":242},{"o":243},{"o":244},{"o":245},{"o":246},{"o":247},{"o":248},{"o":249},{"o":250},{"o":251},{"o":252},{"o":253},{"o":254},{"o":255},{"o":256},{"o":257},{"o":258},{"o":259},{"o":260},{"o":261},{"o":262},{"o":263},{"o":264},{"o":265},{"o":266},{"o":267},{"o":268},{"o":269},{"o":270},{"o":271},{"o":272},{"o":273},{"o":274},{"o":275},{"o":276},{"o":277},{"o":278},{"o":279},{"o":280},{"o":281},{"o":282},{"o":283},{"o":284},{"o":285},{"o":286},{"o":287},{"o":288},{"o":289},{"o":290},{"o":291},{"o":292},{"o":293},{"o":294},{"o":295},{"o":296},{"o":297},{"o":298},{"o":299},{"o":300},{"o":301},{"o":302},{"o":303},{"o":304},{"o":305},{"o":306},{"o":307},{"o":308},{"o":309},{"o":310},{"o":311},{"o":312},{"o":313},{"o":314},{"o":315},{"o":316},{"o":317},{"o":318},{"o":319},{"o":320},{"o":321},{"o":322},{"o":323},{"o":324},{"o":325},{"o":326},{"o":327},{"o":328},{"o":329},{"o":330},{"o":331},{"o":332},{"o":333},{"o":334},{"o":335},{"o":336},{"o":337},{"o":338},{"o":339},{"o":340},{"o":341},{"o":342},{"o":343},{"o":344},{"o":345},{"o":346},{"o":347},{"o":348},{"o":349},{"o":350},{"o":351},{"o":352},{"o":353},{"o":354},{"o":355},{"o":356},{"o":357},{"o":358},{"o":359},{"o":360},{"o":361},{"o":362},{"o":363},{"o":364},{"o":365},{"o":366},{"o":367},{"o":368},{"o":369},{"o":370},{"o":371},{"o":372},{"o":373},{"o":374},{"o":375},{"o":376},{"o":377},{"o":378},{"o":379},{"o":380},{"o":381},{"o":382},{"o":383},{"o":384},{"o":385},{"o":386},{"o":387},{"o":388},{"o":389},{"o":390},{"o":391},{"o":392},{"o":393},{"o":394},{"o":395},{"o":396},{"o":397},{"o":398},{"o":399},{"o":400},{"o":401},{"o":402},{"o":403},{"o":404},{"o":405},{"o":406},{"o":407},{"o":408},{"o":409},{"o":410},{"o":411},{"o":412},{"o":413},{"o":414},{"o":415},{"o":416},{"o":417},{"o":418},{"o":419},{"o":420},{"o":421},{"o":422},{"o":423},{"o":424},{"o":425},{"o":426},{"o":427},{"o":428},{"o":429},{"o":430},{"o":431},{"o":432},{"o":433},{"o":434},{"o":435},{"o":436},{"o":437},{"o":438},{"o":439},{"o":440},{"o":441},{"o":442},{"o":443},{"o":444},{"o":445},{"o":446},{"o":447},{"o":448},{"o":449},{"o":450},{"o":451},{"o":452},{"o":453},{"o":454},{"o":455},{"o":456},{"o":457},{"o":458},{"o":459},{"o":460},{"o":461},{"o":462},{"o":463},{"o":464},{"o":465},{"o":466},{"o":467},{"o":468},{"o":469},{"o":470},{"o":471},{"o":472},{"o":473},{"o":474},{"o":475},{"o":476},{"o":477},{"o":478},{"o":479},{"o":480},{"o":481},{"o":482},{"o":483},{"o":484},{"o":485},{"o":486},{"o":487},{"o":488},{"o":489},{"o":490},{"o":491},{"o":492},{"o":493},{"o":494},{"o":495},{"o":496},{"o":497},{"o":498},{"o":499},{"o":500},{"o":501},{"o":502},{"o":503},{"o":504},{"o":505},{"o":506},{"o":507},{"o":508},{"o":509},{"o":510},{"o":511},{"o":512},{"o":513},{"o":514},{"o":515},{"o":516},{"o":517},{"o":518},{"o":519},{"o":520},{"o":521},{"o":522},{"o":523},{"o":524},{"o":525},{"o":526},{"o":527},{"o":528},{"o":529},{"o":530},{"o":531},{"o":532},{"o":533},{"o":534},{"o":535},{"o":536},{"o":537},{"o":538},{"o":539},{"o":540},{"o":541},{"o":542},{"o":543},{"o":544},{"o":545},{"o":546},{"o":547},{"o":548},{"o":549},{"o":550},{"o":551},{"o":552},{"o":553},{"o":554},{"o":555},{"o":556},{"o":557},{"o":558},{"o":559},{"o":560},{"o":561},{"o":562},{"o":563},{"o":564},{"o":565},{"o":566},{"o":567},{"o":568},{"o":569},{"o":570},{"o":571},{"o":572},{"o":573},{"o":574},{"o":575},{"o":576},{"o":577},{"o":578},{"o":579},{"o":580},{"o":581},{"o":582},{"o":583},{"o":584},{"o":585},{"o":586},{"o":587},{"o":588},{"o":589},{"o":590},{"o":591},{"o":592},{"o":593},{"o":594},{"o":595},{"o":596},{"o":597},{"o":598},{"o":599},{"o":600},{"o":601},{"o":602},{"o":603},{"o":604},{"o":605},{"o":606},{"o":607},{"o":608},{"o":609},{"o":610},{"o":611},{"o":612},{"o":613},{"o":614},{"o":615},{"o":616},{"o":617},{"o":618},{"o":619},{"o":620},{"o":621},{"o":622},{"o":623},{"o":624},{"o":625},{"o":626},{"o":627},{"o":628},{"o":629},{"o":630},{"o":631},{"o":632},{"o":633},{"o":634},{"o":635},{"o":636},{"o":637},{"o":638},{"o":639},{"o":640},{"o":641},{"o":642},{"o":643},{"o":644},{"o":645},{"o":646},{"o":647},{"o":648},{"o":649},{"o":650},{"o":651},{"o":652},{"o":653},{"o":654},{"o":655},{"o":656},{"o":657},{"o":658},{"o":659},{"o":660},{"o":661},{"o":662},{"o":663},{"o":664},{"o":665},{"o":666},{"o":667},{"o":668},{"o":669},{"o":670},{"o":671},{"o":672},{"o":673},{"o":674},{"o":675},{"o":676},{"o":677},{"o":678},{"o":679},{"o":680},{"o":681},{"o":682},{"o":683},{"o":684},{"o":685},{"o":686},{"o":687},{"o":688},{"o":689},{"o":690},{"o":691},{"o":692},{"o":693},{"o":694},{"o":695},{"o":696},{"o":697},{"o":698},{"o":699},{"o":700},{"o":701},{"o":702},{"o":703},{"o":704},{"o":705},{"o":706},{"o":707},{"o":708},{"o":709},{"o":710},{"o":711},{"o":712},{"o":713},{"o":714},{"o":715},{"o":716},{"o":717},{"o":718},{"o":719},{"o":720},{"o":721},{"o":722},{"o":723},{"o":724},{"o":725},{"o":726},{"o":727},{"o":728},{"o":729},{"o":730},{"o":731},{"o":732},{"o":733},{"o":734},{"o":735},{"o":736},{"o":737},{"o":738},{"o":739},{"o":740},{"o":741},{"o":742},{"o":743},{"o":744},{"o":745},{"o":746},{"o":747},{"o":748},{"o":749},{"o":750},{"o":751},{"o":752},{"o":753},{"o":754},{"o":755},{"o":756},{"o":757},{"o":758},{"o":759},{"o":760},{"o":761},{"o":762},{"o":763},{"o":764},{"o":765},{"o":766},{"o":767},{"o":768},{"o":769},{"o":770},{"o":771},{"o":772},{"o":773},{"o":774},{"o":775},{"o":776},{"o":777},{"o":778},{"o":779},{"o":780},{"o":781},{"o":782},{"o":783},{"o":784},{"o":785},{"o":786},{"o":787},{"o":788},{"o":789},{"o":790},{"o":791},{"o":792},{"o":793},{"o":794},{"o":795},{"o":796},{"o":797},{"o":798},{"o":799},{"o":800},{"o":801},{"o":802},{"o":803},{"o":804},{"o":805},{"o":806},{"o":807},{"o":808},{"o":809},{"o":810},{"o":811},{"o":812},{"o":813},{"o":814},{"o":815},{"o":816},{"o":817},{"o":818},{"o":819},{"o":820},{"o":821},{"o":822},{"o":823},{"o":824},{"o":825},{"o":826},{"o":827},{"o":828},{"o":829},{"o":830},{"o":831},{"o":832},{"o":833},{"o":834},{"o":835},{"o":836},{"o":837},{"o":838},{"o":839},{"o":840},{"o":841},{"o":842},{"o":843},{"o":844},{"o":845},{"o":846},{"o":847},{"o":848},{"o":849},{"o":850},{"o":851},{"o":852},{"o":853},{"o":854},{"o":855},{"o":856},{"o":857},{"o":858},{"o":859},{"o":860},{"o":861},{"o":862},{"o":863},{"o":864},{"o":865},{"o":866},{"o":867},{"o":868},{"o":869},{"o":870},{"o":871},{"o":872},{"o":873},{"o":874},{"o":875},{"o":876},{"o":877},{"o":878},{"o":879},{"o":880},{"o":881},{"o":882},{"o":883},{"o":884},{"o":885},{"o":886},{"o":887},{"o":888},{"o":889},{"o":890},{"o":891},{"o":892},{"o":893},{"o":894},{"o":895},{"o":896},{"o":897},{"o":898},{"o":899},{"o":900},{"o":901},{"o":902},{"o":903},{"o":904},{"o":905},{"o":906},{"o":907},{"o":908},{"o":909},{"o":910},{"o":911},{"o":912},{"o":913},{"o":914},{"o":915},{"o":916},{"o":917},{"o":918},{"o":919},{"o":920},{"o":921},{"o":922},{"o":923},{"o":924},{"o":925},{"o":926},{"o":927},{"o":928},{"o":929},{"o":930},{"o":931},{"o":932},{"o":933},{"o":934},{"o":935},{"o":936},{"o":937},{"o":938},{"o":939},{"o":940},{"o":941},{"o":942},{"o":943},{"o":944},{"o":945},{"o":946},{"o":947},{"o":948},{"o":949},{"o":950},{"o":951},{"o":952},{"o":953},{"o":954},{"o":955},{"o":956},{"o":957},{"o":958},{"o":959},{"o":960},{"o":961},{"o":962},{"o":963},{"o":964},{"o":965},{"o":966},{"o":967},{"o":968},{"o":969},{"o":970},{"o":971},{"o":972},{"o":973},{"o":974},{"o":975},{"o":976},{"o":977},{"o":978},{"o":979},{"o":980},{"o":981},{"o":982},{"o":983},{"o":984},{"o":985},{"o":986},{"o":987},{"o":988},{"o":989},{"o":990},{"o":991},{"o":992},{"o":993},{"o":994},{"o":995},{"o":996},{"o":997},{"o":998},{"o":999},{"o":1000}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"a87c08703c1ac542c704086ada8372ba78925a4f1e6f008d8556a92b9d9c7f2b","content_size_bytes":10153,"control_totals":[{"name":"impressions","value":"1200","value_type":"integer"},{"name":"spend","unit":"USD","value":"42.17","value_type":"decimal"}],"digest_profile":"revision_envelope_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_three-segments_revision_reports","row_set_kind":"revision","rows":[{"o":0},{"o":1},{"o":2},{"o":3},{"o":4},{"o":5},{"o":6},{"o":7},{"o":8},{"o":9},{"o":10},{"o":11},{"o":12},{"o":13},{"o":14},{"o":15},{"o":16},{"o":17},{"o":18},{"o":19},{"o":20},{"o":21},{"o":22},{"o":23},{"o":24},{"o":25},{"o":26},{"o":27},{"o":28},{"o":29},{"o":30},{"o":31},{"o":32},{"o":33},{"o":34},{"o":35},{"o":36},{"o":37},{"o":38},{"o":39},{"o":40},{"o":41},{"o":42},{"o":43},{"o":44},{"o":45},{"o":46},{"o":47},{"o":48},{"o":49},{"o":50},{"o":51},{"o":52},{"o":53},{"o":54},{"o":55},{"o":56},{"o":57},{"o":58},{"o":59},{"o":60},{"o":61},{"o":62},{"o":63},{"o":64},{"o":65},{"o":66},{"o":67},{"o":68},{"o":69},{"o":70},{"o":71},{"o":72},{"o":73},{"o":74},{"o":75},{"o":76},{"o":77},{"o":78},{"o":79},{"o":80},{"o":81},{"o":82},{"o":83},{"o":84},{"o":85},{"o":86},{"o":87},{"o":88},{"o":89},{"o":90},{"o":91},{"o":92},{"o":93},{"o":94},{"o":95},{"o":96},{"o":97},{"o":98},{"o":99},{"o":100},{"o":101},{"o":102},{"o":103},{"o":104},{"o":105},{"o":106},{"o":107},{"o":108},{"o":109},{"o":110},{"o":111},{"o":112},{"o":113},{"o":114},{"o":115},{"o":116},{"o":117},{"o":118},{"o":119},{"o":120},{"o":121},{"o":122},{"o":123},{"o":124},{"o":125},{"o":126},{"o":127},{"o":128},{"o":129},{"o":130},{"o":131},{"o":132},{"o":133},{"o":134},{"o":135},{"o":136},{"o":137},{"o":138},{"o":139},{"o":140},{"o":141},{"o":142},{"o":143},{"o":144},{"o":145},{"o":146},{"o":147},{"o":148},{"o":149},{"o":150},{"o":151},{"o":152},{"o":153},{"o":154},{"o":155},{"o":156},{"o":157},{"o":158},{"o":159},{"o":160},{"o":161},{"o":162},{"o":163},{"o":164},{"o":165},{"o":166},{"o":167},{"o":168},{"o":169},{"o":170},{"o":171},{"o":172},{"o":173},{"o":174},{"o":175},{"o":176},{"o":177},{"o":178},{"o":179},{"o":180},{"o":181},{"o":182},{"o":183},{"o":184},{"o":185},{"o":186},{"o":187},{"o":188},{"o":189},{"o":190},{"o":191},{"o":192},{"o":193},{"o":194},{"o":195},{"o":196},{"o":197},{"o":198},{"o":199},{"o":200},{"o":201},{"o":202},{"o":203},{"o":204},{"o":205},{"o":206},{"o":207},{"o":208},{"o":209},{"o":210},{"o":211},{"o":212},{"o":213},{"o":214},{"o":215},{"o":216},{"o":217},{"o":218},{"o":219},{"o":220},{"o":221},{"o":222},{"o":223},{"o":224},{"o":225},{"o":226},{"o":227},{"o":228},{"o":229},{"o":230},{"o":231},{"o":232},{"o":233},{"o":234},{"o":235},{"o":236},{"o":237},{"o":238},{"o":239},{"o":240},{"o":241},{"o":242},{"o":243},{"o":244},{"o":245},{"o":246},{"o":247},{"o":248},{"o":249},{"o":250},{"o":251},{"o":252},{"o":253},{"o":254},{"o":255},{"o":256},{"o":257},{"o":258},{"o":259},{"o":260},{"o":261},{"o":262},{"o":263},{"o":264},{"o":265},{"o":266},{"o":267},{"o":268},{"o":269},{"o":270},{"o":271},{"o":272},{"o":273},{"o":274},{"o":275},{"o":276},{"o":277},{"o":278},{"o":279},{"o":280},{"o":281},{"o":282},{"o":283},{"o":284},{"o":285},{"o":286},{"o":287},{"o":288},{"o":289},{"o":290},{"o":291},{"o":292},{"o":293},{"o":294},{"o":295},{"o":296},{"o":297},{"o":298},{"o":299},{"o":300},{"o":301},{"o":302},{"o":303},{"o":304},{"o":305},{"o":306},{"o":307},{"o":308},{"o":309},{"o":310},{"o":311},{"o":312},{"o":313},{"o":314},{"o":315},{"o":316},{"o":317},{"o":318},{"o":319},{"o":320},{"o":321},{"o":322},{"o":323},{"o":324},{"o":325},{"o":326},{"o":327},{"o":328},{"o":329},{"o":330},{"o":331},{"o":332},{"o":333},{"o":334},{"o":335},{"o":336},{"o":337},{"o":338},{"o":339},{"o":340},{"o":341},{"o":342},{"o":343},{"o":344},{"o":345},{"o":346},{"o":347},{"o":348},{"o":349},{"o":350},{"o":351},{"o":352},{"o":353},{"o":354},{"o":355},{"o":356},{"o":357},{"o":358},{"o":359},{"o":360},{"o":361},{"o":362},{"o":363},{"o":364},{"o":365},{"o":366},{"o":367},{"o":368},{"o":369},{"o":370},{"o":371},{"o":372},{"o":373},{"o":374},{"o":375},{"o":376},{"o":377},{"o":378},{"o":379},{"o":380},{"o":381},{"o":382},{"o":383},{"o":384},{"o":385},{"o":386},{"o":387},{"o":388},{"o":389},{"o":390},{"o":391},{"o":392},{"o":393},{"o":394},{"o":395},{"o":396},{"o":397},{"o":398},{"o":399},{"o":400},{"o":401},{"o":402},{"o":403},{"o":404},{"o":405},{"o":406},{"o":407},{"o":408},{"o":409},{"o":410},{"o":411},{"o":412},{"o":413},{"o":414},{"o":415},{"o":416},{"o":417},{"o":418},{"o":419},{"o":420},{"o":421},{"o":422},{"o":423},{"o":424},{"o":425},{"o":426},{"o":427},{"o":428},{"o":429},{"o":430},{"o":431},{"o":432},{"o":433},{"o":434},{"o":435},{"o":436},{"o":437},{"o":438},{"o":439},{"o":440},{"o":441},{"o":442},{"o":443},{"o":444},{"o":445},{"o":446},{"o":447},{"o":448},{"o":449},{"o":450},{"o":451},{"o":452},{"o":453},{"o":454},{"o":455},{"o":456},{"o":457},{"o":458},{"o":459},{"o":460},{"o":461},{"o":462},{"o":463},{"o":464},{"o":465},{"o":466},{"o":467},{"o":468},{"o":469},{"o":470},{"o":471},{"o":472},{"o":473},{"o":474},{"o":475},{"o":476},{"o":477},{"o":478},{"o":479},{"o":480},{"o":481},{"o":482},{"o":483},{"o":484},{"o":485},{"o":486},{"o":487},{"o":488},{"o":489},{"o":490},{"o":491},{"o":492},{"o":493},{"o":494},{"o":495},{"o":496},{"o":497},{"o":498},{"o":499},{"o":500},{"o":501},{"o":502},{"o":503},{"o":504},{"o":505},{"o":506},{"o":507},{"o":508},{"o":509},{"o":510},{"o":511},{"o":512},{"o":513},{"o":514},{"o":515},{"o":516},{"o":517},{"o":518},{"o":519},{"o":520},{"o":521},{"o":522},{"o":523},{"o":524},{"o":525},{"o":526},{"o":527},{"o":528},{"o":529},{"o":530},{"o":531},{"o":532},{"o":533},{"o":534},{"o":535},{"o":536},{"o":537},{"o":538},{"o":539},{"o":540},{"o":541},{"o":542},{"o":543},{"o":544},{"o":545},{"o":546},{"o":547},{"o":548},{"o":549},{"o":550},{"o":551},{"o":552},{"o":553},{"o":554},{"o":555},{"o":556},{"o":557},{"o":558},{"o":559},{"o":560},{"o":561},{"o":562},{"o":563},{"o":564},{"o":565},{"o":566},{"o":567},{"o":568},{"o":569},{"o":570},{"o":571},{"o":572},{"o":573},{"o":574},{"o":575},{"o":576},{"o":577},{"o":578},{"o":579},{"o":580},{"o":581},{"o":582},{"o":583},{"o":584},{"o":585},{"o":586},{"o":587},{"o":588},{"o":589},{"o":590},{"o":591},{"o":592},{"o":593},{"o":594},{"o":595},{"o":596},{"o":597},{"o":598},{"o":599},{"o":600},{"o":601},{"o":602},{"o":603},{"o":604},{"o":605},{"o":606},{"o":607},{"o":608},{"o":609},{"o":610},{"o":611},{"o":612},{"o":613},{"o":614},{"o":615},{"o":616},{"o":617},{"o":618},{"o":619},{"o":620},{"o":621},{"o":622},{"o":623},{"o":624},{"o":625},{"o":626},{"o":627},{"o":628},{"o":629},{"o":630},{"o":631},{"o":632},{"o":633},{"o":634},{"o":635},{"o":636},{"o":637},{"o":638},{"o":639},{"o":640},{"o":641},{"o":642},{"o":643},{"o":644},{"o":645},{"o":646},{"o":647},{"o":648},{"o":649},{"o":650},{"o":651},{"o":652},{"o":653},{"o":654},{"o":655},{"o":656},{"o":657},{"o":658},{"o":659},{"o":660},{"o":661},{"o":662},{"o":663},{"o":664},{"o":665},{"o":666},{"o":667},{"o":668},{"o":669},{"o":670},{"o":671},{"o":672},{"o":673},{"o":674},{"o":675},{"o":676},{"o":677},{"o":678},{"o":679},{"o":680},{"o":681},{"o":682},{"o":683},{"o":684},{"o":685},{"o":686},{"o":687},{"o":688},{"o":689},{"o":690},{"o":691},{"o":692},{"o":693},{"o":694},{"o":695},{"o":696},{"o":697},{"o":698},{"o":699},{"o":700},{"o":701},{"o":702},{"o":703},{"o":704},{"o":705},{"o":706},{"o":707},{"o":708},{"o":709},{"o":710},{"o":711},{"o":712},{"o":713},{"o":714},{"o":715},{"o":716},{"o":717},{"o":718},{"o":719},{"o":720},{"o":721},{"o":722},{"o":723},{"o":724},{"o":725},{"o":726},{"o":727},{"o":728},{"o":729},{"o":730},{"o":731},{"o":732},{"o":733},{"o":734},{"o":735},{"o":736},{"o":737},{"o":738},{"o":739},{"o":740},{"o":741},{"o":742},{"o":743},{"o":744},{"o":745},{"o":746},{"o":747},{"o":748},{"o":749},{"o":750},{"o":751},{"o":752},{"o":753},{"o":754},{"o":755},{"o":756},{"o":757},{"o":758},{"o":759},{"o":760},{"o":761},{"o":762},{"o":763},{"o":764},{"o":765},{"o":766},{"o":767},{"o":768},{"o":769},{"o":770},{"o":771},{"o":772},{"o":773},{"o":774},{"o":775},{"o":776},{"o":777},{"o":778},{"o":779},{"o":780},{"o":781},{"o":782},{"o":783},{"o":784},{"o":785},{"o":786},{"o":787},{"o":788},{"o":789},{"o":790},{"o":791},{"o":792},{"o":793},{"o":794},{"o":795},{"o":796},{"o":797},{"o":798},{"o":799},{"o":800},{"o":801},{"o":802},{"o":803},{"o":804},{"o":805},{"o":806},{"o":807},{"o":808},{"o":809},{"o":810},{"o":811},{"o":812},{"o":813},{"o":814},{"o":815},{"o":816},{"o":817},{"o":818},{"o":819},{"o":820},{"o":821},{"o":822},{"o":823},{"o":824},{"o":825},{"o":826},{"o":827},{"o":828},{"o":829},{"o":830},{"o":831},{"o":832},{"o":833},{"o":834},{"o":835},{"o":836},{"o":837},{"o":838},{"o":839},{"o":840},{"o":841},{"o":842},{"o":843},{"o":844},{"o":845},{"o":846},{"o":847},{"o":848},{"o":849},{"o":850},{"o":851},{"o":852},{"o":853},{"o":854},{"o":855},{"o":856},{"o":857},{"o":858},{"o":859},{"o":860},{"o":861},{"o":862},{"o":863},{"o":864},{"o":865},{"o":866},{"o":867},{"o":868},{"o":869},{"o":870},{"o":871},{"o":872},{"o":873},{"o":874},{"o":875},{"o":876},{"o":877},{"o":878},{"o":879},{"o":880},{"o":881},{"o":882},{"o":883},{"o":884},{"o":885},{"o":886},{"o":887},{"o":888},{"o":889},{"o":890},{"o":891},{"o":892},{"o":893},{"o":894},{"o":895},{"o":896},{"o":897},{"o":898},{"o":899},{"o":900},{"o":901},{"o":902},{"o":903},{"o":904},{"o":905},{"o":906},{"o":907},{"o":908},{"o":909},{"o":910},{"o":911},{"o":912},{"o":913},{"o":914},{"o":915},{"o":916},{"o":917},{"o":918},{"o":919},{"o":920},{"o":921},{"o":922},{"o":923},{"o":924},{"o":925},{"o":926},{"o":927},{"o":928},{"o":929},{"o":930},{"o":931},{"o":932},{"o":933},{"o":934},{"o":935},{"o":936},{"o":937},{"o":938},{"o":939},{"o":940},{"o":941},{"o":942},{"o":943},{"o":944},{"o":945},{"o":946},{"o":947},{"o":948},{"o":949},{"o":950},{"o":951},{"o":952},{"o":953},{"o":954},{"o":955},{"o":956},{"o":957},{"o":958},{"o":959},{"o":960},{"o":961},{"o":962},{"o":963},{"o":964},{"o":965},{"o":966},{"o":967},{"o":968},{"o":969},{"o":970},{"o":971},{"o":972},{"o":973},{"o":974},{"o":975},{"o":976},{"o":977},{"o":978},{"o":979},{"o":980},{"o":981},{"o":982},{"o":983},{"o":984},{"o":985},{"o":986},{"o":987},{"o":988},{"o":989},{"o":990},{"o":991},{"o":992},{"o":993},{"o":994},{"o":995},{"o":996},{"o":997},{"o":998},{"o":999},{"o":1000}]}, + {"account_id":"acct_interop","binding_id":"inline","content_sha256":"49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe","content_size_bytes":9902,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_three-segments_adjustment_inline","row_set_kind":"adjustment","rows":[{"o":0},{"o":1},{"o":2},{"o":3},{"o":4},{"o":5},{"o":6},{"o":7},{"o":8},{"o":9},{"o":10},{"o":11},{"o":12},{"o":13},{"o":14},{"o":15},{"o":16},{"o":17},{"o":18},{"o":19},{"o":20},{"o":21},{"o":22},{"o":23},{"o":24},{"o":25},{"o":26},{"o":27},{"o":28},{"o":29},{"o":30},{"o":31},{"o":32},{"o":33},{"o":34},{"o":35},{"o":36},{"o":37},{"o":38},{"o":39},{"o":40},{"o":41},{"o":42},{"o":43},{"o":44},{"o":45},{"o":46},{"o":47},{"o":48},{"o":49},{"o":50},{"o":51},{"o":52},{"o":53},{"o":54},{"o":55},{"o":56},{"o":57},{"o":58},{"o":59},{"o":60},{"o":61},{"o":62},{"o":63},{"o":64},{"o":65},{"o":66},{"o":67},{"o":68},{"o":69},{"o":70},{"o":71},{"o":72},{"o":73},{"o":74},{"o":75},{"o":76},{"o":77},{"o":78},{"o":79},{"o":80},{"o":81},{"o":82},{"o":83},{"o":84},{"o":85},{"o":86},{"o":87},{"o":88},{"o":89},{"o":90},{"o":91},{"o":92},{"o":93},{"o":94},{"o":95},{"o":96},{"o":97},{"o":98},{"o":99},{"o":100},{"o":101},{"o":102},{"o":103},{"o":104},{"o":105},{"o":106},{"o":107},{"o":108},{"o":109},{"o":110},{"o":111},{"o":112},{"o":113},{"o":114},{"o":115},{"o":116},{"o":117},{"o":118},{"o":119},{"o":120},{"o":121},{"o":122},{"o":123},{"o":124},{"o":125},{"o":126},{"o":127},{"o":128},{"o":129},{"o":130},{"o":131},{"o":132},{"o":133},{"o":134},{"o":135},{"o":136},{"o":137},{"o":138},{"o":139},{"o":140},{"o":141},{"o":142},{"o":143},{"o":144},{"o":145},{"o":146},{"o":147},{"o":148},{"o":149},{"o":150},{"o":151},{"o":152},{"o":153},{"o":154},{"o":155},{"o":156},{"o":157},{"o":158},{"o":159},{"o":160},{"o":161},{"o":162},{"o":163},{"o":164},{"o":165},{"o":166},{"o":167},{"o":168},{"o":169},{"o":170},{"o":171},{"o":172},{"o":173},{"o":174},{"o":175},{"o":176},{"o":177},{"o":178},{"o":179},{"o":180},{"o":181},{"o":182},{"o":183},{"o":184},{"o":185},{"o":186},{"o":187},{"o":188},{"o":189},{"o":190},{"o":191},{"o":192},{"o":193},{"o":194},{"o":195},{"o":196},{"o":197},{"o":198},{"o":199},{"o":200},{"o":201},{"o":202},{"o":203},{"o":204},{"o":205},{"o":206},{"o":207},{"o":208},{"o":209},{"o":210},{"o":211},{"o":212},{"o":213},{"o":214},{"o":215},{"o":216},{"o":217},{"o":218},{"o":219},{"o":220},{"o":221},{"o":222},{"o":223},{"o":224},{"o":225},{"o":226},{"o":227},{"o":228},{"o":229},{"o":230},{"o":231},{"o":232},{"o":233},{"o":234},{"o":235},{"o":236},{"o":237},{"o":238},{"o":239},{"o":240},{"o":241},{"o":242},{"o":243},{"o":244},{"o":245},{"o":246},{"o":247},{"o":248},{"o":249},{"o":250},{"o":251},{"o":252},{"o":253},{"o":254},{"o":255},{"o":256},{"o":257},{"o":258},{"o":259},{"o":260},{"o":261},{"o":262},{"o":263},{"o":264},{"o":265},{"o":266},{"o":267},{"o":268},{"o":269},{"o":270},{"o":271},{"o":272},{"o":273},{"o":274},{"o":275},{"o":276},{"o":277},{"o":278},{"o":279},{"o":280},{"o":281},{"o":282},{"o":283},{"o":284},{"o":285},{"o":286},{"o":287},{"o":288},{"o":289},{"o":290},{"o":291},{"o":292},{"o":293},{"o":294},{"o":295},{"o":296},{"o":297},{"o":298},{"o":299},{"o":300},{"o":301},{"o":302},{"o":303},{"o":304},{"o":305},{"o":306},{"o":307},{"o":308},{"o":309},{"o":310},{"o":311},{"o":312},{"o":313},{"o":314},{"o":315},{"o":316},{"o":317},{"o":318},{"o":319},{"o":320},{"o":321},{"o":322},{"o":323},{"o":324},{"o":325},{"o":326},{"o":327},{"o":328},{"o":329},{"o":330},{"o":331},{"o":332},{"o":333},{"o":334},{"o":335},{"o":336},{"o":337},{"o":338},{"o":339},{"o":340},{"o":341},{"o":342},{"o":343},{"o":344},{"o":345},{"o":346},{"o":347},{"o":348},{"o":349},{"o":350},{"o":351},{"o":352},{"o":353},{"o":354},{"o":355},{"o":356},{"o":357},{"o":358},{"o":359},{"o":360},{"o":361},{"o":362},{"o":363},{"o":364},{"o":365},{"o":366},{"o":367},{"o":368},{"o":369},{"o":370},{"o":371},{"o":372},{"o":373},{"o":374},{"o":375},{"o":376},{"o":377},{"o":378},{"o":379},{"o":380},{"o":381},{"o":382},{"o":383},{"o":384},{"o":385},{"o":386},{"o":387},{"o":388},{"o":389},{"o":390},{"o":391},{"o":392},{"o":393},{"o":394},{"o":395},{"o":396},{"o":397},{"o":398},{"o":399},{"o":400},{"o":401},{"o":402},{"o":403},{"o":404},{"o":405},{"o":406},{"o":407},{"o":408},{"o":409},{"o":410},{"o":411},{"o":412},{"o":413},{"o":414},{"o":415},{"o":416},{"o":417},{"o":418},{"o":419},{"o":420},{"o":421},{"o":422},{"o":423},{"o":424},{"o":425},{"o":426},{"o":427},{"o":428},{"o":429},{"o":430},{"o":431},{"o":432},{"o":433},{"o":434},{"o":435},{"o":436},{"o":437},{"o":438},{"o":439},{"o":440},{"o":441},{"o":442},{"o":443},{"o":444},{"o":445},{"o":446},{"o":447},{"o":448},{"o":449},{"o":450},{"o":451},{"o":452},{"o":453},{"o":454},{"o":455},{"o":456},{"o":457},{"o":458},{"o":459},{"o":460},{"o":461},{"o":462},{"o":463},{"o":464},{"o":465},{"o":466},{"o":467},{"o":468},{"o":469},{"o":470},{"o":471},{"o":472},{"o":473},{"o":474},{"o":475},{"o":476},{"o":477},{"o":478},{"o":479},{"o":480},{"o":481},{"o":482},{"o":483},{"o":484},{"o":485},{"o":486},{"o":487},{"o":488},{"o":489},{"o":490},{"o":491},{"o":492},{"o":493},{"o":494},{"o":495},{"o":496},{"o":497},{"o":498},{"o":499},{"o":500},{"o":501},{"o":502},{"o":503},{"o":504},{"o":505},{"o":506},{"o":507},{"o":508},{"o":509},{"o":510},{"o":511},{"o":512},{"o":513},{"o":514},{"o":515},{"o":516},{"o":517},{"o":518},{"o":519},{"o":520},{"o":521},{"o":522},{"o":523},{"o":524},{"o":525},{"o":526},{"o":527},{"o":528},{"o":529},{"o":530},{"o":531},{"o":532},{"o":533},{"o":534},{"o":535},{"o":536},{"o":537},{"o":538},{"o":539},{"o":540},{"o":541},{"o":542},{"o":543},{"o":544},{"o":545},{"o":546},{"o":547},{"o":548},{"o":549},{"o":550},{"o":551},{"o":552},{"o":553},{"o":554},{"o":555},{"o":556},{"o":557},{"o":558},{"o":559},{"o":560},{"o":561},{"o":562},{"o":563},{"o":564},{"o":565},{"o":566},{"o":567},{"o":568},{"o":569},{"o":570},{"o":571},{"o":572},{"o":573},{"o":574},{"o":575},{"o":576},{"o":577},{"o":578},{"o":579},{"o":580},{"o":581},{"o":582},{"o":583},{"o":584},{"o":585},{"o":586},{"o":587},{"o":588},{"o":589},{"o":590},{"o":591},{"o":592},{"o":593},{"o":594},{"o":595},{"o":596},{"o":597},{"o":598},{"o":599},{"o":600},{"o":601},{"o":602},{"o":603},{"o":604},{"o":605},{"o":606},{"o":607},{"o":608},{"o":609},{"o":610},{"o":611},{"o":612},{"o":613},{"o":614},{"o":615},{"o":616},{"o":617},{"o":618},{"o":619},{"o":620},{"o":621},{"o":622},{"o":623},{"o":624},{"o":625},{"o":626},{"o":627},{"o":628},{"o":629},{"o":630},{"o":631},{"o":632},{"o":633},{"o":634},{"o":635},{"o":636},{"o":637},{"o":638},{"o":639},{"o":640},{"o":641},{"o":642},{"o":643},{"o":644},{"o":645},{"o":646},{"o":647},{"o":648},{"o":649},{"o":650},{"o":651},{"o":652},{"o":653},{"o":654},{"o":655},{"o":656},{"o":657},{"o":658},{"o":659},{"o":660},{"o":661},{"o":662},{"o":663},{"o":664},{"o":665},{"o":666},{"o":667},{"o":668},{"o":669},{"o":670},{"o":671},{"o":672},{"o":673},{"o":674},{"o":675},{"o":676},{"o":677},{"o":678},{"o":679},{"o":680},{"o":681},{"o":682},{"o":683},{"o":684},{"o":685},{"o":686},{"o":687},{"o":688},{"o":689},{"o":690},{"o":691},{"o":692},{"o":693},{"o":694},{"o":695},{"o":696},{"o":697},{"o":698},{"o":699},{"o":700},{"o":701},{"o":702},{"o":703},{"o":704},{"o":705},{"o":706},{"o":707},{"o":708},{"o":709},{"o":710},{"o":711},{"o":712},{"o":713},{"o":714},{"o":715},{"o":716},{"o":717},{"o":718},{"o":719},{"o":720},{"o":721},{"o":722},{"o":723},{"o":724},{"o":725},{"o":726},{"o":727},{"o":728},{"o":729},{"o":730},{"o":731},{"o":732},{"o":733},{"o":734},{"o":735},{"o":736},{"o":737},{"o":738},{"o":739},{"o":740},{"o":741},{"o":742},{"o":743},{"o":744},{"o":745},{"o":746},{"o":747},{"o":748},{"o":749},{"o":750},{"o":751},{"o":752},{"o":753},{"o":754},{"o":755},{"o":756},{"o":757},{"o":758},{"o":759},{"o":760},{"o":761},{"o":762},{"o":763},{"o":764},{"o":765},{"o":766},{"o":767},{"o":768},{"o":769},{"o":770},{"o":771},{"o":772},{"o":773},{"o":774},{"o":775},{"o":776},{"o":777},{"o":778},{"o":779},{"o":780},{"o":781},{"o":782},{"o":783},{"o":784},{"o":785},{"o":786},{"o":787},{"o":788},{"o":789},{"o":790},{"o":791},{"o":792},{"o":793},{"o":794},{"o":795},{"o":796},{"o":797},{"o":798},{"o":799},{"o":800},{"o":801},{"o":802},{"o":803},{"o":804},{"o":805},{"o":806},{"o":807},{"o":808},{"o":809},{"o":810},{"o":811},{"o":812},{"o":813},{"o":814},{"o":815},{"o":816},{"o":817},{"o":818},{"o":819},{"o":820},{"o":821},{"o":822},{"o":823},{"o":824},{"o":825},{"o":826},{"o":827},{"o":828},{"o":829},{"o":830},{"o":831},{"o":832},{"o":833},{"o":834},{"o":835},{"o":836},{"o":837},{"o":838},{"o":839},{"o":840},{"o":841},{"o":842},{"o":843},{"o":844},{"o":845},{"o":846},{"o":847},{"o":848},{"o":849},{"o":850},{"o":851},{"o":852},{"o":853},{"o":854},{"o":855},{"o":856},{"o":857},{"o":858},{"o":859},{"o":860},{"o":861},{"o":862},{"o":863},{"o":864},{"o":865},{"o":866},{"o":867},{"o":868},{"o":869},{"o":870},{"o":871},{"o":872},{"o":873},{"o":874},{"o":875},{"o":876},{"o":877},{"o":878},{"o":879},{"o":880},{"o":881},{"o":882},{"o":883},{"o":884},{"o":885},{"o":886},{"o":887},{"o":888},{"o":889},{"o":890},{"o":891},{"o":892},{"o":893},{"o":894},{"o":895},{"o":896},{"o":897},{"o":898},{"o":899},{"o":900},{"o":901},{"o":902},{"o":903},{"o":904},{"o":905},{"o":906},{"o":907},{"o":908},{"o":909},{"o":910},{"o":911},{"o":912},{"o":913},{"o":914},{"o":915},{"o":916},{"o":917},{"o":918},{"o":919},{"o":920},{"o":921},{"o":922},{"o":923},{"o":924},{"o":925},{"o":926},{"o":927},{"o":928},{"o":929},{"o":930},{"o":931},{"o":932},{"o":933},{"o":934},{"o":935},{"o":936},{"o":937},{"o":938},{"o":939},{"o":940},{"o":941},{"o":942},{"o":943},{"o":944},{"o":945},{"o":946},{"o":947},{"o":948},{"o":949},{"o":950},{"o":951},{"o":952},{"o":953},{"o":954},{"o":955},{"o":956},{"o":957},{"o":958},{"o":959},{"o":960},{"o":961},{"o":962},{"o":963},{"o":964},{"o":965},{"o":966},{"o":967},{"o":968},{"o":969},{"o":970},{"o":971},{"o":972},{"o":973},{"o":974},{"o":975},{"o":976},{"o":977},{"o":978},{"o":979},{"o":980},{"o":981},{"o":982},{"o":983},{"o":984},{"o":985},{"o":986},{"o":987},{"o":988},{"o":989},{"o":990},{"o":991},{"o":992},{"o":993},{"o":994},{"o":995},{"o":996},{"o":997},{"o":998},{"o":999},{"o":1000}]}, + {"account_id":"acct_interop","binding_id":"reports","content_sha256":"49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe","content_size_bytes":9902,"control_totals":null,"digest_profile":"rows_v1","obligation_id":"robl_interop","row_set_id":"rrev_py_three-segments_adjustment_reports","row_set_kind":"adjustment","rows":[{"o":0},{"o":1},{"o":2},{"o":3},{"o":4},{"o":5},{"o":6},{"o":7},{"o":8},{"o":9},{"o":10},{"o":11},{"o":12},{"o":13},{"o":14},{"o":15},{"o":16},{"o":17},{"o":18},{"o":19},{"o":20},{"o":21},{"o":22},{"o":23},{"o":24},{"o":25},{"o":26},{"o":27},{"o":28},{"o":29},{"o":30},{"o":31},{"o":32},{"o":33},{"o":34},{"o":35},{"o":36},{"o":37},{"o":38},{"o":39},{"o":40},{"o":41},{"o":42},{"o":43},{"o":44},{"o":45},{"o":46},{"o":47},{"o":48},{"o":49},{"o":50},{"o":51},{"o":52},{"o":53},{"o":54},{"o":55},{"o":56},{"o":57},{"o":58},{"o":59},{"o":60},{"o":61},{"o":62},{"o":63},{"o":64},{"o":65},{"o":66},{"o":67},{"o":68},{"o":69},{"o":70},{"o":71},{"o":72},{"o":73},{"o":74},{"o":75},{"o":76},{"o":77},{"o":78},{"o":79},{"o":80},{"o":81},{"o":82},{"o":83},{"o":84},{"o":85},{"o":86},{"o":87},{"o":88},{"o":89},{"o":90},{"o":91},{"o":92},{"o":93},{"o":94},{"o":95},{"o":96},{"o":97},{"o":98},{"o":99},{"o":100},{"o":101},{"o":102},{"o":103},{"o":104},{"o":105},{"o":106},{"o":107},{"o":108},{"o":109},{"o":110},{"o":111},{"o":112},{"o":113},{"o":114},{"o":115},{"o":116},{"o":117},{"o":118},{"o":119},{"o":120},{"o":121},{"o":122},{"o":123},{"o":124},{"o":125},{"o":126},{"o":127},{"o":128},{"o":129},{"o":130},{"o":131},{"o":132},{"o":133},{"o":134},{"o":135},{"o":136},{"o":137},{"o":138},{"o":139},{"o":140},{"o":141},{"o":142},{"o":143},{"o":144},{"o":145},{"o":146},{"o":147},{"o":148},{"o":149},{"o":150},{"o":151},{"o":152},{"o":153},{"o":154},{"o":155},{"o":156},{"o":157},{"o":158},{"o":159},{"o":160},{"o":161},{"o":162},{"o":163},{"o":164},{"o":165},{"o":166},{"o":167},{"o":168},{"o":169},{"o":170},{"o":171},{"o":172},{"o":173},{"o":174},{"o":175},{"o":176},{"o":177},{"o":178},{"o":179},{"o":180},{"o":181},{"o":182},{"o":183},{"o":184},{"o":185},{"o":186},{"o":187},{"o":188},{"o":189},{"o":190},{"o":191},{"o":192},{"o":193},{"o":194},{"o":195},{"o":196},{"o":197},{"o":198},{"o":199},{"o":200},{"o":201},{"o":202},{"o":203},{"o":204},{"o":205},{"o":206},{"o":207},{"o":208},{"o":209},{"o":210},{"o":211},{"o":212},{"o":213},{"o":214},{"o":215},{"o":216},{"o":217},{"o":218},{"o":219},{"o":220},{"o":221},{"o":222},{"o":223},{"o":224},{"o":225},{"o":226},{"o":227},{"o":228},{"o":229},{"o":230},{"o":231},{"o":232},{"o":233},{"o":234},{"o":235},{"o":236},{"o":237},{"o":238},{"o":239},{"o":240},{"o":241},{"o":242},{"o":243},{"o":244},{"o":245},{"o":246},{"o":247},{"o":248},{"o":249},{"o":250},{"o":251},{"o":252},{"o":253},{"o":254},{"o":255},{"o":256},{"o":257},{"o":258},{"o":259},{"o":260},{"o":261},{"o":262},{"o":263},{"o":264},{"o":265},{"o":266},{"o":267},{"o":268},{"o":269},{"o":270},{"o":271},{"o":272},{"o":273},{"o":274},{"o":275},{"o":276},{"o":277},{"o":278},{"o":279},{"o":280},{"o":281},{"o":282},{"o":283},{"o":284},{"o":285},{"o":286},{"o":287},{"o":288},{"o":289},{"o":290},{"o":291},{"o":292},{"o":293},{"o":294},{"o":295},{"o":296},{"o":297},{"o":298},{"o":299},{"o":300},{"o":301},{"o":302},{"o":303},{"o":304},{"o":305},{"o":306},{"o":307},{"o":308},{"o":309},{"o":310},{"o":311},{"o":312},{"o":313},{"o":314},{"o":315},{"o":316},{"o":317},{"o":318},{"o":319},{"o":320},{"o":321},{"o":322},{"o":323},{"o":324},{"o":325},{"o":326},{"o":327},{"o":328},{"o":329},{"o":330},{"o":331},{"o":332},{"o":333},{"o":334},{"o":335},{"o":336},{"o":337},{"o":338},{"o":339},{"o":340},{"o":341},{"o":342},{"o":343},{"o":344},{"o":345},{"o":346},{"o":347},{"o":348},{"o":349},{"o":350},{"o":351},{"o":352},{"o":353},{"o":354},{"o":355},{"o":356},{"o":357},{"o":358},{"o":359},{"o":360},{"o":361},{"o":362},{"o":363},{"o":364},{"o":365},{"o":366},{"o":367},{"o":368},{"o":369},{"o":370},{"o":371},{"o":372},{"o":373},{"o":374},{"o":375},{"o":376},{"o":377},{"o":378},{"o":379},{"o":380},{"o":381},{"o":382},{"o":383},{"o":384},{"o":385},{"o":386},{"o":387},{"o":388},{"o":389},{"o":390},{"o":391},{"o":392},{"o":393},{"o":394},{"o":395},{"o":396},{"o":397},{"o":398},{"o":399},{"o":400},{"o":401},{"o":402},{"o":403},{"o":404},{"o":405},{"o":406},{"o":407},{"o":408},{"o":409},{"o":410},{"o":411},{"o":412},{"o":413},{"o":414},{"o":415},{"o":416},{"o":417},{"o":418},{"o":419},{"o":420},{"o":421},{"o":422},{"o":423},{"o":424},{"o":425},{"o":426},{"o":427},{"o":428},{"o":429},{"o":430},{"o":431},{"o":432},{"o":433},{"o":434},{"o":435},{"o":436},{"o":437},{"o":438},{"o":439},{"o":440},{"o":441},{"o":442},{"o":443},{"o":444},{"o":445},{"o":446},{"o":447},{"o":448},{"o":449},{"o":450},{"o":451},{"o":452},{"o":453},{"o":454},{"o":455},{"o":456},{"o":457},{"o":458},{"o":459},{"o":460},{"o":461},{"o":462},{"o":463},{"o":464},{"o":465},{"o":466},{"o":467},{"o":468},{"o":469},{"o":470},{"o":471},{"o":472},{"o":473},{"o":474},{"o":475},{"o":476},{"o":477},{"o":478},{"o":479},{"o":480},{"o":481},{"o":482},{"o":483},{"o":484},{"o":485},{"o":486},{"o":487},{"o":488},{"o":489},{"o":490},{"o":491},{"o":492},{"o":493},{"o":494},{"o":495},{"o":496},{"o":497},{"o":498},{"o":499},{"o":500},{"o":501},{"o":502},{"o":503},{"o":504},{"o":505},{"o":506},{"o":507},{"o":508},{"o":509},{"o":510},{"o":511},{"o":512},{"o":513},{"o":514},{"o":515},{"o":516},{"o":517},{"o":518},{"o":519},{"o":520},{"o":521},{"o":522},{"o":523},{"o":524},{"o":525},{"o":526},{"o":527},{"o":528},{"o":529},{"o":530},{"o":531},{"o":532},{"o":533},{"o":534},{"o":535},{"o":536},{"o":537},{"o":538},{"o":539},{"o":540},{"o":541},{"o":542},{"o":543},{"o":544},{"o":545},{"o":546},{"o":547},{"o":548},{"o":549},{"o":550},{"o":551},{"o":552},{"o":553},{"o":554},{"o":555},{"o":556},{"o":557},{"o":558},{"o":559},{"o":560},{"o":561},{"o":562},{"o":563},{"o":564},{"o":565},{"o":566},{"o":567},{"o":568},{"o":569},{"o":570},{"o":571},{"o":572},{"o":573},{"o":574},{"o":575},{"o":576},{"o":577},{"o":578},{"o":579},{"o":580},{"o":581},{"o":582},{"o":583},{"o":584},{"o":585},{"o":586},{"o":587},{"o":588},{"o":589},{"o":590},{"o":591},{"o":592},{"o":593},{"o":594},{"o":595},{"o":596},{"o":597},{"o":598},{"o":599},{"o":600},{"o":601},{"o":602},{"o":603},{"o":604},{"o":605},{"o":606},{"o":607},{"o":608},{"o":609},{"o":610},{"o":611},{"o":612},{"o":613},{"o":614},{"o":615},{"o":616},{"o":617},{"o":618},{"o":619},{"o":620},{"o":621},{"o":622},{"o":623},{"o":624},{"o":625},{"o":626},{"o":627},{"o":628},{"o":629},{"o":630},{"o":631},{"o":632},{"o":633},{"o":634},{"o":635},{"o":636},{"o":637},{"o":638},{"o":639},{"o":640},{"o":641},{"o":642},{"o":643},{"o":644},{"o":645},{"o":646},{"o":647},{"o":648},{"o":649},{"o":650},{"o":651},{"o":652},{"o":653},{"o":654},{"o":655},{"o":656},{"o":657},{"o":658},{"o":659},{"o":660},{"o":661},{"o":662},{"o":663},{"o":664},{"o":665},{"o":666},{"o":667},{"o":668},{"o":669},{"o":670},{"o":671},{"o":672},{"o":673},{"o":674},{"o":675},{"o":676},{"o":677},{"o":678},{"o":679},{"o":680},{"o":681},{"o":682},{"o":683},{"o":684},{"o":685},{"o":686},{"o":687},{"o":688},{"o":689},{"o":690},{"o":691},{"o":692},{"o":693},{"o":694},{"o":695},{"o":696},{"o":697},{"o":698},{"o":699},{"o":700},{"o":701},{"o":702},{"o":703},{"o":704},{"o":705},{"o":706},{"o":707},{"o":708},{"o":709},{"o":710},{"o":711},{"o":712},{"o":713},{"o":714},{"o":715},{"o":716},{"o":717},{"o":718},{"o":719},{"o":720},{"o":721},{"o":722},{"o":723},{"o":724},{"o":725},{"o":726},{"o":727},{"o":728},{"o":729},{"o":730},{"o":731},{"o":732},{"o":733},{"o":734},{"o":735},{"o":736},{"o":737},{"o":738},{"o":739},{"o":740},{"o":741},{"o":742},{"o":743},{"o":744},{"o":745},{"o":746},{"o":747},{"o":748},{"o":749},{"o":750},{"o":751},{"o":752},{"o":753},{"o":754},{"o":755},{"o":756},{"o":757},{"o":758},{"o":759},{"o":760},{"o":761},{"o":762},{"o":763},{"o":764},{"o":765},{"o":766},{"o":767},{"o":768},{"o":769},{"o":770},{"o":771},{"o":772},{"o":773},{"o":774},{"o":775},{"o":776},{"o":777},{"o":778},{"o":779},{"o":780},{"o":781},{"o":782},{"o":783},{"o":784},{"o":785},{"o":786},{"o":787},{"o":788},{"o":789},{"o":790},{"o":791},{"o":792},{"o":793},{"o":794},{"o":795},{"o":796},{"o":797},{"o":798},{"o":799},{"o":800},{"o":801},{"o":802},{"o":803},{"o":804},{"o":805},{"o":806},{"o":807},{"o":808},{"o":809},{"o":810},{"o":811},{"o":812},{"o":813},{"o":814},{"o":815},{"o":816},{"o":817},{"o":818},{"o":819},{"o":820},{"o":821},{"o":822},{"o":823},{"o":824},{"o":825},{"o":826},{"o":827},{"o":828},{"o":829},{"o":830},{"o":831},{"o":832},{"o":833},{"o":834},{"o":835},{"o":836},{"o":837},{"o":838},{"o":839},{"o":840},{"o":841},{"o":842},{"o":843},{"o":844},{"o":845},{"o":846},{"o":847},{"o":848},{"o":849},{"o":850},{"o":851},{"o":852},{"o":853},{"o":854},{"o":855},{"o":856},{"o":857},{"o":858},{"o":859},{"o":860},{"o":861},{"o":862},{"o":863},{"o":864},{"o":865},{"o":866},{"o":867},{"o":868},{"o":869},{"o":870},{"o":871},{"o":872},{"o":873},{"o":874},{"o":875},{"o":876},{"o":877},{"o":878},{"o":879},{"o":880},{"o":881},{"o":882},{"o":883},{"o":884},{"o":885},{"o":886},{"o":887},{"o":888},{"o":889},{"o":890},{"o":891},{"o":892},{"o":893},{"o":894},{"o":895},{"o":896},{"o":897},{"o":898},{"o":899},{"o":900},{"o":901},{"o":902},{"o":903},{"o":904},{"o":905},{"o":906},{"o":907},{"o":908},{"o":909},{"o":910},{"o":911},{"o":912},{"o":913},{"o":914},{"o":915},{"o":916},{"o":917},{"o":918},{"o":919},{"o":920},{"o":921},{"o":922},{"o":923},{"o":924},{"o":925},{"o":926},{"o":927},{"o":928},{"o":929},{"o":930},{"o":931},{"o":932},{"o":933},{"o":934},{"o":935},{"o":936},{"o":937},{"o":938},{"o":939},{"o":940},{"o":941},{"o":942},{"o":943},{"o":944},{"o":945},{"o":946},{"o":947},{"o":948},{"o":949},{"o":950},{"o":951},{"o":952},{"o":953},{"o":954},{"o":955},{"o":956},{"o":957},{"o":958},{"o":959},{"o":960},{"o":961},{"o":962},{"o":963},{"o":964},{"o":965},{"o":966},{"o":967},{"o":968},{"o":969},{"o":970},{"o":971},{"o":972},{"o":973},{"o":974},{"o":975},{"o":976},{"o":977},{"o":978},{"o":979},{"o":980},{"o":981},{"o":982},{"o":983},{"o":984},{"o":985},{"o":986},{"o":987},{"o":988},{"o":989},{"o":990},{"o":991},{"o":992},{"o":993},{"o":994},{"o":995},{"o":996},{"o":997},{"o":998},{"o":999},{"o":1000}]} ], "state": { "chunk_bodies": [ - { - "account_id": "acct_interop", - "body_hex": "7b226f223a307d0a7b226f223a317d0a7b226f223a327d0a7b226f223a337d0a7b226f223a347d0a7b226f223a357d0a7b226f223a367d0a7b226f223a377d0a7b226f223a387d0a7b226f223a397d0a7b226f223a31307d0a7b226f223a31317d0a7b226f223a31327d0a7b226f223a31337d0a7b226f223a31347d0a7b226f223a31357d0a7b226f223a31367d0a7b226f223a31377d0a7b226f223a31387d0a7b226f223a31397d0a7b226f223a32307d0a7b226f223a32317d0a7b226f223a32327d0a7b226f223a32337d0a7b226f223a32347d0a7b226f223a32357d0a7b226f223a32367d0a7b226f223a32377d0a7b226f223a32387d0a7b226f223a32397d0a7b226f223a33307d0a7b226f223a33317d0a7b226f223a33327d0a7b226f223a33337d0a7b226f223a33347d0a7b226f223a33357d0a7b226f223a33367d0a7b226f223a33377d0a7b226f223a33387d0a7b226f223a33397d0a7b226f223a34307d0a7b226f223a34317d0a7b226f223a34327d0a7b226f223a34337d0a7b226f223a34347d0a7b226f223a34357d0a7b226f223a34367d0a7b226f223a34377d0a7b226f223a34387d0a7b226f223a34397d0a7b226f223a35307d0a7b226f223a35317d0a7b226f223a35327d0a7b226f223a35337d0a7b226f223a35347d0a7b226f223a35357d0a7b226f223a35367d0a7b226f223a35377d0a7b226f223a35387d0a7b226f223a35397d0a7b226f223a36307d0a7b226f223a36317d0a7b226f223a36327d0a7b226f223a36337d0a7b226f223a36347d0a7b226f223a36357d0a7b226f223a36367d0a7b226f223a36377d0a7b226f223a36387d0a7b226f223a36397d0a7b226f223a37307d0a7b226f223a37317d0a7b226f223a37327d0a7b226f223a37337d0a7b226f223a37347d0a7b226f223a37357d0a7b226f223a37367d0a7b226f223a37377d0a7b226f223a37387d0a7b226f223a37397d0a7b226f223a38307d0a7b226f223a38317d0a7b226f223a38327d0a7b226f223a38337d0a7b226f223a38347d0a7b226f223a38357d0a7b226f223a38367d0a7b226f223a38377d0a7b226f223a38387d0a7b226f223a38397d0a7b226f223a39307d0a7b226f223a39317d0a7b226f223a39327d0a7b226f223a39337d0a7b226f223a39347d0a7b226f223a39357d0a7b226f223a39367d0a7b226f223a39377d0a7b226f223a39387d0a7b226f223a39397d0a7b226f223a3130307d0a7b226f223a3130317d0a7b226f223a3130327d0a7b226f223a3130337d0a7b226f223a3130347d0a7b226f223a3130357d0a7b226f223a3130367d0a7b226f223a3130377d0a7b226f223a3130387d0a7b226f223a3130397d0a7b226f223a3131307d0a7b226f223a3131317d0a7b226f223a3131327d0a7b226f223a3131337d0a7b226f223a3131347d0a7b226f223a3131357d0a7b226f223a3131367d0a7b226f223a3131377d0a7b226f223a3131387d0a7b226f223a3131397d0a7b226f223a3132307d0a7b226f223a3132317d0a7b226f223a3132327d0a7b226f223a3132337d0a7b226f223a3132347d0a7b226f223a3132357d0a7b226f223a3132367d0a7b226f223a3132377d0a7b226f223a3132387d0a7b226f223a3132397d0a7b226f223a3133307d0a7b226f223a3133317d0a7b226f223a3133327d0a7b226f223a3133337d0a7b226f223a3133347d0a7b226f223a3133357d0a7b226f223a3133367d0a7b226f223a3133377d0a7b226f223a3133387d0a7b226f223a3133397d0a7b226f223a3134307d0a7b226f223a3134317d0a7b226f223a3134327d0a7b226f223a3134337d0a7b226f223a3134347d0a7b226f223a3134357d0a7b226f223a3134367d0a7b226f223a3134377d0a7b226f223a3134387d0a7b226f223a3134397d0a7b226f223a3135307d0a7b226f223a3135317d0a7b226f223a3135327d0a7b226f223a3135337d0a7b226f223a3135347d0a7b226f223a3135357d0a7b226f223a3135367d0a7b226f223a3135377d0a7b226f223a3135387d0a7b226f223a3135397d0a7b226f223a3136307d0a7b226f223a3136317d0a7b226f223a3136327d0a7b226f223a3136337d0a7b226f223a3136347d0a7b226f223a3136357d0a7b226f223a3136367d0a7b226f223a3136377d0a7b226f223a3136387d0a7b226f223a3136397d0a7b226f223a3137307d0a7b226f223a3137317d0a7b226f223a3137327d0a7b226f223a3137337d0a7b226f223a3137347d0a7b226f223a3137357d0a7b226f223a3137367d0a7b226f223a3137377d0a7b226f223a3137387d0a7b226f223a3137397d0a7b226f223a3138307d0a7b226f223a3138317d0a7b226f223a3138327d0a7b226f223a3138337d0a7b226f223a3138347d0a7b226f223a3138357d0a7b226f223a3138367d0a7b226f223a3138377d0a7b226f223a3138387d0a7b226f223a3138397d0a7b226f223a3139307d0a7b226f223a3139317d0a7b226f223a3139327d0a7b226f223a3139337d0a7b226f223a3139347d0a7b226f223a3139357d0a7b226f223a3139367d0a7b226f223a3139377d0a7b226f223a3139387d0a7b226f223a3139397d0a7b226f223a3230307d0a7b226f223a3230317d0a7b226f223a3230327d0a7b226f223a3230337d0a7b226f223a3230347d0a7b226f223a3230357d0a7b226f223a3230367d0a7b226f223a3230377d0a7b226f223a3230387d0a7b226f223a3230397d0a7b226f223a3231307d0a7b226f223a3231317d0a7b226f223a3231327d0a7b226f223a3231337d0a7b226f223a3231347d0a7b226f223a3231357d0a7b226f223a3231367d0a7b226f223a3231377d0a7b226f223a3231387d0a7b226f223a3231397d0a7b226f223a3232307d0a7b226f223a3232317d0a7b226f223a3232327d0a7b226f223a3232337d0a7b226f223a3232347d0a7b226f223a3232357d0a7b226f223a3232367d0a7b226f223a3232377d0a7b226f223a3232387d0a7b226f223a3232397d0a7b226f223a3233307d0a7b226f223a3233317d0a7b226f223a3233327d0a7b226f223a3233337d0a7b226f223a3233347d0a7b226f223a3233357d0a7b226f223a3233367d0a7b226f223a3233377d0a7b226f223a3233387d0a7b226f223a3233397d0a7b226f223a3234307d0a7b226f223a3234317d0a7b226f223a3234327d0a7b226f223a3234337d0a7b226f223a3234347d0a7b226f223a3234357d0a7b226f223a3234367d0a7b226f223a3234377d0a7b226f223a3234387d0a7b226f223a3234397d0a7b226f223a3235307d0a7b226f223a3235317d0a7b226f223a3235327d0a7b226f223a3235337d0a7b226f223a3235347d0a7b226f223a3235357d0a7b226f223a3235367d0a7b226f223a3235377d0a7b226f223a3235387d0a7b226f223a3235397d0a7b226f223a3236307d0a7b226f223a3236317d0a7b226f223a3236327d0a7b226f223a3236337d0a7b226f223a3236347d0a7b226f223a3236357d0a7b226f223a3236367d0a7b226f223a3236377d0a7b226f223a3236387d0a7b226f223a3236397d0a7b226f223a3237307d0a7b226f223a3237317d0a7b226f223a3237327d0a7b226f223a3237337d0a7b226f223a3237347d0a7b226f223a3237357d0a7b226f223a3237367d0a7b226f223a3237377d0a7b226f223a3237387d0a7b226f223a3237397d0a7b226f223a3238307d0a7b226f223a3238317d0a7b226f223a3238327d0a7b226f223a3238337d0a7b226f223a3238347d0a7b226f223a3238357d0a7b226f223a3238367d0a7b226f223a3238377d0a7b226f223a3238387d0a7b226f223a3238397d0a7b226f223a3239307d0a7b226f223a3239317d0a7b226f223a3239327d0a7b226f223a3239337d0a7b226f223a3239347d0a7b226f223a3239357d0a7b226f223a3239367d0a7b226f223a3239377d0a7b226f223a3239387d0a7b226f223a3239397d0a7b226f223a3330307d0a7b226f223a3330317d0a7b226f223a3330327d0a7b226f223a3330337d0a7b226f223a3330347d0a7b226f223a3330357d0a7b226f223a3330367d0a7b226f223a3330377d0a7b226f223a3330387d0a7b226f223a3330397d0a7b226f223a3331307d0a7b226f223a3331317d0a7b226f223a3331327d0a7b226f223a3331337d0a7b226f223a3331347d0a7b226f223a3331357d0a7b226f223a3331367d0a7b226f223a3331377d0a7b226f223a3331387d0a7b226f223a3331397d0a7b226f223a3332307d0a7b226f223a3332317d0a7b226f223a3332327d0a7b226f223a3332337d0a7b226f223a3332347d0a7b226f223a3332357d0a7b226f223a3332367d0a7b226f223a3332377d0a7b226f223a3332387d0a7b226f223a3332397d0a7b226f223a3333307d0a7b226f223a3333317d0a7b226f223a3333327d0a7b226f223a3333337d0a7b226f223a3333347d0a7b226f223a3333357d0a7b226f223a3333367d0a7b226f223a3333377d0a7b226f223a3333387d0a7b226f223a3333397d0a7b226f223a3334307d0a7b226f223a3334317d0a7b226f223a3334327d0a7b226f223a3334337d0a7b226f223a3334347d0a7b226f223a3334357d0a7b226f223a3334367d0a7b226f223a3334377d0a7b226f223a3334387d0a7b226f223a3334397d0a7b226f223a3335307d0a7b226f223a3335317d0a7b226f223a3335327d0a7b226f223a3335337d0a7b226f223a3335347d0a7b226f223a3335357d0a7b226f223a3335367d0a7b226f223a3335377d0a7b226f223a3335387d0a7b226f223a3335397d0a7b226f223a3336307d0a7b226f223a3336317d0a7b226f223a3336327d0a7b226f223a3336337d0a7b226f223a3336347d0a7b226f223a3336357d0a7b226f223a3336367d0a7b226f223a3336377d0a7b226f223a3336387d0a7b226f223a3336397d0a7b226f223a3337307d0a7b226f223a3337317d0a7b226f223a3337327d0a7b226f223a3337337d0a7b226f223a3337347d0a7b226f223a3337357d0a7b226f223a3337367d0a7b226f223a3337377d0a7b226f223a3337387d0a7b226f223a3337397d0a7b226f223a3338307d0a7b226f223a3338317d0a7b226f223a3338327d0a7b226f223a3338337d0a7b226f223a3338347d0a7b226f223a3338357d0a7b226f223a3338367d0a7b226f223a3338377d0a7b226f223a3338387d0a7b226f223a3338397d0a7b226f223a3339307d0a7b226f223a3339317d0a7b226f223a3339327d0a7b226f223a3339337d0a7b226f223a3339347d0a7b226f223a3339357d0a7b226f223a3339367d0a7b226f223a3339377d0a7b226f223a3339387d0a7b226f223a3339397d0a7b226f223a3430307d0a7b226f223a3430317d0a7b226f223a3430327d0a7b226f223a3430337d0a7b226f223a3430347d0a7b226f223a3430357d0a7b226f223a3430367d0a7b226f223a3430377d0a7b226f223a3430387d0a7b226f223a3430397d0a7b226f223a3431307d0a7b226f223a3431317d0a7b226f223a3431327d0a7b226f223a3431337d0a7b226f223a3431347d0a7b226f223a3431357d0a7b226f223a3431367d0a7b226f223a3431377d0a7b226f223a3431387d0a7b226f223a3431397d0a7b226f223a3432307d0a7b226f223a3432317d0a7b226f223a3432327d0a7b226f223a3432337d0a7b226f223a3432347d0a7b226f223a3432357d0a7b226f223a3432367d0a7b226f223a3432377d0a7b226f223a3432387d0a7b226f223a3432397d0a7b226f223a3433307d0a7b226f223a3433317d0a7b226f223a3433327d0a7b226f223a3433337d0a7b226f223a3433347d0a7b226f223a3433357d0a7b226f223a3433367d0a7b226f223a3433377d0a7b226f223a3433387d0a7b226f223a3433397d0a7b226f223a3434307d0a7b226f223a3434317d0a7b226f223a3434327d0a7b226f223a3434337d0a7b226f223a3434347d0a7b226f223a3434357d0a7b226f223a3434367d0a7b226f223a3434377d0a7b226f223a3434387d0a7b226f223a3434397d0a7b226f223a3435307d0a7b226f223a3435317d0a7b226f223a3435327d0a7b226f223a3435337d0a7b226f223a3435347d0a7b226f223a3435357d0a7b226f223a3435367d0a7b226f223a3435377d0a7b226f223a3435387d0a7b226f223a3435397d0a7b226f223a3436307d0a7b226f223a3436317d0a7b226f223a3436327d0a7b226f223a3436337d0a7b226f223a3436347d0a7b226f223a3436357d0a7b226f223a3436367d0a7b226f223a3436377d0a7b226f223a3436387d0a7b226f223a3436397d0a7b226f223a3437307d0a7b226f223a3437317d0a7b226f223a3437327d0a7b226f223a3437337d0a7b226f223a3437347d0a7b226f223a3437357d0a7b226f223a3437367d0a7b226f223a3437377d0a7b226f223a3437387d0a7b226f223a3437397d0a7b226f223a3438307d0a7b226f223a3438317d0a7b226f223a3438327d0a7b226f223a3438337d0a7b226f223a3438347d0a7b226f223a3438357d0a7b226f223a3438367d0a7b226f223a3438377d0a7b226f223a3438387d0a7b226f223a3438397d0a7b226f223a3439307d0a7b226f223a3439317d0a7b226f223a3439327d0a7b226f223a3439337d0a7b226f223a3439347d0a7b226f223a3439357d0a7b226f223a3439367d0a7b226f223a3439377d0a7b226f223a3439387d0a7b226f223a3439397d0a7b226f223a3530307d0a7b226f223a3530317d0a7b226f223a3530327d0a7b226f223a3530337d0a7b226f223a3530347d0a7b226f223a3530357d0a7b226f223a3530367d0a7b226f223a3530377d0a7b226f223a3530387d0a7b226f223a3530397d0a7b226f223a3531307d0a7b226f223a3531317d0a7b226f223a3531327d0a7b226f223a3531337d0a7b226f223a3531347d0a7b226f223a3531357d0a7b226f223a3531367d0a7b226f223a3531377d0a7b226f223a3531387d0a7b226f223a3531397d0a7b226f223a3532307d0a7b226f223a3532317d0a7b226f223a3532327d0a7b226f223a3532337d0a7b226f223a3532347d0a7b226f223a3532357d0a7b226f223a3532367d0a7b226f223a3532377d0a7b226f223a3532387d0a7b226f223a3532397d0a7b226f223a3533307d0a7b226f223a3533317d0a7b226f223a3533327d0a7b226f223a3533337d0a7b226f223a3533347d0a7b226f223a3533357d0a7b226f223a3533367d0a7b226f223a3533377d0a7b226f223a3533387d0a7b226f223a3533397d0a7b226f223a3534307d0a7b226f223a3534317d0a7b226f223a3534327d0a7b226f223a3534337d0a7b226f223a3534347d0a7b226f223a3534357d0a7b226f223a3534367d0a7b226f223a3534377d0a7b226f223a3534387d0a7b226f223a3534397d0a7b226f223a3535307d0a7b226f223a3535317d0a7b226f223a3535327d0a7b226f223a3535337d0a7b226f223a3535347d0a7b226f223a3535357d0a7b226f223a3535367d0a7b226f223a3535377d0a7b226f223a3535387d0a7b226f223a3535397d0a7b226f223a3536307d0a7b226f223a3536317d0a7b226f223a3536327d0a7b226f223a3536337d0a7b226f223a3536347d0a7b226f223a3536357d0a7b226f223a3536367d0a7b226f223a3536377d0a7b226f223a3536387d0a7b226f223a3536397d0a7b226f223a3537307d0a7b226f223a3537317d0a7b226f223a3537327d0a7b226f223a3537337d0a7b226f223a3537347d0a7b226f223a3537357d0a7b226f223a3537367d0a7b226f223a3537377d0a7b226f223a3537387d0a7b226f223a3537397d0a7b226f223a3538307d0a7b226f223a3538317d0a7b226f223a3538327d0a7b226f223a3538337d0a7b226f223a3538347d0a7b226f223a3538357d0a7b226f223a3538367d0a7b226f223a3538377d0a7b226f223a3538387d0a7b226f223a3538397d0a7b226f223a3539307d0a7b226f223a3539317d0a7b226f223a3539327d0a7b226f223a3539337d0a7b226f223a3539347d0a7b226f223a3539357d0a7b226f223a3539367d0a7b226f223a3539377d0a7b226f223a3539387d0a7b226f223a3539397d0a7b226f223a3630307d0a7b226f223a3630317d0a7b226f223a3630327d0a7b226f223a3630337d0a7b226f223a3630347d0a7b226f223a3630357d0a7b226f223a3630367d0a7b226f223a3630377d0a7b226f223a3630387d0a7b226f223a3630397d0a7b226f223a3631307d0a7b226f223a3631317d0a7b226f223a3631327d0a7b226f223a3631337d0a7b226f223a3631347d0a7b226f223a3631357d0a7b226f223a3631367d0a7b226f223a3631377d0a7b226f223a3631387d0a7b226f223a3631397d0a7b226f223a3632307d0a7b226f223a3632317d0a7b226f223a3632327d0a7b226f223a3632337d0a7b226f223a3632347d0a7b226f223a3632357d0a7b226f223a3632367d0a7b226f223a3632377d0a7b226f223a3632387d0a7b226f223a3632397d0a7b226f223a3633307d0a7b226f223a3633317d0a7b226f223a3633327d0a7b226f223a3633337d0a7b226f223a3633347d0a7b226f223a3633357d0a7b226f223a3633367d0a7b226f223a3633377d0a7b226f223a3633387d0a7b226f223a3633397d0a7b226f223a3634307d0a7b226f223a3634317d0a7b226f223a3634327d0a7b226f223a3634337d0a7b226f223a3634347d0a7b226f223a3634357d0a7b226f223a3634367d0a7b226f223a3634377d0a7b226f223a3634387d0a7b226f223a3634397d0a7b226f223a3635307d0a7b226f223a3635317d0a7b226f223a3635327d0a7b226f223a3635337d0a7b226f223a3635347d0a7b226f223a3635357d0a7b226f223a3635367d0a7b226f223a3635377d0a7b226f223a3635387d0a7b226f223a3635397d0a7b226f223a3636307d0a7b226f223a3636317d0a7b226f223a3636327d0a7b226f223a3636337d0a7b226f223a3636347d0a7b226f223a3636357d0a7b226f223a3636367d0a7b226f223a3636377d0a7b226f223a3636387d0a7b226f223a3636397d0a7b226f223a3637307d0a7b226f223a3637317d0a7b226f223a3637327d0a7b226f223a3637337d0a7b226f223a3637347d0a7b226f223a3637357d0a7b226f223a3637367d0a7b226f223a3637377d0a7b226f223a3637387d0a7b226f223a3637397d0a7b226f223a3638307d0a7b226f223a3638317d0a7b226f223a3638327d0a7b226f223a3638337d0a7b226f223a3638347d0a7b226f223a3638357d0a7b226f223a3638367d0a7b226f223a3638377d0a7b226f223a3638387d0a7b226f223a3638397d0a7b226f223a3639307d0a7b226f223a3639317d0a7b226f223a3639327d0a7b226f223a3639337d0a7b226f223a3639347d0a7b226f223a3639357d0a7b226f223a3639367d0a7b226f223a3639377d0a7b226f223a3639387d0a7b226f223a3639397d0a7b226f223a3730307d0a7b226f223a3730317d0a7b226f223a3730327d0a7b226f223a3730337d0a7b226f223a3730347d0a7b226f223a3730357d0a7b226f223a3730367d0a7b226f223a3730377d0a7b226f223a3730387d0a7b226f223a3730397d0a7b226f223a3731307d0a7b226f223a3731317d0a7b226f223a3731327d0a7b226f223a3731337d0a7b226f223a3731347d0a7b226f223a3731357d0a7b226f223a3731367d0a7b226f223a3731377d0a7b226f223a3731387d0a7b226f223a3731397d0a7b226f223a3732307d0a7b226f223a3732317d0a7b226f223a3732327d0a7b226f223a3732337d0a7b226f223a3732347d0a7b226f223a3732357d0a7b226f223a3732367d0a7b226f223a3732377d0a7b226f223a3732387d0a7b226f223a3732397d0a7b226f223a3733307d0a7b226f223a3733317d0a7b226f223a3733327d0a7b226f223a3733337d0a7b226f223a3733347d0a7b226f223a3733357d0a7b226f223a3733367d0a7b226f223a3733377d0a7b226f223a3733387d0a7b226f223a3733397d0a7b226f223a3734307d0a7b226f223a3734317d0a7b226f223a3734327d0a7b226f223a3734337d0a7b226f223a3734347d0a7b226f223a3734357d0a7b226f223a3734367d0a7b226f223a3734377d0a7b226f223a3734387d0a7b226f223a3734397d0a7b226f223a3735307d0a7b226f223a3735317d0a7b226f223a3735327d0a7b226f223a3735337d0a7b226f223a3735347d0a7b226f223a3735357d0a7b226f223a3735367d0a7b226f223a3735377d0a7b226f223a3735387d0a7b226f223a3735397d0a7b226f223a3736307d0a7b226f223a3736317d0a7b226f223a3736327d0a7b226f223a3736337d0a7b226f223a3736347d0a7b226f223a3736357d0a7b226f223a3736367d0a7b226f223a3736377d0a7b226f223a3736387d0a7b226f223a3736397d0a7b226f223a3737307d0a7b226f223a3737317d0a7b226f223a3737327d0a7b226f223a3737337d0a7b226f223a3737347d0a7b226f223a3737357d0a7b226f223a3737367d0a7b226f223a3737377d0a7b226f223a3737387d0a7b226f223a3737397d0a7b226f223a3738307d0a7b226f223a3738317d0a7b226f223a3738327d0a7b226f223a3738337d0a7b226f223a3738347d0a7b226f223a3738357d0a7b226f223a3738367d0a7b226f223a3738377d0a7b226f223a3738387d0a7b226f223a3738397d0a7b226f223a3739307d0a7b226f223a3739317d0a7b226f223a3739327d0a7b226f223a3739337d0a7b226f223a3739347d0a7b226f223a3739357d0a7b226f223a3739367d0a7b226f223a3739377d0a7b226f223a3739387d0a7b226f223a3739397d0a7b226f223a3830307d0a7b226f223a3830317d0a7b226f223a3830327d0a7b226f223a3830337d0a7b226f223a3830347d0a7b226f223a3830357d0a7b226f223a3830367d0a7b226f223a3830377d0a7b226f223a3830387d0a7b226f223a3830397d0a7b226f223a3831307d0a7b226f223a3831317d0a7b226f223a3831327d0a7b226f223a3831337d0a7b226f223a3831347d0a7b226f223a3831357d0a7b226f223a3831367d0a7b226f223a3831377d0a7b226f223a3831387d0a7b226f223a3831397d0a7b226f223a3832307d0a7b226f223a3832317d0a7b226f223a3832327d0a7b226f223a3832337d0a7b226f223a3832347d0a7b226f223a3832357d0a7b226f223a3832367d0a7b226f223a3832377d0a7b226f223a3832387d0a7b226f223a3832397d0a7b226f223a3833307d0a7b226f223a3833317d0a7b226f223a3833327d0a7b226f223a3833337d0a7b226f223a3833347d0a7b226f223a3833357d0a7b226f223a3833367d0a7b226f223a3833377d0a7b226f223a3833387d0a7b226f223a3833397d0a7b226f223a3834307d0a7b226f223a3834317d0a7b226f223a3834327d0a7b226f223a3834337d0a7b226f223a3834347d0a7b226f223a3834357d0a7b226f223a3834367d0a7b226f223a3834377d0a7b226f223a3834387d0a7b226f223a3834397d0a7b226f223a3835307d0a7b226f223a3835317d0a7b226f223a3835327d0a7b226f223a3835337d0a7b226f223a3835347d0a7b226f223a3835357d0a7b226f223a3835367d0a7b226f223a3835377d0a7b226f223a3835387d0a7b226f223a3835397d0a7b226f223a3836307d0a7b226f223a3836317d0a7b226f223a3836327d0a7b226f223a3836337d0a7b226f223a3836347d0a7b226f223a3836357d0a7b226f223a3836367d0a7b226f223a3836377d0a7b226f223a3836387d0a7b226f223a3836397d0a7b226f223a3837307d0a7b226f223a3837317d0a7b226f223a3837327d0a7b226f223a3837337d0a7b226f223a3837347d0a7b226f223a3837357d0a7b226f223a3837367d0a7b226f223a3837377d0a7b226f223a3837387d0a7b226f223a3837397d0a7b226f223a3838307d0a7b226f223a3838317d0a7b226f223a3838327d0a7b226f223a3838337d0a7b226f223a3838347d0a7b226f223a3838357d0a7b226f223a3838367d0a7b226f223a3838377d0a7b226f223a3838387d0a7b226f223a3838397d0a7b226f223a3839307d0a7b226f223a3839317d0a7b226f223a3839327d0a7b226f223a3839337d0a7b226f223a3839347d0a7b226f223a3839357d0a7b226f223a3839367d0a7b226f223a3839377d0a7b226f223a3839387d0a7b226f223a3839397d0a7b226f223a3930307d0a7b226f223a3930317d0a7b226f223a3930327d0a7b226f223a3930337d0a7b226f223a3930347d0a7b226f223a3930357d0a7b226f223a3930367d0a7b226f223a3930377d0a7b226f223a3930387d0a7b226f223a3930397d0a7b226f223a3931307d0a7b226f223a3931317d0a7b226f223a3931327d0a7b226f223a3931337d0a7b226f223a3931347d0a7b226f223a3931357d0a7b226f223a3931367d0a7b226f223a3931377d0a7b226f223a3931387d0a7b226f223a3931397d0a7b226f223a3932307d0a7b226f223a3932317d0a7b226f223a3932327d0a7b226f223a3932337d0a7b226f223a3932347d0a7b226f223a3932357d0a7b226f223a3932367d0a7b226f223a3932377d0a7b226f223a3932387d0a7b226f223a3932397d0a7b226f223a3933307d0a7b226f223a3933317d0a7b226f223a3933327d0a7b226f223a3933337d0a7b226f223a3933347d0a7b226f223a3933357d0a7b226f223a3933367d0a7b226f223a3933377d0a7b226f223a3933387d0a7b226f223a3933397d0a7b226f223a3934307d0a7b226f223a3934317d0a7b226f223a3934327d0a7b226f223a3934337d0a7b226f223a3934347d0a7b226f223a3934357d0a7b226f223a3934367d0a7b226f223a3934377d0a7b226f223a3934387d0a7b226f223a3934397d0a7b226f223a3935307d0a7b226f223a3935317d0a7b226f223a3935327d0a7b226f223a3935337d0a7b226f223a3935347d0a7b226f223a3935357d0a7b226f223a3935367d0a7b226f223a3935377d0a7b226f223a3935387d0a7b226f223a3935397d0a7b226f223a3936307d0a7b226f223a3936317d0a7b226f223a3936327d0a7b226f223a3936337d0a7b226f223a3936347d0a7b226f223a3936357d0a7b226f223a3936367d0a7b226f223a3936377d0a7b226f223a3936387d0a7b226f223a3936397d0a7b226f223a3937307d0a7b226f223a3937317d0a7b226f223a3937327d0a7b226f223a3937337d0a7b226f223a3937347d0a7b226f223a3937357d0a7b226f223a3937367d0a7b226f223a3937377d0a7b226f223a3937387d0a7b226f223a3937397d0a7b226f223a3938307d0a7b226f223a3938317d0a7b226f223a3938327d0a7b226f223a3938337d0a7b226f223a3938347d0a7b226f223a3938357d0a7b226f223a3938367d0a7b226f223a3938377d0a7b226f223a3938387d0a7b226f223a3938397d0a7b226f223a3939307d0a7b226f223a3939317d0a7b226f223a3939327d0a7b226f223a3939337d0a7b226f223a3939347d0a7b226f223a3939357d0a7b226f223a3939367d0a7b226f223a3939377d0a7b226f223a3939387d0a7b226f223a3939397d0a7b226f223a313030307d0a", - "obligation_id": "robl_interop", - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277" - }, - { - "account_id": "acct_interop", - "body_hex": "7b2261223a322c22636f6e74726f6c223a225c75303030375c6e5c225c5c222c226e616d65223a22636166c3a920f09f988020e697a5e69cace8aa9e222c22736570223a22e280a8222c227a223a317d0a7b22666c6f6174223a302e33303030303030303030303030303030342c226d61785f696e74223a393030373139393235343734303939312c226e223a302c226e6567223a2d31322e352c226e6573746564223a7b2261223a5b5d2c227a223a5b312c7b2261223a747275652c2262223a6e756c6c7d5d7d2c2274696e79223a31652d377d0a", - "obligation_id": "robl_interop", - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - }, - { - "account_id": "acct_interop", - "body_hex": "7b2264617465223a22323032362d31302d3031222c22696d7072657373696f6e73223a313230302c226d656469615f6275795f6964223a226d625f31222c227061636b6167655f6964223a22706b675f31222c227370656e64223a2234322e3137227d0a", - "obligation_id": "robl_interop", - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } + {"account_id":"acct_interop","body_hex":"7b226f223a307d0a7b226f223a317d0a7b226f223a327d0a7b226f223a337d0a7b226f223a347d0a7b226f223a357d0a7b226f223a367d0a7b226f223a377d0a7b226f223a387d0a7b226f223a397d0a7b226f223a31307d0a7b226f223a31317d0a7b226f223a31327d0a7b226f223a31337d0a7b226f223a31347d0a7b226f223a31357d0a7b226f223a31367d0a7b226f223a31377d0a7b226f223a31387d0a7b226f223a31397d0a7b226f223a32307d0a7b226f223a32317d0a7b226f223a32327d0a7b226f223a32337d0a7b226f223a32347d0a7b226f223a32357d0a7b226f223a32367d0a7b226f223a32377d0a7b226f223a32387d0a7b226f223a32397d0a7b226f223a33307d0a7b226f223a33317d0a7b226f223a33327d0a7b226f223a33337d0a7b226f223a33347d0a7b226f223a33357d0a7b226f223a33367d0a7b226f223a33377d0a7b226f223a33387d0a7b226f223a33397d0a7b226f223a34307d0a7b226f223a34317d0a7b226f223a34327d0a7b226f223a34337d0a7b226f223a34347d0a7b226f223a34357d0a7b226f223a34367d0a7b226f223a34377d0a7b226f223a34387d0a7b226f223a34397d0a7b226f223a35307d0a7b226f223a35317d0a7b226f223a35327d0a7b226f223a35337d0a7b226f223a35347d0a7b226f223a35357d0a7b226f223a35367d0a7b226f223a35377d0a7b226f223a35387d0a7b226f223a35397d0a7b226f223a36307d0a7b226f223a36317d0a7b226f223a36327d0a7b226f223a36337d0a7b226f223a36347d0a7b226f223a36357d0a7b226f223a36367d0a7b226f223a36377d0a7b226f223a36387d0a7b226f223a36397d0a7b226f223a37307d0a7b226f223a37317d0a7b226f223a37327d0a7b226f223a37337d0a7b226f223a37347d0a7b226f223a37357d0a7b226f223a37367d0a7b226f223a37377d0a7b226f223a37387d0a7b226f223a37397d0a7b226f223a38307d0a7b226f223a38317d0a7b226f223a38327d0a7b226f223a38337d0a7b226f223a38347d0a7b226f223a38357d0a7b226f223a38367d0a7b226f223a38377d0a7b226f223a38387d0a7b226f223a38397d0a7b226f223a39307d0a7b226f223a39317d0a7b226f223a39327d0a7b226f223a39337d0a7b226f223a39347d0a7b226f223a39357d0a7b226f223a39367d0a7b226f223a39377d0a7b226f223a39387d0a7b226f223a39397d0a7b226f223a3130307d0a7b226f223a3130317d0a7b226f223a3130327d0a7b226f223a3130337d0a7b226f223a3130347d0a7b226f223a3130357d0a7b226f223a3130367d0a7b226f223a3130377d0a7b226f223a3130387d0a7b226f223a3130397d0a7b226f223a3131307d0a7b226f223a3131317d0a7b226f223a3131327d0a7b226f223a3131337d0a7b226f223a3131347d0a7b226f223a3131357d0a7b226f223a3131367d0a7b226f223a3131377d0a7b226f223a3131387d0a7b226f223a3131397d0a7b226f223a3132307d0a7b226f223a3132317d0a7b226f223a3132327d0a7b226f223a3132337d0a7b226f223a3132347d0a7b226f223a3132357d0a7b226f223a3132367d0a7b226f223a3132377d0a7b226f223a3132387d0a7b226f223a3132397d0a7b226f223a3133307d0a7b226f223a3133317d0a7b226f223a3133327d0a7b226f223a3133337d0a7b226f223a3133347d0a7b226f223a3133357d0a7b226f223a3133367d0a7b226f223a3133377d0a7b226f223a3133387d0a7b226f223a3133397d0a7b226f223a3134307d0a7b226f223a3134317d0a7b226f223a3134327d0a7b226f223a3134337d0a7b226f223a3134347d0a7b226f223a3134357d0a7b226f223a3134367d0a7b226f223a3134377d0a7b226f223a3134387d0a7b226f223a3134397d0a7b226f223a3135307d0a7b226f223a3135317d0a7b226f223a3135327d0a7b226f223a3135337d0a7b226f223a3135347d0a7b226f223a3135357d0a7b226f223a3135367d0a7b226f223a3135377d0a7b226f223a3135387d0a7b226f223a3135397d0a7b226f223a3136307d0a7b226f223a3136317d0a7b226f223a3136327d0a7b226f223a3136337d0a7b226f223a3136347d0a7b226f223a3136357d0a7b226f223a3136367d0a7b226f223a3136377d0a7b226f223a3136387d0a7b226f223a3136397d0a7b226f223a3137307d0a7b226f223a3137317d0a7b226f223a3137327d0a7b226f223a3137337d0a7b226f223a3137347d0a7b226f223a3137357d0a7b226f223a3137367d0a7b226f223a3137377d0a7b226f223a3137387d0a7b226f223a3137397d0a7b226f223a3138307d0a7b226f223a3138317d0a7b226f223a3138327d0a7b226f223a3138337d0a7b226f223a3138347d0a7b226f223a3138357d0a7b226f223a3138367d0a7b226f223a3138377d0a7b226f223a3138387d0a7b226f223a3138397d0a7b226f223a3139307d0a7b226f223a3139317d0a7b226f223a3139327d0a7b226f223a3139337d0a7b226f223a3139347d0a7b226f223a3139357d0a7b226f223a3139367d0a7b226f223a3139377d0a7b226f223a3139387d0a7b226f223a3139397d0a7b226f223a3230307d0a7b226f223a3230317d0a7b226f223a3230327d0a7b226f223a3230337d0a7b226f223a3230347d0a7b226f223a3230357d0a7b226f223a3230367d0a7b226f223a3230377d0a7b226f223a3230387d0a7b226f223a3230397d0a7b226f223a3231307d0a7b226f223a3231317d0a7b226f223a3231327d0a7b226f223a3231337d0a7b226f223a3231347d0a7b226f223a3231357d0a7b226f223a3231367d0a7b226f223a3231377d0a7b226f223a3231387d0a7b226f223a3231397d0a7b226f223a3232307d0a7b226f223a3232317d0a7b226f223a3232327d0a7b226f223a3232337d0a7b226f223a3232347d0a7b226f223a3232357d0a7b226f223a3232367d0a7b226f223a3232377d0a7b226f223a3232387d0a7b226f223a3232397d0a7b226f223a3233307d0a7b226f223a3233317d0a7b226f223a3233327d0a7b226f223a3233337d0a7b226f223a3233347d0a7b226f223a3233357d0a7b226f223a3233367d0a7b226f223a3233377d0a7b226f223a3233387d0a7b226f223a3233397d0a7b226f223a3234307d0a7b226f223a3234317d0a7b226f223a3234327d0a7b226f223a3234337d0a7b226f223a3234347d0a7b226f223a3234357d0a7b226f223a3234367d0a7b226f223a3234377d0a7b226f223a3234387d0a7b226f223a3234397d0a7b226f223a3235307d0a7b226f223a3235317d0a7b226f223a3235327d0a7b226f223a3235337d0a7b226f223a3235347d0a7b226f223a3235357d0a7b226f223a3235367d0a7b226f223a3235377d0a7b226f223a3235387d0a7b226f223a3235397d0a7b226f223a3236307d0a7b226f223a3236317d0a7b226f223a3236327d0a7b226f223a3236337d0a7b226f223a3236347d0a7b226f223a3236357d0a7b226f223a3236367d0a7b226f223a3236377d0a7b226f223a3236387d0a7b226f223a3236397d0a7b226f223a3237307d0a7b226f223a3237317d0a7b226f223a3237327d0a7b226f223a3237337d0a7b226f223a3237347d0a7b226f223a3237357d0a7b226f223a3237367d0a7b226f223a3237377d0a7b226f223a3237387d0a7b226f223a3237397d0a7b226f223a3238307d0a7b226f223a3238317d0a7b226f223a3238327d0a7b226f223a3238337d0a7b226f223a3238347d0a7b226f223a3238357d0a7b226f223a3238367d0a7b226f223a3238377d0a7b226f223a3238387d0a7b226f223a3238397d0a7b226f223a3239307d0a7b226f223a3239317d0a7b226f223a3239327d0a7b226f223a3239337d0a7b226f223a3239347d0a7b226f223a3239357d0a7b226f223a3239367d0a7b226f223a3239377d0a7b226f223a3239387d0a7b226f223a3239397d0a7b226f223a3330307d0a7b226f223a3330317d0a7b226f223a3330327d0a7b226f223a3330337d0a7b226f223a3330347d0a7b226f223a3330357d0a7b226f223a3330367d0a7b226f223a3330377d0a7b226f223a3330387d0a7b226f223a3330397d0a7b226f223a3331307d0a7b226f223a3331317d0a7b226f223a3331327d0a7b226f223a3331337d0a7b226f223a3331347d0a7b226f223a3331357d0a7b226f223a3331367d0a7b226f223a3331377d0a7b226f223a3331387d0a7b226f223a3331397d0a7b226f223a3332307d0a7b226f223a3332317d0a7b226f223a3332327d0a7b226f223a3332337d0a7b226f223a3332347d0a7b226f223a3332357d0a7b226f223a3332367d0a7b226f223a3332377d0a7b226f223a3332387d0a7b226f223a3332397d0a7b226f223a3333307d0a7b226f223a3333317d0a7b226f223a3333327d0a7b226f223a3333337d0a7b226f223a3333347d0a7b226f223a3333357d0a7b226f223a3333367d0a7b226f223a3333377d0a7b226f223a3333387d0a7b226f223a3333397d0a7b226f223a3334307d0a7b226f223a3334317d0a7b226f223a3334327d0a7b226f223a3334337d0a7b226f223a3334347d0a7b226f223a3334357d0a7b226f223a3334367d0a7b226f223a3334377d0a7b226f223a3334387d0a7b226f223a3334397d0a7b226f223a3335307d0a7b226f223a3335317d0a7b226f223a3335327d0a7b226f223a3335337d0a7b226f223a3335347d0a7b226f223a3335357d0a7b226f223a3335367d0a7b226f223a3335377d0a7b226f223a3335387d0a7b226f223a3335397d0a7b226f223a3336307d0a7b226f223a3336317d0a7b226f223a3336327d0a7b226f223a3336337d0a7b226f223a3336347d0a7b226f223a3336357d0a7b226f223a3336367d0a7b226f223a3336377d0a7b226f223a3336387d0a7b226f223a3336397d0a7b226f223a3337307d0a7b226f223a3337317d0a7b226f223a3337327d0a7b226f223a3337337d0a7b226f223a3337347d0a7b226f223a3337357d0a7b226f223a3337367d0a7b226f223a3337377d0a7b226f223a3337387d0a7b226f223a3337397d0a7b226f223a3338307d0a7b226f223a3338317d0a7b226f223a3338327d0a7b226f223a3338337d0a7b226f223a3338347d0a7b226f223a3338357d0a7b226f223a3338367d0a7b226f223a3338377d0a7b226f223a3338387d0a7b226f223a3338397d0a7b226f223a3339307d0a7b226f223a3339317d0a7b226f223a3339327d0a7b226f223a3339337d0a7b226f223a3339347d0a7b226f223a3339357d0a7b226f223a3339367d0a7b226f223a3339377d0a7b226f223a3339387d0a7b226f223a3339397d0a7b226f223a3430307d0a7b226f223a3430317d0a7b226f223a3430327d0a7b226f223a3430337d0a7b226f223a3430347d0a7b226f223a3430357d0a7b226f223a3430367d0a7b226f223a3430377d0a7b226f223a3430387d0a7b226f223a3430397d0a7b226f223a3431307d0a7b226f223a3431317d0a7b226f223a3431327d0a7b226f223a3431337d0a7b226f223a3431347d0a7b226f223a3431357d0a7b226f223a3431367d0a7b226f223a3431377d0a7b226f223a3431387d0a7b226f223a3431397d0a7b226f223a3432307d0a7b226f223a3432317d0a7b226f223a3432327d0a7b226f223a3432337d0a7b226f223a3432347d0a7b226f223a3432357d0a7b226f223a3432367d0a7b226f223a3432377d0a7b226f223a3432387d0a7b226f223a3432397d0a7b226f223a3433307d0a7b226f223a3433317d0a7b226f223a3433327d0a7b226f223a3433337d0a7b226f223a3433347d0a7b226f223a3433357d0a7b226f223a3433367d0a7b226f223a3433377d0a7b226f223a3433387d0a7b226f223a3433397d0a7b226f223a3434307d0a7b226f223a3434317d0a7b226f223a3434327d0a7b226f223a3434337d0a7b226f223a3434347d0a7b226f223a3434357d0a7b226f223a3434367d0a7b226f223a3434377d0a7b226f223a3434387d0a7b226f223a3434397d0a7b226f223a3435307d0a7b226f223a3435317d0a7b226f223a3435327d0a7b226f223a3435337d0a7b226f223a3435347d0a7b226f223a3435357d0a7b226f223a3435367d0a7b226f223a3435377d0a7b226f223a3435387d0a7b226f223a3435397d0a7b226f223a3436307d0a7b226f223a3436317d0a7b226f223a3436327d0a7b226f223a3436337d0a7b226f223a3436347d0a7b226f223a3436357d0a7b226f223a3436367d0a7b226f223a3436377d0a7b226f223a3436387d0a7b226f223a3436397d0a7b226f223a3437307d0a7b226f223a3437317d0a7b226f223a3437327d0a7b226f223a3437337d0a7b226f223a3437347d0a7b226f223a3437357d0a7b226f223a3437367d0a7b226f223a3437377d0a7b226f223a3437387d0a7b226f223a3437397d0a7b226f223a3438307d0a7b226f223a3438317d0a7b226f223a3438327d0a7b226f223a3438337d0a7b226f223a3438347d0a7b226f223a3438357d0a7b226f223a3438367d0a7b226f223a3438377d0a7b226f223a3438387d0a7b226f223a3438397d0a7b226f223a3439307d0a7b226f223a3439317d0a7b226f223a3439327d0a7b226f223a3439337d0a7b226f223a3439347d0a7b226f223a3439357d0a7b226f223a3439367d0a7b226f223a3439377d0a7b226f223a3439387d0a7b226f223a3439397d0a7b226f223a3530307d0a7b226f223a3530317d0a7b226f223a3530327d0a7b226f223a3530337d0a7b226f223a3530347d0a7b226f223a3530357d0a7b226f223a3530367d0a7b226f223a3530377d0a7b226f223a3530387d0a7b226f223a3530397d0a7b226f223a3531307d0a7b226f223a3531317d0a7b226f223a3531327d0a7b226f223a3531337d0a7b226f223a3531347d0a7b226f223a3531357d0a7b226f223a3531367d0a7b226f223a3531377d0a7b226f223a3531387d0a7b226f223a3531397d0a7b226f223a3532307d0a7b226f223a3532317d0a7b226f223a3532327d0a7b226f223a3532337d0a7b226f223a3532347d0a7b226f223a3532357d0a7b226f223a3532367d0a7b226f223a3532377d0a7b226f223a3532387d0a7b226f223a3532397d0a7b226f223a3533307d0a7b226f223a3533317d0a7b226f223a3533327d0a7b226f223a3533337d0a7b226f223a3533347d0a7b226f223a3533357d0a7b226f223a3533367d0a7b226f223a3533377d0a7b226f223a3533387d0a7b226f223a3533397d0a7b226f223a3534307d0a7b226f223a3534317d0a7b226f223a3534327d0a7b226f223a3534337d0a7b226f223a3534347d0a7b226f223a3534357d0a7b226f223a3534367d0a7b226f223a3534377d0a7b226f223a3534387d0a7b226f223a3534397d0a7b226f223a3535307d0a7b226f223a3535317d0a7b226f223a3535327d0a7b226f223a3535337d0a7b226f223a3535347d0a7b226f223a3535357d0a7b226f223a3535367d0a7b226f223a3535377d0a7b226f223a3535387d0a7b226f223a3535397d0a7b226f223a3536307d0a7b226f223a3536317d0a7b226f223a3536327d0a7b226f223a3536337d0a7b226f223a3536347d0a7b226f223a3536357d0a7b226f223a3536367d0a7b226f223a3536377d0a7b226f223a3536387d0a7b226f223a3536397d0a7b226f223a3537307d0a7b226f223a3537317d0a7b226f223a3537327d0a7b226f223a3537337d0a7b226f223a3537347d0a7b226f223a3537357d0a7b226f223a3537367d0a7b226f223a3537377d0a7b226f223a3537387d0a7b226f223a3537397d0a7b226f223a3538307d0a7b226f223a3538317d0a7b226f223a3538327d0a7b226f223a3538337d0a7b226f223a3538347d0a7b226f223a3538357d0a7b226f223a3538367d0a7b226f223a3538377d0a7b226f223a3538387d0a7b226f223a3538397d0a7b226f223a3539307d0a7b226f223a3539317d0a7b226f223a3539327d0a7b226f223a3539337d0a7b226f223a3539347d0a7b226f223a3539357d0a7b226f223a3539367d0a7b226f223a3539377d0a7b226f223a3539387d0a7b226f223a3539397d0a7b226f223a3630307d0a7b226f223a3630317d0a7b226f223a3630327d0a7b226f223a3630337d0a7b226f223a3630347d0a7b226f223a3630357d0a7b226f223a3630367d0a7b226f223a3630377d0a7b226f223a3630387d0a7b226f223a3630397d0a7b226f223a3631307d0a7b226f223a3631317d0a7b226f223a3631327d0a7b226f223a3631337d0a7b226f223a3631347d0a7b226f223a3631357d0a7b226f223a3631367d0a7b226f223a3631377d0a7b226f223a3631387d0a7b226f223a3631397d0a7b226f223a3632307d0a7b226f223a3632317d0a7b226f223a3632327d0a7b226f223a3632337d0a7b226f223a3632347d0a7b226f223a3632357d0a7b226f223a3632367d0a7b226f223a3632377d0a7b226f223a3632387d0a7b226f223a3632397d0a7b226f223a3633307d0a7b226f223a3633317d0a7b226f223a3633327d0a7b226f223a3633337d0a7b226f223a3633347d0a7b226f223a3633357d0a7b226f223a3633367d0a7b226f223a3633377d0a7b226f223a3633387d0a7b226f223a3633397d0a7b226f223a3634307d0a7b226f223a3634317d0a7b226f223a3634327d0a7b226f223a3634337d0a7b226f223a3634347d0a7b226f223a3634357d0a7b226f223a3634367d0a7b226f223a3634377d0a7b226f223a3634387d0a7b226f223a3634397d0a7b226f223a3635307d0a7b226f223a3635317d0a7b226f223a3635327d0a7b226f223a3635337d0a7b226f223a3635347d0a7b226f223a3635357d0a7b226f223a3635367d0a7b226f223a3635377d0a7b226f223a3635387d0a7b226f223a3635397d0a7b226f223a3636307d0a7b226f223a3636317d0a7b226f223a3636327d0a7b226f223a3636337d0a7b226f223a3636347d0a7b226f223a3636357d0a7b226f223a3636367d0a7b226f223a3636377d0a7b226f223a3636387d0a7b226f223a3636397d0a7b226f223a3637307d0a7b226f223a3637317d0a7b226f223a3637327d0a7b226f223a3637337d0a7b226f223a3637347d0a7b226f223a3637357d0a7b226f223a3637367d0a7b226f223a3637377d0a7b226f223a3637387d0a7b226f223a3637397d0a7b226f223a3638307d0a7b226f223a3638317d0a7b226f223a3638327d0a7b226f223a3638337d0a7b226f223a3638347d0a7b226f223a3638357d0a7b226f223a3638367d0a7b226f223a3638377d0a7b226f223a3638387d0a7b226f223a3638397d0a7b226f223a3639307d0a7b226f223a3639317d0a7b226f223a3639327d0a7b226f223a3639337d0a7b226f223a3639347d0a7b226f223a3639357d0a7b226f223a3639367d0a7b226f223a3639377d0a7b226f223a3639387d0a7b226f223a3639397d0a7b226f223a3730307d0a7b226f223a3730317d0a7b226f223a3730327d0a7b226f223a3730337d0a7b226f223a3730347d0a7b226f223a3730357d0a7b226f223a3730367d0a7b226f223a3730377d0a7b226f223a3730387d0a7b226f223a3730397d0a7b226f223a3731307d0a7b226f223a3731317d0a7b226f223a3731327d0a7b226f223a3731337d0a7b226f223a3731347d0a7b226f223a3731357d0a7b226f223a3731367d0a7b226f223a3731377d0a7b226f223a3731387d0a7b226f223a3731397d0a7b226f223a3732307d0a7b226f223a3732317d0a7b226f223a3732327d0a7b226f223a3732337d0a7b226f223a3732347d0a7b226f223a3732357d0a7b226f223a3732367d0a7b226f223a3732377d0a7b226f223a3732387d0a7b226f223a3732397d0a7b226f223a3733307d0a7b226f223a3733317d0a7b226f223a3733327d0a7b226f223a3733337d0a7b226f223a3733347d0a7b226f223a3733357d0a7b226f223a3733367d0a7b226f223a3733377d0a7b226f223a3733387d0a7b226f223a3733397d0a7b226f223a3734307d0a7b226f223a3734317d0a7b226f223a3734327d0a7b226f223a3734337d0a7b226f223a3734347d0a7b226f223a3734357d0a7b226f223a3734367d0a7b226f223a3734377d0a7b226f223a3734387d0a7b226f223a3734397d0a7b226f223a3735307d0a7b226f223a3735317d0a7b226f223a3735327d0a7b226f223a3735337d0a7b226f223a3735347d0a7b226f223a3735357d0a7b226f223a3735367d0a7b226f223a3735377d0a7b226f223a3735387d0a7b226f223a3735397d0a7b226f223a3736307d0a7b226f223a3736317d0a7b226f223a3736327d0a7b226f223a3736337d0a7b226f223a3736347d0a7b226f223a3736357d0a7b226f223a3736367d0a7b226f223a3736377d0a7b226f223a3736387d0a7b226f223a3736397d0a7b226f223a3737307d0a7b226f223a3737317d0a7b226f223a3737327d0a7b226f223a3737337d0a7b226f223a3737347d0a7b226f223a3737357d0a7b226f223a3737367d0a7b226f223a3737377d0a7b226f223a3737387d0a7b226f223a3737397d0a7b226f223a3738307d0a7b226f223a3738317d0a7b226f223a3738327d0a7b226f223a3738337d0a7b226f223a3738347d0a7b226f223a3738357d0a7b226f223a3738367d0a7b226f223a3738377d0a7b226f223a3738387d0a7b226f223a3738397d0a7b226f223a3739307d0a7b226f223a3739317d0a7b226f223a3739327d0a7b226f223a3739337d0a7b226f223a3739347d0a7b226f223a3739357d0a7b226f223a3739367d0a7b226f223a3739377d0a7b226f223a3739387d0a7b226f223a3739397d0a7b226f223a3830307d0a7b226f223a3830317d0a7b226f223a3830327d0a7b226f223a3830337d0a7b226f223a3830347d0a7b226f223a3830357d0a7b226f223a3830367d0a7b226f223a3830377d0a7b226f223a3830387d0a7b226f223a3830397d0a7b226f223a3831307d0a7b226f223a3831317d0a7b226f223a3831327d0a7b226f223a3831337d0a7b226f223a3831347d0a7b226f223a3831357d0a7b226f223a3831367d0a7b226f223a3831377d0a7b226f223a3831387d0a7b226f223a3831397d0a7b226f223a3832307d0a7b226f223a3832317d0a7b226f223a3832327d0a7b226f223a3832337d0a7b226f223a3832347d0a7b226f223a3832357d0a7b226f223a3832367d0a7b226f223a3832377d0a7b226f223a3832387d0a7b226f223a3832397d0a7b226f223a3833307d0a7b226f223a3833317d0a7b226f223a3833327d0a7b226f223a3833337d0a7b226f223a3833347d0a7b226f223a3833357d0a7b226f223a3833367d0a7b226f223a3833377d0a7b226f223a3833387d0a7b226f223a3833397d0a7b226f223a3834307d0a7b226f223a3834317d0a7b226f223a3834327d0a7b226f223a3834337d0a7b226f223a3834347d0a7b226f223a3834357d0a7b226f223a3834367d0a7b226f223a3834377d0a7b226f223a3834387d0a7b226f223a3834397d0a7b226f223a3835307d0a7b226f223a3835317d0a7b226f223a3835327d0a7b226f223a3835337d0a7b226f223a3835347d0a7b226f223a3835357d0a7b226f223a3835367d0a7b226f223a3835377d0a7b226f223a3835387d0a7b226f223a3835397d0a7b226f223a3836307d0a7b226f223a3836317d0a7b226f223a3836327d0a7b226f223a3836337d0a7b226f223a3836347d0a7b226f223a3836357d0a7b226f223a3836367d0a7b226f223a3836377d0a7b226f223a3836387d0a7b226f223a3836397d0a7b226f223a3837307d0a7b226f223a3837317d0a7b226f223a3837327d0a7b226f223a3837337d0a7b226f223a3837347d0a7b226f223a3837357d0a7b226f223a3837367d0a7b226f223a3837377d0a7b226f223a3837387d0a7b226f223a3837397d0a7b226f223a3838307d0a7b226f223a3838317d0a7b226f223a3838327d0a7b226f223a3838337d0a7b226f223a3838347d0a7b226f223a3838357d0a7b226f223a3838367d0a7b226f223a3838377d0a7b226f223a3838387d0a7b226f223a3838397d0a7b226f223a3839307d0a7b226f223a3839317d0a7b226f223a3839327d0a7b226f223a3839337d0a7b226f223a3839347d0a7b226f223a3839357d0a7b226f223a3839367d0a7b226f223a3839377d0a7b226f223a3839387d0a7b226f223a3839397d0a7b226f223a3930307d0a7b226f223a3930317d0a7b226f223a3930327d0a7b226f223a3930337d0a7b226f223a3930347d0a7b226f223a3930357d0a7b226f223a3930367d0a7b226f223a3930377d0a7b226f223a3930387d0a7b226f223a3930397d0a7b226f223a3931307d0a7b226f223a3931317d0a7b226f223a3931327d0a7b226f223a3931337d0a7b226f223a3931347d0a7b226f223a3931357d0a7b226f223a3931367d0a7b226f223a3931377d0a7b226f223a3931387d0a7b226f223a3931397d0a7b226f223a3932307d0a7b226f223a3932317d0a7b226f223a3932327d0a7b226f223a3932337d0a7b226f223a3932347d0a7b226f223a3932357d0a7b226f223a3932367d0a7b226f223a3932377d0a7b226f223a3932387d0a7b226f223a3932397d0a7b226f223a3933307d0a7b226f223a3933317d0a7b226f223a3933327d0a7b226f223a3933337d0a7b226f223a3933347d0a7b226f223a3933357d0a7b226f223a3933367d0a7b226f223a3933377d0a7b226f223a3933387d0a7b226f223a3933397d0a7b226f223a3934307d0a7b226f223a3934317d0a7b226f223a3934327d0a7b226f223a3934337d0a7b226f223a3934347d0a7b226f223a3934357d0a7b226f223a3934367d0a7b226f223a3934377d0a7b226f223a3934387d0a7b226f223a3934397d0a7b226f223a3935307d0a7b226f223a3935317d0a7b226f223a3935327d0a7b226f223a3935337d0a7b226f223a3935347d0a7b226f223a3935357d0a7b226f223a3935367d0a7b226f223a3935377d0a7b226f223a3935387d0a7b226f223a3935397d0a7b226f223a3936307d0a7b226f223a3936317d0a7b226f223a3936327d0a7b226f223a3936337d0a7b226f223a3936347d0a7b226f223a3936357d0a7b226f223a3936367d0a7b226f223a3936377d0a7b226f223a3936387d0a7b226f223a3936397d0a7b226f223a3937307d0a7b226f223a3937317d0a7b226f223a3937327d0a7b226f223a3937337d0a7b226f223a3937347d0a7b226f223a3937357d0a7b226f223a3937367d0a7b226f223a3937377d0a7b226f223a3937387d0a7b226f223a3937397d0a7b226f223a3938307d0a7b226f223a3938317d0a7b226f223a3938327d0a7b226f223a3938337d0a7b226f223a3938347d0a7b226f223a3938357d0a7b226f223a3938367d0a7b226f223a3938377d0a7b226f223a3938387d0a7b226f223a3938397d0a7b226f223a3939307d0a7b226f223a3939317d0a7b226f223a3939327d0a7b226f223a3939337d0a7b226f223a3939347d0a7b226f223a3939357d0a7b226f223a3939367d0a7b226f223a3939377d0a7b226f223a3939387d0a7b226f223a3939397d0a7b226f223a313030307d0a","obligation_id":"robl_interop","sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277"}, + {"account_id":"acct_interop","body_hex":"7b2261223a322c22636f6e74726f6c223a225c75303030375c6e5c225c5c222c226e616d65223a22636166c3a920f09f988020e697a5e69cace8aa9e222c22736570223a22e280a8222c227a223a317d0a7b22666c6f6174223a302e33303030303030303030303030303030342c226d61785f696e74223a393030373139393235343734303939312c226e223a302c226e6567223a2d31322e352c226e6573746564223a7b2261223a5b5d2c227a223a5b312c7b2261223a747275652c2262223a6e756c6c7d5d7d2c2274696e79223a31652d377d0a","obligation_id":"robl_interop","sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}, + {"account_id":"acct_interop","body_hex":"7b2264617465223a22323032362d31302d3031222c22696d7072657373696f6e73223a313230302c226d656469615f6275795f6964223a226d625f31222c227061636b6167655f6964223a22706b675f31222c227370656e64223a2234322e3137227d0a","obligation_id":"robl_interop","sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"} ], "objects": { - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/0aca7cfc22e75fff0d7a566b8ddddf8cc5b336889cf87f72205bec7206137cee/5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187/000000.jsonl.gz": "H4sIAAAAAAAAAyXKQQqAIBBA0X3HmLXFjESBlxHNQUQ0yVpEdPeylv/xL3BmZ1AgUU49YY8EAkIqG9ca1lxBkUQUkNgFo+1x6uDePVndxmKWaDz/VqL/sBbOrUc50Ax39wBBEPOlZAAAAA==", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/27e955cd7fbfa6a59c45ae49c074730a25eccd701bfb8c96e296135f5c9df2fd/49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe/000000.jsonl.gz": "H4sIAAAAAAAAA03aQbIsJw5A0bmX8VdQgARSb8oLcHjv3R1RHGqUsyvH9zsFCfnPn7///Ofz71///P85vs/5fa7vM77P/D7393m+z/o++3YEb3Hc5LjNcaPjVsfNjtsdNzxued7y9N96y/OW5y3PW563PG953vK85XXL65aXf4ZbXre8bnnd8rrldcvrluOW45bjlsO/8C3HLcctxy3HLcct5y3nLect5y2n/3m3nLect5y3nLe8b3nf8r7lfcv7lre/i1vet7xved/yueVzy+eWzy2fWz63fPzJ3fK55XPLdct1y3XLdct1y3XLdcvlr/mW65b7lvuW+5b7lvuW+5b7lvuWG5QnBZUPKx9YPrR8cPnw8gHmQ8wHmY8ZPxzNeCCfyEfymXwon8rHkssB5pjPvBlsDjgHnQPPwecAdBA6EB2MjvV+WMzAdHA6QB2kDlQHqwPWQevAdcT79TKD2IHsYHZAO6gd2A5uB7iD3JHvJ9IMeAe9A9/B7wB4EDwQHgwPiMd+v8NmcDxAHiQPlAfLA+ZB88B58DzO+7E3A+nB9IB6UD2wHlwPsAfZA+1Rb0Uxg+6B9+B7AD4IH4gPxgfkg/LRb9l665aFi/PJ+eR8cj45n5xPzifnk/M53uJoBueT88n55HxyPjmfnM+3/r4F+GcFNuOtwW8RfqvwW4bfOvwWYs4n55Pzud4ybwbnk/PJ+eR8cj45n5xPzifnM95ewgzOJ+eT88n55HxyPjmfnE/OZ74NixmcT84n55PzyfnkfHI+OZ+cz/12RWZwPjmfnE/OJ+eT88n55HxyPs/bepnB+eR8cj45n5xPzifnk/PJ+ay3vzOD88n55HxyPjmfnE/OJ+eT89lvE/l2kbaRnC/OF+eL88X54nxxvjhfnK/xtqpmcL44X5wvzhfni/PF+eJ8cb7m2w+bwfnifHG+OF+cL87X23G/Lffbc/9sus142+63734b77fzfltvzhfni/PF+Yq3szeD88X54nxxvjhfnC/OF+eL85Xv9cEMzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PFeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfn8d6x30v2e8t+r9k/79lmvDft96r93rXfyzbnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwnpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpznO1V7x2rvXO0drL2TtZ+jNTPe4do7XXvHa5wn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vz/c7R30H6O0l/R+nvLP0dpv+cppvxztPfgTrnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8vJuzd3X27s7e5dm7PXvXZ+/+7OcCzYx3hcb54fxwfjg/nB/OD+eH88P54fxwfjg/nB/OD+eH88P54fxwfjg/nBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV7vrvxdlr/b8ndd/u7L34X5uzF/V+Y/d+ZmcF6cF+fFeXFenBfnxXlxXpwX5815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeb+vY97nMe/7mPeBzPtC5n0i876ReR/JvK9kfj6T+flO5n9D/gsn4sd0rSYAAA==", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/480d5636d1a3d4eea19ed0915558ba0a53c23f9b8e9705235872e6f4350cf761/628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095/000000.jsonl.gz": "H4sIAAAAAAAAA1WOQQrCMBBF9z1F+etUktpSkqsYkaipCDUVTUEtBW+hC9GVoOItvImL4tIjOLpzFsP/8xner2GgYoZR6fyiLKCgK855pp2G1mBwZmbpOjL54xq+T/tt2O7O7eH+uh0pXdo5hc/thfQGSjRBjbwojYfinS7/n4RhZlaDqaNUEkNIGadJlnApBYHohbadQEUi7qRfvfR2DFV/O/b6P0JPsJ/1i8oyDKFcVRRNv2HwU7emBjbKmuAD5D6qgNYAAAA=", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/5a75b70aa480c4eb2b21387f3f727c4ea85ef87b86f93f2b05aa54ac48cd3a66/b7f659d4967acd5e1d55aaee8fd97a1e18d197a610b6c994c2bc16cc718302a8/000000.jsonl.gz": "H4sIAAAAAAAAAyXKQQqAIBBA0X3HmLXFjESBlxHNQUQ0yVpEdPeylv/xL3BmZ1AgUU49YY8EAkIqG9ca1lxBkUQUkNgFo+1x6uDePVndxmKWaDz/VqL/sBbOrUc50Ax39wBBEPOlZAAAAA==", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/76fd73e617881fd6a8038570f07fe2a82bb95306474cfa6ccbd38781110ec845/9da4eb545db09a88341b27b05d81247eae4bfeea407c4bdc4a57f16f3b497869/000000.jsonl.gz": "H4sIAAAAAAAAA1WOQQrCMBBF9z1F+etUktpSkqsYkaipCDUVTUEtBW+hC9GVoOItvImL4tIjOLpzFsP/8xner2GgYoZR6fyiLKCgK855pp2G1mBwZmbpOjL54xq+T/tt2O7O7eH+uh0pXdo5hc/thfQGSjRBjbwojYfinS7/n4RhZlaDqaNUEkNIGadJlnApBYHohbadQEUi7qRfvfR2DFV/O/b6P0JPsJ/1i8oyDKFcVRRNv2HwU7emBjbKmuAD5D6qgNYAAAA=", - "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/9bf8b4413d524f9a77782d9cc7ecc980e03bdf15799c53861169030784f56419/a87c08703c1ac542c704086ada8372ba78925a4f1e6f008d8556a92b9d9c7f2b/000000.jsonl.gz": "H4sIAAAAAAAAA03aQbIsJw5A0bmX8VdQgARSb8oLcHjv3R1RHGqUsyvH9zsFCfnPn7///Ofz71///P85vs/5fa7vM77P/D7393m+z/o++3YEb3Hc5LjNcaPjVsfNjtsdNzxued7y9N96y/OW5y3PW563PG953vK85XXL65aXf4ZbXre8bnnd8rrldcvrluOW45bjlsO/8C3HLcctxy3HLcct5y3nLect5y2n/3m3nLect5y3nLe8b3nf8r7lfcv7lre/i1vet7xved/yueVzy+eWzy2fWz63fPzJ3fK55XPLdct1y3XLdct1y3XLdcvlr/mW65b7lvuW+5b7lvuW+5b7lvuWG5QnBZUPKx9YPrR8cPnw8gHmQ8wHmY8ZPxzNeCCfyEfymXwon8rHkssB5pjPvBlsDjgHnQPPwecAdBA6EB2MjvV+WMzAdHA6QB2kDlQHqwPWQevAdcT79TKD2IHsYHZAO6gd2A5uB7iD3JHvJ9IMeAe9A9/B7wB4EDwQHgwPiMd+v8NmcDxAHiQPlAfLA+ZB88B58DzO+7E3A+nB9IB6UD2wHlwPsAfZA+1Rb0Uxg+6B9+B7AD4IH4gPxgfkg/LRb9l665aFi/PJ+eR8cj45n5xPzifnk/M53uJoBueT88n55HxyPjmfnM+3/r4F+GcFNuOtwW8RfqvwW4bfOvwWYs4n55Pzud4ybwbnk/PJ+eR8cj45n5xPzifnM95ewgzOJ+eT88n55HxyPjmfnE/OZ74NixmcT84n55PzyfnkfHI+OZ+cz/12RWZwPjmfnE/OJ+eT88n55HxyPs/bepnB+eR8cj45n5xPzifnk/PJ+ay3vzOD88n55HxyPjmfnE/OJ+eT89lvE/l2kbaRnC/OF+eL88X54nxxvjhfnK/xtqpmcL44X5wvzhfni/PF+eJ8cb7m2w+bwfnifHG+OF+cL87X23G/Lffbc/9sus142+63734b77fzfltvzhfni/PF+Yq3szeD88X54nxxvjhfnC/OF+eL85Xv9cEMzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PFeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfn8d6x30v2e8t+r9k/79lmvDft96r93rXfyzbnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwnpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpznO1V7x2rvXO0drL2TtZ+jNTPe4do7XXvHa5wn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vz/c7R30H6O0l/R+nvLP0dpv+cppvxztPfgTrnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8vJuzd3X27s7e5dm7PXvXZ+/+7OcCzYx3hcb54fxwfjg/nB/OD+eH88P54fxwfjg/nB/OD+eH88P54fxwfjg/nBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV7vrvxdlr/b8ndd/u7L34X5uzF/V+Y/d+ZmcF6cF+fFeXFenBfnxXlxXpwX5815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeb+vY97nMe/7mPeBzPtC5n0i876ReR/JvK9kfj6T+flO5n9D/gsn4sd0rSYAAA==" + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/0aca7cfc22e75fff0d7a566b8ddddf8cc5b336889cf87f72205bec7206137cee/5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187/000000.jsonl.gz":"H4sIAAAAAAAAAyXKQQqAIBBA0X3HmLXFjESBlxHNQUQ0yVpEdPeylv/xL3BmZ1AgUU49YY8EAkIqG9ca1lxBkUQUkNgFo+1x6uDePVndxmKWaDz/VqL/sBbOrUc50Ax39wBBEPOlZAAAAA==", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/27e955cd7fbfa6a59c45ae49c074730a25eccd701bfb8c96e296135f5c9df2fd/49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe/000000.jsonl.gz":"H4sIAAAAAAAAA03aQbIsJw5A0bmX8VdQgARSb8oLcHjv3R1RHGqUsyvH9zsFCfnPn7///Ofz71///P85vs/5fa7vM77P/D7393m+z/o++3YEb3Hc5LjNcaPjVsfNjtsdNzxued7y9N96y/OW5y3PW563PG953vK85XXL65aXf4ZbXre8bnnd8rrldcvrluOW45bjlsO/8C3HLcctxy3HLcct5y3nLect5y2n/3m3nLect5y3nLe8b3nf8r7lfcv7lre/i1vet7xved/yueVzy+eWzy2fWz63fPzJ3fK55XPLdct1y3XLdct1y3XLdcvlr/mW65b7lvuW+5b7lvuW+5b7lvuWG5QnBZUPKx9YPrR8cPnw8gHmQ8wHmY8ZPxzNeCCfyEfymXwon8rHkssB5pjPvBlsDjgHnQPPwecAdBA6EB2MjvV+WMzAdHA6QB2kDlQHqwPWQevAdcT79TKD2IHsYHZAO6gd2A5uB7iD3JHvJ9IMeAe9A9/B7wB4EDwQHgwPiMd+v8NmcDxAHiQPlAfLA+ZB88B58DzO+7E3A+nB9IB6UD2wHlwPsAfZA+1Rb0Uxg+6B9+B7AD4IH4gPxgfkg/LRb9l665aFi/PJ+eR8cj45n5xPzifnk/M53uJoBueT88n55HxyPjmfnM+3/r4F+GcFNuOtwW8RfqvwW4bfOvwWYs4n55Pzud4ybwbnk/PJ+eR8cj45n5xPzifnM95ewgzOJ+eT88n55HxyPjmfnE/OZ74NixmcT84n55PzyfnkfHI+OZ+cz/12RWZwPjmfnE/OJ+eT88n55HxyPs/bepnB+eR8cj45n5xPzifnk/PJ+ay3vzOD88n55HxyPjmfnE/OJ+eT89lvE/l2kbaRnC/OF+eL88X54nxxvjhfnK/xtqpmcL44X5wvzhfni/PF+eJ8cb7m2w+bwfnifHG+OF+cL87X23G/Lffbc/9sus142+63734b77fzfltvzhfni/PF+Yq3szeD88X54nxxvjhfnC/OF+eL85Xv9cEMzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PFeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfn8d6x30v2e8t+r9k/79lmvDft96r93rXfyzbnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwnpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpznO1V7x2rvXO0drL2TtZ+jNTPe4do7XXvHa5wn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vz/c7R30H6O0l/R+nvLP0dpv+cppvxztPfgTrnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8vJuzd3X27s7e5dm7PXvXZ+/+7OcCzYx3hcb54fxwfjg/nB/OD+eH88P54fxwfjg/nB/OD+eH88P54fxwfjg/nBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV7vrvxdlr/b8ndd/u7L34X5uzF/V+Y/d+ZmcF6cF+fFeXFenBfnxXlxXpwX5815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeb+vY97nMe/7mPeBzPtC5n0i876ReR/JvK9kfj6T+flO5n9D/gsn4sd0rSYAAA==", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/480d5636d1a3d4eea19ed0915558ba0a53c23f9b8e9705235872e6f4350cf761/628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095/000000.jsonl.gz":"H4sIAAAAAAAAA1WOQQrCMBBF9z1F+etUktpSkqsYkaipCDUVTUEtBW+hC9GVoOItvImL4tIjOLpzFsP/8xner2GgYoZR6fyiLKCgK855pp2G1mBwZmbpOjL54xq+T/tt2O7O7eH+uh0pXdo5hc/thfQGSjRBjbwojYfinS7/n4RhZlaDqaNUEkNIGadJlnApBYHohbadQEUi7qRfvfR2DFV/O/b6P0JPsJ/1i8oyDKFcVRRNv2HwU7emBjbKmuAD5D6qgNYAAAA=", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/5a75b70aa480c4eb2b21387f3f727c4ea85ef87b86f93f2b05aa54ac48cd3a66/b7f659d4967acd5e1d55aaee8fd97a1e18d197a610b6c994c2bc16cc718302a8/000000.jsonl.gz":"H4sIAAAAAAAAAyXKQQqAIBBA0X3HmLXFjESBlxHNQUQ0yVpEdPeylv/xL3BmZ1AgUU49YY8EAkIqG9ca1lxBkUQUkNgFo+1x6uDePVndxmKWaDz/VqL/sBbOrUc50Ax39wBBEPOlZAAAAA==", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/76fd73e617881fd6a8038570f07fe2a82bb95306474cfa6ccbd38781110ec845/9da4eb545db09a88341b27b05d81247eae4bfeea407c4bdc4a57f16f3b497869/000000.jsonl.gz":"H4sIAAAAAAAAA1WOQQrCMBBF9z1F+etUktpSkqsYkaipCDUVTUEtBW+hC9GVoOItvImL4tIjOLpzFsP/8xner2GgYoZR6fyiLKCgK855pp2G1mBwZmbpOjL54xq+T/tt2O7O7eH+uh0pXdo5hc/thfQGSjRBjbwojYfinS7/n4RhZlaDqaNUEkNIGadJlnApBYHohbadQEUi7qRfvfR2DFV/O/b6P0JPsJ/1i8oyDKFcVRRNv2HwU7emBjbKmuAD5D6qgNYAAAA=", + "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/9bf8b4413d524f9a77782d9cc7ecc980e03bdf15799c53861169030784f56419/a87c08703c1ac542c704086ada8372ba78925a4f1e6f008d8556a92b9d9c7f2b/000000.jsonl.gz":"H4sIAAAAAAAAA03aQbIsJw5A0bmX8VdQgARSb8oLcHjv3R1RHGqUsyvH9zsFCfnPn7///Ofz71///P85vs/5fa7vM77P/D7393m+z/o++3YEb3Hc5LjNcaPjVsfNjtsdNzxued7y9N96y/OW5y3PW563PG953vK85XXL65aXf4ZbXre8bnnd8rrldcvrluOW45bjlsO/8C3HLcctxy3HLcct5y3nLect5y2n/3m3nLect5y3nLe8b3nf8r7lfcv7lre/i1vet7xved/yueVzy+eWzy2fWz63fPzJ3fK55XPLdct1y3XLdct1y3XLdcvlr/mW65b7lvuW+5b7lvuW+5b7lvuWG5QnBZUPKx9YPrR8cPnw8gHmQ8wHmY8ZPxzNeCCfyEfymXwon8rHkssB5pjPvBlsDjgHnQPPwecAdBA6EB2MjvV+WMzAdHA6QB2kDlQHqwPWQevAdcT79TKD2IHsYHZAO6gd2A5uB7iD3JHvJ9IMeAe9A9/B7wB4EDwQHgwPiMd+v8NmcDxAHiQPlAfLA+ZB88B58DzO+7E3A+nB9IB6UD2wHlwPsAfZA+1Rb0Uxg+6B9+B7AD4IH4gPxgfkg/LRb9l665aFi/PJ+eR8cj45n5xPzifnk/M53uJoBueT88n55HxyPjmfnM+3/r4F+GcFNuOtwW8RfqvwW4bfOvwWYs4n55Pzud4ybwbnk/PJ+eR8cj45n5xPzifnM95ewgzOJ+eT88n55HxyPjmfnE/OZ74NixmcT84n55PzyfnkfHI+OZ+cz/12RWZwPjmfnE/OJ+eT88n55HxyPs/bepnB+eR8cj45n5xPzifnk/PJ+ay3vzOD88n55HxyPjmfnE/OJ+eT89lvE/l2kbaRnC/OF+eL88X54nxxvjhfnK/xtqpmcL44X5wvzhfni/PF+eJ8cb7m2w+bwfnifHG+OF+cL87X23G/Lffbc/9sus142+63734b77fzfltvzhfni/PF+Yq3szeD88X54nxxvjhfnC/OF+eL85Xv9cEMzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PF+eJ8cb44X5wvzhfni/PFeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfn8d6x30v2e8t+r9k/79lmvDft96r93rXfyzbnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwHpwH58F5cB6cB+fBeXAenAfnwXlwnpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpznO1V7x2rvXO0drL2TtZ+jNTPe4do7XXvHa5wn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cJ+fJeXKenCfnyXlynpwn58l5cp6cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vzzfnmfHO+Od+cb84355vz/c7R30H6O0l/R+nvLP0dpv+cppvxztPfgTrnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535xvzjfnm/PN+eZ8c74535wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8cH44P5wfzg/nh/PD+eH8vJuzd3X27s7e5dm7PXvXZ+/+7OcCzYx3hcb54fxwfjg/nB/OD+eH88P54fxwfjg/nB/OD+eH88P54fxwfjg/nBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV6cF+fFeXFenBfnxXlxXpwX58V5cV7vrvxdlr/b8ndd/u7L34X5uzF/V+Y/d+ZmcF6cF+fFeXFenBfnxXlxXpwX5815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeXPenDfnzXlz3pw35815c96cN+fNeb+vY97nMe/7mPeBzPtC5n0i876ReR/JvK9kfj6T+flO5n9D/gsn4sd0rSYAAA==" }, "row_chunks": [ - { - "account_id": "acct_interop", - "canonical_size_bytes": 100, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 1, - "row_set_id": "rrev_py_single-row_adjustment_inline", - "segments": [ - { - "byte_count": 100, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 1, - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } - ], - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 100, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/0aca7cfc22e75fff0d7a566b8ddddf8cc5b336889cf87f72205bec7206137cee/5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "451e665e46f08ecbe80a24afde05a61b5762dc57aee04e611382fc022277ecc5", - "row_count": 1, - "row_set_id": "rrev_py_single-row_adjustment_reports", - "segments": [ - { - "byte_count": 100, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 1, - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } - ], - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336", - "size_bytes": 106 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 100, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 1, - "row_set_id": "rrev_py_single-row_revision_inline", - "segments": [ - { - "byte_count": 100, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 1, - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } - ], - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 100, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/5a75b70aa480c4eb2b21387f3f727c4ea85ef87b86f93f2b05aa54ac48cd3a66/b7f659d4967acd5e1d55aaee8fd97a1e18d197a610b6c994c2bc16cc718302a8/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "451e665e46f08ecbe80a24afde05a61b5762dc57aee04e611382fc022277ecc5", - "row_count": 1, - "row_set_id": "rrev_py_single-row_revision_reports", - "segments": [ - { - "byte_count": 100, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 1, - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336" - } - ], - "sha256": "8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336", - "size_bytes": 106 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 9901, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 1001, - "row_set_id": "rrev_py_three-segments_adjustment_inline", - "segments": [ - { - "byte_count": 4890, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 500, - "sha256": "6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502" - }, - { - "byte_count": 5000, - "byte_offset": 4890, - "first_ordinal": 500, - "row_count": 500, - "sha256": "18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9" - }, - { - "byte_count": 11, - "byte_offset": 9890, - "first_ordinal": 1000, - "row_count": 1, - "sha256": "34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f" - } - ], - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 9901, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/27e955cd7fbfa6a59c45ae49c074730a25eccd701bfb8c96e296135f5c9df2fd/49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "8ebd7f074937968afb03873bb41d081da29607404639eb28dbc6a833b88f588b", - "row_count": 1001, - "row_set_id": "rrev_py_three-segments_adjustment_reports", - "segments": [ - { - "byte_count": 4890, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 500, - "sha256": "6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502" - }, - { - "byte_count": 5000, - "byte_offset": 4890, - "first_ordinal": 500, - "row_count": 500, - "sha256": "18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9" - }, - { - "byte_count": 11, - "byte_offset": 9890, - "first_ordinal": 1000, - "row_count": 1, - "sha256": "34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f" - } - ], - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277", - "size_bytes": 1987 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 9901, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 1001, - "row_set_id": "rrev_py_three-segments_revision_inline", - "segments": [ - { - "byte_count": 4890, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 500, - "sha256": "6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502" - }, - { - "byte_count": 5000, - "byte_offset": 4890, - "first_ordinal": 500, - "row_count": 500, - "sha256": "18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9" - }, - { - "byte_count": 11, - "byte_offset": 9890, - "first_ordinal": 1000, - "row_count": 1, - "sha256": "34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f" - } - ], - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 9901, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/9bf8b4413d524f9a77782d9cc7ecc980e03bdf15799c53861169030784f56419/a87c08703c1ac542c704086ada8372ba78925a4f1e6f008d8556a92b9d9c7f2b/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "8ebd7f074937968afb03873bb41d081da29607404639eb28dbc6a833b88f588b", - "row_count": 1001, - "row_set_id": "rrev_py_three-segments_revision_reports", - "segments": [ - { - "byte_count": 4890, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 500, - "sha256": "6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502" - }, - { - "byte_count": 5000, - "byte_offset": 4890, - "first_ordinal": 500, - "row_count": 500, - "sha256": "18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9" - }, - { - "byte_count": 11, - "byte_offset": 9890, - "first_ordinal": 1000, - "row_count": 1, - "sha256": "34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f" - } - ], - "sha256": "0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277", - "size_bytes": 1987 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 214, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 2, - "row_set_id": "rrev_py_unicode-and-numbers_adjustment_inline", - "segments": [ - { - "byte_count": 214, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 2, - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - } - ], - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 214, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/480d5636d1a3d4eea19ed0915558ba0a53c23f9b8e9705235872e6f4350cf761/628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "3ec156c72bc93f75c35a90f228102774d1e9350cbee607554413bb1d38e9d2fb", - "row_count": 2, - "row_set_id": "rrev_py_unicode-and-numbers_adjustment_reports", - "segments": [ - { - "byte_count": 214, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 2, - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - } - ], - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023", - "size_bytes": 200 - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 214, - "chunk_index": 0, - "compression": "none", - "first_ordinal": 0, - "object_ref": null, - "obligation_id": "robl_interop", - "physical_sha256": null, - "row_count": 2, - "row_set_id": "rrev_py_unicode-and-numbers_revision_inline", - "segments": [ - { - "byte_count": 214, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 2, - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - } - ], - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023", - "size_bytes": null - }, - { - "account_id": "acct_interop", - "canonical_size_bytes": 214, - "chunk_index": 0, - "compression": "gzip", - "first_ordinal": 0, - "object_ref": "adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/76fd73e617881fd6a8038570f07fe2a82bb95306474cfa6ccbd38781110ec845/9da4eb545db09a88341b27b05d81247eae4bfeea407c4bdc4a57f16f3b497869/000000.jsonl.gz", - "obligation_id": "robl_interop", - "physical_sha256": "3ec156c72bc93f75c35a90f228102774d1e9350cbee607554413bb1d38e9d2fb", - "row_count": 2, - "row_set_id": "rrev_py_unicode-and-numbers_revision_reports", - "segments": [ - { - "byte_count": 214, - "byte_offset": 0, - "first_ordinal": 0, - "row_count": 2, - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023" - } - ], - "sha256": "10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023", - "size_bytes": 200 - } + {"account_id":"acct_interop","canonical_size_bytes":100,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":1,"row_set_id":"rrev_py_single-row_adjustment_inline","segments":[{"byte_count":100,"byte_offset":0,"first_ordinal":0,"row_count":1,"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"}],"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":100,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/0aca7cfc22e75fff0d7a566b8ddddf8cc5b336889cf87f72205bec7206137cee/5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"451e665e46f08ecbe80a24afde05a61b5762dc57aee04e611382fc022277ecc5","row_count":1,"row_set_id":"rrev_py_single-row_adjustment_reports","segments":[{"byte_count":100,"byte_offset":0,"first_ordinal":0,"row_count":1,"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"}],"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336","size_bytes":106}, + {"account_id":"acct_interop","canonical_size_bytes":100,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":1,"row_set_id":"rrev_py_single-row_revision_inline","segments":[{"byte_count":100,"byte_offset":0,"first_ordinal":0,"row_count":1,"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"}],"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":100,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/5a75b70aa480c4eb2b21387f3f727c4ea85ef87b86f93f2b05aa54ac48cd3a66/b7f659d4967acd5e1d55aaee8fd97a1e18d197a610b6c994c2bc16cc718302a8/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"451e665e46f08ecbe80a24afde05a61b5762dc57aee04e611382fc022277ecc5","row_count":1,"row_set_id":"rrev_py_single-row_revision_reports","segments":[{"byte_count":100,"byte_offset":0,"first_ordinal":0,"row_count":1,"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336"}],"sha256":"8fcc0c1b57966f5ab49d8bed4782722734119ae1875a761550209e87a6d2a336","size_bytes":106}, + {"account_id":"acct_interop","canonical_size_bytes":9901,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":1001,"row_set_id":"rrev_py_three-segments_adjustment_inline","segments":[{"byte_count":4890,"byte_offset":0,"first_ordinal":0,"row_count":500,"sha256":"6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502"},{"byte_count":5000,"byte_offset":4890,"first_ordinal":500,"row_count":500,"sha256":"18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9"},{"byte_count":11,"byte_offset":9890,"first_ordinal":1000,"row_count":1,"sha256":"34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f"}],"sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":9901,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/27e955cd7fbfa6a59c45ae49c074730a25eccd701bfb8c96e296135f5c9df2fd/49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"8ebd7f074937968afb03873bb41d081da29607404639eb28dbc6a833b88f588b","row_count":1001,"row_set_id":"rrev_py_three-segments_adjustment_reports","segments":[{"byte_count":4890,"byte_offset":0,"first_ordinal":0,"row_count":500,"sha256":"6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502"},{"byte_count":5000,"byte_offset":4890,"first_ordinal":500,"row_count":500,"sha256":"18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9"},{"byte_count":11,"byte_offset":9890,"first_ordinal":1000,"row_count":1,"sha256":"34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f"}],"sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277","size_bytes":1987}, + {"account_id":"acct_interop","canonical_size_bytes":9901,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":1001,"row_set_id":"rrev_py_three-segments_revision_inline","segments":[{"byte_count":4890,"byte_offset":0,"first_ordinal":0,"row_count":500,"sha256":"6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502"},{"byte_count":5000,"byte_offset":4890,"first_ordinal":500,"row_count":500,"sha256":"18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9"},{"byte_count":11,"byte_offset":9890,"first_ordinal":1000,"row_count":1,"sha256":"34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f"}],"sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":9901,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/9bf8b4413d524f9a77782d9cc7ecc980e03bdf15799c53861169030784f56419/a87c08703c1ac542c704086ada8372ba78925a4f1e6f008d8556a92b9d9c7f2b/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"8ebd7f074937968afb03873bb41d081da29607404639eb28dbc6a833b88f588b","row_count":1001,"row_set_id":"rrev_py_three-segments_revision_reports","segments":[{"byte_count":4890,"byte_offset":0,"first_ordinal":0,"row_count":500,"sha256":"6bb5ee41da32436cb1c9eabe9222133864103cb34357f8428d11d37c5f0c4502"},{"byte_count":5000,"byte_offset":4890,"first_ordinal":500,"row_count":500,"sha256":"18081778436f618fa78cd93f348daffd4f8b32146cb2a46a09f0d48118b178d9"},{"byte_count":11,"byte_offset":9890,"first_ordinal":1000,"row_count":1,"sha256":"34372091671ff2e0982e59df385c1e928082db35c3e288a575831498e24bf21f"}],"sha256":"0779a24363382975e70ba1fa57ec9114d97cbba03a85253f7c3c776b29e07277","size_bytes":1987}, + {"account_id":"acct_interop","canonical_size_bytes":214,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":2,"row_set_id":"rrev_py_unicode-and-numbers_adjustment_inline","segments":[{"byte_count":214,"byte_offset":0,"first_ordinal":0,"row_count":2,"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}],"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":214,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/adjustment/480d5636d1a3d4eea19ed0915558ba0a53c23f9b8e9705235872e6f4350cf761/628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"3ec156c72bc93f75c35a90f228102774d1e9350cbee607554413bb1d38e9d2fb","row_count":2,"row_set_id":"rrev_py_unicode-and-numbers_adjustment_reports","segments":[{"byte_count":214,"byte_offset":0,"first_ordinal":0,"row_count":2,"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}],"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023","size_bytes":200}, + {"account_id":"acct_interop","canonical_size_bytes":214,"chunk_index":0,"compression":"none","first_ordinal":0,"object_ref":null,"obligation_id":"robl_interop","physical_sha256":null,"row_count":2,"row_set_id":"rrev_py_unicode-and-numbers_revision_inline","segments":[{"byte_count":214,"byte_offset":0,"first_ordinal":0,"row_count":2,"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}],"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023","size_bytes":null}, + {"account_id":"acct_interop","canonical_size_bytes":214,"chunk_index":0,"compression":"gzip","first_ordinal":0,"object_ref":"adcp-rows/1b6d0668f0d40a94bdd9b9c938602ec73c7fb4a007cd24495b088f6780f3e3ae/50fab37758f9ad43d91228fecc9bb96e0154f20e4e997d142e08890e1c40796d/2026-09-30/snapshot/76fd73e617881fd6a8038570f07fe2a82bb95306474cfa6ccbd38781110ec845/9da4eb545db09a88341b27b05d81247eae4bfeea407c4bdc4a57f16f3b497869/000000.jsonl.gz","obligation_id":"robl_interop","physical_sha256":"3ec156c72bc93f75c35a90f228102774d1e9350cbee607554413bb1d38e9d2fb","row_count":2,"row_set_id":"rrev_py_unicode-and-numbers_revision_reports","segments":[{"byte_count":214,"byte_offset":0,"first_ordinal":0,"row_count":2,"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023"}],"sha256":"10799084bfe8f8fc94f0d61a4df1c6dc752f14fe804b8c8baa92392faac15023","size_bytes":200} ], "row_sets": [ - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187", - "content_size_bytes": 101, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 1, - "row_manifest_sha256": "8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a", - "row_set_id": "rrev_py_single-row_adjustment_inline", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187", - "content_size_bytes": 101, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 1, - "row_manifest_sha256": "8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a", - "row_set_id": "rrev_py_single-row_adjustment_reports", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "f04cd965936bf4821f54148bcaa037a7a9b4f0315e7d9cead0b6e90baed2f818", - "content_size_bytes": 344, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 1, - "row_manifest_sha256": "8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a", - "row_set_id": "rrev_py_single-row_revision_inline", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "b7f659d4967acd5e1d55aaee8fd97a1e18d197a610b6c994c2bc16cc718302a8", - "content_size_bytes": 345, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 1, - "row_manifest_sha256": "8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a", - "row_set_id": "rrev_py_single-row_revision_reports", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe", - "content_size_bytes": 9902, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 1001, - "row_manifest_sha256": "c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927", - "row_set_id": "rrev_py_three-segments_adjustment_inline", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe", - "content_size_bytes": 9902, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 1001, - "row_manifest_sha256": "c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927", - "row_set_id": "rrev_py_three-segments_adjustment_reports", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "cd76d91d96b99808a64c0a9348d81d82584153af22ea74d1138970381705f57d", - "content_size_bytes": 10152, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 1001, - "row_manifest_sha256": "c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927", - "row_set_id": "rrev_py_three-segments_revision_inline", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "a87c08703c1ac542c704086ada8372ba78925a4f1e6f008d8556a92b9d9c7f2b", - "content_size_bytes": 10153, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 1001, - "row_manifest_sha256": "c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927", - "row_set_id": "rrev_py_three-segments_revision_reports", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095", - "content_size_bytes": 215, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 2, - "row_manifest_sha256": "a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f", - "row_set_id": "rrev_py_unicode-and-numbers_adjustment_inline", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095", - "content_size_bytes": 215, - "digest_profile": "rows_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 2, - "row_manifest_sha256": "a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f", - "row_set_id": "rrev_py_unicode-and-numbers_adjustment_reports", - "row_set_kind": "adjustment", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "795bc2f0c2979e4e3178d994692f994f34220bca84af68db33705ac6fd52efdd", - "content_size_bytes": 467, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "inline", - "row_count": 2, - "row_manifest_sha256": "a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f", - "row_set_id": "rrev_py_unicode-and-numbers_revision_inline", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - }, - { - "account_id": "acct_interop", - "algorithm": "adcp_jcs_rows_v1", - "chunk_count": 1, - "content_sha256": "9da4eb545db09a88341b27b05d81247eae4bfeea407c4bdc4a57f16f3b497869", - "content_size_bytes": 468, - "digest_profile": "revision_envelope_v1", - "format": "jsonl", - "obligation_id": "robl_interop", - "row_binding_id": "reports", - "row_count": 2, - "row_manifest_sha256": "a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f", - "row_set_id": "rrev_py_unicode-and-numbers_revision_reports", - "row_set_kind": "revision", - "rows_shared_from_row_set_id": null - } + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187","content_size_bytes":101,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":1,"row_manifest_sha256":"8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a","row_set_id":"rrev_py_single-row_adjustment_inline","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"5768a21e0e903063a5b0158a5053c240289f1ac1e563957fa5fc8915c2ffc187","content_size_bytes":101,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":1,"row_manifest_sha256":"8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a","row_set_id":"rrev_py_single-row_adjustment_reports","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"f04cd965936bf4821f54148bcaa037a7a9b4f0315e7d9cead0b6e90baed2f818","content_size_bytes":344,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":1,"row_manifest_sha256":"8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a","row_set_id":"rrev_py_single-row_revision_inline","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"b7f659d4967acd5e1d55aaee8fd97a1e18d197a610b6c994c2bc16cc718302a8","content_size_bytes":345,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":1,"row_manifest_sha256":"8d8a4a20352022452e253c68b47297a5f5ef0807881e4e7f042f76efab13577a","row_set_id":"rrev_py_single-row_revision_reports","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe","content_size_bytes":9902,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":1001,"row_manifest_sha256":"c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927","row_set_id":"rrev_py_three-segments_adjustment_inline","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"49cea3ff67422f369aef062db1faf3aa541ef88aa47cf36369b894c891e02cfe","content_size_bytes":9902,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":1001,"row_manifest_sha256":"c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927","row_set_id":"rrev_py_three-segments_adjustment_reports","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"cd76d91d96b99808a64c0a9348d81d82584153af22ea74d1138970381705f57d","content_size_bytes":10152,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":1001,"row_manifest_sha256":"c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927","row_set_id":"rrev_py_three-segments_revision_inline","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"a87c08703c1ac542c704086ada8372ba78925a4f1e6f008d8556a92b9d9c7f2b","content_size_bytes":10153,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":1001,"row_manifest_sha256":"c7c02b87deb344ae06f63be3b37189538b0c1c7f53035e6699c8d83733b92927","row_set_id":"rrev_py_three-segments_revision_reports","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095","content_size_bytes":215,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":2,"row_manifest_sha256":"a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f","row_set_id":"rrev_py_unicode-and-numbers_adjustment_inline","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"628122e5320202b9d578bfac11857240e5c0826ea8c30ae5034c4b90b7bfe095","content_size_bytes":215,"digest_profile":"rows_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":2,"row_manifest_sha256":"a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f","row_set_id":"rrev_py_unicode-and-numbers_adjustment_reports","row_set_kind":"adjustment","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"795bc2f0c2979e4e3178d994692f994f34220bca84af68db33705ac6fd52efdd","content_size_bytes":467,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"inline","row_count":2,"row_manifest_sha256":"a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f","row_set_id":"rrev_py_unicode-and-numbers_revision_inline","row_set_kind":"revision","rows_shared_from_row_set_id":null}, + {"account_id":"acct_interop","algorithm":"adcp_jcs_rows_v1","chunk_count":1,"content_sha256":"9da4eb545db09a88341b27b05d81247eae4bfeea407c4bdc4a57f16f3b497869","content_size_bytes":468,"digest_profile":"revision_envelope_v1","format":"jsonl","obligation_id":"robl_interop","row_binding_id":"reports","row_count":2,"row_manifest_sha256":"a31c73bc5f9f0b05b5496585647521898d33dc9e9e490a5efea6146e05049b6f","row_set_id":"rrev_py_unicode-and-numbers_revision_reports","row_set_kind":"revision","rows_shared_from_row_set_id":null} ] } } diff --git a/tests/test_normalize_pyproject_prerelease.py b/tests/test_normalize_pyproject_prerelease.py index 64329e7c2..48407b8dd 100644 --- a/tests/test_normalize_pyproject_prerelease.py +++ b/tests/test_normalize_pyproject_prerelease.py @@ -88,6 +88,43 @@ def github(path: str, token: str, *, body: dict | None = None) -> dict: ) +def test_release_pr_normalization_handles_the_storage_component_pr( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Each component's PR normalizes its own pyproject.toml; a duplicate branch is refused.""" + calls: list[tuple[str, dict | None]] = [] + branches = { + "pulls/1": "release-please--branches--main--components--adcp", + "pulls/2": "release-please--branches--main--components--adcp-reporting-storage", + } + + def github(path: str, token: str, *, body: dict | None = None) -> dict: + calls.append((path, body)) + if path in branches: + return { + "base": {"ref": "main", "repo": {"full_name": normalizer._REPOSITORY}}, + "head": {"ref": branches[path], "repo": {"full_name": normalizer._REPOSITORY}}, + } + if path.startswith("contents/"): + return { + "content": base64.b64encode(b'[project]\nversion = "0.1.0-beta.1"\n').decode(), + "sha": "blob", + } + return {} + + monkeypatch.setattr(normalizer, "_github", github) + normalizer.normalize_release_prs([{"number": 1}, {"number": 2}], "app-token") + writes = [(path, body["branch"]) for path, body in calls if body is not None] + assert writes == [ + ("contents/pyproject.toml", branches["pulls/1"]), + ("contents/packages/adcp-reporting-storage/pyproject.toml", branches["pulls/2"]), + ] + with pytest.raises(ValueError, match="unexpected release PR identity"): + normalizer.normalize_release_prs([{"number": 2}, {"number": 2}], "app-token") + with pytest.raises(ValueError, match="more release PRs"): + normalizer.normalize_release_prs([{"number": 1}] * 4, "app-token") + + def test_release_pr_normalization_refuses_foreign_pr(monkeypatch: pytest.MonkeyPatch) -> None: def github(path: str, token: str, *, body: dict | None = None) -> dict: return { diff --git a/tests/test_release_configuration.py b/tests/test_release_configuration.py index 902238d82..62df1004d 100644 --- a/tests/test_release_configuration.py +++ b/tests/test_release_configuration.py @@ -35,3 +35,41 @@ def test_release_please_keeps_sdk_9_on_the_beta_channel() -> None: assert package["versioning"] == "prerelease" assert package["prerelease-type"] == "beta" assert package["prerelease"] is True + + +STORAGE = ROOT / "packages/adcp-reporting-storage" + + +def test_storage_distribution_version_matches_its_manifest_entry() -> None: + """adcp-reporting-storage is its own Release Please component on the beta channel.""" + pyproject = (STORAGE / "pyproject.toml").read_text() + project_section = re.search( + r'^\[project\]\s*$.*?^version\s*=\s*"([^"]+)"', + pyproject, + flags=re.MULTILINE | re.DOTALL, + ) + assert project_section is not None + manifest = json.loads((ROOT / ".release-please-manifest.json").read_text()) + assert project_section.group(1) == pep440_prerelease( + manifest["packages/adcp-reporting-storage"] + ) + config = json.loads((ROOT / "release-please-config.json").read_text()) + package = config["packages"]["packages/adcp-reporting-storage"] + assert package["component"] == package["package-name"] == "adcp-reporting-storage" + assert package["versioning"] == "prerelease" + assert package["prerelease-type"] == "beta" + assert package["prerelease"] is True + # Its tags carry the component name; the root keeps bare vX.Y.Z tags. + assert package["include-component-in-tag"] is True + assert config["packages"]["."]["include-component-in-tag"] is False + # Two components, two release PRs: the normalizer and the publish queue + # are written for that. + assert config["separate-pull-requests"] is True + # Release Please hands the root path every commit unless told otherwise; + # without this a storage-only fix or breaking change would release adcp. + assert config["packages"]["."]["exclude-paths"] == ["packages/adcp-reporting-storage"] + + +def test_storage_distribution_pins_one_adcp_major() -> None: + pyproject = (STORAGE / "pyproject.toml").read_text() + assert re.search(r'"adcp>=9\.0\.0(?:b\d+)?,<10"', pyproject), "adcp range must be one major" diff --git a/tests/test_reporting_ledger.py b/tests/test_reporting_ledger.py index 2100e5058..e6dc209e4 100644 --- a/tests/test_reporting_ledger.py +++ b/tests/test_reporting_ledger.py @@ -1462,8 +1462,9 @@ def test_the_identity_digest_omits_an_absent_mismatch_code() -> None: # retry would then fail with STATUS_IDENTITY_CONFLICT instead of replaying # as `unchanged` -- the one thing an idempotent append-only surface must # never do. + from adcp_reporting_storage.ledger.pg import _consumer_status_payload + from adcp.reporting.ledger.models import ConsumerStatusRecord - from adcp.reporting.ledger.pg import _consumer_status_payload from adcp.reporting.ledger.store import _consumer_status_identity def record(**overrides): diff --git a/tests/test_reporting_period_date.py b/tests/test_reporting_period_date.py index 4e2447747..23237a29e 100644 --- a/tests/test_reporting_period_date.py +++ b/tests/test_reporting_period_date.py @@ -10,14 +10,14 @@ from typing import Any import pytest - -from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError -from adcp.reporting.ledger.row_storage_object import ( +from adcp_reporting_storage.ledger.row_storage_object import ( REPORTING_ROW_DEFAULT_KEY_TEMPLATE, render_reporting_row_object_key_v1, reporting_period_date_v1, ) +from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError + date = reporting_period_date_v1 diff --git a/tests/test_reporting_production_maintenance.py b/tests/test_reporting_production_maintenance.py index e4237ff3e..d814590a6 100644 --- a/tests/test_reporting_production_maintenance.py +++ b/tests/test_reporting_production_maintenance.py @@ -11,13 +11,25 @@ class Store: + """A store listing the retention passes it opted into, as the shipped stores do.""" + def __init__(self, *, rows: bool, feed: bool, fail: str | None = None) -> None: - self._row_storage = object() if rows else None - self._change_feed = feed + self.rows, self.feed = rows, feed self.calls: list[str] = [] self.fail = fail self.report: Any = None + def maintenance_operations(self) -> tuple[tuple[str, Any], ...]: + operations: list[tuple[str, Any]] = [] + if self.rows: + operations.append(("intent_sweep", lambda: self.sweep_row_write_intents(limit=100))) + operations.append(("row_pruning", lambda: self.prune_expired_row_sets(limit=20))) + if self.rows or self.feed: + operations.append(("period_retirement", lambda: self.retire_expired_periods(limit=20))) + if self.feed: + operations.append(("change_pruning", lambda: self.prune_changes())) + return tuple(operations) + async def _record(self, name: str) -> None: self.calls.append(name) if self.fail == name: @@ -48,6 +60,28 @@ def service(store: Any, seconds: float | None = 300.0) -> ReportingProductionSup return instance +def test_postgres_store_lists_the_passes_for_its_features() -> None: + """The selection lives in the store; the worker runs whatever it lists.""" + pytest.importorskip("psycopg") # the store refuses to construct without its driver + from adcp_reporting_storage.ledger.pg import PgReportingLedgerStore + + cases = [ + ( + dict(row_storage=True, change_feed=True), + ["intent_sweep", "row_pruning", "period_retirement", "change_pruning"], + ), + ( + dict(row_storage=True, change_feed=False), + ["intent_sweep", "row_pruning", "period_retirement"], + ), + (dict(row_storage=False, change_feed=True), ["period_retirement", "change_pruning"]), + (dict(row_storage=False, change_feed=False), []), + ] + for options, expected in cases: + store = PgReportingLedgerStore(pool=object(), **options) # type: ignore[arg-type] + assert [name for name, _ in store.maintenance_operations()] == expected, options + + async def test_maintenance_runs_only_what_the_store_opted_into() -> None: cases = [ (dict(rows=True, feed=True), ["sweep", "prune_rows", "retire", "prune"]), diff --git a/tests/test_reporting_production_public.py b/tests/test_reporting_production_public.py index 87322a1b3..916bb9295 100644 --- a/tests/test_reporting_production_public.py +++ b/tests/test_reporting_production_public.py @@ -1,4 +1,4 @@ -"""All public exports work without optional drivers; concrete PG classes are lazy.""" +"""All public exports work without optional drivers; the PostgreSQL stores explain their extra.""" import subprocess import sys @@ -6,7 +6,15 @@ import pytest -@pytest.mark.parametrize("module", ["adcp.reporting.production", "adcp.reporting.projection"]) +@pytest.mark.parametrize( + "module", + [ + "adcp.reporting.production", + "adcp.reporting.projection", + "adcp_reporting_storage", + "adcp_reporting_storage.production", + ], +) @pytest.mark.parametrize("drivers", [False, True]) def test_public_exports_and_optional_database_drivers(module, drivers): script = """ @@ -19,21 +27,25 @@ def find_spec(self, fullname, path=None, target=None): raise ModuleNotFoundError(fullname) sys.meta_path.insert(0, NoDrivers()) module = importlib.import_module(sys.argv[1]) -assert len(module.__all__) == len(set(module.__all__)) +exports = getattr(module, '__all__', []) +assert len(exports) == len(set(exports)) assert module.__name__ + '.pg' not in sys.modules -for name in module.__all__: +for name in exports: assert getattr(module, name) is not None, name if not drivers: assert 'psycopg' not in sys.modules assert 'psycopg_pool' not in sys.modules - name = ('PgReportingProductionStore' if module.__name__.endswith('production') - else 'PgReportingProjectionStore') - try: - getattr(module, name)(pool=object()) - except ImportError as error: - assert 'pg' in str(error) - else: - raise AssertionError('PG store constructed without its driver') + # The stores moved to adcp-reporting-storage; adcp no longer binds them. + for name in ('PgReportingProductionStore', 'PgReportingProjectionStore'): + assert not hasattr(module, name) or module.__name__.startswith('adcp_reporting_storage') + if module.__name__ == 'adcp_reporting_storage': + try: + module.PgReportingProductionStore(pool=object()) + except ImportError as error: + assert 'adcp-reporting-storage[pg]' in str(error), str(error) + else: + raise AssertionError('PG store constructed without its driver') + assert 'psycopg' not in sys.modules try: getattr(module, 'NoSuchReportingExport') except AttributeError: diff --git a/tests/test_reporting_provisional_observations.py b/tests/test_reporting_provisional_observations.py index a62d284b6..bd13fc29d 100644 --- a/tests/test_reporting_provisional_observations.py +++ b/tests/test_reporting_provisional_observations.py @@ -8,12 +8,12 @@ from datetime import timedelta, timezone import pytest +from adcp_reporting_storage import PgReportingLedgerStore from pydantic import ValidationError from adcp.reporting.fixtures import OFFICIAL_OFFERING_ID from adcp.reporting.ledger import ( InMemoryReportingLedgerStore, - PgReportingLedgerStore, ReportingProducer, revision_content_sha256, ) diff --git a/tests/test_reporting_row_storage_azure.py b/tests/test_reporting_row_storage_azure.py index 239e09989..c42c2b5af 100644 --- a/tests/test_reporting_row_storage_azure.py +++ b/tests/test_reporting_row_storage_azure.py @@ -11,11 +11,10 @@ core_exceptions = pytest.importorskip("azure.core.exceptions") pytest.importorskip("azure.storage.blob") -from adcp.reporting.ledger.row_storage_azure import ( # noqa: E402 +from adcp_reporting_storage.ledger.row_storage_azure import ( # noqa: E402 create_azure_blob_reporting_row_object_provider_v1, ) -from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError # noqa: E402 -from adcp.reporting.ledger.row_storage_object import ( # noqa: E402 +from adcp_reporting_storage.ledger.row_storage_object import ( # noqa: E402 ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, @@ -23,6 +22,8 @@ run_reporting_row_object_provider_conformance_v1, ) +from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError # noqa: E402 + CONTEXT = ReportingRowObjectContextV1() LOCATION = {"account": "rowsaccount", "container": "rows"} diff --git a/tests/test_reporting_row_storage_filesystem.py b/tests/test_reporting_row_storage_filesystem.py index dc0275a36..099f0e082 100644 --- a/tests/test_reporting_row_storage_filesystem.py +++ b/tests/test_reporting_row_storage_filesystem.py @@ -6,9 +6,7 @@ from pathlib import Path import pytest - -from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError -from adcp.reporting.ledger.row_storage_object import ( +from adcp_reporting_storage.ledger.row_storage_object import ( REPORTING_ROW_DEFAULT_KEY_TEMPLATE, ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, @@ -22,6 +20,8 @@ run_reporting_row_object_provider_conformance_v1, ) +from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError + CONTEXT = ReportingRowObjectContextV1() NAMESPACE = "a" * 64 @@ -226,7 +226,7 @@ def test_unsafe_prefixes_are_refused(prefix: str) -> None: def object_options(tmp_path: Path, **binding: object): - from adcp.reporting.ledger.row_storage import ( + from adcp_reporting_storage.ledger.row_storage import ( ReportingObjectRowBindingV1, ReportingRowStorageOptionsV1, ) @@ -248,7 +248,7 @@ def object_options(tmp_path: Path, **binding: object): def test_a_writable_template_without_revision_key_is_refused_at_configuration( tmp_path: Path, ) -> None: - from adcp.reporting.ledger.row_storage import ReportingRowStorageV1 + from adcp_reporting_storage.ledger.row_storage import ReportingRowStorageV1 with pytest.raises(ReportingRowStoreError) as refused: ReportingRowStorageV1(object_options(tmp_path, key_template=OLD_TEMPLATE)) @@ -264,7 +264,7 @@ def test_a_writable_template_without_revision_key_is_refused_at_configuration( def test_the_default_and_an_explicit_template_with_revision_key_are_accepted( tmp_path: Path, ) -> None: - from adcp.reporting.ledger.row_storage import ReportingRowStorageV1 + from adcp_reporting_storage.ledger.row_storage import ReportingRowStorageV1 ReportingRowStorageV1(object_options(tmp_path)) ReportingRowStorageV1(object_options(tmp_path, key_template=NEW_TEMPLATE)) @@ -274,7 +274,7 @@ def test_the_default_and_an_explicit_template_with_revision_key_are_accepted( def test_a_read_only_binding_is_exempt_but_its_template_is_otherwise_validated( tmp_path: Path, ) -> None: - from adcp.reporting.ledger.row_storage import ReportingRowStorageV1 + from adcp_reporting_storage.ledger.row_storage import ReportingRowStorageV1 ReportingRowStorageV1(object_options(tmp_path, key_template=OLD_TEMPLATE, read_only=True)) for template in ( @@ -287,7 +287,7 @@ def test_a_read_only_binding_is_exempt_but_its_template_is_otherwise_validated( def test_read_only_is_not_part_of_the_binding_identity(tmp_path: Path) -> None: - from adcp.reporting.ledger.row_storage import ( + from adcp_reporting_storage.ledger.row_storage import ( ReportingObjectRowBindingV1, _binding_identity_sha256, ) diff --git a/tests/test_reporting_row_storage_gcs.py b/tests/test_reporting_row_storage_gcs.py index b9dc1644d..59ecb68f4 100644 --- a/tests/test_reporting_row_storage_gcs.py +++ b/tests/test_reporting_row_storage_gcs.py @@ -10,12 +10,11 @@ exceptions = pytest.importorskip("google.api_core.exceptions") -from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError # noqa: E402 -from adcp.reporting.ledger.row_storage_gcs import ( # noqa: E402 +from adcp_reporting_storage.ledger.row_storage_gcs import ( # noqa: E402 assert_gcs_reporting_row_bucket_policy_v1, create_gcs_reporting_row_object_provider_v1, ) -from adcp.reporting.ledger.row_storage_object import ( # noqa: E402 +from adcp_reporting_storage.ledger.row_storage_object import ( # noqa: E402 ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, @@ -23,6 +22,8 @@ run_reporting_row_object_provider_conformance_v1, ) +from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError # noqa: E402 + CONTEXT = ReportingRowObjectContextV1() LOCATION = {"bucket": "rows-bucket"} @@ -182,7 +183,7 @@ async def test_create_only_upload_sends_the_precondition_and_a_declared_checksum provider = create_gcs_reporting_row_object_provider_v1(storage=storage) created = await provider.put_if_absent(put("adcp-rows/ns/a.jsonl.gz"), CONTEXT) assert created.created and created.native_version_ref.isdigit() - (_, options) = next(call for call in storage.calls if call[0] == "upload") + _, options = next(call for call in storage.calls if call[0] == "upload") assert options["if_generation_match"] == 0 assert options["content_type"] == "application/gzip" and options["checksum"] == "md5" stored = storage.objects[("rows-bucket", "adcp-rows/ns/a.jsonl.gz")][0] @@ -217,7 +218,7 @@ def read(**kwargs: Any) -> Any: ) assert await read(max_bytes=6, byte_range=(4, 6)) == b"456789" - (_, options) = [call for call in storage.calls if call[0] == "download"][-1] + _, options = [call for call in storage.calls if call[0] == "download"][-1] assert options["generation"] == int(created.native_version_ref) assert options["raw_download"] is True and options["checksum"] is None assert (options["start"], options["end"]) == (4, 9) @@ -368,8 +369,8 @@ def test_locations_are_closed_and_options_validated() -> None: async def test_provider_errors_never_leak_their_messages() -> None: - from adcp.reporting.ledger import ReportingObjectRowBindingV1, ReportingRowStorageOptionsV1 - from adcp.reporting.ledger.row_storage import ReportingRowStorageV1 + from adcp_reporting_storage import ReportingObjectRowBindingV1, ReportingRowStorageOptionsV1 + from adcp_reporting_storage.ledger.row_storage import ReportingRowStorageV1 class Broken(FakeStorage): def bucket(self, name: str) -> Any: diff --git a/tests/test_reporting_row_storage_s3.py b/tests/test_reporting_row_storage_s3.py index dc955e663..0a0ff48ab 100644 --- a/tests/test_reporting_row_storage_s3.py +++ b/tests/test_reporting_row_storage_s3.py @@ -10,18 +10,19 @@ boto3 = pytest.importorskip("boto3") moto = pytest.importorskip("moto") -from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError # noqa: E402 -from adcp.reporting.ledger.row_storage_object import ( # noqa: E402 +from adcp_reporting_storage.ledger.row_storage_object import ( # noqa: E402 ReportingRowObjectContextV1, ReportingRowObjectDeleteInputV1, ReportingRowObjectGetInputV1, ReportingRowObjectPutInputV1, run_reporting_row_object_provider_conformance_v1, ) -from adcp.reporting.ledger.row_storage_s3 import ( # noqa: E402 +from adcp_reporting_storage.ledger.row_storage_s3 import ( # noqa: E402 create_s3_reporting_row_object_provider_v1, ) +from adcp.reporting.ledger.row_storage_errors import ReportingRowStoreError # noqa: E402 + CONTEXT = ReportingRowObjectContextV1() LOCATION = {"bucket": "rows-bucket"} diff --git a/tests/test_reporting_startup_recovery.py b/tests/test_reporting_startup_recovery.py index 33df42db4..f1b1bd1d3 100644 --- a/tests/test_reporting_startup_recovery.py +++ b/tests/test_reporting_startup_recovery.py @@ -11,6 +11,7 @@ from unittest.mock import AsyncMock import pytest +from adcp_reporting_storage import postgres_reliable_reporting_service from adcp.reporting.fixtures import redacted_capabilities from adcp.reporting.ledger import ReportingProducer, WorkerTurn @@ -191,7 +192,7 @@ async def recovery_service_factory(request: Any) -> AsyncIterator[Any]: async with isolated_reporting_pool(autocommit=request.param == "pg-autocommit") as pool: def create(resolver: Any) -> ReliableReportingService: - service = ReliableReportingService.postgres( + service = postgres_reliable_reporting_service( pool=pool, account_context=resolver, clock=lambda: NOW ) service.sources.register( diff --git a/tests/test_reporting_storage_boundary.py b/tests/test_reporting_storage_boundary.py new file mode 100644 index 000000000..df28cf6db --- /dev/null +++ b/tests/test_reporting_storage_boundary.py @@ -0,0 +1,135 @@ +"""The adcp / adcp-reporting-storage boundary. + +``adcp`` defines the reporting store protocols and the in-memory stores; +``adcp-reporting-storage`` implements them over PostgreSQL and object storage. +The dependency points one way: the storage distribution imports ``adcp``, and +``adcp`` never imports the storage distribution or a database driver from its +reporting package. The storage package may reach a short, fixed list of private +``adcp.reporting`` helpers; that list can only shrink. +""" + +from __future__ import annotations + +import ast +import subprocess +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ADCP_SRC = ROOT / "src" / "adcp" +STORAGE_SRC = ROOT / "packages" / "adcp-reporting-storage" / "src" / "adcp_reporting_storage" + +#: Private ``adcp`` modules the storage distribution imports. Each is a shared +#: codec or helper that both sides must agree on byte for byte; promote one to a +#: public name before adding another. +ALLOWED_PRIVATE_ADCP_IMPORTS = { + "adcp._deferred_adapters", + "adcp.reporting._source_authorization", + "adcp.reporting.feed._errors", + "adcp.reporting.ledger._delivery_state", + "adcp.reporting.ledger._record_rows", + "adcp.reporting.materializer._errors", + "adcp.reporting.receipts._diagnostics", +} + + +def _imports(path: Path) -> set[str]: + tree = ast.parse(path.read_text(encoding="utf-8")) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module and not node.level: + found.add(node.module) + return found + + +def test_adcp_never_imports_the_storage_distribution_or_a_reporting_driver() -> None: + offenders: dict[str, set[str]] = {} + for path in ADCP_SRC.rglob("*.py"): + modules = _imports(path) + bad = {m for m in modules if m.split(".")[0] == "adcp_reporting_storage"} + if path.is_relative_to(ADCP_SRC / "reporting"): + bad |= {m for m in modules if m.split(".")[0] in {"psycopg", "psycopg_pool"}} + if bad: + offenders[str(path.relative_to(ROOT))] = bad + assert not offenders, offenders + + +def test_storage_distribution_imports_adcp_through_public_names_or_the_allowlist() -> None: + reaches: dict[str, set[str]] = {} + for path in STORAGE_SRC.rglob("*.py"): + private = { + m + for m in _imports(path) + if m.split(".")[0] == "adcp" + and any(part.startswith("_") for part in m.split(".")) + and m not in ALLOWED_PRIVATE_ADCP_IMPORTS + } + if private: + reaches[str(path.relative_to(ROOT))] = private + assert not reaches, reaches + + +def test_allowlist_names_only_modules_that_exist_and_are_used() -> None: + used: set[str] = set() + for path in STORAGE_SRC.rglob("*.py"): + used |= {m for m in _imports(path) if m in ALLOWED_PRIVATE_ADCP_IMPORTS} + assert used == ALLOWED_PRIVATE_ADCP_IMPORTS, { + "unused": ALLOWED_PRIVATE_ADCP_IMPORTS - used, + } + for module in ALLOWED_PRIVATE_ADCP_IMPORTS: + assert (ADCP_SRC.parent / module.replace(".", "/")).with_suffix(".py").is_file(), module + + +def test_importing_the_storage_facade_loads_no_store_driver_or_cloud_sdk() -> None: + """The facade is lazy: a store loads on first use, a cloud SDK only with its provider.""" + script = f""" +import sys +sys.path[:0] = [{str(ADCP_SRC.parent)!r}, {str(STORAGE_SRC.parent)!r}] +import adcp_reporting_storage +assert not [m for m in sys.modules if m.startswith("adcp_reporting_storage.")] +assert "psycopg" not in sys.modules and "adcp.reporting" not in sys.modules +from adcp_reporting_storage import PgReportingLedgerStore, ReportingRowStorageOptionsV1 +assert PgReportingLedgerStore.__module__ == "adcp_reporting_storage.ledger.pg" +# psycopg may now be loaded (the store probes for it); the cloud SDKs never are. +for name in ("boto3", "google.cloud.storage", "azure.storage.blob"): + assert name not in sys.modules, name +assert "adcp.reporting.ledger.pg" not in sys.modules +missing = [n for n in adcp_reporting_storage.__all__ if getattr(adcp_reporting_storage, n) is None] +assert not missing, missing +print("ok") +""" + result = subprocess.run( + [sys.executable, "-I", "-c", script], capture_output=True, text=True, timeout=120 + ) + assert result.returncode == 0, result.stdout + result.stderr + assert result.stdout.strip() == "ok" + + +def test_moved_names_are_gone_from_adcp() -> None: + """A clean cut: the old addresses raise, with nothing shimming them.""" + import adcp.reporting.ledger as ledger + import adcp.reporting.outbox as outbox + import adcp.reporting.production as production + import adcp.reporting.service as service + + for module, name in ( + (ledger, "PgReportingLedgerStore"), + (ledger, "PgReportingReconciliationStore"), + (ledger, "ReportingRowStorageOptionsV1"), + (ledger, "ReportingChangesPageV1"), + (outbox, "PgReportingOutbox"), + (outbox, "PgStatusNotificationStore"), + (production, "PgReportingProductionStore"), + ): + assert not hasattr(module, name), name + assert name not in module.__all__, name + assert not hasattr(service.ReliableReportingService, "postgres") + for module in ( + "adcp.reporting.ledger.pg", + "adcp.reporting.ledger.row_storage", + "adcp.reporting.outbox.pg", + "adcp.reporting.migration", + ): + assert not (ADCP_SRC.parent / module.replace(".", "/")).with_suffix(".py").exists(), module diff --git a/tests/test_reporting_typed_control_totals.py b/tests/test_reporting_typed_control_totals.py index bf9fbbc87..792b67f45 100644 --- a/tests/test_reporting_typed_control_totals.py +++ b/tests/test_reporting_typed_control_totals.py @@ -15,10 +15,10 @@ import pytest import rfc8785 +from adcp_reporting_storage import PgReportingLedgerStore from adcp.reporting.ledger import ( InMemoryReportingLedgerStore, - PgReportingLedgerStore, ReportingControlTotalRecord, revision_content_sha256, ) diff --git a/tests/type_checks/reliable_reporting_production_context.py b/tests/type_checks/reliable_reporting_production_context.py index 93db6d0f6..429a84fd2 100644 --- a/tests/type_checks/reliable_reporting_production_context.py +++ b/tests/type_checks/reliable_reporting_production_context.py @@ -1,3 +1,5 @@ +from adcp_reporting_storage import postgres_reliable_reporting_service + """Installed public fixed-profile production bridge typing.""" from typing import Any @@ -33,7 +35,7 @@ def compose_adapters( application: ADCPHandler[Any], ) -> ReliableReportingService: offering: ReportingServiceOffering = options.offerings[0] - service = ReliableReportingService.postgres( + service = postgres_reliable_reporting_service( pool=pool, account_context=context, production=options ) service.sources.register(offering.adapter, adapter) diff --git a/tests/type_checks/reporting_caller_ownership.py b/tests/type_checks/reporting_caller_ownership.py index 4b03b76d9..4e35160d4 100644 --- a/tests/type_checks/reporting_caller_ownership.py +++ b/tests/type_checks/reporting_caller_ownership.py @@ -3,6 +3,16 @@ from datetime import datetime from typing import Any +from adcp_reporting_storage.migration import ( + ReportingOwnershipBackfill, + backfill_legacy_reporting, + legacy_generation_digest, + migrate_legacy_reporting, + replace_empty_legacy_reporting, + replace_empty_legacy_reporting_sync, + replace_empty_owned_reporting, + replace_empty_owned_reporting_sync, +) from typing_extensions import assert_type from adcp.reporting.ledger import ( @@ -13,16 +23,6 @@ ReportingLedgerStore, ReportingStatusCaller, ) -from adcp.reporting.migration import ( - ReportingOwnershipBackfill, - backfill_legacy_reporting, - legacy_generation_digest, - migrate_legacy_reporting, - replace_empty_legacy_reporting, - replace_empty_legacy_reporting_sync, - replace_empty_owned_reporting, - replace_empty_owned_reporting_sync, -) async def ownership( diff --git a/tests/type_checks/reporting_durable_materializer.py b/tests/type_checks/reporting_durable_materializer.py index 7ff9b7f4c..6aafceb2c 100644 --- a/tests/type_checks/reporting_durable_materializer.py +++ b/tests/type_checks/reporting_durable_materializer.py @@ -1,10 +1,10 @@ """The optional durable protocol and public lazy imports remain strict-adopter APIs.""" +from adcp_reporting_storage import PgReportingMaterializerStore from typing_extensions import assert_type from adcp.reporting.materializer import ( InMemoryReportingMaterializerStore, - PgReportingMaterializerStore, ReportingDestinationIO, ReportingDestinationWriter, ReportingMaterializerLease, diff --git a/tests/type_checks/reporting_frozen_feed.py b/tests/type_checks/reporting_frozen_feed.py index 9feff103e..613cdb5d3 100644 --- a/tests/type_checks/reporting_frozen_feed.py +++ b/tests/type_checks/reporting_frozen_feed.py @@ -2,11 +2,11 @@ from typing import Any +from adcp_reporting_storage import PgReportingFeedStore from typing_extensions import assert_type from adcp.reporting.feed import ( InMemoryReportingFeedStore, - PgReportingFeedStore, ReportingFeedError, ReportingFeedSnapshot, ReportingFeedStore, diff --git a/tests/type_checks/reporting_inline_storage.py b/tests/type_checks/reporting_inline_storage.py index 04e0a4856..68f6f0727 100644 --- a/tests/type_checks/reporting_inline_storage.py +++ b/tests/type_checks/reporting_inline_storage.py @@ -1,5 +1,6 @@ """Borrowed pool composition with the existing public source protocols.""" +from adcp_reporting_storage.inline_storage import PgReportingSealStore, PgReportingStagingStore from psycopg_pool import AsyncConnectionPool from adcp.reporting.inline_source import ( @@ -8,7 +9,6 @@ ReportingSealStore, ReportingStagingStore, ) -from adcp.reporting.inline_storage import PgReportingSealStore, PgReportingStagingStore from adcp.reporting.source import ReportingSourceCapabilitiesV1, ReportingSourceExecutor diff --git a/tests/type_checks/reporting_notification_outbox.py b/tests/type_checks/reporting_notification_outbox.py index 7267972ae..c0bc09bf0 100644 --- a/tests/type_checks/reporting_notification_outbox.py +++ b/tests/type_checks/reporting_notification_outbox.py @@ -3,19 +3,18 @@ from collections.abc import Callable from datetime import datetime +from adcp_reporting_storage import PgReportingOutbox, PgReportingReconciliationStore from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ( InMemoryReportingLedgerStore, InMemoryReportingReconciliationStore, - PgReportingReconciliationStore, ReportingLedgerStore, ReportingObligationRecord, ) from adcp.reporting.outbox import ( InMemoryReportingOutbox, - PgReportingOutbox, ReportingEnvelopeCipher, ReportingNotificationOutbox, ReportingNotificationSubscription, diff --git a/tests/type_checks/reporting_receipt_ingress.py b/tests/type_checks/reporting_receipt_ingress.py index c4f65756c..f3471a051 100644 --- a/tests/type_checks/reporting_receipt_ingress.py +++ b/tests/type_checks/reporting_receipt_ingress.py @@ -2,13 +2,13 @@ from typing import Any +from adcp_reporting_storage import PgReportingReceiptStore from typing_extensions import assert_type from adcp.reporting.ledger import ReportingDeliveryPrincipal from adcp.reporting.materializer import ReportingMaterializerStore from adcp.reporting.receipts import ( InMemoryReportingReceiptStore, - PgReportingReceiptStore, ReceiptAccountResolver, ReportingReceiptBatchStore, ReportingReceiptBoundary, diff --git a/tests/type_checks/reporting_reconciliation_records.py b/tests/type_checks/reporting_reconciliation_records.py index 577c730a5..7cac7d89e 100644 --- a/tests/type_checks/reporting_reconciliation_records.py +++ b/tests/type_checks/reporting_reconciliation_records.py @@ -7,12 +7,12 @@ from datetime import datetime, timezone from typing import Any +from adcp_reporting_storage import PgReportingReconciliationStore from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ( InMemoryReportingLedgerStore, InMemoryReportingReconciliationStore, - PgReportingReconciliationStore, ReportingCanonicalDigest, ReportingConfigurationGenerationKey, ReportingControlTotalRecord, diff --git a/tests/type_checks/reporting_status_notifications.py b/tests/type_checks/reporting_status_notifications.py index b18507fa7..7c373eef8 100644 --- a/tests/type_checks/reporting_status_notifications.py +++ b/tests/type_checks/reporting_status_notifications.py @@ -2,11 +2,15 @@ from datetime import datetime +from adcp_reporting_storage import ( + PgReportingLedgerStore, + PgReportingOutbox, + PgStatusNotificationStore, +) from psycopg_pool import AsyncConnectionPool from adcp.reporting.ledger import ( InMemoryReportingLedgerStore, - PgReportingLedgerStore, ReportingLedgerStore, ReportingStatusSnapshot, StatusProjectionInput, @@ -17,8 +21,6 @@ from adcp.reporting.outbox import ( InMemoryReportingOutbox, InMemoryStatusNotificationStore, - PgReportingOutbox, - PgStatusNotificationStore, ReportingNotificationOutbox, ReportingStatusNotificationLifecycle, ReportingStatusProjector, diff --git a/tests/type_checks/reporting_webhook_activity.py b/tests/type_checks/reporting_webhook_activity.py index d73b7a3d8..8a4a5194f 100644 --- a/tests/type_checks/reporting_webhook_activity.py +++ b/tests/type_checks/reporting_webhook_activity.py @@ -3,12 +3,13 @@ from datetime import datetime from typing import Any +from adcp_reporting_storage import PgReportingOutbox + from adcp.decisioning.accounts import ResolveContext from adcp.reporting.outbox import ( ActivityOutcome, ActivityRequest, InMemoryReportingOutbox, - PgReportingOutbox, ReportingActivityProjector, ReportingActivityStore, WebhookAttempt, From 4e9c1231600335bb45acada61523050b77dccfc5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 01:23:39 -0400 Subject: [PATCH 15/54] chore(release): leave the beta channel for SDK 9.0.0 GA (#1567) Remove the prerelease keys from the root release-please package so the next adcp release on main is stable 9.0.0, flip the release-channel contract test, and update the releasing guide and README install notes. adcp-reporting-storage stays on its beta channel; SDK 10 prereleases continue on next. Co-Authored-By: Claude Opus 5.5 (1M context) Release-As: 9.0.0 --- README.md | 18 ++++-------------- docs/releasing.md | 21 +++++++++------------ release-please-config.json | 3 --- tests/test_release_configuration.py | 17 ++++++++--------- 4 files changed, 21 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 351669a66..9268e22e3 100644 --- a/README.md +++ b/README.md @@ -221,21 +221,11 @@ pip install adcp > **Note**: This client requires Python 3.10 or later and supports both synchronous and asynchronous workflows. -### SDK 9 beta +### Upgrading to SDK 9 -SDK 9 is available as a prerelease. Ordinary installation selects the stable -SDK 8 release; a stable `adcp==9.0.0` pin becomes available at GA. The release -tag `9.0.0-beta.3` corresponds to the Python package version `9.0.0b3`. - -```bash -pip install 'adcp==9.0.0b3' # exact beta -pip install --pre 'adcp>=9.0.0b3,<10' # latest SDK 9 prerelease -uv add --prerelease allow 'adcp>=9.0.0b3,<10' # uv prerelease opt-in -``` - -Read [the SDK 9 migration guide](MIGRATION_v8_to_v9.md) before upgrading. -The package's Production/Stable classifier describes the stable channel; -SDK 9 betas remain prereleases regardless of that classifier. +`pip install adcp` selects SDK 9, which targets AdCP 3.2. To stay on SDK 8, +pin `adcp>=8,<9`. Read [the SDK 9 migration guide](MIGRATION_v8_to_v9.md) +before upgrading. ## Quick Start: Test Helpers diff --git a/docs/releasing.md b/docs/releasing.md index 5b1a930f1..de9500ce1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -9,13 +9,11 @@ and reviews pass. Version-only bot release PRs, including betas, run interpreter compatibility checks and verify the metadata in their built wheel and sdist. -SDK 9 is on the beta channel. The root package in -`release-please-config.json` uses `versioning: prerelease`, -`prerelease-type: beta`, and `prerelease: true`, so subsequent releases -remain beta and GitHub marks them as prereleases. The adopting commit ends -with `Release-As: 9.0.0-beta.1`; the release workflow normalizes that to -`9.0.0b1` in `pyproject.toml` and on PyPI. Do not merge a release PR that -proposes stable `9.0.0` while this beta channel is active. +SDK 9 is on the stable channel. The 9.0.0 GA commit removed the +`versioning: prerelease`, `prerelease-type: beta`, and `prerelease: true` +keys from the root package in `release-please-config.json` and ended with +`Release-As: 9.0.0`. Subsequent releases on `main` are stable 9.x versions. +SDK 10 prereleases are cut from `next`. To choose an exact version, the squash commit that lands on `main` must end its body with a `Release-As: X.Y.Z` footer. The repository squashes with @@ -36,9 +34,6 @@ screening, the explicit legacy projection boundary, and a constructible, 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`. - On the merge, Release Please creates the tag and GitHub release, then starts `release-publish.yml` on `main`. The publish workflow waits for successful CI on the **exact release commit**, builds the sdist and wheel once, and checks both @@ -61,8 +56,10 @@ One-time setup: ## adcp-reporting-storage `packages/adcp-reporting-storage` is a second Release Please component -(`separate-pull-requests` is on, so it gets its own release PR) on the same -beta channel. Its tags carry the component name +(`separate-pull-requests` is on, so it gets its own release PR) that stays on +the beta channel while `adcp` is stable. Its `adcp>=…,<10` floor must name a +published `adcp` release that contains the split (9.0.0 or later); move it +before merging the storage release PR. Its tags carry the component name (`adcp-reporting-storage-v0.1.0-beta.1`); the root keeps bare `vX.Y.Z` tags. Only commits that touch `packages/adcp-reporting-storage/` bump it, and the root package lists that path in `exclude-paths` (Release Please otherwise hands diff --git a/release-please-config.json b/release-please-config.json index 8b69b51a2..162be566e 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -4,9 +4,6 @@ "release-type": "python", "package-name": "adcp", "changelog-path": "CHANGELOG.md", - "versioning": "prerelease", - "prerelease-type": "beta", - "prerelease": true, "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": false, "include-component-in-tag": false, diff --git a/tests/test_release_configuration.py b/tests/test_release_configuration.py index 62df1004d..a55915045 100644 --- a/tests/test_release_configuration.py +++ b/tests/test_release_configuration.py @@ -1,4 +1,4 @@ -"""Release automation owns the SDK version and the SDK 9 beta channel.""" +"""Release automation owns the SDK version and the SDK 9 stable channel.""" from __future__ import annotations @@ -23,18 +23,17 @@ def test_worktree_version_matches_normalized_release_manifest() -> None: assert project_section.group(1) == pep440_prerelease(manifest["."]) -def test_release_please_keeps_sdk_9_on_the_beta_channel() -> None: - """The breaking SDK 9 surface must remain beta until the reviewed GA exit. +def test_release_please_is_on_the_stable_channel() -> None: + """SDK 9 left the beta channel at the reviewed GA exit. - The adopting commit's ``Release-As: 9.0.0-beta.1`` footer selects the - first beta. Subsequent fixes increment the beta, and GitHub releases - are marked as prereleases rather than presented as stable releases. + The GA commit's ``Release-As: 9.0.0`` footer selects the stable release. + With the prerelease keys removed, later fixes bump stable versions and + GitHub releases are no longer marked as prereleases. """ config = json.loads((ROOT / "release-please-config.json").read_text()) package = config["packages"]["."] - assert package["versioning"] == "prerelease" - assert package["prerelease-type"] == "beta" - assert package["prerelease"] is True + for key in ("versioning", "prerelease-type", "prerelease"): + assert key not in package, key STORAGE = ROOT / "packages/adcp-reporting-storage" From 1dcb846cca7eb8773fb928a78939af687995340d Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 09:00:38 -0400 Subject: [PATCH 16/54] chore(main): release 9.0.0 (#1540) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4ce00a003..b6ffae373 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.0.0-beta.4", + ".": "9.0.0", "packages/adcp-reporting-storage": "0.1.0-beta.0" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 354aed72e..db0218164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,6 +169,40 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.0.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.0.0-beta.4...v9.0.0) (2026-10-24) + + +### ⚠ BREAKING CHANGES + +* **reporting:** the PostgreSQL reporting stores, the row storage providers, the change feed, the ownership-migration helpers and `ReliableReportingService.postgres` leave `adcp`; install `adcp-reporting-storage[pg]` (plus `gcs`/`s3`/`azure`) and import them from `adcp_reporting_storage`. The `adcp[reporting-gcs]`, `[reporting-s3]` and `[reporting-azure]` extras are removed. See docs/reporting-storage-package-migration.md. +* **codegen:** a JSON document carrying `null` for an optional property whose schema does not admit `null` is now refused by `model_validate_json` and `TypeAdapter.validate_json`, and the published JSON Schema of such a property has no null arm. A Python `None` is still accepted. JSON produced with `exclude_none=False` must be read back in Python mode (`model_validate(json.loads(...))`). +* **types:** SyncCreativesRequest.creatives is now list[CreativeAsset] | None. Handlers must guard it before iteration or use request.creatives or [] for assignment-only requests. + +### chore + +* **release:** leave the beta channel for SDK 9.0.0 GA ([#1567](https://github.com/adcontextprotocol/adcp-client-python/issues/1567)) ([4e9c123](https://github.com/adcontextprotocol/adcp-client-python/commit/4e9c1231600335bb45acada61523050b77dccfc5)) + + +### Features + +* **compat:** negotiate and translate AdCP minor versions ([#1559](https://github.com/adcontextprotocol/adcp-client-python/issues/1559)) ([31d7217](https://github.com/adcontextprotocol/adcp-client-python/commit/31d7217e81533e6513cfe4ab28441abb0ed3db77)) +* **reporting:** ship the Reliable Reporting storage as adcp-reporting-storage ([#1557](https://github.com/adcontextprotocol/adcp-client-python/issues/1557)) ([d59eee9](https://github.com/adcontextprotocol/adcp-client-python/commit/d59eee9da4d246557bfacf42119729e1d68e78a7)) +* **reporting:** sync empty-ledger replacement and owned-ledger rollback for 9.0 adopters ([#1554](https://github.com/adcontextprotocol/adcp-client-python/issues/1554)) ([38d046a](https://github.com/adcontextprotocol/adcp-client-python/commit/38d046a809bab9c684d8c0cac761f8d59a50072d)) + + +### Bug Fixes + +* **canonical-formats:** compare every parameter a format reference declares ([#1518](https://github.com/adcontextprotocol/adcp-client-python/issues/1518)) ([d073712](https://github.com/adcontextprotocol/adcp-client-python/commit/d0737127b94b58355221c3ab9b1e7353e66ac2d6)), closes [#1517](https://github.com/adcontextprotocol/adcp-client-python/issues/1517) +* **canonical-formats:** read an omitted pixel_ratio as 1x on the supported side too ([#1553](https://github.com/adcontextprotocol/adcp-client-python/issues/1553)) ([b07adbc](https://github.com/adcontextprotocol/adcp-client-python/commit/b07adbc59c66cef8a7ada8189ab7739fc77c7733)) +* **codegen:** refuse a JSON null on optional properties whose schema forbids one ([#1555](https://github.com/adcontextprotocol/adcp-client-python/issues/1555)) ([a40c2f0](https://github.com/adcontextprotocol/adcp-client-python/commit/a40c2f0a71f9110cedc844f125630d3055e209f4)) +* **governance:** require crit markers with their claims, and run the signing tests CI skips ([#1539](https://github.com/adcontextprotocol/adcp-client-python/issues/1539)) ([15c85cf](https://github.com/adcontextprotocol/adcp-client-python/commit/15c85cf6e5de6b1b401c688d3e3e8470a47ba449)) +* **release:** tighten V9 reporting and CI readiness checks ([#1550](https://github.com/adcontextprotocol/adcp-client-python/issues/1550)) ([41c2be7](https://github.com/adcontextprotocol/adcp-client-python/commit/41c2be73e6851264f71edbab13fcd801d2a94454)), closes [#1383](https://github.com/adcontextprotocol/adcp-client-python/issues/1383) [#1493](https://github.com/adcontextprotocol/adcp-client-python/issues/1493) +* **types:** allow assignment-only creative syncs ([81c2a8b](https://github.com/adcontextprotocol/adcp-client-python/commit/81c2a8b2b0fb221fff3d5d8ddcb96c673b9fbe68)), closes [#1545](https://github.com/adcontextprotocol/adcp-client-python/issues/1545) +* **types:** generate the open asset unions, and derive the vocabulary they route by ([#1501](https://github.com/adcontextprotocol/adcp-client-python/issues/1501)) ([30a72ee](https://github.com/adcontextprotocol/adcp-client-python/commit/30a72ee2a409bccb53fe961816584bdea2518f70)) +* **types:** inherit the generated constraints on the canonical creatives field ([#1548](https://github.com/adcontextprotocol/adcp-client-python/issues/1548)) ([69a0f6b](https://github.com/adcontextprotocol/adcp-client-python/commit/69a0f6b9525b400b17b25c1b18294a4650c56afb)) +* **types:** refuse a JSON null on reporting_webhook.operation_id too ([#1570](https://github.com/adcontextprotocol/adcp-client-python/issues/1570)) ([9e238f3](https://github.com/adcontextprotocol/adcp-client-python/commit/9e238f34f75870d4d27b07156e26093a11b30151)) +* **webhooks:** echo reporting_webhook.operation_id on scheduled delivery reports ([#1552](https://github.com/adcontextprotocol/adcp-client-python/issues/1552)) ([0bac033](https://github.com/adcontextprotocol/adcp-client-python/commit/0bac033511d5b73908f2720d670b84a2642d2dba)), closes [#1092](https://github.com/adcontextprotocol/adcp-client-python/issues/1092) + ## [9.0.0-beta.4](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.0.0-beta.3...v9.0.0-beta.4) (2026-10-18) diff --git a/pyproject.toml b/pyproject.toml index cec53208f..0129587cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.0.0b4" +version = "9.0.0" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From 78d079dff277277a4bf12a42a8e9ed356241cc48 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 10:02:37 -0400 Subject: [PATCH 17/54] test(signing): accept any HTTP status in the real TLS handshake check (#1576) The test proves the pinned transport still validates the hostname; a certificate mismatch raises before any response. Asserting 200 made it fail whenever example.com answered 503, which happened twice this week on main and on #1557. Co-authored-by: Claude Opus 5.5 (1M context) --- tests/conformance/signing/test_ip_pinned_transport.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/conformance/signing/test_ip_pinned_transport.py b/tests/conformance/signing/test_ip_pinned_transport.py index af47b1ad1..997d6d0f4 100644 --- a/tests/conformance/signing/test_ip_pinned_transport.py +++ b/tests/conformance/signing/test_ip_pinned_transport.py @@ -377,7 +377,10 @@ def test_real_tls_handshake_still_validates_hostname() -> None: # machines. The intent is "handshake didn't reject", not speed. with httpx.Client(transport=transport, timeout=60.0) as client: response = client.get("https://example.com/") - assert response.status_code == 200 + # Any HTTP response proves the handshake and hostname check passed (a + # certificate mismatch raises before a response exists). The origin's + # own status is not under test: example.com intermittently answers 503. + assert 100 <= response.status_code < 600 @pytest.mark.skipif(not _internet_ok(), reason="no outbound internet") From a2315b79d31bf257b42fb09d9140abc66fce604b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 10:02:41 -0400 Subject: [PATCH 18/54] fix(reporting-storage): require the adcp release that contains the split (#1577) adcp 9.0.0b4 predates the storage split and lacks the modules this package imports (adcp.reporting.ledger._record_rows, adcp.reporting.production.store, adcp.reporting.outbox.durable), so installing it with the b4 beta fails at import. Require adcp 9.0.0. Co-authored-by: Claude Opus 5.5 (1M context) --- packages/adcp-reporting-storage/README.md | 2 +- packages/adcp-reporting-storage/pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/adcp-reporting-storage/README.md b/packages/adcp-reporting-storage/README.md index 63ede42ae..a4b8adf45 100644 --- a/packages/adcp-reporting-storage/README.md +++ b/packages/adcp-reporting-storage/README.md @@ -54,7 +54,7 @@ client you build; the extras only pin the client libraries. This package implements the protocols of one `adcp` major and imports a few private helpers of `adcp.reporting` to do so (`adcp.reporting.ledger._record_rows`, `_delivery_state`, `status_snapshot`). Its dependency range is therefore one -major (`adcp>=9.0.0b4,<10`), and a new `adcp` major means a new release here. +major (`adcp>=9.0.0,<10`), and a new `adcp` major means a new release here. `adcp` never imports this package; a test in the main repository enforces that. ## Versioning diff --git a/packages/adcp-reporting-storage/pyproject.toml b/packages/adcp-reporting-storage/pyproject.toml index a650f5126..05162f64a 100644 --- a/packages/adcp-reporting-storage/pyproject.toml +++ b/packages/adcp-reporting-storage/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ # this package subclass nothing in ``adcp`` but import its private # ``adcp.reporting`` helpers, so the range is one major: a new ``adcp`` # major means a new release here. - "adcp>=9.0.0b4,<10", + "adcp>=9.0.0,<10", # RFC 8785 canonical JSON for row bodies and object keys; the same pin as adcp. "rfc8785>=0.1.4", ] From b31538f24c2c877a28912a15439880f3d3f79a68 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:56:37 -0400 Subject: [PATCH 19/54] chore(main): release adcp-reporting-storage 0.1.0-beta.1 (#1571) * chore(main): release adcp-reporting-storage 0.1.0-beta.1 * chore: normalize prerelease version to PEP 440 --------- Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- packages/adcp-reporting-storage/CHANGELOG.md | 18 ++++++++++++++++++ packages/adcp-reporting-storage/pyproject.toml | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b6ffae373..bafbef05b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { ".": "9.0.0", - "packages/adcp-reporting-storage": "0.1.0-beta.0" + "packages/adcp-reporting-storage": "0.1.0-beta.1" } diff --git a/packages/adcp-reporting-storage/CHANGELOG.md b/packages/adcp-reporting-storage/CHANGELOG.md index 15f6f65ba..f8b6f2058 100644 --- a/packages/adcp-reporting-storage/CHANGELOG.md +++ b/packages/adcp-reporting-storage/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [0.1.0-beta.1](https://github.com/adcontextprotocol/adcp-client-python/compare/adcp-reporting-storage-v0.1.0-beta.0...adcp-reporting-storage-v0.1.0-beta.1) (2026-10-24) + + +### ⚠ BREAKING CHANGES + +* **reporting:** the PostgreSQL reporting stores, the row storage providers, the change feed, the ownership-migration helpers and `ReliableReportingService.postgres` leave `adcp`; install `adcp-reporting-storage[pg]` (plus `gcs`/`s3`/`azure`) and import them from `adcp_reporting_storage`. The `adcp[reporting-gcs]`, `[reporting-s3]` and `[reporting-azure]` extras are removed. See docs/reporting-storage-package-migration.md. + +### Features + +* **reporting:** ship the Reliable Reporting storage as adcp-reporting-storage ([#1557](https://github.com/adcontextprotocol/adcp-client-python/issues/1557)) ([d59eee9](https://github.com/adcontextprotocol/adcp-client-python/commit/d59eee9da4d246557bfacf42119729e1d68e78a7)) + + +### Bug Fixes + +* **reporting-storage:** require the adcp release that contains the split ([#1577](https://github.com/adcontextprotocol/adcp-client-python/issues/1577)) ([a2315b7](https://github.com/adcontextprotocol/adcp-client-python/commit/a2315b79d31bf257b42fb09d9140abc66fce604b)) + +## Changelog + ## Unreleased ### Features diff --git a/packages/adcp-reporting-storage/pyproject.toml b/packages/adcp-reporting-storage/pyproject.toml index 05162f64a..7894f072f 100644 --- a/packages/adcp-reporting-storage/pyproject.toml +++ b/packages/adcp-reporting-storage/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp-reporting-storage" -version = "0.1.0b0" +version = "0.1.0b1" description = "PostgreSQL and object-storage implementations of the AdCP Reliable Reporting stores" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From 6f13ce07e69bc24de23ce63b97958875f6866cc7 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 14:36:40 -0400 Subject: [PATCH 20/54] fix(types): inherit the generated creatives ceiling on PackageRequest (#1579) PackageRequest.creatives redeclared with only min_length=1, dropping the generated maxItems=100 and description, so the model accepted 101 inline creatives that validate_request rejects. #1548 fixed the same override on SyncCreativesRequest and PackageUpdate; use _inherit here too and cover both package models. Co-authored-by: Claude Opus 5.5 (1M context) --- src/adcp/types/canonical_creative.py | 2 +- tests/test_sync_creatives_assignment_only.py | 29 ++++++++++++++------ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 994079d44..178c21aab 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -1099,7 +1099,7 @@ class PackageRequest(_LegacyPackageRequest, CanonicalBoundaryModel): if TYPE_CHECKING: # the removed field, hidden from the constructor too format_ids: _RemovedFormatIds = Field(default=None, init=False) - creatives: list[CreativeAsset] | None = Field(default=None, min_length=1) + creatives: list[CreativeAsset] | None = _inherit(_LegacyPackageRequest, "creatives") @model_validator(mode="after") def _validate_format_params(self) -> PackageRequest: diff --git a/tests/test_sync_creatives_assignment_only.py b/tests/test_sync_creatives_assignment_only.py index 97baaeac5..f8caf1db7 100644 --- a/tests/test_sync_creatives_assignment_only.py +++ b/tests/test_sync_creatives_assignment_only.py @@ -10,7 +10,8 @@ from adcp.server.base import ADCPHandler, ToolContext from adcp.server.mcp_tools import create_tool_caller -from adcp.types import CreativeAsset, PackageUpdate, SyncCreativesRequest +from adcp.types import CreativeAsset, PackageRequest, PackageUpdate, SyncCreativesRequest +from adcp.types.domains.media_buy.package_request import PackageRequest as _LegacyPackageRequest from adcp.types.domains.media_buy.package_update import PackageUpdate as _LegacyPackageUpdate from adcp.validation import ValidationHookConfig, get_schema, validate_request @@ -147,19 +148,31 @@ def test_sync_refuses_more_creatives_than_the_bundle_permits() -> None: ) -def test_package_update_keeps_the_generated_creatives_constraints() -> None: - """``PackageUpdate`` narrows ``creatives`` the same way and must not lose its ceiling.""" - generated = _LegacyPackageUpdate.model_fields["creatives"] - canonical = PackageUpdate.model_fields["creatives"] +@pytest.mark.parametrize( + ("model", "generated_model", "required"), + [ + pytest.param(PackageUpdate, _LegacyPackageUpdate, {"package_id": "pkg_1"}, id="update"), + pytest.param( + PackageRequest, + _LegacyPackageRequest, + {"product_id": "prod_1", "pricing_option_id": "po_1"}, + id="request", + ), + ], +) +def test_package_models_keep_the_generated_creatives_constraints( + model: type, generated_model: type, required: dict[str, Any] +) -> None: + """Package models narrow ``creatives`` the same way and must not lose its ceiling.""" + generated = generated_model.model_fields["creatives"] + canonical = model.model_fields["creatives"] assert canonical.metadata == generated.metadata assert canonical.description == generated.description ceiling = next(m.max_length for m in generated.metadata if hasattr(m, "max_length")) creative = {"creative_id": "cr", "name": "Banner", "format_kind": "image", "assets": {}} with pytest.raises(ValidationError) as caught: - PackageUpdate.model_validate( - {"package_id": "pkg_1", "creatives": [creative] * (ceiling + 1)} - ) + model.model_validate({**required, "creatives": [creative] * (ceiling + 1)}) assert caught.value.errors()[0]["loc"] == ("creatives",) assert caught.value.errors()[0]["type"] == "too_long" From dad5e8d9668e0ebd3ced406780e659b42ee22001 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 21:40:42 +0000 Subject: [PATCH 21/54] feat(reporting-storage): let the Postgres service factory build a subclass postgres_reliable_reporting_service always constructed the base ReliableReportingService, while ReliableReportingService.memory builds cls. Hosts override the installed tool methods on a subclass, so the Embedded Sales Agent had to copy the factory body to keep its subclass. Add a service_class keyword, typed so the call returns the subclass. Fixes #1588 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/adcp_reporting_storage/service.py | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/service.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/service.py index a8fa46d38..8d202bfe4 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/service.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/service.py @@ -4,7 +4,7 @@ from collections.abc import Callable from datetime import datetime -from typing import Any +from typing import Any, TypeVar, overload from adcp.reporting.service import ( ReliableReportingService, @@ -15,6 +15,34 @@ __all__ = ["postgres_reliable_reporting_service"] +_ServiceT = TypeVar("_ServiceT", bound=ReliableReportingService) + + +@overload +def postgres_reliable_reporting_service( + *, + pool: Any, + account_context: ReportingContextResolver, + caller_resolver: ReportingCallerResolver | None = ..., + clock: Callable[[], datetime] | None = ..., + production: ReportingProductionOptions | None = ..., + service_class: None = ..., + **kwargs: Any, +) -> ReliableReportingService: ... + + +@overload +def postgres_reliable_reporting_service( + *, + pool: Any, + account_context: ReportingContextResolver, + caller_resolver: ReportingCallerResolver | None = ..., + clock: Callable[[], datetime] | None = ..., + production: ReportingProductionOptions | None = ..., + service_class: type[_ServiceT], + **kwargs: Any, +) -> _ServiceT: ... + def postgres_reliable_reporting_service( *, @@ -23,6 +51,7 @@ def postgres_reliable_reporting_service( caller_resolver: ReportingCallerResolver | None = None, clock: Callable[[], datetime] | None = None, production: ReportingProductionOptions | None = None, + service_class: type[ReliableReportingService] | None = None, **kwargs: Any, ) -> ReliableReportingService: """Create durable ledger, staging and seal stores over a borrowed pool. @@ -32,6 +61,10 @@ def postgres_reliable_reporting_service( Startup installs all SDK-owned schemas. Explicit source-store overrides remain the adopter's responsibility. No pool is opened or closed here. + ``service_class`` builds an adopter's ``ReliableReportingService`` + subclass, as ``SubClass.memory(...)`` does, so a host can override the + installed tool methods without copying this composition. + This is what ``ReliableReportingService.postgres(...)`` was before the storage implementations moved to this distribution. """ @@ -45,7 +78,8 @@ def postgres_reliable_reporting_service( else PgReportingLedgerStore(pool=pool, clock=clock) ) staging = PgReportingStagingStore(pool=pool) - return ReliableReportingService( + cls = ReliableReportingService if service_class is None else service_class + return cls( store=store, account_context=account_context, caller_resolver=caller_resolver, From a9cfd0cb90b354179c598b23b76cda8601f90274 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 21:41:36 +0000 Subject: [PATCH 22/54] test(reporting-storage): cover the Postgres factory's service_class Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/test_reporting_storage_service_class.py | 35 +++++++++++++++++++ .../reporting_storage_service_class.py | 21 +++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/test_reporting_storage_service_class.py create mode 100644 tests/type_checks/reporting_storage_service_class.py diff --git a/tests/test_reporting_storage_service_class.py b/tests/test_reporting_storage_service_class.py new file mode 100644 index 000000000..1e79b502a --- /dev/null +++ b/tests/test_reporting_storage_service_class.py @@ -0,0 +1,35 @@ +"""``postgres_reliable_reporting_service`` builds an adopter's service subclass (#1588).""" + +from __future__ import annotations + +import pytest + +pytest.importorskip("psycopg_pool") + +from adcp_reporting_storage import postgres_reliable_reporting_service # noqa: E402 +from psycopg_pool import AsyncConnectionPool # noqa: E402 + +from adcp.reporting import ReliableReportingService # noqa: E402 +from tests.test_reliable_reporting_service import _account_context # noqa: E402 + + +class HostReportingService(ReliableReportingService): + """A host subclass overriding an installed tool method.""" + + +def _pool() -> AsyncConnectionPool: + return AsyncConnectionPool("postgresql://unused", open=False) + + +def test_default_builds_the_base_service() -> None: + service = postgres_reliable_reporting_service(pool=_pool(), account_context=_account_context) + assert type(service) is ReliableReportingService + + +def test_service_class_builds_the_subclass_like_memory() -> None: + pg = postgres_reliable_reporting_service( + pool=_pool(), account_context=_account_context, service_class=HostReportingService + ) + memory = HostReportingService.memory(account_context=_account_context) + assert type(pg) is HostReportingService + assert type(memory) is HostReportingService diff --git a/tests/type_checks/reporting_storage_service_class.py b/tests/type_checks/reporting_storage_service_class.py new file mode 100644 index 000000000..03ee37f82 --- /dev/null +++ b/tests/type_checks/reporting_storage_service_class.py @@ -0,0 +1,21 @@ +"""Adopter-facing type contract: the Postgres factory returns the requested subclass.""" + +from typing import Any + +from adcp_reporting_storage import postgres_reliable_reporting_service + +from adcp.reporting import ReliableReportingService + + +class HostReportingService(ReliableReportingService): + pass + + +def build(pool: Any, account_context: Any) -> HostReportingService: + return postgres_reliable_reporting_service( + pool=pool, account_context=account_context, service_class=HostReportingService + ) + + +def build_default(pool: Any, account_context: Any) -> ReliableReportingService: + return postgres_reliable_reporting_service(pool=pool, account_context=account_context) From 084d837c820c8d5bb2c472e3df6c065671789486 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 19:37:46 -0400 Subject: [PATCH 23/54] ci: keep Dependabot off the frozen reporting interop lockfiles (#1590) The rc41-rc48 lockfiles under scripts/ci/reporting_interop/npm pin historical JavaScript SDK releases whose installed bytes the interop matrix verifies. A Dependabot bump (#1556) breaks those controls, so ignore every dependency in those directories. Co-authored-by: Claude Opus 5.5 (1M context) --- .github/dependabot.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..bb0e69fa2 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + # Frozen JavaScript SDK release candidates for the reporting interop replay. + # Each lockfile pins a historical release whose installed bytes the interop + # matrix verifies, so a bump breaks the control instead of fixing anything. + # The ignore rule also applies to Dependabot security updates. + - package-ecosystem: npm + directories: + - "/scripts/ci/reporting_interop/npm/*" + schedule: + interval: monthly + open-pull-requests-limit: 0 + ignore: + - dependency-name: "*" From cd0319b57d27ab8bbb06ddb091537645dadf887b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 19:44:47 -0400 Subject: [PATCH 24/54] fix(server): mirror a raised AdcpError into the payload errors array (#1592) build_mcp_error_result and the A2A failed-task payload carried only the adcp_error envelope. AdCP's L3 error handling says a fatal failure SHOULD populate both layers, and storyboards such as media_buy_seller/product_signal_targeting read errors[0].field, so a seller raising AdcpError as documented failed those steps. Add errors: [adcp_error] on both transports. Fixes #1558 Co-authored-by: Claude Opus 5.5 (1M context) --- src/adcp/server/a2a_server.py | 4 +++- src/adcp/server/translate.py | 13 ++++++++----- tests/test_a2a_structured_error.py | 16 ++++++++++++++++ tests/test_mcp_structured_error.py | 18 ++++++++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index 4b837d690..a6d42afc5 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -839,7 +839,9 @@ async def _send_adcp_error( if details: adcp_error["details"] = dict(details) - data: dict[str, Any] = {"adcp_error": adcp_error} + # A fatal failure populates both layers (L3 error handling): the payload + # carries the protocol-neutral ``errors[]`` and the envelope ``adcp_error``. + data: dict[str, Any] = {"adcp_error": adcp_error, "errors": [dict(adcp_error)]} if params is not None: inject_context(params, data) diff --git a/src/adcp/server/translate.py b/src/adcp/server/translate.py index a48029497..e18b30cc7 100644 --- a/src/adcp/server/translate.py +++ b/src/adcp/server/translate.py @@ -204,10 +204,11 @@ def build_mcp_error_result( The framework dispatcher returns this when a platform method raises a structured AdCP error. The result has ``isError=True`` AND - ``structuredContent={"adcp_error": {...}}`` on the same envelope — - matching the spec's transport-errors.mdx §MCP Binding shape that the - storyboard runner's ``/adcp_error/code`` JSON-pointer assertion - expects. + ``structuredContent={"adcp_error": {...}, "errors": [{...}]}`` on the + same envelope — the transport-errors.mdx §MCP Binding ``adcp_error`` + plus the payload ``errors[]`` that L3 error handling says a fatal + failure SHOULD also carry (storyboards read both ``/adcp_error/code`` + and ``errors[0].field``). The text fallback in ``content[]`` preserves human-readable display for clients that do not consume ``structuredContent`` (LLM tool-use @@ -260,7 +261,9 @@ def build_mcp_error_result( if suggestion: text += f"\nSuggestion: {suggestion}" - structured: dict[str, Any] = {"adcp_error": adcp_error} + # A fatal failure populates both layers (L3 error handling): the payload + # carries the protocol-neutral ``errors[]`` and the envelope ``adcp_error``. + structured: dict[str, Any] = {"adcp_error": adcp_error, "errors": [dict(adcp_error)]} if params is not None: inject_context(params, structured) diff --git a/tests/test_a2a_structured_error.py b/tests/test_a2a_structured_error.py index 4aefd05be..7044566bf 100644 --- a/tests/test_a2a_structured_error.py +++ b/tests/test_a2a_structured_error.py @@ -416,6 +416,22 @@ async def test_request_context_echoes_into_error_envelope() -> None: assert payload.get("context") == request_context +@pytest.mark.asyncio +async def test_error_payload_mirrors_adcp_error_into_errors_array() -> None: + """A2A fills both layers like MCP: payload ``errors[]`` and ``adcp_error`` (#1558).""" + handler = _DecisioningRaiser( + lambda: DecisioningAdcpError("INVALID_REQUEST", message="bad", field="packages[0]") + ) + executor = _executor(handler) + queue = EventQueue() + + await executor.execute(_request_context("get_products", {}), queue) + + payload = _adcp_error_full_payload(await queue.dequeue_event()) + assert payload["errors"] == [payload["adcp_error"]] + assert payload["errors"][0]["field"] == "packages[0]" + + @pytest.mark.asyncio async def test_no_request_context_omits_context_from_error_envelope() -> None: """When the request carries no ``context`` field, the error DataPart diff --git a/tests/test_mcp_structured_error.py b/tests/test_mcp_structured_error.py index 726328b30..edb0783de 100644 --- a/tests/test_mcp_structured_error.py +++ b/tests/test_mcp_structured_error.py @@ -67,6 +67,24 @@ def test_text_fallback_present_in_content(self): assert "no such buy" in result.content[0].text +class TestBuildMcpErrorResultErrorsArray: + """A fatal failure fills payload ``errors[]`` and envelope ``adcp_error`` (#1558).""" + + def test_errors_array_mirrors_adcp_error(self): + exc = DecisioningAdcpError( + "INVALID_REQUEST", message="unknown signal", field="packages[0].targeting_overlay" + ) + structured = build_mcp_error_result(exc).structured_content + assert structured["errors"] == [structured["adcp_error"]] + assert structured["errors"][0]["field"] == "packages[0].targeting_overlay" + + def test_errors_array_is_not_the_same_object_as_adcp_error(self): + structured = build_mcp_error_result( + DecisioningAdcpError("INTERNAL_ERROR") + ).structured_content + assert structured["errors"][0] is not structured["adcp_error"] + + class TestBuildMcpErrorResultOptionalFields: """Optional fields populate when present, omit when absent.""" From 9146e3f5b52790d5d5be3783b322dee1560cd448 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:09:04 -0400 Subject: [PATCH 25/54] chore(main): release adcp-reporting-storage 0.1.0-beta.2 (#1593) * chore(main): release adcp-reporting-storage 0.1.0-beta.2 * chore: normalize prerelease version to PEP 440 --------- Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- packages/adcp-reporting-storage/CHANGELOG.md | 7 +++++++ packages/adcp-reporting-storage/pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index bafbef05b..740a8105b 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { ".": "9.0.0", - "packages/adcp-reporting-storage": "0.1.0-beta.1" + "packages/adcp-reporting-storage": "0.1.0-beta.2" } diff --git a/packages/adcp-reporting-storage/CHANGELOG.md b/packages/adcp-reporting-storage/CHANGELOG.md index f8b6f2058..e8ed9f26f 100644 --- a/packages/adcp-reporting-storage/CHANGELOG.md +++ b/packages/adcp-reporting-storage/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.1.0-beta.2](https://github.com/adcontextprotocol/adcp-client-python/compare/adcp-reporting-storage-v0.1.0-beta.1...adcp-reporting-storage-v0.1.0-beta.2) (2026-10-24) + + +### Features + +* **reporting-storage:** let the Postgres service factory build a subclass ([dad5e8d](https://github.com/adcontextprotocol/adcp-client-python/commit/dad5e8d9668e0ebd3ced406780e659b42ee22001)), closes [#1588](https://github.com/adcontextprotocol/adcp-client-python/issues/1588) + ## [0.1.0-beta.1](https://github.com/adcontextprotocol/adcp-client-python/compare/adcp-reporting-storage-v0.1.0-beta.0...adcp-reporting-storage-v0.1.0-beta.1) (2026-10-24) diff --git a/packages/adcp-reporting-storage/pyproject.toml b/packages/adcp-reporting-storage/pyproject.toml index 7894f072f..128f4814b 100644 --- a/packages/adcp-reporting-storage/pyproject.toml +++ b/packages/adcp-reporting-storage/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp-reporting-storage" -version = "0.1.0b1" +version = "0.1.0b2" description = "PostgreSQL and object-storage implementations of the AdCP Reliable Reporting stores" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From a202a3cbeb41ce6f89dcac8db943a0a0f9d72da3 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 22:40:30 +0000 Subject: [PATCH 26/54] fix(reporting): name the cause of a reporting startup failure ReliableReportingService startup raised its closed diagnostic with the cause suppressed, so Sentry and logs showed only 'reporting service failed (startup)'. Keep the cause unchained, because provider errors can carry response bodies or credentials, but name its class in the message and in cause_type. Add ReportingOperatorActionError for SDK-authored operator preconditions, which startup now re-raises unwrapped like configuration errors. Fixes #1561 Co-Authored-By: Claude Opus 5.5 (1M context) --- src/adcp/reporting/__init__.py | 5 +++ src/adcp/reporting/service_lifecycle.py | 36 ++++++++++++++---- tests/fixtures/public_api_snapshot.json | 1 + tests/test_reliable_reporting_lifecycle.py | 43 ++++++++++++++++++++++ 4 files changed, 78 insertions(+), 7 deletions(-) diff --git a/src/adcp/reporting/__init__.py b/src/adcp/reporting/__init__.py index 0a14cae03..47fea6c52 100644 --- a/src/adcp/reporting/__init__.py +++ b/src/adcp/reporting/__init__.py @@ -105,6 +105,9 @@ from adcp.reporting.service_lifecycle import ( ReliableReportingUnavailableError as ReliableReportingUnavailableError, ) + from adcp.reporting.service_lifecycle import ( + ReportingOperatorActionError as ReportingOperatorActionError, + ) from adcp.reporting.service_lifecycle import ( ReportingServiceResource as ReportingServiceResource, ) @@ -172,6 +175,7 @@ ), "ReliableReportingState": ("service_lifecycle", "ReliableReportingState"), "ReliableReportingUnavailableError": ("service_lifecycle", "ReliableReportingUnavailableError"), + "ReportingOperatorActionError": ("service_lifecycle", "ReportingOperatorActionError"), "ReportingServiceResource": ("service_lifecycle", "ReportingServiceResource"), "ReportingAccountContext": ("service", "ReportingAccountContext"), "ReportingAdapter": ("service", "ReportingAdapter"), @@ -226,6 +230,7 @@ def __dir__() -> list[str]: "ReliableReportingShutdownTimeoutError", "ReliableReportingState", "ReliableReportingUnavailableError", + "ReportingOperatorActionError", "ReportingServiceResource", "ReportingAccountContext", "ReportingAdapter", diff --git a/src/adcp/reporting/service_lifecycle.py b/src/adcp/reporting/service_lifecycle.py index 83613db76..3766e8a61 100644 --- a/src/adcp/reporting/service_lifecycle.py +++ b/src/adcp/reporting/service_lifecycle.py @@ -22,6 +22,7 @@ __all__ = [ "ReliableReportingServiceError", + "ReportingOperatorActionError", "ReliableReportingShutdownTimeoutError", "ReliableReportingState", "ReliableReportingUnavailableError", @@ -52,10 +53,23 @@ def __init__(self, state: ReliableReportingState) -> None: ] +class ReportingOperatorActionError(RuntimeError): + """An SDK-authored precondition an operator must act on before startup. + + Subclasses carry only SDK-written messages (never provider bodies or + credentials), so service startup re-raises them unwrapped and the + operator sees the remedy, as it does for configuration errors. + """ + + class ReliableReportingServiceError(RuntimeError): - """Closed diagnostic for an unexpected failure; contains no provider body.""" + """Closed diagnostic for an unexpected failure; contains no provider body. - def __init__(self, component: FailureComponent) -> None: + ``cause_type`` names the class of the exception that caused the failure, + when known, without retaining the exception or its message. + """ + + def __init__(self, component: FailureComponent, *, cause_type: str | None = None) -> None: if component not in ( "startup", "configuration", @@ -66,7 +80,9 @@ def __init__(self, component: FailureComponent) -> None: ): raise ValueError("unknown reporting service failure component") self.component = component - super().__init__(f"reporting service failed ({component})") + self.cause_type = cause_type + detail = component if cause_type is None else f"{component}: {cause_type}" + super().__init__(f"reporting service failed ({detail})") class ReliableReportingShutdownTimeoutError(TimeoutError): @@ -201,9 +217,11 @@ async def _open(self) -> None: if self.state is ReliableReportingState.STARTING: self.state = ReliableReportingState.INITIALIZED except BaseException as error: - failure = await self.fail("startup") - if isinstance(error, self._configuration_error): + failure = await self.fail("startup", cause=error) + if isinstance(error, (self._configuration_error, ReportingOperatorActionError)): raise + # The cause is not chained: a provider error can carry response + # bodies or credentials. ``cause_type`` names its class instead. raise failure from None async def activate(self, run: Callable[[], Awaitable[None]] | None = None) -> None: @@ -288,10 +306,14 @@ async def request_stop(self) -> None: async with self._lock: self._stop_locked() - async def fail(self, component: FailureComponent) -> ReliableReportingServiceError: + async def fail( + self, component: FailureComponent, *, cause: BaseException | None = None + ) -> ReliableReportingServiceError: async with self._lock: if self.failure is None: - self.failure = ReliableReportingServiceError(component) + self.failure = ReliableReportingServiceError( + component, cause_type=None if cause is None else type(cause).__qualname__ + ) self._stop_locked() return self.failure diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 084450cae..e448656d0 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -781,6 +781,7 @@ "ReportingLifecycleSnapshot": "adcp.reporting._testing_lifecycle", "ReportingObservation": "adcp.reporting._reconcile", "ReportingOperationsContactView": "adcp.reporting._consumer", + "ReportingOperatorActionError": "adcp.reporting.service_lifecycle", "ReportingPinnedDefinition": "adcp.reporting._consumer", "ReportingProductionOptions": "adcp.reporting.service_production", "ReportingReconciliationClient": "adcp.reporting._reconcile", diff --git a/tests/test_reliable_reporting_lifecycle.py b/tests/test_reliable_reporting_lifecycle.py index e95cd8d50..0412fd997 100644 --- a/tests/test_reliable_reporting_lifecycle.py +++ b/tests/test_reliable_reporting_lifecycle.py @@ -11,6 +11,7 @@ import pytest +from adcp.reporting import ReportingOperatorActionError from adcp.reporting.fixtures import redacted_capabilities, redacted_snapshot_request from adcp.reporting.inline_source import FileSystemStagingStore, InlineReportingSource from adcp.reporting.ledger import ( @@ -219,6 +220,48 @@ async def closing() -> None: assert events == ["open:pool", "open:writer", "close:sender", "close:writer", "close:pool"] +async def test_startup_failure_names_its_cause_type_without_its_message() -> None: + """#1561: telemetry sees which exception stopped startup, never its body.""" + + class ProviderTimeoutError(Exception): + pass + + async def opening() -> None: + raise ProviderTimeoutError("secret-provider-response") + + service = ReliableReportingService.memory( + account_context=_account_context, + owned_resources=(ReportingServiceResource(open=opening, close=lambda: None),), + ) + with pytest.raises(ReliableReportingServiceError) as caught: + await service.start() + error = caught.value + assert error.cause_type is not None and error.cause_type.endswith("ProviderTimeoutError") + assert str(error).startswith("reporting service failed (startup: ") + assert "secret" not in str(error) and "secret" not in repr(error) + assert error.__cause__ is None and error.__suppress_context__ + await service.close() + + +async def test_operator_action_error_surfaces_unwrapped_from_startup() -> None: + """#1561: an SDK-authored precondition reaches the operator with its remedy.""" + + class LedgerNeedsMigrationError(ReportingOperatorActionError): + pass + + async def opening() -> None: + raise LedgerNeedsMigrationError("call replace_empty_legacy_reporting first") + + service = ReliableReportingService.memory( + account_context=_account_context, + owned_resources=(ReportingServiceResource(open=opening, close=lambda: None),), + ) + with pytest.raises(LedgerNeedsMigrationError, match="replace_empty_legacy_reporting"): + await service.start() + assert service.state is ReliableReportingState.FAILED + await service.close() + + async def test_failed_closer_does_not_skip_other_resources_or_repeat_close() -> None: events: list[str] = [] From 51365c9715c3b74c11b3353991525af93d04f392 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 22:40:52 +0000 Subject: [PATCH 27/54] fix(reporting-storage): surface ownership migration errors from startup ReportingOwnershipMigrationError now subclasses adcp's ReportingOperatorActionError, so a ledger that needs replace_empty_legacy_reporting or migrate_legacy_reporting stops service startup with that remedy instead of an opaque wrapper. Older adcp releases without the base class keep the previous behaviour. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/adcp_reporting_storage/migration.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py b/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py index 226a29af2..75cca712a 100644 --- a/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py +++ b/packages/adcp-reporting-storage/src/adcp_reporting_storage/migration.py @@ -18,6 +18,11 @@ from adcp.reporting.canonical_json import canonical_json_utf8_v1 from adcp.reporting.evidence import consumer_reference, principal_reference +try: + from adcp.reporting.service_lifecycle import ReportingOperatorActionError +except ImportError: # adcp 9.0.0 predates it; startup then wraps the error. + ReportingOperatorActionError = RuntimeError # type: ignore[misc,assignment] + __all__ = [ "ReportingOwnershipBackfill", "ReportingOwnershipMigrationError", @@ -31,8 +36,11 @@ ] -class ReportingOwnershipMigrationError(RuntimeError): - """An operator migration precondition failed; no partial changes commit.""" +class ReportingOwnershipMigrationError(ReportingOperatorActionError): + """An operator migration precondition failed; no partial changes commit. + + Service startup re-raises it unwrapped, so the operator sees the remedy. + """ @dataclass(frozen=True) From bf6d96641910ccdaa2e2b95bf7965a564c9e1d9b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 20:15:10 -0400 Subject: [PATCH 28/54] fix(a2a): keep integers integral in echoed context across the protobuf Struct (#1594) A2A carries parameters and results in a protobuf Struct, which stores every JSON number as a double, and a2a-sdk prints it back as 7.0. A buyer's context {"count": 7} reached the handler as 7.0 and left the response as 7.0, while MCP returned 7. Restoring integers only in the parsed parameters is not enough: the response DataPart crosses a Struct again on the way out. Restore integral doubles where DataPart parameters leave protobuf, and rewrite integral doubles as JSON integers in the A2A JSON-RPC response body (JSON and SSE). The SDK's A2A client restores them when reading a result. A Struct cannot tell 7 from 7.0, so a buyer's 7.0 is echoed as the JSON-equal 7; raw-JSON echo would not help because the response leg is a Struct too. Values beyond 2**53 stay doubles. TextPart JSON is untouched. Fixes #1572 Co-authored-by: Claude Opus 5.5 (1M context) --- docs/handler-authoring.md | 22 ++ src/adcp/_a2a_numbers.py | 174 ++++++++++++++ src/adcp/protocols/a2a.py | 8 +- src/adcp/server/a2a_server.py | 21 +- tests/test_context_echo_transport_parity.py | 250 ++++++++++++++++++++ 5 files changed, 469 insertions(+), 6 deletions(-) create mode 100644 src/adcp/_a2a_numbers.py create mode 100644 tests/test_context_echo_transport_parity.py diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index b2314bd58..1178f931b 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -1065,6 +1065,28 @@ A2A protocol with auto-generated agent card (`/.well-known/agent-card.json`, with the 0.3 alias `/.well-known/agent.json` also served for backwards compatibility) derived from the `ADCPHandler` methods your class overrides. +### JSON numbers and the `context` echo + +A2A carries skill parameters and results in a protobuf `Struct`, which stores +every JSON number as a double. The SDK restores integers on both sides of that +boundary, so the transport stays invisible to handlers and buyers: + +- A DataPart's parameters reach your handler with `7` as `int`, as they do + over MCP. +- The A2A JSON-RPC response writes integral numbers as JSON integers, so a + buyer's `context` of `{"count": 7}` is echoed as `7`, not `7.0`. This + covers every field, not just `context`, which helps buyers whose JSON + decoders reject `7.0` for an integer field. +- The SDK's A2A client restores integers when it reads a result. + +A `Struct` cannot tell `7` from `7.0`, so a buyer's `7.0` comes back over +A2A as `7`. The two are the same JSON number, and MCP still echoes `7.0` +as sent. Integers beyond 2^53 lose precision in the `Struct` before the +SDK receives them, so they stay doubles. TextPart JSON never passes through +a `Struct`, so the handler receives its numbers exactly as the buyer sent +them. A custom `message_parser` that calls `MessageToDict` itself receives +the raw doubles. + ### Durable task storage A2A tracks each long-running operation as a `Task` — the default diff --git a/src/adcp/_a2a_numbers.py b/src/adcp/_a2a_numbers.py new file mode 100644 index 000000000..d36620248 --- /dev/null +++ b/src/adcp/_a2a_numbers.py @@ -0,0 +1,174 @@ +"""Keep JSON integers integral across the A2A protobuf ``Struct`` boundary. + +A2A carries skill parameters and results in ``Part.data``, a protobuf +``google.protobuf.Value``. ``Struct`` stores every JSON number as a double, +and the Python protobuf JSON printer writes a double back out as ``7.0``. +Without correction a buyer's ``{"count": 7}`` reaches the handler as +``7.0`` and leaves the A2A response as ``7.0``, while MCP, which never +round-trips through protobuf, keeps ``7``. AdCP requires ``context`` to be +echoed unchanged, and decoders that bind JSON integers to integer types +(Go, Rust, Java) reject ``7.0``. + +Both directions apply the same rule: a finite double with no fractional +part, within the IEEE 754 exactly-representable integer range, is written +as an integer. A ``Struct`` cannot tell ``7`` from ``7.0`` -- both arrive as +the same double -- so a buyer's ``7.0`` comes back as ``7``. Both spellings +are the same JSON number. Beyond 2**53 a double cannot say which integer the +sender wrote, so those values stay doubles; ``Struct`` already lost the exact +value before the SDK saw it. +""" + +from __future__ import annotations + +import json +from typing import Any + +#: Largest magnitude at which every integer has an exact double. +_MAX_EXACT_INTEGER = 2**53 + + +def _restore(value: Any) -> tuple[Any, bool]: + if type(value) is float: + if value.is_integer() and abs(value) <= _MAX_EXACT_INTEGER: + return int(value), True + return value, False + if type(value) is dict: + changed = False + out: dict[str, Any] = {} + for key, item in value.items(): + out[key], item_changed = _restore(item) + changed = changed or item_changed + return (out, True) if changed else (value, False) + if type(value) is list: + changed = False + items: list[Any] = [] + for item in value: + restored, item_changed = _restore(item) + items.append(restored) + changed = changed or item_changed + return (items, True) if changed else (value, False) + return value, False + + +def restore_struct_integers(value: Any) -> Any: + """Return ``value`` with integral ``Struct`` doubles written as ``int``.""" + return _restore(value)[0] + + +def _rewrite_json(payload: bytes, **dumps_kwargs: Any) -> bytes: + try: + decoded = json.loads(payload) + except ValueError: + return payload + restored, changed = _restore(decoded) + if not changed: + return payload + return json.dumps(restored, **dumps_kwargs).encode("utf-8") + + +# Starlette's ``JSONResponse.render`` settings, so only the numbers change. +_JSON_RESPONSE_DUMPS: dict[str, Any] = { + "ensure_ascii": False, + "allow_nan": False, + "indent": None, + "separators": (",", ":"), +} + + +def _rewrite_sse(chunk: bytes) -> bytes: + lines = chunk.splitlines(keepends=True) + for index, line in enumerate(lines): + if not line.startswith(b"data:"): + continue + body = line[len(b"data:") :] + stripped = body.rstrip(b"\r\n") + ending = body[len(stripped) :] + lead = b" " if stripped.startswith(b" ") else b"" + rewritten = _rewrite_json(stripped[len(lead) :]) + lines[index] = b"data:" + lead + rewritten + ending + return b"".join(lines) + + +def _route_path(scope: Any) -> str: + path: str = scope.get("path", "") + root: str = scope.get("root_path", "") + if root and path.startswith(root): + path = path[len(root) :] + return path or "/" + + +def _content_type(message: Any) -> bytes: + for name, value in message.get("headers", ()): + if name.lower() == b"content-type": + return bytes(value).split(b";", 1)[0].strip().lower() + return b"" + + +class A2AStructNumberMiddleware: + """Write integral ``Struct`` doubles as JSON integers in A2A JSON-RPC responses. + + Covers the JSON body of ``message/send`` / ``SendMessage`` / ``tasks/get`` + and every ``data:`` event of a streaming response. a2a-sdk prints each + protobuf response with ``MessageToDict``, so this is the one place every + JSON-RPC result passes through after the last ``Struct`` round trip. + """ + + def __init__(self, app: Any, *, rpc_path: str = "/") -> None: + self.app = app + self.rpc_path = rpc_path + + async def __call__(self, scope: Any, receive: Any, send: Any) -> None: + if ( + scope["type"] != "http" + or scope.get("method") != "POST" + or _route_path(scope) != self.rpc_path + ): + await self.app(scope, receive, send) + return + + start: Any = None + mode = "" + body = bytearray() + pending = b"" + + async def rewrite_send(message: Any) -> None: + nonlocal start, mode, pending + if message["type"] == "http.response.start": + content_type = _content_type(message) + if content_type == b"application/json": + # Hold the start until the body is final: the rewrite + # changes Content-Length. + mode, start = "json", message + return + if content_type == b"text/event-stream": + mode = "sse" + await send(message) + return + if message["type"] != "http.response.body" or not mode: + await send(message) + return + more_body = message.get("more_body", False) + if mode == "json": + body.extend(message.get("body", b"")) + if more_body: + return + rewritten = _rewrite_json(bytes(body), **_JSON_RESPONSE_DUMPS) + headers = [ + (name, value) + for name, value in start.get("headers", ()) + if name.lower() != b"content-length" + ] + headers.append((b"content-length", str(len(rewritten)).encode("ascii"))) + await send({**start, "headers": headers}) + await send({"type": "http.response.body", "body": rewritten}) + return + # SSE: rewrite whole lines only; carry a partial line forward. + data = pending + message.get("body", b"") + if more_body: + cut = data.rfind(b"\n") + 1 + data, pending = data[:cut], data[cut:] + else: + pending = b"" + await send({**message, "body": _rewrite_sse(data)}) + + await self.app(scope, receive, rewrite_send) diff --git a/src/adcp/protocols/a2a.py b/src/adcp/protocols/a2a.py index b07d87b49..b03d46595 100644 --- a/src/adcp/protocols/a2a.py +++ b/src/adcp/protocols/a2a.py @@ -14,6 +14,7 @@ from google.protobuf.struct_pb2 import Value from adcp import _idempotency +from adcp._a2a_numbers import restore_struct_integers from adcp._response_limit import ( ResponseLimitPolicy, find_response_limit_error, @@ -822,8 +823,13 @@ def _extract_result_from_task(self, task: pb.Task) -> Any: logger.warning("A2A Task artifact has no parts") return {} + # The response crossed a protobuf ``Struct``; integral doubles come + # back as ``int`` so a buyer's echoed ``context`` (and every integer + # field) reads the same as over MCP. See :mod:`adcp._a2a_numbers`. data_parts = [ - d for d in (_part_data_dict(p) for p in target_artifact.parts) if d is not None + restore_struct_integers(d) + for d in (_part_data_dict(p) for p in target_artifact.parts) + if d is not None ] if not data_parts: diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index a6d42afc5..acf3a4f98 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -40,6 +40,7 @@ from starlette.applications import Starlette from starlette.requests import Request +from adcp._a2a_numbers import A2AStructNumberMiddleware, restore_struct_integers from adcp.exceptions import ADCPError from adcp.server._hooks import PreValidationHooks from adcp.server.base import ADCPHandler, ToolContext @@ -232,10 +233,15 @@ async def replay_receive() -> Any: def _part_data_dict(part: pb.Part) -> dict[str, Any] | None: - """Return the dict payload of a Part if it carries a ``data`` oneof, else None.""" + """Return the dict payload of a Part if it carries a ``data`` oneof, else None. + + ``Struct`` turns every JSON number into a double; integral values are + restored to ``int`` so handlers and the ``context`` echo see what MCP + sees (see :mod:`adcp._a2a_numbers`). + """ if part.WhichOneof("content") != "data": return None - value: Any = MessageToDict(part.data) + value: Any = restore_struct_integers(MessageToDict(part.data)) if not isinstance(value, dict): return None return value @@ -254,9 +260,9 @@ def _normalize_a2a_parameters(params: Any) -> dict[str, Any]: return {} normalized = dict(params) major = normalized.get("adcp_major_version") - # Protobuf Struct stores all JSON numbers as doubles, so MessageToDict - # turns ``3`` into ``3.0``. Restore the integer wire field before the - # shared AdCP version resolver sees it. + # A TextPart JSON body may spell the major version ``3.0``. Restore the + # integer wire field before the shared AdCP version resolver sees it. + # (DataPart numbers are restored where they leave the protobuf Struct.) if isinstance(major, float) and major.is_integer(): normalized["adcp_major_version"] = int(major) return normalized @@ -1575,6 +1581,11 @@ def agent_card_url(request: Request) -> str: ) app = Starlette(routes=routes) + # a2a-sdk prints every protobuf ``Struct`` number as a double (``7.0``). + # Write integral values back as JSON integers so a buyer's ``context`` + # (and every integer field) leaves A2A the way it leaves MCP. + app.add_middleware(A2AStructNumberMiddleware, rpc_path=jsonrpc_kwargs["rpc_url"]) + # Keep the originating Starlette Request available to context factories # during executor dispatch. This is installed for direct # ``create_a2a_server`` adopters as well as the unified ``serve`` path, diff --git a/tests/test_context_echo_transport_parity.py b/tests/test_context_echo_transport_parity.py new file mode 100644 index 000000000..bfcda0cf7 --- /dev/null +++ b/tests/test_context_echo_transport_parity.py @@ -0,0 +1,250 @@ +"""``context`` is echoed unchanged over MCP and A2A, through the real servers. + +AdCP's ``context`` is opaque buyer data that every response echoes. These +tests drive the mounted MCP + A2A app over HTTP (``tools/call``, +``message/send``, ``SendMessage``) and compare the +echoed ``context`` byte-for-byte as JSON, so ``7`` and ``7.0`` differ. +""" + +from __future__ import annotations + +import json +from collections.abc import Iterator +from typing import Any, ClassVar + +import pytest +from starlette.testclient import TestClient + +from adcp.server import ADCPHandler +from adcp.server.responses import capabilities_response +from adcp.server.serve import _build_mcp_and_a2a_app + +#: Integers at every depth, a fractional number, null, and a nested +#: ``context`` key. Every value here survives a protobuf ``Struct`` exactly. +CONTEXT: dict[str, Any] = { + "count": 7, + "list": [1, 2.5, -3, 0], + "nil": None, + "flag": True, + "nested": {"context": {"retries": 3, "ratio": 0.25}}, +} + +REQUEST: dict[str, Any] = {"buying_mode": "brief", "brief": "premium video"} + + +def _json(value: Any) -> str: + """JSON text, so ``7`` and ``7.0`` compare unequal.""" + return json.dumps(value, sort_keys=True) + + +class _EchoHandler(ADCPHandler[Any]): + advertised_tools: ClassVar[set[str]] = {"get_adcp_capabilities", "get_products"} + + def __init__(self) -> None: + self.seen: list[Any] = [] + + async def get_adcp_capabilities(self, params: Any, context: Any = None) -> dict[str, Any]: + return capabilities_response(["media_buy"]) + + async def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: + raw = params if isinstance(params, dict) else params.model_dump(mode="python") + self.seen.append(raw.get("context")) + return {"products": []} + + +@pytest.fixture(scope="module") +def handler() -> _EchoHandler: + return _EchoHandler() + + +@pytest.fixture(scope="module") +def client(handler: _EchoHandler) -> Iterator[TestClient]: + app = _build_mcp_and_a2a_app( + handler, + name="context-echo", + port=3001, + host="127.0.0.1", + instructions=None, + test_controller=None, + stateless_http=True, + allowed_hosts=["testserver"], + ) + with TestClient(app, base_url="http://localhost:3001") as test_client: + yield test_client + + +def _mcp(client: TestClient, tool: str, arguments: dict[str, Any]) -> dict[str, Any]: + response = client.post( + "/mcp/", + headers={"accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": tool, "arguments": arguments}, + }, + ) + assert response.status_code == 200, response.text + result: dict[str, Any] = response.json()["result"] + return result["structuredContent"] + + +def _a2a_task(body: dict[str, Any]) -> dict[str, Any]: + result = body["result"] + return result.get("task", result) + + +def _a2a_data(task: dict[str, Any]) -> dict[str, Any]: + for part in task["artifacts"][0]["parts"]: + if "data" in part: + data: dict[str, Any] = part["data"] + return data + raise AssertionError(f"no DataPart in {task}") + + +def _a2a(client: TestClient, wire: str, tool: str, parameters: dict[str, Any]) -> dict[str, Any]: + invocation = {"skill": tool, "parameters": parameters} + if wire == "v0.3": + headers: dict[str, str] = {} + method = "message/send" + part: dict[str, Any] = {"kind": "data", "data": invocation} + role = "user" + else: + headers = {"A2A-Version": "1.0"} + method = "SendMessage" + part = {"data": invocation} + role = "ROLE_USER" + response = client.post( + "/", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": {"message": {"messageId": f"m-{wire}", "role": role, "parts": [part]}}, + }, + ) + assert response.status_code == 200, response.text + return _a2a_data(_a2a_task(response.json())) + + +@pytest.mark.parametrize("wire", ["v0.3", "v1.0"]) +def test_a2a_echoes_integer_context_like_mcp(client: TestClient, wire: str) -> None: + mcp = _mcp(client, "get_products", {**REQUEST, "context": CONTEXT}) + a2a = _a2a(client, wire, "get_products", {**REQUEST, "context": CONTEXT}) + assert "adcp_error" not in mcp and "adcp_error" not in a2a + assert _json(mcp["context"]) == _json(CONTEXT) + assert _json(a2a["context"]) == _json(CONTEXT) + + +@pytest.mark.parametrize("wire", ["v0.3", "v1.0"]) +def test_a2a_handler_receives_integers_like_mcp( + client: TestClient, handler: _EchoHandler, wire: str +) -> None: + handler.seen.clear() + _mcp(client, "get_products", {**REQUEST, "context": CONTEXT}) + _a2a(client, wire, "get_products", {**REQUEST, "context": CONTEXT}) + mcp_seen, a2a_seen = handler.seen + assert _json(a2a_seen) == _json(mcp_seen) == _json(CONTEXT) + + +@pytest.mark.parametrize("wire", ["v0.3", "v1.0"]) +def test_a2a_error_echo_keeps_integers(client: TestClient, wire: str) -> None: + """The structured-error envelope crosses the same ``Struct`` boundary.""" + # ``brief`` must be a string: the request fails validation before the handler. + data = _a2a(client, wire, "get_products", {**REQUEST, "brief": 5, "context": CONTEXT}) + assert "adcp_error" in data + assert _json(data["context"]) == _json(CONTEXT) + + +@pytest.mark.parametrize("wire", ["v0.3", "v1.0"]) +def test_struct_cannot_carry_an_integral_float(client: TestClient, wire: str) -> None: + """Documented tradeoff: a ``Struct`` holds ``7`` and ``7.0`` as one double. + + MCP echoes ``7.0`` as sent. A2A echoes the JSON-equal ``7``: the protobuf + layer has already erased the spelling before the SDK sees the request. + """ + context = {"whole": 7.0, "half": 7.5} + mcp = _mcp(client, "get_products", {**REQUEST, "context": context}) + a2a = _a2a(client, wire, "get_products", {**REQUEST, "context": context}) + assert _json(mcp["context"]) == _json({"whole": 7.0, "half": 7.5}) + assert _json(a2a["context"]) == _json({"whole": 7, "half": 7.5}) + assert a2a["context"] == mcp["context"] + + +def test_text_part_json_reaches_the_handler_exactly( + client: TestClient, handler: _EchoHandler +) -> None: + """TextPart JSON never enters a ``Struct``, so ``7.0`` reaches the handler as sent.""" + handler.seen.clear() + invocation = {"skill": "get_products", "parameters": {**REQUEST, "context": {"v": 7.0}}} + response = client.post( + "/", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "message/send", + "params": { + "message": { + "messageId": "m-text", + "role": "user", + "parts": [{"kind": "text", "text": json.dumps(invocation)}], + } + }, + }, + ) + assert response.status_code == 200, response.text + assert _json(handler.seen) == _json([{"v": 7.0}]) + + +@pytest.mark.asyncio +async def test_sse_rewrite_handles_events_split_across_chunks() -> None: + from adcp._a2a_numbers import A2AStructNumberMiddleware + + event = b'data: {"context":{"n":7.0,"f":1.5}}\r\n\r\n' + + async def app(scope: Any, receive: Any, send: Any) -> None: + await send( + { + "type": "http.response.start", + "status": 200, + "headers": [(b"content-type", b"text/event-stream; charset=utf-8")], + } + ) + for chunk in (event[:9], event[9:20], event[20:]): + await send({"type": "http.response.body", "body": chunk, "more_body": True}) + await send({"type": "http.response.body", "body": b"", "more_body": False}) + + sent: list[Any] = [] + + async def send(message: Any) -> None: + sent.append(message) + + middleware = A2AStructNumberMiddleware(app) + await middleware({"type": "http", "method": "POST", "path": "/"}, None, send) + body = b"".join(m.get("body", b"") for m in sent if m["type"] == "http.response.body") + assert body == b'data: {"context": {"n": 7, "f": 1.5}}\r\n\r\n' + + +def test_client_reads_integers_from_a_struct_response() -> None: + """The SDK's own A2A client restores integers when reading a result.""" + from a2a import types as pb + from google.protobuf.json_format import ParseDict + from google.protobuf.struct_pb2 import Value + + from adcp.protocols.a2a import A2AAdapter + from adcp.types.core import AgentConfig, Protocol + + value = Value() + ParseDict({"products": [], "context": CONTEXT}, value) + task = pb.Task( + id="t", + context_id="c", + status=pb.TaskStatus(state=pb.TaskState.TASK_STATE_COMPLETED), + artifacts=[pb.Artifact(artifact_id="a", parts=[pb.Part(data=value)])], + ) + adapter = A2AAdapter( + AgentConfig(id="seller", agent_uri="https://seller.example", protocol=Protocol.A2A) + ) + result = adapter._extract_result_from_task(task) + assert _json(result["context"]) == _json(CONTEXT) From 7b44d8d5d1627f98fede0ed5660ab3d804f230bd Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 21:34:48 -0400 Subject: [PATCH 29/54] fix(a2a): echo request context on unknown-skill and unexpected-error tasks (#1597) ADCPAgentExecutor._send_error built the failed task for an unknown skill or a non-AdCP exception from the message alone, so a request's context was dropped. Thread the parsed params through and echo context via inject_context in a DataPart beside the generic text part. Requests without context keep the text-only task, and exception detail stays in the log. Fixes #1573 Co-authored-by: Claude Opus 5.5 (1M context) --- docs/handler-authoring.md | 3 +- src/adcp/server/a2a_server.py | 22 +++++- tests/test_a2a_error_context_echo.py | 101 +++++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 tests/test_a2a_error_context_echo.py diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index 1178f931b..91e35e0ec 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -1299,7 +1299,8 @@ serve(MyAgent(), transport="a2a", middleware=[audit_middleware]) - **Exception observation — never swallow an `ADCPError`.** Catch around `await call_next()` to log failures. Re-raise to let the executor's normal error path take over (`ADCPError` → failed task - with `adcp_error` DataPart; other exceptions → opaque failed task). + with `adcp_error` DataPart; other exceptions → opaque failed task + whose only DataPart is the request's echoed `context`, if it had one). Swallowing an `ADCPError` (especially `IdempotencyConflictError` or `ADCPTaskError`) and returning a fake-success dict silently converts a rejected mutation into a "completed" task — double-billing, diff --git a/src/adcp/server/a2a_server.py b/src/adcp/server/a2a_server.py index acf3a4f98..7ef9769e3 100644 --- a/src/adcp/server/a2a_server.py +++ b/src/adcp/server/a2a_server.py @@ -511,7 +511,7 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non return if skill_name not in self._tool_callers: - await self._send_error(event_queue, context, f"Unknown skill: {skill_name}") + await self._send_error(event_queue, context, f"Unknown skill: {skill_name}", params) return tool_context = self._build_tool_context(skill_name, context) @@ -544,7 +544,9 @@ async def execute(self, context: RequestContext, event_queue: EventQueue) -> Non ) except Exception: logger.exception("Error executing skill %s", skill_name) - await self._send_error(event_queue, context, f"Skill execution failed: {skill_name}") + await self._send_error( + event_queue, context, f"Skill execution failed: {skill_name}", params + ) async def _dispatch_with_middleware( self, @@ -779,11 +781,25 @@ async def _send_error( event_queue: EventQueue, context: RequestContext, error_msg: str, + params: dict[str, Any] | None = None, ) -> None: - """Publish a failed task.""" + """Publish a failed task for a failure with no AdCP error envelope. + + Covers an unknown skill and an unexpected exception. When ``params`` + carries a wire ``context``, a DataPart echoes it: AdCP echoes + ``context`` on every response, errors included, and buyers correlate + failures by it. The text part keeps the generic message; the + exception detail stays in the server log. + """ + data: dict[str, Any] = {} + if params is not None: + from adcp.server.helpers import inject_context + + inject_context(params, data) task = _make_task( context, state=pb.TaskState.TASK_STATE_FAILED, + data=data or None, message=error_msg, ) await event_queue.enqueue_event(task) diff --git a/tests/test_a2a_error_context_echo.py b/tests/test_a2a_error_context_echo.py new file mode 100644 index 000000000..5f13fe8d2 --- /dev/null +++ b/tests/test_a2a_error_context_echo.py @@ -0,0 +1,101 @@ +"""A2A failures without an AdCP error envelope still echo the request ``context``. + +An unknown skill and an unexpected handler exception produce a failed task +from ``ADCPAgentExecutor._send_error``. AdCP echoes ``context`` on every +response, errors included. These tests drive the real A2A server over HTTP +on both wire versions. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from typing import Any, ClassVar + +import pytest +from starlette.testclient import TestClient + +from adcp.server import ADCPHandler, create_a2a_server +from adcp.server.responses import capabilities_response + +CONTEXT: dict[str, Any] = {"correlation_id": "buyer-req-1573", "trace": {"hop": "a"}} +REQUEST: dict[str, Any] = {"buying_mode": "brief", "brief": "premium video"} +_SECRET = "upstream-said-something-private" + + +class _CrashingHandler(ADCPHandler[Any]): + advertised_tools: ClassVar[set[str]] = {"get_adcp_capabilities", "get_products"} + + async def get_adcp_capabilities(self, params: Any, context: Any = None) -> dict[str, Any]: + return capabilities_response(["media_buy"]) + + async def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: + raise RuntimeError(_SECRET) + + +@pytest.fixture(scope="module") +def client() -> Iterator[TestClient]: + app = create_a2a_server(_CrashingHandler(), name="a2a-errors", allowed_hosts=["testserver"]) + with TestClient(app) as test_client: + yield test_client + + +def _send(client: TestClient, wire: str, skill: str, parameters: dict[str, Any]) -> dict[str, Any]: + invocation = {"skill": skill, "parameters": parameters} + if wire == "v0.3": + headers: dict[str, str] = {} + method, role = "message/send", "user" + part: dict[str, Any] = {"kind": "data", "data": invocation} + else: + headers = {"A2A-Version": "1.0"} + method, role = "SendMessage", "ROLE_USER" + part = {"data": invocation} + response = client.post( + "/", + headers=headers, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": { + "message": {"messageId": f"m-{wire}-{skill}", "role": role, "parts": [part]} + }, + }, + ) + assert response.status_code == 200, response.text + assert _SECRET not in response.text + result = response.json()["result"] + task: dict[str, Any] = result.get("task", result) + assert task["status"]["state"] in {"failed", "TASK_STATE_FAILED"}, task + return task + + +def _parts(task: dict[str, Any]) -> tuple[list[dict[str, Any]], list[str]]: + parts = task["artifacts"][0]["parts"] + return [p["data"] for p in parts if "data" in p], [p["text"] for p in parts if "text" in p] + + +WIRES = pytest.mark.parametrize("wire", ["v0.3", "v1.0"]) + + +@WIRES +def test_unknown_skill_echoes_context(client: TestClient, wire: str) -> None: + data, text = _parts(_send(client, wire, "no_such_skill", {**REQUEST, "context": CONTEXT})) + assert data == [{"context": CONTEXT}] + assert text == ["Unknown skill: no_such_skill"] + + +@WIRES +def test_unexpected_exception_echoes_context(client: TestClient, wire: str) -> None: + data, text = _parts(_send(client, wire, "get_products", {**REQUEST, "context": CONTEXT})) + assert data == [{"context": CONTEXT}] + assert text == ["Skill execution failed: get_products"] + + +@WIRES +@pytest.mark.parametrize("skill", ["no_such_skill", "get_products"]) +def test_failure_without_context_keeps_the_text_only_task( + client: TestClient, wire: str, skill: str +) -> None: + data, text = _parts(_send(client, wire, skill, REQUEST)) + assert data == [] + assert len(text) == 1 From 7cd667f9e134d958aadffadef1420d0c91c941ef Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 21:58:24 -0400 Subject: [PATCH 30/54] fix(targeting): bridge package forecast request inputs (#1598) --- src/adcp/types/_forward_compat.py | 36 +++++++++++++ tests/test_code_generation.py | 15 +++++- tests/test_targeting_overlay_compat.py | 72 ++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 2 deletions(-) diff --git a/src/adcp/types/_forward_compat.py b/src/adcp/types/_forward_compat.py index 42851a573..f6113c22a 100644 --- a/src/adcp/types/_forward_compat.py +++ b/src/adcp/types/_forward_compat.py @@ -38,6 +38,7 @@ from collections.abc import Callable from copy import copy, deepcopy from functools import partial +from importlib import import_module from types import GenericAlias from typing import TYPE_CHECKING, Annotated, Any, cast, get_args @@ -60,6 +61,7 @@ from adcp.types.base import AdCPBaseModel, NoExplicitNull from adcp.types.canonical_creative import CreateMediaBuyRequest as _PublicCreateMediaBuyRequest +from adcp.types.canonical_creative import GetProductsRequest as _PublicGetProductsRequest from adcp.types.canonical_creative import PackageRequest as PublicPackageRequest from adcp.types.canonical_creative import PackageUpdate as PublicPackageUpdate from adcp.types.canonical_creative import UpdateMediaBuyRequest as _PublicUpdateMediaBuyRequest @@ -439,6 +441,39 @@ def _patch_targeting_overlay(model: type[BaseModel]) -> None: model.model_rebuild(force=True) +def _patch_package_forecast_targeting() -> None: + """Bridge the optional 3.3 package input without changing older generated trees.""" + module_name = "adcp.types.domains.media_buy.package_forecast_input" + try: + forecast_module = import_module(module_name) + except ModuleNotFoundError as exc: + # Only the optional model itself may be absent. A broken dependency + # inside a present generated module must still fail the import. + if exc.name != module_name: + raise + return + + forecast_model = forecast_module.PackageForecastInput + _patch_targeting_overlay(forecast_model) + request_module = import_module("adcp.types.domains.media_buy.get_products_request") + # _ergonomic eagerly builds get_products before this patch. Rebuild its + # product-scoped union arm first, then both public request parents. Names + # of generated anonymous union arms change during code generation. + for model in vars(request_module).values(): + if ( + isinstance(model, type) + and issubclass(model, BaseModel) + and model.__module__ == request_module.__name__ + and any( + _annotation_contains(field.annotation, forecast_model) + for field in model.model_fields.values() + ) + ): + model.model_rebuild(force=True) + request_module.GetProductsRequest.model_rebuild(force=True) + _PublicGetProductsRequest.model_rebuild(force=True) + + def _apply_forward_compat() -> None: """Apply readback, capability, and public-model compatibility patches.""" for model in ( @@ -452,6 +487,7 @@ def _apply_forward_compat() -> None: PublicPackageUpdate, ): _patch_targeting_overlay(model) + _patch_package_forecast_targeting() # _ergonomic eagerly builds this legacy parent before the targeting patch. # Refresh its cached nested validator as well as the package model itself. diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index 2edd41988..206179cc1 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -1801,7 +1801,7 @@ def test_targeting_public_surface_guard_accepts_a_compatible_new_subclass(monkey test_targeting_overlay_input_is_generated_and_public() -def test_current_targeting_input_has_exactly_four_generated_request_sites(): +def test_targeting_input_generated_request_sites_match_pinned_schemas(): """Pin schema sites and pre-patch annotations, independent of runtime widening.""" import ast import json @@ -1817,6 +1817,17 @@ def test_current_targeting_input_has_exactly_four_generated_request_sites(): "package-control": "PackageControl", "product-purchase-input": "ProductPurchaseInput", } + expected_generated = dict(expected) + forecast_schema = schema_root / "media-buy/package-forecast-input.json" + if forecast_schema.exists(): + # This optional 3.3 input reuses the booking field by JSON pointer; + # it introduces a generated composition site without adding another + # direct reference to targeting-input.json in the schema scan below. + forecast = json.loads(forecast_schema.read_text()) + assert forecast["properties"]["targeting_overlay"]["$ref"].endswith( + "/media-buy/package-request.json#/properties/targeting_overlay" + ) + expected_generated["package-forecast-input"] = "PackageForecastInput" def references(node, path=()): if isinstance(node, dict): @@ -1871,7 +1882,7 @@ def references(node, path=()): ) assert generated_sites == { (f"media_buy/{stem.replace('-', '_')}.py", name, "targeting_overlay") - for stem, name in expected.items() + for stem, name in expected_generated.items() } diff --git a/tests/test_targeting_overlay_compat.py b/tests/test_targeting_overlay_compat.py index 5aa892c65..944b31245 100644 --- a/tests/test_targeting_overlay_compat.py +++ b/tests/test_targeting_overlay_compat.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from importlib import import_module from typing import Annotated, Any, get_args, get_origin import pytest @@ -52,6 +53,19 @@ pytest.param(PackageRequest, _NEW_PACKAGE, id="public-PackageRequest"), pytest.param(PackageUpdate, _EXISTING_PACKAGE, id="public-PackageUpdate"), ] +try: + _forecast_module = import_module("adcp.types.domains.media_buy.package_forecast_input") +except ModuleNotFoundError as exc: + if exc.name != "adcp.types.domains.media_buy.package_forecast_input": + raise +else: + _CONTAINERS.append( + pytest.param( + _forecast_module.PackageForecastInput, + {"pricing_option_id": "price-1", "budget": 1000}, + id="PackageForecastInput", + ) + ) _REQUEST_PATHS = [ pytest.param("create_media_buy", "packages", id="create"), pytest.param("update_media_buy", "packages", id="update"), @@ -378,3 +392,61 @@ def test_targeting_patch_preserves_generated_field_metadata() -> None: assert field.title == "Targeting" assert field.description == "Mutation input" assert type(model.model_validate({"targeting": {}}).targeting_overlay) is GeneratedInput + + +def test_optional_forecast_bridge_only_ignores_the_absent_model(monkeypatch) -> None: + from adcp.types import _forward_compat + + def absent_module(name): + raise ModuleNotFoundError(name=name) + + monkeypatch.setattr(_forward_compat, "import_module", absent_module) + _forward_compat._patch_package_forecast_targeting() + + def broken_dependency(name): + raise ModuleNotFoundError(name="broken_generated_dependency") + + monkeypatch.setattr(_forward_compat, "import_module", broken_dependency) + with pytest.raises(ModuleNotFoundError) as error: + _forward_compat._patch_package_forecast_targeting() + assert error.value.name == "broken_generated_dependency" + + +def test_optional_forecast_bridge_refreshes_cached_refinement_parents(monkeypatch) -> None: + from types import ModuleType + + from adcp.types import _forward_compat + + forecast_module = ModuleType("adcp.types.domains.media_buy.package_forecast_input") + forecast_model = create_model( + "PackageForecastInput", + targeting_overlay=(GeneratedInput | None, None), + ) + forecast_module.PackageForecastInput = forecast_model + request_module = ModuleType("adcp.types.domains.media_buy.get_products_request") + refinement = create_model( + "RenumberedProductRefinement", + __module__=request_module.__name__, + package=(forecast_model, ...), + ) + generated_request = create_model("GeneratedGetProductsRequest", refine=(list[refinement], ...)) + public_request = create_model("PublicGetProductsRequest", __base__=generated_request) + request_module.Refinement = refinement + request_module.GetProductsRequest = generated_request + for request in (generated_request, public_request): + request.model_rebuild(force=True) + overlay = TargetingOverlay(geo_regions=["US-NY"]) + payload = {"refine": [{"package": {"targeting_overlay": overlay}}]} + with pytest.raises(ValidationError): + public_request.model_validate(payload) + + modules = {module.__name__: module for module in (forecast_module, request_module)} + monkeypatch.setattr(_forward_compat, "import_module", modules.__getitem__) + monkeypatch.setattr(_forward_compat, "_PublicGetProductsRequest", public_request) + _forward_compat._patch_package_forecast_targeting() + + for request in (generated_request, public_request): + result = request.model_validate(payload) + assert result.refine[0].package.targeting_overlay is overlay + raw = request.model_validate({"refine": [{"package": {"targeting_overlay": {}}}]}) + assert type(raw.refine[0].package.targeting_overlay) is GeneratedInput From f1f75930152510de374d98bd286f2462af2100bb Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 22:50:41 -0400 Subject: [PATCH 31/54] fix(tasks): echo the poll's own context on tasks/get (#1599) get_task_status projected the creating request's context as the poll response's top-level context, so inject_context never echoed the poll request's own. context is per request: the poll now echoes its own. The creating request's context belongs to that task's response. The 3.2 tasks-get-response says result validates against the task type's response schema, which carries context, so project_task echoes it as result.context when include_result is set and the stored result has none. No new field is introduced. list_tasks items no longer carry it either. A task with no progress already omits progress rather than sending null; a regression test pins that against validate_response("tasks_get"). Fixes #1574 Co-authored-by: Claude Opus 5.5 (1M context) --- docs/task-registry.md | 21 ++-- src/adcp/decisioning/dispatch.py | 39 +++---- src/adcp/decisioning/task_queries.py | 25 ++++- src/adcp/decisioning/task_registry.py | 23 ++-- tests/test_decisioning_task_polling.py | 144 +++++++++++++++++++++++++ 5 files changed, 210 insertions(+), 42 deletions(-) diff --git a/docs/task-registry.md b/docs/task-registry.md index 3d3161ad8..42939bfa6 100644 --- a/docs/task-registry.md +++ b/docs/task-registry.md @@ -8,12 +8,21 @@ and return `None` for expired records; an optional epoch `expires_at` is also recognized. SDK registries retain tasks until explicit discard; no TTL is installed by this change. -Polls use `status`, ISO UTC timestamps and protocol metadata. Progress, failure -summary and the original task's context are preserved. Set `include_result=true` -to retrieve a terminal artifact. A failed task exposes the stored fatal error -in its convenience `error` summary, `result.adcp_error`, and the canonical -`result.errors` array. Successful results retain the adopter's original wire -response; the background callable must return a valid response for its task type. +Polls use `status`, ISO UTC timestamps and protocol metadata. Progress and the +failure summary are preserved; a task with no progress omits `progress` rather +than sending `null`. Set `include_result=true` to retrieve a terminal artifact. +A failed task exposes the stored fatal error in its convenience `error` summary, +`result.adcp_error`, and the canonical `result.errors` array. Successful results +retain the adopter's original wire response; the background callable must return +a valid response for its task type. + +`context` is per request. A poll's top-level `context` echoes the poll request's +own `context`, like every other response. The `context` of the request that +created the task is echoed in `result.context`, because `result` is that task's +terminal response and its schema carries the echo. A response the adopter +already gave a `context` keeps it. Polls without `include_result=true` carry no +creating-request context, and `list_tasks` items carry none. The registry still +stores it, and `list_tasks` filters such as `context_contains` still read it. `list_tasks` is advertised only for the separate optional `ListableTaskRegistry` protocol. Existing third-party `TaskRegistry` implementations need no changes. diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index 61b33faef..eb56ddd0b 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -2210,12 +2210,11 @@ async def _project_handoff( fn = handoff._fn # Extract the buyer's ``context`` extension from the original - # request and lock it onto the TaskRecord at issue-time. The - # registry surfaces it at the top level of ``tasks/get`` reads - # (sibling of ``result`` / ``error`` per - # ``schemas/cache/core/tasks_get_response.json``). Capturing once - # at issue-time means the terminal-state helpers (_fail, - # registry.complete) never need to know about request-side + # request and lock it onto the TaskRecord at issue-time. + # ``tasks/get`` echoes it inside ``result`` (the original task's + # response); the poll's top-level ``context`` is the poll's own. + # Capturing once at issue-time means the terminal-state helpers + # (_fail, registry.complete) never need to know about request-side # context — keeps the wire-shape boundary in one place. issue_kwargs: dict[str, Any] = { "account_id": ctx.account.id, @@ -2254,14 +2253,12 @@ async def _fail(exc: AdcpError) -> None: registry.fail — the buyer needs the failure visible via tasks/get regardless of hook outcomes. - Note: the request's ``context`` extension lands on the - ``tasks/get`` response at the top level (sibling of - ``error``), not inside the ``adcp_error`` envelope — see - :meth:`TaskRecord.to_dict` and the - ``schemas/cache/core/tasks_get_response.json`` - ``TasksGetResponse.context`` field. The context is captured - once at ``registry.issue()`` time below; ``_fail`` doesn't - touch it. + Note: the request's ``context`` extension is echoed in the + ``tasks/get`` ``result`` (sibling of ``adcp_error``), not inside + the ``adcp_error`` envelope — see + :func:`adcp.decisioning.task_queries.project_task`. The context + is captured once at ``registry.issue()`` time below; ``_fail`` + doesn't touch it. """ if on_failure is not None: try: @@ -2379,12 +2376,10 @@ async def _run() -> None: # the typed Pydantic response. persisted = {"value": str(result)} persisted = strip_credentials_from_wire_result(method_name, persisted) - # ``request.context`` echo lands at the top level of the - # ``tasks/get`` response (sibling of ``result``), not inside - # the typed result body. ``TaskRecord.request_context`` was - # captured at ``registry.issue()`` time and ``to_dict()`` - # surfaces it under the top-level ``context`` key; nothing to - # do here on the result path. + # ``request.context`` is echoed into ``result`` at ``tasks/get`` + # projection time (``project_task``), from + # ``TaskRecord.request_context`` captured at ``registry.issue()``; + # nothing to do here on the result path. await registry.complete(task_id, persisted) # Terminal completion webhook (spec MUST when push config present). # Fired AFTER registry.complete so the buyer's tasks/get poll and @@ -2491,8 +2486,8 @@ async def _project_workflow_handoff( fn = handoff._fn # Same context-echo capture as :func:`_project_handoff`: the - # request's ``context`` extension lives on the TaskRecord and - # surfaces at the top level of ``tasks/get`` reads (#563). The + # request's ``context`` extension lives on the TaskRecord and is + # echoed in the ``tasks/get`` ``result`` (#563, #1574). The # WorkflowHandoff path persists the task and immediately returns # — the adopter's enqueue fn does not write to the registry — so # context capture must happen here at issue-time too. diff --git a/src/adcp/decisioning/task_queries.py b/src/adcp/decisioning/task_queries.py index eb2c65fb7..e626fc777 100644 --- a/src/adcp/decisioning/task_queries.py +++ b/src/adcp/decisioning/task_queries.py @@ -46,6 +46,15 @@ def task_is_expired(record: dict[str, Any]) -> bool: def project_task(record: dict[str, Any], *, include_result: bool = False) -> dict[str, Any]: + """Project a registry record onto the ``tasks/get`` wire shape. + + The response's top-level ``context`` is the poll request's own: the + transport echoes it, as for every request. The ``context`` of the request + that created the task belongs to that task's response, so it is echoed in + ``result`` -- the original task's terminal response document, whose schema + carries ``context`` -- and nowhere else. Absent fields are omitted rather + than sent as ``null``. + """ response: dict[str, Any] = { "task_id": record["task_id"], "task_type": record["task_type"], @@ -54,20 +63,30 @@ def project_task(record: dict[str, Any], *, include_result: bool = False) -> dic "created_at": datetime.fromtimestamp(record["created_at"], timezone.utc).isoformat(), "updated_at": datetime.fromtimestamp(record["updated_at"], timezone.utc).isoformat(), } - for key in ("progress", "error", "context", "has_webhook"): + for key in ("progress", "error", "has_webhook"): if record.get(key) is not None: response[key] = record[key] if record["state"] in {"completed", "failed", "canceled", "rejected"}: response["completed_at"] = response["updated_at"] if include_result: + result: dict[str, Any] | None = None if record.get("result") is not None: - response["result"] = record["result"] + result = record["result"] elif record["state"] == "failed" and record.get("error") is not None: - response["result"] = { + result = { "status": "failed", "adcp_error": record["error"], "errors": [record["error"]], } + if result is not None: + origin_context = record.get("context") + if ( + isinstance(result, dict) + and result.get("context") is None + and origin_context is not None + ): + result = {**result, "context": origin_context} + response["result"] = result return response diff --git a/src/adcp/decisioning/task_registry.py b/src/adcp/decisioning/task_registry.py index d3f0a116c..73af7088e 100644 --- a/src/adcp/decisioning/task_registry.py +++ b/src/adcp/decisioning/task_registry.py @@ -189,10 +189,10 @@ class TaskRecord: error: dict[str, Any] | None = None request_context: dict[str, Any] | None = None """Buyer-supplied ``context`` extension from the request that - issued this task. Echoed to ``tasks/get`` responses at the - top-level ``context`` field per - ``schemas/cache/core/tasks_get_response.json`` (sibling of - ``result`` / ``error``). Captured at ``issue()`` time and + issued this task. ``tasks/get`` echoes it inside ``result`` — the + original task's terminal response, whose schema carries ``context`` — + never as the poll response's top-level ``context``, which echoes the + poll request's own (#1574). Captured at ``issue()`` time and immutable afterwards — terminal-state transitions (``complete`` / ``fail``) MUST NOT touch this field. Closes #563. """ @@ -200,15 +200,16 @@ class TaskRecord: updated_at: float = field(default_factory=time.time) def to_dict(self) -> dict[str, Any]: - """Serialize for buyer consumption via ``tasks/get``. + """Serialize the registry record. - Adopters or middleware reading the dict shape get the exact - wire-relevant fields. ``created_at`` / ``updated_at`` are - included so admin tooling can build SLA reports. + This is the record shape, not the wire shape: + :func:`adcp.decisioning.task_queries.project_task` projects it onto + ``tasks/get``. ``created_at`` / ``updated_at`` are included so admin + tooling can build SLA reports. - ``context`` lands at the top level — sibling of ``result`` - and ``error`` — matching the spec's - ``TasksGetResponse.context`` placement (#563). + ``context`` is the creating request's ``context``. The projection + echoes it inside ``result``; the poll response's top-level + ``context`` belongs to the poll request (#1574). """ out: dict[str, Any] = { "task_id": self.task_id, diff --git a/tests/test_decisioning_task_polling.py b/tests/test_decisioning_task_polling.py index 65a88938f..84f3d2332 100644 --- a/tests/test_decisioning_task_polling.py +++ b/tests/test_decisioning_task_polling.py @@ -363,3 +363,147 @@ def resolve(self, ref: dict[str, Any] | None, *, auth_info: Any = None) -> Accou listing.assert_not_awaited() finally: executor.shutdown() + + +class QuietSeller(Seller): + """Hands off a task that never reports progress.""" + + async def work(self, ctx: Any) -> dict[str, Any]: + self.started.set() + await self.finish.wait() + if self.failed: + raise AdcpError("INTERNAL_ERROR", message="Background failed", recovery="terminal") + return {"products": [], "cache_scope": "account", "status": "completed"} + + +async def _call(client: Any, transport: str, tool: str, arguments: dict[str, Any]) -> Any: + if transport == "a2a": + response = await client.post( + "/", + json={ + "jsonrpc": "2.0", + "id": secrets.token_hex(4), + "method": "message/send", + "params": { + "message": { + "messageId": secrets.token_hex(8), + "role": "user", + "parts": [ + {"kind": "data", "data": {"skill": tool, "parameters": arguments}} + ], + } + }, + }, + ) + else: + response = await client.post( + "/mcp/", + headers={"accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": tool, "arguments": arguments}, + }, + ) + assert response.status_code == 200, response.text + return rpc_data(response) + + +CREATE_CONTEXT = {"origin": "get_products", "attempt": 1, "nil": None} +POLL_CONTEXT = {"origin": "poll", "seq": 2} + + +@pytest.mark.parametrize("failed", [False, True]) +@pytest.mark.parametrize("transport", ["mcp", "a2a"]) +async def test_poll_echoes_its_own_context_and_result_echoes_the_creating_one( + registry: Any, failed: bool, transport: str +) -> None: + """#1574: a poll's ``context`` is its own; the task's is inside ``result``.""" + from adcp.validation.schema_validator import validate_response + + seller = QuietSeller() + seller.failed = failed + async with build_test_client( + seller, + registry=registry, + validate_at_init=False, + stateless_http=True, + validation=None, + transport=transport, + ) as client: + issued = await _call( + client, + transport, + "get_products", + { + "brief": "Test", + "buying_mode": "brief", + "account": {"account_id": "acct_1"}, + "context": CREATE_CONTEXT, + }, + ) + assert issued["context"] == CREATE_CONTEXT + task_id = issued["task_id"] + await asyncio.wait_for(seller.started.wait(), 5) + + working = await _call( + client, + transport, + "get_task_status", + {"task_id": task_id, "context": POLL_CONTEXT}, + ) + assert working.get("status") in {"submitted", "working"}, working + assert working["context"] == POLL_CONTEXT + assert "progress" not in working + assert validate_response("tasks_get", working).valid + + bare = await _call(client, transport, "get_task_status", {"task_id": task_id}) + assert "context" not in bare + + seller.finish.set() + for _ in range(100): + record = await registry.get(task_id) + if record["state"] in {"completed", "failed"}: + break + await asyncio.sleep(0.01) + terminal = await _call( + client, + transport, + "get_task_status", + {"task_id": task_id, "include_result": True, "context": POLL_CONTEXT}, + ) + assert terminal["status"] == ("failed" if failed else "completed") + assert terminal["context"] == POLL_CONTEXT + assert terminal["result"]["context"] == CREATE_CONTEXT + assert validate_response("tasks_get", terminal).valid + if not failed: + assert validate_response("get_products", terminal["result"]).valid + + listed = await _call( + client, + transport, + "list_tasks", + {"filters": {"task_ids": [task_id]}, "context": POLL_CONTEXT}, + ) + assert listed["context"] == POLL_CONTEXT + assert "context" not in listed["tasks"][0] + + +async def test_adopter_result_context_is_not_overwritten() -> None: + from adcp.decisioning.task_queries import project_task + + record = { + "task_id": "t", + "task_type": "get_products", + "state": "completed", + "created_at": 0.0, + "updated_at": 0.0, + "progress": None, + "result": {"products": [], "context": {"set_by": "adopter"}}, + "context": {"origin": "create"}, + } + projected = project_task(record, include_result=True) + assert projected["result"]["context"] == {"set_by": "adopter"} + assert "context" not in projected + assert "progress" not in projected From 4083dc52702ddd1d20aa840dd4265bf61dd75ddd Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 24 Oct 2026 23:53:13 -0400 Subject: [PATCH 32/54] feat(media-buy): persist and echo create context on get_media_buys (#1600) AdCP 3.2 get_media_buys echoes, on each media buy and each package, the context of the create_media_buy request and package request that created it. create_media_buy_store now does this for every seller, on by default: it records both levels when a create completes (inline or after a handoff) and fills in any context the platform left out of get_media_buys. A context the platform returns itself is never replaced. Context lives in a new MediaBuyContextStore: InMemoryMediaBuyContextStore by default, or PgMediaBuyContextStore for a durable, multi-worker echo. The Postgres store adds one new table (JSON column, so key order, nulls and number spelling round-trip as written); nothing existing changes, so there is no data migration. create_schema(), migration_sql() and a .sql file are provided. The targeting_overlay delegation stays gated on property-lists / collection-lists; the adopter store is now optional for sellers claiming neither. echo_context=False opts out. Store failures are logged, not raised, because the buy already exists upstream. AdCPBaseModel's dump default exclude_none=True also dropped an opaque object's own null values, so a buyer's {"a": null, "b": 1} came back as {"b": 1}. ContextObject (and the opaque ext objects) now keep their nulls on every dump; keys excluded by name stay excluded. Fixes #1575 Co-authored-by: Claude Opus 5.5 (1M context) --- docs/media-buy-store.md | 103 +++++ docs/production-seller.md | 3 + src/adcp/decisioning/__init__.py | 6 + src/adcp/decisioning/media_buy_store.py | 376 ++++++++++++++---- src/adcp/decisioning/pg/__init__.py | 5 + .../decisioning/pg/media_buy_context_store.py | 168 ++++++++ .../pg/media_buy_context_store.sql | 23 ++ src/adcp/types/base.py | 38 +- tests/test_media_buy_context_echo.py | 363 +++++++++++++++++ 9 files changed, 1006 insertions(+), 79 deletions(-) create mode 100644 docs/media-buy-store.md create mode 100644 src/adcp/decisioning/pg/media_buy_context_store.py create mode 100644 src/adcp/decisioning/pg/media_buy_context_store.sql create mode 100644 tests/test_media_buy_context_echo.py diff --git a/docs/media-buy-store.md b/docs/media-buy-store.md new file mode 100644 index 000000000..ae423cb47 --- /dev/null +++ b/docs/media-buy-store.md @@ -0,0 +1,103 @@ +# Media buy store: what `get_media_buys` echoes + +AdCP 3.2 `get_media_buys` echoes values the seller persisted when the buy was +created. `create_media_buy_store` wires that persistence at the framework +boundary, so a `DecisioningPlatform` doesn't have to persist and echo by hand. + +```python +from adcp.decisioning import create_media_buy_store, serve +from adcp.decisioning.pg import PgMediaBuyContextStore + +context_store = PgMediaBuyContextStore(pool=pool) +await context_store.create_schema() # idempotent; safe on every boot + +serve( + platform, + media_buy_store=create_media_buy_store( + capabilities=platform.capabilities, + context_store=context_store, + ), +) +``` + +## `context`: on for every seller + +`get_media_buys` must echo, unchanged: + +- on each media buy (`media_buys[].context`), the `context` of the + `create_media_buy` request that created it, and +- on each package (`media_buys[].packages[].context`), the `context` of the + package request that created it (`core/package.json`: "echoed unchanged in + responses, webhooks, and read surfaces"). + +The store records both levels when `create_media_buy` completes, whether +inline or after a task handoff. On `get_media_buys` it fills in any media buy +or package whose `context` the platform left out. A `context` the platform +returns itself is never replaced. + +Each response package is paired with its request in this order: + +1. A `product_id` that only one package request carries. +2. Otherwise, position, when the response lists as many packages as the + request and no echoed `product_id` contradicts the pairing. +3. Otherwise, a `context` the platform echoed on that response package. + +Echo `product_id` on created packages if you reorder them. The stored objects +are the create request's: `update_media_buy` doesn't change them, and packages +added by `update_media_buy` aren't recorded. + +Context echo is on by default. The default `InMemoryMediaBuyContextStore` is +process-local, so a restart or another worker loses the echo. Production +sellers pass `PgMediaBuyContextStore`, or any object implementing the +`MediaBuyContextStore` protocol (`save_contexts` / `load_contexts`, sync or +async, scoped by `account_id`, first write wins). Pass `echo_context=False` +if the platform persists and echoes `context` itself. + +The SDK has already created the media buy upstream when the store writes, so a +store failure is logged, not raised: the buyer still gets the created buy, and +`get_media_buys` returns it without the persisted `context`. A failed read is +logged the same way. + +### PostgreSQL table + +`PgMediaBuyContextStore` adds one new table (default `adcp_media_buy_contexts`) +and changes nothing that already exists, so there's no data migration. Create +it in one of three ways: + +- Call `create_schema()` at boot. It's idempotent. +- Use `PgMediaBuyContextStore.migration_sql(table_name=...)`, which returns + `upgrade` / `downgrade` statements for Alembic, dbmate and similar tools. +- Apply `src/adcp/decisioning/pg/media_buy_context_store.sql` (default table + name only). + +The table holds one row per echoed object. Its `context` column is `JSON`, not +`JSONB`, so the stored text round-trips as written: key order, `null` values +and number spelling (`7.0` stays `7.0`). Every query filters on `account_id`. + +## `targeting_overlay`: for property-list and collection-list sellers + +A seller claiming `property-lists` or `collection-lists` must also echo +persisted `property_list` / `collection_list` references inside +`packages[].targeting_overlay`. You supply that persistence as a +`MediaBuyStore`, the first argument: + +```python +create_media_buy_store( + overlay_store, + capabilities=platform.capabilities, + context_store=context_store, +) +``` + +The wrapper delegates `persist_from_create`, `merge_from_update` and +`backfill` to it only for those specialisms. A seller that claims one of them +without supplying an overlay store gets a `ValueError` at construction. Sellers +that claim neither may omit the overlay store. + +## `null` values in buyer `context` + +A buyer's `context` is opaque. `ContextObject` (and the opaque `ext` objects) +keep their `null` values on every dump, including the default +`exclude_none=True`. A buyer's `{"a": null, "b": 1}` is echoed as sent, both +by the persisted echo above and by any platform that returns a typed response +carrying the request's `context`. Keys you exclude by name stay excluded. diff --git a/docs/production-seller.md b/docs/production-seller.md index e3d4936b9..36fc0ccc2 100644 --- a/docs/production-seller.md +++ b/docs/production-seller.md @@ -238,6 +238,9 @@ the shutdown path used by ordinary container and process supervisors. covers every supported mutation and durable task handoff. - Apply URL challenge and SSRF validation before accepting durable callback destinations. +- Persist the `create_media_buy` `context` that `get_media_buys` must echo: + pass `create_media_buy_store(..., context_store=PgMediaBuyContextStore(...))` + (see [`media-buy-store.md`](media-buy-store.md)). - Run the in-process tests and the media-buy seller storyboard before deploy. `DurableTaskWiring` remains example-owned so its configuration surface can be diff --git a/src/adcp/decisioning/__init__.py b/src/adcp/decisioning/__init__.py index 2be7e736c..3516e8aef 100644 --- a/src/adcp/decisioning/__init__.py +++ b/src/adcp/decisioning/__init__.py @@ -101,6 +101,9 @@ def create_media_buy( from adcp.decisioning.helpers import ref_account_id from adcp.decisioning.implementation_config import ProductConfigStore from adcp.decisioning.media_buy_store import ( + InMemoryMediaBuyContextStore, + MediaBuyContexts, + MediaBuyContextStore, MediaBuyStore, create_media_buy_store, ) @@ -405,12 +408,15 @@ def __init__(self, *args: object, **kwargs: object) -> None: "GovernanceContextJWS", "HttpSigCredential", "IncrementalGetProducts", + "InMemoryMediaBuyContextStore", "InMemoryMockAdServer", "InMemoryTaskRegistry", "ListableTaskRegistry", "MEDIA_BUY_TRANSITIONS", "MaybeAsync", "MediaBuyNotFoundError", + "MediaBuyContextStore", + "MediaBuyContexts", "MediaBuyStore", "MockAdServer", "MockProposalManager", diff --git a/src/adcp/decisioning/media_buy_store.py b/src/adcp/decisioning/media_buy_store.py index 9ab032b74..dddd5b78c 100644 --- a/src/adcp/decisioning/media_buy_store.py +++ b/src/adcp/decisioning/media_buy_store.py @@ -1,39 +1,46 @@ -"""``create_media_buy_store`` — opt-in framework wiring that gates -``targeting_overlay`` echo on the seller's declared specialisms. - -The seller's spec contract on -``schemas/cache//media-buy/get-media-buys-response.json`` requires -that sellers claiming the ``property-lists`` or ``collection-lists`` -specialism echo persisted ``property_list`` / ``collection_list`` -references inside the ``packages[].targeting_overlay`` they return on -``get_media_buys``. This factory lets adopters wire that contract once, -at the framework boundary, instead of every adapter persisting + -echoing by hand. +"""``create_media_buy_store`` — framework wiring for what ``get_media_buys`` +must echo from ``create_media_buy``. + +AdCP 3.2 ``get_media_buys`` read surfaces echo two things a seller persisted +at create time: + +* **``context``**, on every media buy and every package. The + ``create_media_buy`` request's ``context`` comes back on + ``media_buys[].context``, and each package request's ``context`` on + ``media_buys[].packages[].context``, unchanged + (``schemas/cache//media-buy/get-media-buys-response.json``, + ``core/package.json``: "echoed unchanged in responses, webhooks, and read + surfaces"). This is a MUST for every seller, so it is on by default: the + wrapper persists both levels into a :class:`MediaBuyContextStore` and + backfills them on ``get_media_buys`` wherever the platform left them out. + The default store is in-memory; pass + :class:`adcp.decisioning.pg.PgMediaBuyContextStore` (or any + :class:`MediaBuyContextStore`) so the echo survives restarts and spans + workers. ``echo_context=False`` opts out. +* **``targeting_overlay``**, for sellers claiming the ``property-lists`` or + ``collection-lists`` specialism, which must echo persisted + ``property_list`` / ``collection_list`` references inside + ``packages[].targeting_overlay``. The adopter supplies that persistence as + a :class:`MediaBuyStore`; the wrapper delegates to it only for sellers + claiming one of the two specialisms and never invokes it otherwise. Mirrors the JS-side ``createMediaBuyStore`` from ``@adcp/sdk@6.7`` (commit ``dda2a77e``) with one shape change: the JS factory builds the -persistence itself on top of an ``AdcpStateStore``. The Python SDK -doesn't ship an ``AdcpStateStore`` Protocol, so adopters supply their -own :class:`MediaBuyStore` implementation; this factory wraps it with -specialism-aware gating. Adopters who want a reference impl can crib -the in-memory pattern from the test suite. +overlay persistence itself on top of an ``AdcpStateStore``. The Python SDK +doesn't ship an ``AdcpStateStore`` Protocol, so adopters supply their own +:class:`MediaBuyStore` for overlays; the SDK owns the ``context`` store. Usage:: from adcp.decisioning import create_media_buy_store + from adcp.decisioning.pg import PgMediaBuyContextStore platform.media_buy_store = create_media_buy_store( - adopter_store, capabilities=platform.capabilities, + adopter_store, # optional unless claiming property-/collection-lists + capabilities=platform.capabilities, + context_store=PgMediaBuyContextStore(pool=pool), ) -Behavior: - -* Seller claims ``property-lists`` or ``collection-lists`` → - every method delegates to the adopter store. -* Seller claims neither → ``persist_from_create`` and - ``merge_from_update`` are no-ops; ``backfill`` returns the response - unchanged. The adopter store is never invoked. - The wrapper is always a fresh object so adopters can reason about identity at the assignment site without aliasing surprises. """ @@ -41,16 +48,25 @@ from __future__ import annotations import inspect +import logging +from collections.abc import Mapping, Sequence +from copy import deepcopy +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable if TYPE_CHECKING: from adcp.decisioning.platform import DecisioningCapabilities __all__ = [ + "InMemoryMediaBuyContextStore", + "MediaBuyContextStore", + "MediaBuyContexts", "MediaBuyStore", "create_media_buy_store", ] +logger = logging.getLogger(__name__) + #: Specialism slugs that require ``targeting_overlay`` echo on #: ``get_media_buys``. A seller claiming either MUST echo the persisted #: ``property_list`` / ``collection_list`` reference per @@ -109,84 +125,281 @@ async def _await_maybe(value: Any) -> Any: return value -class _ActiveMediaBuyStore: - """Wrapper that delegates every method to the adopter store. +@dataclass(frozen=True) +class MediaBuyContexts: + """The buyer ``context`` objects a ``create_media_buy`` request carried. - Constructed when the seller claims ``property-lists`` or - ``collection-lists``. Adds the sync/async normalization layer over - the adopter callbacks so the framework can ``await`` uniformly. + :param context: The request's top-level ``context``, echoed on + ``media_buys[].context``. ``None`` when the request carried none. + :param package_contexts: Each package request's ``context``, keyed by + the seller-assigned ``package_id``, echoed on + ``media_buys[].packages[].context``. + + Both are opaque buyer data, stored and returned unchanged — including + ``null`` values, key order and number spelling. """ - __slots__ = ("_inner",) + context: dict[str, Any] | None = None + package_contexts: Mapping[str, dict[str, Any]] = field(default_factory=dict) - def __init__(self, inner: MediaBuyStore) -> None: - self._inner = inner - async def persist_from_create(self, account_id: str, request: Any, result: Any) -> None: - await _await_maybe(self._inner.persist_from_create(account_id, request, result)) +@runtime_checkable +class MediaBuyContextStore(Protocol): + """Persistence for the ``context`` a ``get_media_buys`` read echoes. - async def merge_from_update(self, account_id: str, media_buy_id: str, patch: Any) -> None: - await _await_maybe(self._inner.merge_from_update(account_id, media_buy_id, patch)) + Writes are first-wins: a ``create_media_buy`` request's ``context`` is + echoed unchanged for the life of the buy, so :meth:`save_contexts` never + replaces a stored object. It records the buy-level ``context`` if none is + stored yet and each package ``context`` whose ``package_id`` is new, which + makes an idempotent create replay harmless. + + Every read and write is scoped by ``account_id``; a store MUST NOT return + another account's record. Methods may be sync or async. + + Shipped implementations: :class:`InMemoryMediaBuyContextStore` (the + default, process-local) and + :class:`adcp.decisioning.pg.PgMediaBuyContextStore` (durable). + """ + + def save_contexts( + self, + account_id: str, + media_buy_id: str, + contexts: MediaBuyContexts, + ) -> Any: ... + + def load_contexts( + self, + account_id: str, + media_buy_ids: Sequence[str], + ) -> Any: + """Return ``{media_buy_id: MediaBuyContexts}`` for the stored ids.""" + ... - async def backfill(self, account_id: str, result: Any) -> Any: - return await _await_maybe(self._inner.backfill(account_id, result)) +class InMemoryMediaBuyContextStore: + """Process-local :class:`MediaBuyContextStore`. -class _NoopMediaBuyStore: - """Pass-through wrapper for sellers not claiming the echo specialisms. + The default for :func:`create_media_buy_store`. Records live as long as + the process and are not shared across workers, so a restarted or + load-balanced seller loses the echo for buys created elsewhere. Use + :class:`adcp.decisioning.pg.PgMediaBuyContextStore` in production. + """ - Holds a reference to the adopter store for parity with the active - path — adopters can swap their seller's ``capabilities`` between - builds without touching the wiring. The reference is kept private - (``_inner``) to discourage adopter code from reaching past the - wrapper to an unwrapped store. + def __init__(self) -> None: + self._records: dict[tuple[str, str], tuple[Any, dict[str, Any]]] = {} + + def save_contexts(self, account_id: str, media_buy_id: str, contexts: MediaBuyContexts) -> None: + key = (account_id, media_buy_id) + context, packages = self._records.get(key, (None, {})) + if context is None and contexts.context is not None: + context = deepcopy(contexts.context) + for package_id, package_context in contexts.package_contexts.items(): + packages.setdefault(package_id, deepcopy(package_context)) + self._records[key] = (context, packages) + + def load_contexts( + self, account_id: str, media_buy_ids: Sequence[str] + ) -> dict[str, MediaBuyContexts]: + found: dict[str, MediaBuyContexts] = {} + for media_buy_id in media_buy_ids: + record = self._records.get((account_id, media_buy_id)) + if record is not None: + found[media_buy_id] = MediaBuyContexts( + context=deepcopy(record[0]), package_contexts=deepcopy(record[1]) + ) + return found + + +def _dict_items(value: Any) -> list[dict[str, Any]]: + """The dict entries of a JSON array, or ``[]`` for anything else.""" + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _matching_request_package( + index: int, + response_package: dict[str, Any], + request_packages: list[dict[str, Any]], + response_count: int, +) -> dict[str, Any] | None: + """Pair a created package with the package request that produced it. + + A ``product_id`` the response echoes and only one request carries is + decisive. Otherwise the n-th response package answers the n-th request + when the counts agree and no echoed ``product_id`` contradicts it -- + the order the reference overlay store also relies on. + """ + product_id = response_package.get("product_id") + if product_id is not None: + same_product = [r for r in request_packages if r.get("product_id") == product_id] + if len(same_product) == 1: + return same_product[0] + if len(request_packages) == response_count: + candidate = request_packages[index] + if product_id is None or candidate.get("product_id") in (None, product_id): + return candidate + return None + + +def _contexts_from_create(request: Any, result: Any) -> tuple[str, MediaBuyContexts] | None: + """The ``context`` objects to persist for one successful create.""" + if not isinstance(request, dict) or not isinstance(result, dict): + return None + media_buy_id = result.get("media_buy_id") + if not isinstance(media_buy_id, str) or not media_buy_id: + return None + context = request.get("context") + if not isinstance(context, dict): + echoed = result.get("context") + context = echoed if isinstance(echoed, dict) else None + request_packages = _dict_items(request.get("packages")) + response_packages = _dict_items(result.get("packages")) + package_contexts: dict[str, dict[str, Any]] = {} + for index, package in enumerate(response_packages): + package_id = package.get("package_id") + if not isinstance(package_id, str) or not package_id: + continue + matched = _matching_request_package( + index, package, request_packages, len(response_packages) + ) + source = matched.get("context") if matched is not None else None + if not isinstance(source, dict): + source = package.get("context") + if isinstance(source, dict): + package_contexts[package_id] = source + if context is None and not package_contexts: + return None + return media_buy_id, MediaBuyContexts(context=context, package_contexts=package_contexts) + + +def _fill_contexts(result: Any, stored: Mapping[str, MediaBuyContexts]) -> None: + """Fill each media buy's and package's missing ``context`` in place.""" + for buy in _dict_items(result.get("media_buys")): + record = stored.get(buy.get("media_buy_id", "")) + if record is None: + continue + if buy.get("context") is None and record.context is not None: + buy["context"] = deepcopy(record.context) + for package in _dict_items(buy.get("packages")): + package_context = record.package_contexts.get(package.get("package_id", "")) + if package.get("context") is None and package_context is not None: + package["context"] = deepcopy(package_context) + + +class _MediaBuyStoreWrapper: + """What :func:`create_media_buy_store` returns. + + ``targeting_overlay`` work goes to the adopter store when ``overlays`` is + set (the seller claims an overlay-echo specialism); ``context`` work goes + to ``contexts`` unless the adopter opted out. Adopter callbacks may be + sync or async; the wrapper awaits uniformly. """ - __slots__ = ("_inner",) + __slots__ = ("_inner", "_overlays", "_contexts") - def __init__(self, inner: MediaBuyStore) -> None: + def __init__( + self, + inner: MediaBuyStore | None, + *, + overlays: bool, + contexts: MediaBuyContextStore | None, + ) -> None: self._inner = inner + self._overlays = overlays + self._contexts = contexts async def persist_from_create(self, account_id: str, request: Any, result: Any) -> None: - del account_id, request, result # no-op pass-through + if self._overlays and self._inner is not None: + await _await_maybe(self._inner.persist_from_create(account_id, request, result)) + if self._contexts is not None: + found = _contexts_from_create(request, result) + if found is not None: + media_buy_id, contexts = found + # The buy already exists upstream: failing the create now + # would report an error for a buy the buyer then cannot see. + try: + await _await_maybe( + self._contexts.save_contexts(account_id, media_buy_id, contexts) + ) + except Exception: + logger.exception( + "MediaBuyContextStore.save_contexts failed for media buy %s; " + "get_media_buys will not echo its context", + media_buy_id, + ) async def merge_from_update(self, account_id: str, media_buy_id: str, patch: Any) -> None: - del account_id, media_buy_id, patch # no-op pass-through + # ``context`` is echoed from the create request; an update never + # replaces it. + if self._overlays and self._inner is not None: + await _await_maybe(self._inner.merge_from_update(account_id, media_buy_id, patch)) async def backfill(self, account_id: str, result: Any) -> Any: - del account_id + if self._overlays and self._inner is not None: + result = await _await_maybe(self._inner.backfill(account_id, result)) + if self._contexts is not None and isinstance(result, dict): + ids = list( + dict.fromkeys( + buy["media_buy_id"] + for buy in _dict_items(result.get("media_buys")) + if isinstance(buy.get("media_buy_id"), str) + ) + ) + if ids: + try: + stored = await _await_maybe(self._contexts.load_contexts(account_id, ids)) + except Exception: + logger.exception( + "MediaBuyContextStore.load_contexts failed; " + "get_media_buys returned without the persisted context" + ) + else: + _fill_contexts(result, stored or {}) return result def create_media_buy_store( - adopter_store: MediaBuyStore, + adopter_store: MediaBuyStore | None = None, *, capabilities: DecisioningCapabilities, + context_store: MediaBuyContextStore | None = None, + echo_context: bool = True, ) -> MediaBuyStore: - """Wrap an adopter :class:`MediaBuyStore` with specialism-aware - ``targeting_overlay`` echo gating. - - :param adopter_store: The persistence + echo implementation. Must - satisfy the :class:`MediaBuyStore` Protocol — three methods - (``persist_from_create``, ``merge_from_update``, ``backfill``) - sync or async. + """Build the :class:`MediaBuyStore` the framework calls around + ``create_media_buy``, ``update_media_buy`` and ``get_media_buys``. + + :param adopter_store: The ``targeting_overlay`` persistence + echo + implementation. Must satisfy the :class:`MediaBuyStore` Protocol — + three methods (``persist_from_create``, ``merge_from_update``, + ``backfill``) sync or async. Invoked only when + ``capabilities.specialisms`` intersects + ``{property-lists, collection-lists}``, and required then. Omit it + when the seller claims neither: ``context`` echo needs no adopter + code. :param capabilities: The seller's :class:`DecisioningCapabilities`. - Read once at construction to decide whether the wrapper - delegates or no-ops; not re-read per request, so adopters who - mutate ``capabilities.specialisms`` after building the store - won't see the change reflected. Build-time decision matches the - boot-time validation pattern used elsewhere - (``validate_platform``). - - :returns: A :class:`MediaBuyStore` wrapper. When ``capabilities.specialisms`` - intersects ``{property-lists, collection-lists}``, every method - delegates to ``adopter_store``. Otherwise every method is a - no-op pass-through and the adopter store is never invoked. - - The returned object is always distinct from ``adopter_store`` — even - on the no-op path — so adopters can reason about identity at the - assignment site (``platform.media_buy_store = ...``) without - aliasing the underlying persistence layer. + Read once at construction to decide whether overlay calls delegate + or no-op; not re-read per request, so adopters who mutate + ``capabilities.specialisms`` after building the store won't see the + change reflected. Build-time decision matches the boot-time + validation pattern used elsewhere (``validate_platform``). + :param context_store: Where buy- and package-level ``context`` from + ``create_media_buy`` is persisted for the ``get_media_buys`` echo. + Defaults to a fresh :class:`InMemoryMediaBuyContextStore`; pass + :class:`adcp.decisioning.pg.PgMediaBuyContextStore` for a durable, + multi-worker echo. + :param echo_context: ``False`` disables ``context`` persistence and + echo, for a platform that persists and echoes ``context`` itself. + A ``context`` the platform returns is never overwritten either way. + + :returns: A :class:`MediaBuyStore` wrapper, always distinct from + ``adopter_store`` so adopters can reason about identity at the + assignment site (``platform.media_buy_store = ...``) without + aliasing the underlying persistence layer. + + :raises ValueError: if the seller claims ``property-lists`` or + ``collection-lists`` but no ``adopter_store`` was supplied. """ # ``capabilities.specialisms`` is ``list[Specialism | str]`` per #479 # — spec-known slugs are coerced to ``Specialism`` enum members at @@ -196,6 +409,13 @@ def create_media_buy_store( entry.value if hasattr(entry, "value") else entry for entry in capabilities.specialisms } claimed = declared_slugs & _OVERLAY_ECHO_SPECIALISMS - if claimed: - return _ActiveMediaBuyStore(adopter_store) - return _NoopMediaBuyStore(adopter_store) + if claimed and adopter_store is None: + raise ValueError( + f"create_media_buy_store: the seller claims {sorted(claimed)}, which " + "requires an adopter MediaBuyStore to persist and echo " + "targeting_overlay on get_media_buys" + ) + contexts: MediaBuyContextStore | None = None + if echo_context: + contexts = context_store if context_store is not None else InMemoryMediaBuyContextStore() + return _MediaBuyStoreWrapper(adopter_store, overlays=bool(claimed), contexts=contexts) diff --git a/src/adcp/decisioning/pg/__init__.py b/src/adcp/decisioning/pg/__init__.py index cf4c7f254..c6d02ede3 100644 --- a/src/adcp/decisioning/pg/__init__.py +++ b/src/adcp/decisioning/pg/__init__.py @@ -18,6 +18,9 @@ :class:`~adcp.decisioning.InMemoryTaskRegistry` that satisfies the production-mode durability gate. (``PostgresTaskRegistry`` is the pre-4.4 name and remains as a deprecated alias through 4.4.x.) +* :class:`PgMediaBuyContextStore` — durable + :class:`~adcp.decisioning.MediaBuyContextStore`: the ``create_media_buy`` + ``context`` (buy and package level) that ``get_media_buys`` echoes. * :class:`PgTaskWebhookOutbox` — atomic terminal task-webhook publication coupled to ``PgTaskRegistry``, with lease-based workers and 1–7 day retry retention. @@ -43,6 +46,7 @@ LazyTaskWebhookOutbox, LazyTaskWebhookOutboxFactory, ) +from adcp.decisioning.pg.media_buy_context_store import PgMediaBuyContextStore from adcp.decisioning.pg.proposal_store import PgProposalStore from adcp.decisioning.pg.task_registry import ( PgTaskRegistry, @@ -61,6 +65,7 @@ "LazyTaskWebhookOutbox", "LazyTaskWebhookOutboxFactory", "PgBuyerAgentRegistry", + "PgMediaBuyContextStore", "PgProposalStore", "PgTaskRegistry", "PgTaskWebhookOutbox", diff --git a/src/adcp/decisioning/pg/media_buy_context_store.py b/src/adcp/decisioning/pg/media_buy_context_store.py new file mode 100644 index 000000000..b6d22ca05 --- /dev/null +++ b/src/adcp/decisioning/pg/media_buy_context_store.py @@ -0,0 +1,168 @@ +"""PostgreSQL-backed :class:`~adcp.decisioning.MediaBuyContextStore`. + +Durable counterpart to :class:`~adcp.decisioning.InMemoryMediaBuyContextStore`: +the ``context`` a ``create_media_buy`` request carried — buy-level and per +package — survives restarts and is shared by every worker, so +``get_media_buys`` echoes it wherever the read lands. + +The caller supplies an :class:`psycopg_pool.AsyncConnectionPool`. We don't +open, own, or close the pool. + +:: + + from adcp.decisioning import create_media_buy_store + from adcp.decisioning.pg import PgMediaBuyContextStore + + context_store = PgMediaBuyContextStore(pool=pool) + await context_store.create_schema() # idempotent; safe on every boot + platform.media_buy_store = create_media_buy_store( + capabilities=platform.capabilities, context_store=context_store + ) + +Storage +------- + +One row per echoed object: ``scope = 'media_buy'`` (with ``package_id = ''``) +for the buy-level ``context``, ``scope = 'package'`` for each package's. The +``context`` column is ``JSON``, not ``JSONB``: ``JSON`` keeps the text the SDK +wrote, so key order, duplicate-free spelling and number lexemes (``7.0`` +stays ``7.0``) round-trip as sent, where ``JSONB`` would re-order keys and +normalize numbers. Rows are written once (``ON CONFLICT DO NOTHING``): the +create request's ``context`` is echoed unchanged for the life of the buy. + +This is a new table. Nothing existing changes, so there is no data +migration; create it with :meth:`create_schema`, with the +:meth:`migration_sql` statements in your migration tool, or by applying +:file:`media_buy_context_store.sql` (default table name only). + +Every query filters on ``account_id``; multi-tenant sellers encode tenant +scope into ``Account.id`` as for the other decisioning stores. +""" + +from __future__ import annotations + +import json +import re +from collections.abc import Sequence +from typing import TYPE_CHECKING, Any + +from adcp.decisioning.media_buy_store import MediaBuyContexts + +if TYPE_CHECKING: + from psycopg_pool import AsyncConnectionPool + +try: + from psycopg_pool import AsyncConnectionPool as _AsyncConnectionPool # noqa: F401 + + PG_AVAILABLE = True +except ImportError: + PG_AVAILABLE = False + +_INSTALL_HINT = ( + "PgMediaBuyContextStore requires psycopg3 and psycopg-pool. " + "Install the 'pg' extra: `pip install 'adcp[pg]'`." +) + +DEFAULT_TABLE_NAME = "adcp_media_buy_contexts" + +# The table name is static-formatted into SQL; this ASCII-only guard is the +# only protection against injection or homoglyph substitution. +_SAFE_IDENTIFIER_RE = re.compile(r"^[a-z_][a-z0-9_]{0,62}$") + +_MEDIA_BUY_SCOPE = "media_buy" +_PACKAGE_SCOPE = "package" + + +def _checked_table(table_name: str) -> str: + if not _SAFE_IDENTIFIER_RE.fullmatch(table_name): + raise ValueError( + f"table_name must match [a-z_][a-z0-9_]{{0,62}} (ASCII only), got {table_name!r}" + ) + return table_name + + +def _encode(context: dict[str, Any]) -> str: + return json.dumps(context, ensure_ascii=False, allow_nan=False) + + +def _ddl(table: str) -> str: + return ( + f"CREATE TABLE IF NOT EXISTS {table} (\n" + f' account_id TEXT COLLATE "C" NOT NULL,\n' + f' media_buy_id TEXT COLLATE "C" NOT NULL,\n' + f" scope TEXT NOT NULL CHECK (scope IN ('media_buy', 'package')),\n" + f" package_id TEXT COLLATE \"C\" NOT NULL DEFAULT '',\n" + f" context JSON NOT NULL,\n" + f" created_at TIMESTAMPTZ NOT NULL DEFAULT now(),\n" + f" PRIMARY KEY (account_id, media_buy_id, scope, package_id),\n" + f" CHECK ((scope = 'media_buy') = (package_id = ''))\n" + f")" + ) + + +class PgMediaBuyContextStore: + """Durable :class:`~adcp.decisioning.MediaBuyContextStore` on PostgreSQL.""" + + def __init__(self, *, pool: AsyncConnectionPool, table_name: str = DEFAULT_TABLE_NAME) -> None: + if not PG_AVAILABLE: + raise ImportError(_INSTALL_HINT) + self._pool = pool + self._table = _checked_table(table_name) + + @classmethod + def migration_sql(cls, *, table_name: str = DEFAULT_TABLE_NAME) -> dict[str, str]: + """``{"upgrade": ..., "downgrade": ...}`` statements for a migration tool.""" + table = _checked_table(table_name) + return {"upgrade": _ddl(table) + ";\n", "downgrade": f"DROP TABLE IF EXISTS {table};\n"} + + async def create_schema(self) -> None: + """Create the table if it does not exist. Idempotent.""" + async with self._pool.connection() as conn: + await conn.execute(_ddl(self._table)) + + async def save_contexts( + self, account_id: str, media_buy_id: str, contexts: MediaBuyContexts + ) -> None: + rows: list[tuple[str, str, str, str, str]] = [] + if contexts.context is not None: + rows.append((account_id, media_buy_id, _MEDIA_BUY_SCOPE, "", _encode(contexts.context))) + for package_id, context in contexts.package_contexts.items(): + rows.append((account_id, media_buy_id, _PACKAGE_SCOPE, package_id, _encode(context))) + if not rows: + return + sql = ( # noqa: S608 — table name validated at construction + f"INSERT INTO {self._table} " # nosec B608 + f"(account_id, media_buy_id, scope, package_id, context) " + f"VALUES (%s, %s, %s, %s, %s::json) " + f"ON CONFLICT (account_id, media_buy_id, scope, package_id) DO NOTHING" + ) + async with self._pool.connection() as conn: + async with conn.cursor() as cur: + await cur.executemany(sql, rows) + + async def load_contexts( + self, account_id: str, media_buy_ids: Sequence[str] + ) -> dict[str, MediaBuyContexts]: + if not media_buy_ids: + return {} + sql = ( # noqa: S608 — table name validated at construction + f"SELECT media_buy_id, scope, package_id, context::text FROM {self._table} " # nosec B608 + f"WHERE account_id = %s AND media_buy_id = ANY(%s)" + ) + buy_contexts: dict[str, dict[str, Any]] = {} + package_contexts: dict[str, dict[str, dict[str, Any]]] = {} + async with self._pool.connection() as conn: + cur = await conn.execute(sql, (account_id, list(media_buy_ids))) + for media_buy_id, scope, package_id, text in await cur.fetchall(): + context = json.loads(text) + if scope == _MEDIA_BUY_SCOPE: + buy_contexts[media_buy_id] = context + else: + package_contexts.setdefault(media_buy_id, {})[package_id] = context + return { + media_buy_id: MediaBuyContexts( + context=buy_contexts.get(media_buy_id), + package_contexts=package_contexts.get(media_buy_id, {}), + ) + for media_buy_id in buy_contexts.keys() | package_contexts.keys() + } diff --git a/src/adcp/decisioning/pg/media_buy_context_store.sql b/src/adcp/decisioning/pg/media_buy_context_store.sql new file mode 100644 index 000000000..cbc2679aa --- /dev/null +++ b/src/adcp/decisioning/pg/media_buy_context_store.sql @@ -0,0 +1,23 @@ +-- AdCP get_media_buys context echo — durable MediaBuyContextStore backing. +-- +-- Persists the context a create_media_buy request carried (buy-level and per +-- package) so get_media_buys echoes it unchanged. A new table: applying it +-- changes nothing that exists. Tracked by PgMediaBuyContextStore; see +-- src/adcp/decisioning/pg/media_buy_context_store.py. +-- +-- Alternatively, call PgMediaBuyContextStore.create_schema() from application +-- code, or use PgMediaBuyContextStore.migration_sql() for a custom table name. +-- +-- context is JSON (not JSONB) so key order and number spelling round-trip +-- exactly. COLLATE "C" on identifiers avoids locale-dependent case folding. + +CREATE TABLE IF NOT EXISTS adcp_media_buy_contexts ( + account_id TEXT COLLATE "C" NOT NULL, + media_buy_id TEXT COLLATE "C" NOT NULL, + scope TEXT NOT NULL CHECK (scope IN ('media_buy', 'package')), + package_id TEXT COLLATE "C" NOT NULL DEFAULT '', + context JSON NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (account_id, media_buy_id, scope, package_id), + CHECK ((scope = 'media_buy') = (package_id = '')) +); diff --git a/src/adcp/types/base.py b/src/adcp/types/base.py index 1bfc4832d..0cf217438 100644 --- a/src/adcp/types/base.py +++ b/src/adcp/types/base.py @@ -20,6 +20,7 @@ BeforeValidator, ConfigDict, GetCoreSchemaHandler, + SerializationInfo, SerializerFunctionWrapHandler, StrictInt, StrictStr, @@ -415,6 +416,35 @@ def __hash__(self) -> int: NoExplicitNull = _NoExplicitNull() +def _keep_opaque_nulls( + value: dict[str, Any], extra: dict[str, Any], info: SerializationInfo +) -> dict[str, Any]: + """Put back the ``null`` values ``exclude_none`` dropped from an opaque object. + + A model that declares no fields and allows extras is an opaque JSON object: + ``core/context.json`` (``ContextObject``), which every response echoes + unchanged, and ``core/ext.json``. ``exclude_none`` -- the + :meth:`AdCPBaseModel.model_dump` default -- exists to leave *unset + optional fields* off the wire, but it also dropped the object's own + ``null`` values, so a buyer's ``{"a": null, "b": 1}`` came back as + ``{"b": 1}``. An opaque object has no unset fields, so every key it holds + is data. Keys the caller excluded by name stay excluded; nested values are + plain JSON, which pydantic already dumps with their nulls. + """ + include, exclude = info.include, info.exclude + kept: dict[str, Any] = {} + for key, item in extra.items(): + if key in value: + kept[key] = value[key] + elif ( + item is None + and (include is None or key in include) + and (exclude is None or key not in exclude) + ): + kept[key] = None + return kept + + class AdCPBaseModel(BaseModel): """Base model for AdCP types with spec-compliant serialization. @@ -451,16 +481,22 @@ class AdCPBaseModel(BaseModel): # this serializer's return is unannotated. Even ``-> Any`` erases the shape. @model_serializer(mode="wrap") def _notification_config_wire_defaults( # type: ignore[no-untyped-def] - self, handler: SerializerFunctionWrapHandler + self, handler: SerializerFunctionWrapHandler, info: SerializationInfo ): """Retain the unrelated product default only for product subscriptions. Reporting capability defaults and tier validation live in their own generated models; this wrapper does not mask reporting attributes. + + Also keeps an opaque object's ``null`` values (see + :func:`_keep_opaque_nulls`). """ value = handler(self) if not isinstance(value, dict): return value + extra = self.__pydantic_extra__ + if extra and info.exclude_none and not type(self).model_fields: + return _keep_opaque_nulls(value, extra, info) # Bundled schemas can rename or inline the same wire shape. Match its # declared fields rather than generated class/module names; supplied # values still come exclusively from this instance's fields-set. diff --git a/tests/test_media_buy_context_echo.py b/tests/test_media_buy_context_echo.py new file mode 100644 index 000000000..0b650a525 --- /dev/null +++ b/tests/test_media_buy_context_echo.py @@ -0,0 +1,363 @@ +"""``get_media_buys`` echoes the ``context`` its media buys were created with. + +AdCP 3.2 ``get_media_buys`` echoes, on each media buy and each package, the +``context`` of the ``create_media_buy`` request (and package request) that +created it. ``create_media_buy_store`` persists both and backfills them, on +by default. These tests run ``create_media_buy`` then ``get_media_buys`` +through the real MCP and A2A servers, against the in-memory and PostgreSQL +context stores, and compare the echoed objects as JSON text so ``null`` +values and number types count. +""" + +from __future__ import annotations + +import json +import os +import secrets +from collections.abc import AsyncIterator +from typing import Any, ClassVar + +import pytest + +from adcp.decisioning import ( + DecisioningCapabilities, + DecisioningPlatform, + InMemoryMediaBuyContextStore, + MediaBuyContexts, + MediaBuyContextStore, + SingletonAccounts, + create_media_buy_store, +) +from adcp.server import ADCPHandler +from adcp.testing import build_test_client +from adcp.types import ContextObject, CreateMediaBuySuccessResponse, GetProductsResponse, Package + +BUY_CONTEXT: dict[str, Any] = { + "campaign": "spring-26", + "nil": None, + "n": 7, + "ratio": 2.5, + "nested": {"x": None, "l": [1, None, "z"]}, +} +PACKAGE_CONTEXTS: list[dict[str, Any]] = [ + {"buyer_ref": "line-1", "gone": None}, + {"buyer_ref": "line-2", "seq": 2}, +] + + +def _json(value: Any) -> str: + return json.dumps(value, sort_keys=True) + + +@pytest.fixture(params=["memory", "postgres"]) +async def context_store(request: pytest.FixtureRequest) -> AsyncIterator[MediaBuyContextStore]: + if request.param == "memory": + yield InMemoryMediaBuyContextStore() + return + url = os.environ.get("ADCP_PG_TEST_URL") + if not url: + pytest.skip("ADCP_PG_TEST_URL required") + from psycopg_pool import AsyncConnectionPool + + from adcp.decisioning.pg import PgMediaBuyContextStore + + table = f"test_mb_ctx_{secrets.token_hex(6)}" + async with AsyncConnectionPool(url, open=False) as pool: + store = PgMediaBuyContextStore(pool=pool, table_name=table) + await store.create_schema() + await store.create_schema() # idempotent + yield store + async with pool.connection() as conn: + await conn.execute(f"DROP TABLE {table}") + + +class Seller(DecisioningPlatform): + """Creates buys without echoing context and reads them back without it.""" + + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], channels=["display"], pricing_models=["cpm"] + ) + accounts = SingletonAccounts(account_id="acct_1") + + def __init__(self, *, reverse: bool = True) -> None: + self.reverse = reverse + self.buys: dict[str, list[tuple[str, str]]] = {} + + def create_media_buy(self, req: Any, ctx: Any) -> CreateMediaBuySuccessResponse: + media_buy_id = f"mb_{len(self.buys) + 1}" + packages = [ + (f"{media_buy_id}_pkg_{i}", p.product_id) for i, p in enumerate(req.packages or []) + ] + self.buys[media_buy_id] = packages + # Answer in reverse order, matched only by product_id. + ordered = list(reversed(packages)) if self.reverse else packages + return CreateMediaBuySuccessResponse( + media_buy_id=media_buy_id, + confirmed_at="2026-05-01T00:00:00Z", + revision=1, + status="active", + packages=[Package(package_id=pid, product_id=prod) for pid, prod in ordered], + ) + + def get_media_buys(self, req: Any, ctx: Any) -> dict[str, Any]: + return { + "media_buys": [ + { + "media_buy_id": media_buy_id, + "status": "active", + "currency": "USD", + "total_budget": 2000, + "confirmed_at": "2026-05-01T00:00:00Z", + "revision": 1, + "packages": [{"package_id": pid, "product_id": prod} for pid, prod in pkgs], + } + for media_buy_id, pkgs in self.buys.items() + ] + } + + def update_media_buy(self, media_buy_id: Any, patch: Any, ctx: Any) -> Any: + raise NotImplementedError + + def sync_creatives(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + def get_media_buy_delivery(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + def get_products(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + +def _create_request(context: dict[str, Any] | None = BUY_CONTEXT) -> dict[str, Any]: + request: dict[str, Any] = { + "idempotency_key": f"idem-{secrets.token_hex(8)}", + "account": {"account_id": "acct_1"}, + "brand": {"domain": "brand.example"}, + "start_time": "2026-10-01T00:00:00Z", + "end_time": "2026-10-31T00:00:00Z", + "packages": [ + { + "product_id": f"prod_{i}", + "pricing_option_id": "cpm_1", + "budget": 1000, + "context": package_context, + } + for i, package_context in enumerate(PACKAGE_CONTEXTS) + ], + } + if context is not None: + request["context"] = context + return request + + +async def _call(client: Any, transport: str, tool: str, arguments: dict[str, Any]) -> Any: + if transport == "a2a": + response = await client.post( + "/", + json={ + "jsonrpc": "2.0", + "id": secrets.token_hex(4), + "method": "message/send", + "params": { + "message": { + "messageId": secrets.token_hex(8), + "role": "user", + "parts": [ + {"kind": "data", "data": {"skill": tool, "parameters": arguments}} + ], + } + }, + }, + ) + assert response.status_code == 200, response.text + result = response.json()["result"] + return next(p["data"] for p in result["artifacts"][0]["parts"] if "data" in p) + response = await client.post( + "/mcp/", + headers={"accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": tool, "arguments": arguments}, + }, + ) + assert response.status_code == 200, response.text + return response.json()["result"]["structuredContent"] + + +@pytest.mark.parametrize("transport", ["mcp", "a2a"]) +async def test_get_media_buys_echoes_create_context( + context_store: MediaBuyContextStore, transport: str +) -> None: + seller = Seller() + store = create_media_buy_store(capabilities=seller.capabilities, context_store=context_store) + async with build_test_client( + seller, transport=transport, stateless_http=True, media_buy_store=store + ) as client: + created = await _call(client, transport, "create_media_buy", _create_request()) + assert "adcp_error" not in created, created + read = await _call( + client, + transport, + "get_media_buys", + {"account": {"account_id": "acct_1"}, "context": {"poll": 1}}, + ) + assert "adcp_error" not in read, read + assert read["context"] == {"poll": 1} + (buy,) = read["media_buys"] + assert _json(buy["context"]) == _json(BUY_CONTEXT) + by_product = {p["product_id"]: p for p in buy["packages"]} + for i, package_context in enumerate(PACKAGE_CONTEXTS): + assert _json(by_product[f"prod_{i}"]["context"]) == _json(package_context) + + +async def test_buy_without_context_echoes_only_package_contexts( + context_store: MediaBuyContextStore, +) -> None: + seller = Seller(reverse=False) + store = create_media_buy_store(capabilities=seller.capabilities, context_store=context_store) + async with build_test_client(seller, stateless_http=True, media_buy_store=store) as client: + await _call(client, "mcp", "create_media_buy", _create_request(context=None)) + read = await _call(client, "mcp", "get_media_buys", {"account": {"account_id": "acct_1"}}) + (buy,) = read["media_buys"] + assert "context" not in buy + assert [p["context"] for p in buy["packages"]] == PACKAGE_CONTEXTS + + +async def test_echo_context_false_leaves_reads_alone() -> None: + seller = Seller() + store = create_media_buy_store(capabilities=seller.capabilities, echo_context=False) + async with build_test_client(seller, stateless_http=True, media_buy_store=store) as client: + await _call(client, "mcp", "create_media_buy", _create_request()) + read = await _call(client, "mcp", "get_media_buys", {"account": {"account_id": "acct_1"}}) + (buy,) = read["media_buys"] + assert "context" not in buy + assert all("context" not in p for p in buy["packages"]) + + +async def test_store_is_first_write_wins_and_account_scoped( + context_store: MediaBuyContextStore, +) -> None: + """A replayed create can't replace the echoed objects; reads never cross accounts.""" + first = MediaBuyContexts(context={"v": 1, "f": 7.0}, package_contexts={"p1": {"a": None}}) + replay = MediaBuyContexts(context={"v": 2}, package_contexts={"p1": {"a": 1}, "p2": {"b": 2}}) + await _maybe(context_store.save_contexts("acct_a", "mb_1", first)) + await _maybe(context_store.save_contexts("acct_a", "mb_1", replay)) + stored = await _maybe(context_store.load_contexts("acct_a", ["mb_1", "mb_missing"])) + assert list(stored) == ["mb_1"] + record = stored["mb_1"] + # ``7.0`` keeps its spelling: the store returns the JSON it was given. + assert _json(record.context) == _json({"v": 1, "f": 7.0}) + assert _json(dict(record.package_contexts)) == _json({"p1": {"a": None}, "p2": {"b": 2}}) + assert await _maybe(context_store.load_contexts("acct_b", ["mb_1"])) == {} + + +async def _maybe(value: Any) -> Any: + import inspect + + return await value if inspect.isawaitable(value) else value + + +def test_pg_sql_file_matches_migration_sql() -> None: + from pathlib import Path + + import adcp.decisioning.pg as pg_package + from adcp.decisioning.pg.media_buy_context_store import PgMediaBuyContextStore + + sql = Path(pg_package.__file__).with_name("media_buy_context_store.sql").read_text() + body = "\n".join(line for line in sql.splitlines() if not line.startswith("--")).strip() + assert body == PgMediaBuyContextStore.migration_sql()["upgrade"].strip() + + +def test_claiming_overlay_specialism_still_requires_an_adopter_store() -> None: + with pytest.raises(ValueError, match="property-lists"): + create_media_buy_store(capabilities=DecisioningCapabilities(specialisms=["property-lists"])) + + +# --------------------------------------------------------------------------- +# ContextObject keeps its nulls on every SDK dump path (#1575 footgun) +# --------------------------------------------------------------------------- + + +def test_context_object_dump_keeps_null_values() -> None: + context = ContextObject.model_validate({"a": None, "b": 1, "n": {"x": None}}) + assert context.model_dump() == {"a": None, "b": 1, "n": {"x": None}} + assert json.loads(context.model_dump_json()) == {"a": None, "b": 1, "n": {"x": None}} + response = GetProductsResponse(products=[], context=context) + assert response.model_dump(mode="json")["context"] == {"a": None, "b": 1, "n": {"x": None}} + # An explicit exclusion still wins. + assert context.model_dump(exclude={"a"}) == {"b": 1, "n": {"x": None}} + + +class _TypedEchoHandler(ADCPHandler[Any]): + """Returns a typed response that copies the request's ``context``.""" + + advertised_tools: ClassVar[set[str]] = {"get_adcp_capabilities", "get_products"} + + async def get_adcp_capabilities(self, params: Any, context: Any = None) -> dict[str, Any]: + from adcp.server.responses import capabilities_response + + return capabilities_response(["media_buy"]) + + async def get_products(self, params: Any, context: Any = None) -> GetProductsResponse: + raw = params if isinstance(params, dict) else params.model_dump(exclude_none=False) + return GetProductsResponse(products=[], context=raw.get("context")) + + +@pytest.mark.parametrize("transport", ["mcp", "a2a"]) +def test_typed_response_context_keeps_nulls_on_the_wire(transport: str) -> None: + from starlette.testclient import TestClient + + from adcp.server.serve import _build_mcp_and_a2a_app + + context = {"a": None, "b": "x"} + app = _build_mcp_and_a2a_app( + _TypedEchoHandler(), + name="typed-echo", + port=3001, + host="127.0.0.1", + instructions=None, + test_controller=None, + stateless_http=True, + allowed_hosts=["testserver"], + ) + arguments = {"buying_mode": "brief", "brief": "video", "context": context} + with TestClient(app, base_url="http://localhost:3001") as client: + if transport == "mcp": + response = client.post( + "/mcp/", + headers={"accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "get_products", "arguments": arguments}, + }, + ) + data = response.json()["result"]["structuredContent"] + else: + response = client.post( + "/", + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "message/send", + "params": { + "message": { + "messageId": "m-typed", + "role": "user", + "parts": [ + { + "kind": "data", + "data": {"skill": "get_products", "parameters": arguments}, + } + ], + } + }, + }, + ) + parts = response.json()["result"]["artifacts"][0]["parts"] + data = next(p["data"] for p in parts if "data" in p) + assert "adcp_error" not in data, data + assert data["context"] == context From 1086cd90f70e760873b445f15db5cdcb0d8aeeed Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 06:53:45 -0400 Subject: [PATCH 33/54] fix(types): leave unset optional fields off the wire from any parent (#1591) A nested AdCP model inside a plain Pydantic parent dumped its unset optional fields as null, because the parent's serializer never calls AdCPBaseModel.model_dump's exclude_none default, and 9.0.0 (#1555) refuses those nulls on read. NoExplicitNull now also carries exclude_if, so every field whose schema forbids null drops its own None from any parent, container or RootModel. The published JSON Schema is unchanged. Behaviour note: model_dump(exclude_none=False) no longer emits null for those fields, whose null is invalid on the wire. Fixes #1587 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/validation-contract.md | 13 + src/adcp/decisioning/handler.py | 9 +- src/adcp/reporting/_reconcile.py | 3 +- src/adcp/types/_product_format_declaration.py | 4 +- src/adcp/types/base.py | 118 +++-- src/adcp/types/canonical_creative.py | 11 +- tests/test_forward_compat_format_kind.py | 7 +- ...test_nested_no_explicit_null_round_trip.py | 467 ++++++++++++++++++ tests/test_no_explicit_null.py | 12 +- tests/test_reporting_frozen_read_safety.py | 5 +- tests/test_targeting_overlay_compat.py | 9 +- 11 files changed, 603 insertions(+), 55 deletions(-) create mode 100644 tests/test_nested_no_explicit_null_round_trip.py diff --git a/docs/validation-contract.md b/docs/validation-contract.md index f8e532f4e..738c8331e 100644 --- a/docs/validation-contract.md +++ b/docs/validation-contract.md @@ -30,6 +30,19 @@ off the wire — the same document an omitted property produces. Set `ADCP_STRICT_VALIDATION=1` (the switch that also forbids unknown fields) to refuse a Python `None` as well, for suites that want to catch it. +The omission is the field's own, not the dump call's. A marked field carries +`exclude_if` (pydantic-core level), so a `None` there is left off whoever +drives the serialization: an AdCP model nested in an ordinary Pydantic +parent — a direct field, a list, a dict, a `RootModel` over an AdCP union — +dumps exactly as it does on its own, and `model_validate_json(model_dump_json())` +round-trips from any parent (#1587). That holds under an explicit +`exclude_none=False` too: `null` is never a valid value for a marked property, +so there is nothing to keep. A property whose schema admits `null` is not +marked and still emits `null` under `exclude_none=False`, from a parent or +directly. One consequence for subclasses: a `field_serializer` that substitutes +a default for `None` no longer runs for a `None` value on a marked field, +because `exclude_if` drops the field before the serializer is reached. + ## Wire-boundary behavior - SDK clients validate requests before sending and responses after receiving, diff --git a/src/adcp/decisioning/handler.py b/src/adcp/decisioning/handler.py index d7bc4d74d..c4d33fcdc 100644 --- a/src/adcp/decisioning/handler.py +++ b/src/adcp/decisioning/handler.py @@ -1205,9 +1205,12 @@ def _to_store_dict(value: Any, *, patch: bool = False) -> Any: cosmetic: * ``patch=True`` (a caller-supplied mutation input) uses - ``exclude_unset=True``. Explicit nulls survive, because the merge - contract below treats ``None`` as "clear this field" — but fields the - caller never mentioned are *dropped*. Dumping them as ``None`` handed + ``exclude_unset=True``. Explicit nulls survive where the schema admits + ``null``, because the merge contract below treats ``None`` as "clear + this field" — but fields the caller never mentioned are *dropped*. A + field whose schema admits no ``null`` (``NoExplicitNull``) never emits + one, whatever ``exclude_none`` says (#1587): ``null`` is not a valid + clear there, so a ``None`` on it reads as "not mentioned". Dumping them as ``None`` handed the adopter store a patch asserting "clear every targeting dimension, remove the aggregate frequency cap, drop every package budget", none of which the caller asked for. Per ``core/targeting-input.json``, omission diff --git a/src/adcp/reporting/_reconcile.py b/src/adcp/reporting/_reconcile.py index 2a0193c47..b6fbbe775 100644 --- a/src/adcp/reporting/_reconcile.py +++ b/src/adcp/reporting/_reconcile.py @@ -306,7 +306,8 @@ def _revision_matches_obligation( def _record_json(value: BaseModel) -> str: - # Preserve optional-field presence for immutable typed-page comparisons. + # Preserve optional-field presence for immutable typed-page comparisons + # (a ``NoExplicitNull`` field's ``None`` reads as absent, as on the wire). # This is NOT the received canonical bytes of adjustment evidence. return _json(value.model_dump(mode="json", exclude_unset=True, exclude_none=False)) diff --git a/src/adcp/types/_product_format_declaration.py b/src/adcp/types/_product_format_declaration.py index 6c2bf7b01..623bbe94c 100644 --- a/src/adcp/types/_product_format_declaration.py +++ b/src/adcp/types/_product_format_declaration.py @@ -43,7 +43,9 @@ def check_declaration_rules(data: Any, *, schema_name: str) -> 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. + # nulls retain their presence where the schema admits ``null``: root + # rules test required, not truthiness. A ``NoExplicitNull`` field never + # emits one (#1587). document = data.model_dump(mode="json", exclude_unset=True, exclude_none=False) elif isinstance(data, dict): document = data diff --git a/src/adcp/types/base.py b/src/adcp/types/base.py index 0cf217438..81ae15e28 100644 --- a/src/adcp/types/base.py +++ b/src/adcp/types/base.py @@ -11,7 +11,7 @@ import os from collections.abc import Callable -from typing import TYPE_CHECKING, Annotated, Any, Literal, cast +from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, cast from pydantic import ( AfterValidator, @@ -28,6 +28,7 @@ WithJsonSchema, model_serializer, ) +from pydantic.fields import FieldInfo from pydantic_core import PydanticSerializationError, core_schema from adcp._deferred_adapters import deferred_adapter @@ -340,40 +341,28 @@ def _without_nullable(schema: Any) -> core_schema.CoreSchema: return cast(core_schema.CoreSchema, schema) -class _NoExplicitNull: - """Annotated metadata: the property may be omitted, but a JSON ``null`` is refused. +def _is_none(value: Any) -> bool: + """``exclude_if`` for a :data:`NoExplicitNull` field: ``None`` is "not supplied".""" + return value is None - A JSON Schema property outside ``required`` whose type does not admit - ``null`` permits exactly one way of not supplying it — leaving it out. - The generator renders every optional property as ``T | None = None``, - and that annotation is the right Python surface: the attribute reads as - ``None`` when the property was omitted, and ``model_dump`` (which - defaults to ``exclude_none=True``) leaves it off the wire again. What - the annotation got wrong is *input*: it also admitted an explicit - ``null``, which the schema — and the SDK's own JSON-Schema arm, - :func:`adcp.validation.validate_request` — rejects. - This marker closes that gap without changing the annotation. It builds - a ``json_or_python`` core schema: the JSON side is the type without its - null arm, so a document carrying ``null`` fails with the type's own +class _RefuseJsonNull: + """The validation half of :data:`NoExplicitNull`: a JSON ``null`` is refused. + + Builds a ``json_or_python`` core schema: the JSON side is the type without + its null arm, so a document carrying ``null`` fails with the type's own error (``list_type``, ``model_type``, ...) exactly as a required field - would, and ``model_json_schema()`` publishes no null arm; the Python - side keeps ``T | None``, so ``Model(field=None)`` and ``model_validate`` - on a dict still read ``None`` as the SDK's spelling of "not supplied", - symmetric with ``model_dump``. The ``= None`` default is not validated, - so omission reads back as ``None`` either way. No Python function sits - in the validation path, so nested validation keeps pydantic's native - JSON handling (duplicate-key diagnostics, error order). + would, and ``model_json_schema()`` publishes no null arm; the Python side + keeps ``T | None``, so ``Model(field=None)`` and ``model_validate`` on a + dict still read ``None`` as the SDK's spelling of "not supplied". The + ``= None`` default is not validated, so omission reads back as ``None`` + either way. No Python function sits in the validation path, so nested + validation keeps pydantic's native JSON handling (duplicate-key + diagnostics, error order). ``ADCP_STRICT_VALIDATION`` — the switch that also forbids unknown fields — makes the Python side refuse ``None`` too, for suites that want - to catch a caller spelling absence as an explicit ``None``. A property - whose schema *does* admit ``null`` is generated without the marker and - keeps accepting it. - - The generator marks every such property from the schema - (``scripts/generate_types.py``, ``mark_no_explicit_null_properties``) - and ``scripts/post_generate_fixes.py`` points the mark here. See #1547. + to catch a caller spelling absence as an explicit ``None``. """ __slots__ = () @@ -395,25 +384,82 @@ def __repr__(self) -> str: # redeclaration takes ``copy.deepcopy`` of the generated field # (``canonical_creative._inherit``), and a test asks ``NoExplicitNull in # field.metadata``. - def __copy__(self) -> _NoExplicitNull: + def __copy__(self) -> _RefuseJsonNull: return self - def __deepcopy__(self, memo: dict[int, Any]) -> _NoExplicitNull: + def __deepcopy__(self, memo: dict[int, Any]) -> _RefuseJsonNull: return self def __reduce__(self) -> str: - return "NoExplicitNull" + return "_REFUSE_JSON_NULL" def __eq__(self, other: object) -> bool: - return isinstance(other, _NoExplicitNull) + return isinstance(other, _RefuseJsonNull) def __hash__(self) -> int: - return hash(_NoExplicitNull) + return hash(_RefuseJsonNull) + + +_REFUSE_JSON_NULL = _RefuseJsonNull() #: Marks an optional property whose schema does not admit ``null``: omit it or -#: supply a value. See :class:`_NoExplicitNull`. -NoExplicitNull = _NoExplicitNull() +#: supply a value. A JSON ``null`` is refused on input, and a ``None`` is left +#: off the output from any parent. +#: +#: A JSON Schema property outside ``required`` whose type does not admit +#: ``null`` permits exactly one way of not supplying it — leaving it out. The +#: generator renders every optional property as ``T | None = None``, and that +#: annotation is the right Python surface: the attribute reads as ``None`` when +#: the property was omitted. What the annotation got wrong was *input*: it also +#: admitted an explicit ``null``, which the schema — and the SDK's own +#: JSON-Schema arm, :func:`adcp.validation.validate_request` — rejects. This +#: marker closes that gap without changing the annotation. +#: +#: The marker has two halves, because the two jobs live at different levels of +#: pydantic: +#: +#: * **Validation** (:class:`_RefuseJsonNull`, carried in ``metadata``): the +#: JSON side of the field's core schema has no null arm, so a document +#: carrying ``null`` is refused and the published JSON Schema states what the +#: bundled schema states. A Python ``None`` stays accepted. +#: * **Serialization** (the ``FieldInfo`` itself, ``exclude_if=_is_none``): a +#: ``None`` is left off the output *by the field*, in pydantic-core, whoever +#: drives the dump. ``AdCPBaseModel.model_dump`` defaults +#: ``exclude_none=True``, but a plain pydantic parent's serializer never calls +#: that override, so before #1587 a nested AdCP model wrote ``"ext": null`` +#: that the same model then refused to read back. The per-field exclusion is +#: what makes ``model_validate_json(model_dump_json())`` round-trip from any +#: parent: a direct field, a list, a dict, a ``RootModel`` over a union. It +#: also applies under an explicit ``exclude_none=False``, which is always +#: safe: ``null`` is never a valid value for a marked property, so there is +#: nothing to keep. A property whose schema *does* admit ``null`` is generated +#: without the marker and keeps emitting ``null`` under ``exclude_none=False``. +#: +#: Pydantic merges a ``FieldInfo`` found in ``Annotated`` metadata into the +#: field (``exclude_if`` lands on the field, the inner marker lands in +#: ``field.metadata``), so one ``NoExplicitNull`` beside the generated +#: ``Field(...)`` is all a field needs, and ``copy.deepcopy`` of the merged +#: ``FieldInfo`` (``canonical_creative._inherit``) carries both halves. The +#: marker itself is therefore never in ``field.metadata``; ask +#: :func:`is_no_explicit_null`, which reads the exclusion by identity. Not a +#: subclass: pydantic declares ``FieldInfo`` final. +#: +#: The generator marks every such property from the schema +#: (``scripts/generate_types.py``, ``mark_no_explicit_null_properties``) and +#: ``scripts/post_generate_fixes.py`` points the mark here. See #1547 and #1587. +NoExplicitNull: Final[FieldInfo] = FieldInfo(exclude_if=_is_none) +NoExplicitNull.metadata.append(_REFUSE_JSON_NULL) + + +def is_no_explicit_null(field: FieldInfo) -> bool: + """Whether ``field`` carries :data:`NoExplicitNull`. + + Identity, not equality: the marker is merged into the field, so what the + field keeps is its ``exclude_if`` (and, in ``metadata``, its validation + half). A redeclaration that copies the generated ``FieldInfo`` keeps both. + """ + return field.exclude_if is NoExplicitNull.exclude_if def _keep_opaque_nulls( diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 178c21aab..0312a02ff 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -101,7 +101,7 @@ PRODUCT_FORMAT_DECLARATION_SCHEMA, check_declaration_rules, ) -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import _REFUSE_JSON_NULL, AdCPBaseModel, NoExplicitNull from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind from adcp.types.domains.core.creative_asset import CreativeAsset as _CanonicalCreativeWire from adcp.types.domains.core.creative_filters import CreativeFilters as _LegacyCreativeFilters @@ -321,8 +321,9 @@ def _has_boundary_serialization_metadata(annotation: Any) -> bool: """Find functional serializers, including ones inside container annotations.""" if isinstance(annotation, (PlainSerializer, WrapSerializer)): return True - if annotation is NoExplicitNull: - # Validation-only: it changes nothing about how a value is dumped. + if annotation is NoExplicitNull or annotation is _REFUSE_JSON_NULL: + # Either half of the marker. Its only serialization effect is to leave a + # ``None`` off the dump, which is what the default dump already does. return False if hasattr(annotation, "__get_pydantic_core_schema__"): is_adcp_model = isinstance(annotation, type) and issubclass(annotation, AdCPBaseModel) @@ -358,7 +359,9 @@ def _direct_boundary_fields(cls: type[AdCPBaseModel]) -> frozenset[str] | None: for name, field in cls.model_fields.items(): if field.exclude: continue - if field.exclude_if is not None or _has_boundary_serialization_metadata(field.annotation): + if field.exclude_if is not None and field.exclude_if is not NoExplicitNull.exclude_if: + return None + if _has_boundary_serialization_metadata(field.annotation): return None if any(_has_boundary_serialization_metadata(item) for item in field.metadata): return None diff --git a/tests/test_forward_compat_format_kind.py b/tests/test_forward_compat_format_kind.py index 328b7e637..463381ed2 100644 --- a/tests/test_forward_compat_format_kind.py +++ b/tests/test_forward_compat_format_kind.py @@ -213,10 +213,13 @@ def test_manifest_format_kind_keeps_its_optional_default() -> None: CreativeManifest(assets={}) # An explicit ``None`` satisfies the document rule -- the group is keyed on the - # field being present, not on it being non-null -- and round-trips as null. + # field being present, not on it being non-null -- and reads back as ``None``. + # It is left off the dump even under ``exclude_none=False``: ``format_kind`` + # admits no ``null`` on the wire (``NoExplicitNull``, #1587). explicit = CreativeManifest(assets={}, format_kind=None) assert explicit.format_kind is None - assert explicit.model_dump(exclude_unset=True, exclude_none=False)["format_kind"] is None + assert "format_kind" in explicit.model_fields_set + assert "format_kind" not in explicit.model_dump(exclude_unset=True, exclude_none=False) @pytest.mark.parametrize("json_input", [False, True], ids=["python", "json"]) diff --git a/tests/test_nested_no_explicit_null_round_trip.py b/tests/test_nested_no_explicit_null_round_trip.py new file mode 100644 index 000000000..390911e13 --- /dev/null +++ b/tests/test_nested_no_explicit_null_round_trip.py @@ -0,0 +1,467 @@ +"""A nested AdCP model leaves unset optional fields off the wire from any parent (#1587). + +``AdCPBaseModel.model_dump`` defaults ``exclude_none=True``, but a plain pydantic +parent's serializer never calls that override, so a nested AdCP model wrote +``"ext": null`` -- and since #1555 the same model refuses that ``null`` on the way +back in. The fix moves the omission to the field: every ``NoExplicitNull``-marked +field carries ``exclude_if`` (pydantic-core level, no Python wrap serializer), so +a ``None`` is left off wherever the model sits, and ``model_validate_json`` of +``model_dump_json`` round-trips from a direct field, a list, a dict, several +plain levels down, and a ``RootModel`` over an AdCP union. + +The published JSON Schema is unchanged in both modes, and a property whose +schema admits ``null`` is still emitted as ``null`` when the dump asks for it. +""" + +from __future__ import annotations + +import copy +import json +import warnings +from typing import Any + +import pytest +from pydantic import BaseModel, RootModel, ValidationError +from pydantic.fields import FieldInfo + +import adcp.types as public_types +from adcp.types import ( + CpmVendorPricingOption, + Product, + ReportingWebhook, + SyncCreativesRequest, + TrustedMatch, + UpdateMediaBuyRequest, + VendorPricingOptionUnion, +) +from adcp.types.base import _REFUSE_JSON_NULL, AdCPBaseModel, NoExplicitNull, is_no_explicit_null +from adcp.types.domains.core.product import Provider +from adcp.types.domains.creative.sync_creatives_request import ( + SyncCreativesRequest as GeneratedSyncCreativesRequest, +) +from adcp.types.domains.media_buy.create_media_buy_request import CreateMediaBuyRequest +from tests.conformance import _schema_parity as parity + +# --- the reported case ----------------------------------------------------------- + + +class _Parent(BaseModel): + """The issue's parent: an ordinary pydantic model holding AdCP models.""" + + pricing: CpmVendorPricingOption + trusted_match: TrustedMatch + + +def _parent() -> _Parent: + return _Parent( + pricing=CpmVendorPricingOption( + pricing_option_id="cpm", model="cpm", cpm=1.0, currency="USD" + ), + trusted_match=TrustedMatch(context_match=True), + ) + + +def test_issue_repro_round_trips_from_a_plain_parent() -> None: + parent = _parent() + body = parent.model_dump_json() + document = json.loads(body) + assert "ext" not in document["pricing"] + assert "providers" not in document["trusted_match"] + assert _Parent.model_validate_json(body) == parent + + +def test_nested_dump_matches_the_direct_dump() -> None: + """The model serializes the same way wherever it sits. + + The parent passes no ``exclude_none``, so the precise statement is that the + nested document equals the direct dump under ``exclude_none=False``; with no + nullable field set to ``None`` here, that is also the default direct dump. + """ + parent = _parent() + document = parent.model_dump(mode="json") + assert document["pricing"] == parent.pricing.model_dump(mode="json") + assert document["trusted_match"] == parent.trusted_match.model_dump(mode="json") + assert document["trusted_match"] == parent.trusted_match.model_dump( + mode="json", exclude_none=False + ) + assert json.loads(parent.model_dump_json())["pricing"] == json.loads( + parent.pricing.model_dump_json() + ) + + +def test_provider_level_nesting_round_trips() -> None: + """One level down: a provider that omits ``countries`` / ``uid_types``.""" + parent = _Parent( + pricing=_parent().pricing, + trusted_match=TrustedMatch( + context_match=True, + providers=[Provider(agent_url="https://tmp.example.com/", identity_match=True)], + ), + ) + body = parent.model_dump_json() + provider = json.loads(body)["trusted_match"]["providers"][0] + assert "countries" not in provider + assert "uid_types" not in provider + assert _Parent.model_validate_json(body) == parent + + +class _VendorPricing(RootModel[VendorPricingOptionUnion]): + """A ``RootModel`` over an AdCP union.""" + + +_PRICING_DOCUMENT = {"pricing_option_id": "cpm", "model": "cpm", "cpm": 1.0, "currency": "USD"} + + +def test_root_model_over_an_adcp_union_round_trips() -> None: + wrapped = _VendorPricing.model_validate(_PRICING_DOCUMENT) + assert isinstance(wrapped.root, AdCPBaseModel) + body = wrapped.model_dump_json() + assert "ext" not in json.loads(body) + assert _VendorPricing.model_validate_json(body) == wrapped + + +class _Containers(BaseModel): + items: list[CpmVendorPricingOption] + by_key: dict[str, TrustedMatch] + + +def test_list_and_dict_containers_round_trip() -> None: + parent = _Containers( + items=[_parent().pricing, _parent().pricing], + by_key={"a": TrustedMatch(context_match=True), "b": TrustedMatch(context_match=False)}, + ) + body = parent.model_dump_json() + document = json.loads(body) + assert all("ext" not in item for item in document["items"]) + assert all("providers" not in item for item in document["by_key"].values()) + assert _Containers.model_validate_json(body) == parent + + +class _Inner(BaseModel): + parent: _Parent + + +class _Middle(BaseModel): + inners: list[_Inner] + + +class _Outer(BaseModel): + middle: _Middle + pricing_by_key: dict[str, _VendorPricing] + + +def test_several_plain_levels_down_round_trip() -> None: + outer = _Outer( + middle=_Middle(inners=[_Inner(parent=_parent())]), + pricing_by_key={"cpm": _VendorPricing.model_validate(_PRICING_DOCUMENT)}, + ) + body = outer.model_dump_json() + assert "null" not in body + assert _Outer.model_validate_json(body) == outer + assert _Outer.model_validate(outer.model_dump(mode="json")) == outer + + +# --- legitimate nulls are kept ------------------------------------------------------ + +_NULLABLE_UPDATE: dict[str, Any] = { + "account": {"account_id": "acct_1"}, + "media_buy_id": "mb_1", + "idempotency_key": "nullable-clear-0001", + # Both admit ``null`` in the bundled schema (a clear), neither is marked. + "budget_cap_timezone": None, + "daily_budget_cap": None, +} + + +class _Holder(BaseModel): + request: UpdateMediaBuyRequest + + +def test_nullable_fields_still_emit_null_from_a_plain_parent() -> None: + """A schema-admitted ``null`` is a value, and the parent's dump keeps it.""" + holder = _Holder(request=UpdateMediaBuyRequest.model_validate(_NULLABLE_UPDATE)) + for body in (holder.model_dump_json(), holder.model_dump_json(exclude_none=False)): + document = json.loads(body)["request"] + assert document["budget_cap_timezone"] is None + assert document["daily_budget_cap"] is None + # Marked optional fields that were never supplied stay off the wire. + assert "name" not in document + assert "push_notification_config" not in document + assert _Holder.model_validate_json(body) == holder + + +def test_exclude_none_false_on_a_direct_dump_keeps_only_schema_admitted_nulls() -> None: + """``exclude_none=False`` is still honoured where ``null`` can mean something. + + A marked field is left off even then: ``null`` is never a valid value for it, + so there is nothing to keep, and the document stays readable in JSON mode. + """ + request = UpdateMediaBuyRequest.model_validate(_NULLABLE_UPDATE) + assert "budget_cap_timezone" not in request.model_dump() + loose = request.model_dump(exclude_none=False) + assert loose["budget_cap_timezone"] is None + assert loose["daily_budget_cap"] is None + assert "name" not in loose + assert "push_notification_config" not in loose + wire = request.model_dump_json(exclude_none=False) + assert UpdateMediaBuyRequest.model_validate_json(wire).budget_cap_timezone is None + assert "budget_cap_timezone" in UpdateMediaBuyRequest.model_validate_json(wire).model_fields_set + + +def test_exclude_none_false_keeps_an_unmarked_none_inside_an_open_dict() -> None: + """Only marked *fields* are affected; a ``None`` value in a dict is still data.""" + pricing = CpmVendorPricingOption( + pricing_option_id="cpm", model="cpm", cpm=1.0, currency="USD", ext={"note": None} + ) + assert pricing.model_dump(exclude_none=False)["ext"] == {"note": None} + assert pricing.model_dump()["ext"] == {} + + +# --- the marker carries both halves -------------------------------------------------- + + +def test_marker_carries_the_exclusion_and_the_refusal() -> None: + field = TrustedMatch.model_fields["providers"] + assert is_no_explicit_null(field) + assert field.exclude_if is NoExplicitNull.exclude_if + assert field.exclude_if(None) is True + assert field.exclude_if([]) is False + assert isinstance(NoExplicitNull, FieldInfo) + assert _REFUSE_JSON_NULL in field.metadata + assert [repr(item) for item in field.metadata if item is _REFUSE_JSON_NULL] == [ + "NoExplicitNull" + ] + # The marker is merged into the field, never itself in ``metadata``; the + # halves are distinct objects and compare by identity. + assert NoExplicitNull not in field.metadata + assert _REFUSE_JSON_NULL != NoExplicitNull + # A generated ``Field(description=...)`` beside the marker survives the merge. + assert field.description + + +def test_forward_compat_reporting_operation_id_is_left_off_when_unset() -> None: + """The hand-written ``_ReportingOperationId`` annotation uses the same marker.""" + field = ReportingWebhook.model_fields["operation_id"] + assert is_no_explicit_null(field) + assert _REFUSE_JSON_NULL in field.metadata + + class Holder(BaseModel): + webhook: ReportingWebhook + + holder = Holder( + webhook=ReportingWebhook.model_validate( + { + "url": "https://buyer.example.com/hook", + "authentication": {"schemes": ["Bearer"], "credentials": "x" * 40}, + "reporting_frequency": "daily", + } + ) + ) + body = holder.model_dump_json() + assert "operation_id" not in json.loads(body)["webhook"] + assert Holder.model_validate_json(body) == holder + + +def test_canonical_redeclaration_inherits_the_exclusion() -> None: + """``canonical_creative._inherit`` deep-copies the generated ``FieldInfo``.""" + canonical = SyncCreativesRequest.model_fields["creatives"] + generated = GeneratedSyncCreativesRequest.model_fields["creatives"] + assert canonical is not generated + assert is_no_explicit_null(canonical) + assert _REFUSE_JSON_NULL in canonical.metadata + assert is_no_explicit_null(copy.deepcopy(canonical)) + + class Holder(BaseModel): + request: SyncCreativesRequest + + holder = Holder( + request=SyncCreativesRequest( + adcp_version="3.2", + account={"account_id": "acct_1"}, + idempotency_key="repro-null-creatives-0001", + assignment_operations=[ + {"operation": "unassign", "creative_id": "cr_1", "package_id": "pkg_1"} + ], + ) + ) + body = holder.model_dump_json() + assert "creatives" not in json.loads(body)["request"] + assert Holder.model_validate_json(body).request.creatives is None + + +# --- the published JSON Schema is unchanged ------------------------------------------ + + +def _without_exclusion(model: type[BaseModel]) -> type[BaseModel]: + """The same model as it was before #1587: every field's ``exclude_if`` cleared. + + A subclass under the original name and docstring, so titles and + descriptions match; only the serialization exclusion differs. + """ + namespace: dict[str, Any] = { + "__module__": model.__module__, + "__qualname__": model.__qualname__, + "__doc__": model.__doc__, + "__annotations__": {}, + } + for name, field in model.model_fields.items(): + # A deep copy: ``copy.copy`` would share ``_attributes_set`` with the + # real field, and popping from it would strip the exclusion from every + # later redeclaration of that field. + twin = copy.deepcopy(field) + twin.exclude_if = None + twin._attributes_set.pop("exclude_if", None) + namespace["__annotations__"][name] = field.annotation + namespace[name] = twin + return type(model.__name__, (model,), namespace) + + +@pytest.mark.parametrize( + "model", + [ + CpmVendorPricingOption, + TrustedMatch, + Provider, + Product, + SyncCreativesRequest, + GeneratedSyncCreativesRequest, + UpdateMediaBuyRequest, + CreateMediaBuyRequest, # nested packages + ReportingWebhook, + ], + ids=lambda model: model.__name__, +) +def test_published_schema_is_unchanged_by_the_exclusion(model: type[BaseModel]) -> None: + before = _without_exclusion(model) + assert any(is_no_explicit_null(f) for f in model.model_fields.values()) + assert all(f.exclude_if is None for f in before.model_fields.values()) + # The real fields are untouched by building the twin. + for field in model.model_fields.values(): + if is_no_explicit_null(field): + assert "exclude_if" in field._attributes_set + for mode in ("validation", "serialization"): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert model.model_json_schema(mode=mode) == before.model_json_schema(mode=mode) + + +# --- sweeps over the generated tree ------------------------------------------------------ + + +def _adcp_models() -> list[tuple[str, type[AdCPBaseModel]]]: + models = [] + for name in sorted(dir(public_types)): + if name.startswith("_"): + continue + obj = getattr(public_types, name, None) + if isinstance(obj, type) and issubclass(obj, AdCPBaseModel) and obj.model_fields: + models.append((name, obj)) + return models + + +def test_every_marked_field_in_the_generated_tree_carries_the_exclusion() -> None: + """The two halves never come apart: marked iff excluded-when-None.""" + marked = 0 + for name, model in _adcp_models(): + for field_name, field in model.model_fields.items(): + refuses = _REFUSE_JSON_NULL in field.metadata + if refuses: + marked += 1 + assert is_no_explicit_null(field), (name, field_name) + assert not field.is_required(), (name, field_name) + else: + assert field.exclude_if is None, (name, field_name) + assert marked > 5000 + + +def test_default_instances_of_every_model_dump_no_null_for_marked_fields() -> None: + """Each public model, with only its defaults, dumped by a plain parent. + + ``model_construct`` fills every optional field with its default, so the + dump is the worst case: every marked field holds ``None``. A plain parent + passes no ``exclude_none``, so the only thing keeping ``null`` off the wire + is the field's own exclusion. A ``null`` that does appear must belong to + an unmarked (schema-nullable) field. + """ + checked = 0 + for name, model in _adcp_models(): + instance = model.model_construct() + parent = RootModel[model].model_construct(root=instance) # type: ignore[valid-type] + try: + document = parent.model_dump(mode="json") + except Exception: # noqa: BLE001 -- a serializer that needs a validated instance + continue + if not isinstance(document, dict): + continue + checked += 1 + for field_name, field in model.model_fields.items(): + if is_no_explicit_null(field): + for key in {field_name, field.alias or field_name}: + assert document.get(key, "absent") is not None, (name, field_name) + assert checked > 600 + + +def _models_for(tool: str) -> list[type[AdCPBaseModel]]: + """The canonical request model first, then the ``Legacy*`` spelling when there is one. + + A synthesized document can carry legacy creative identity, which the + canonical model refuses by design; the legacy model reads the same shape. + """ + camel = "".join(part.capitalize() for part in tool.split("_")) + models = [] + for name in (f"{camel}Request", camel, f"Legacy{camel}Request"): + model = getattr(public_types, name, None) + if isinstance(model, type) and issubclass(model, AdCPBaseModel): + models.append(model) + return models + + +@pytest.mark.parametrize( + "tool", + [ + "create_media_buy", + "update_media_buy", + "sync_creatives", + "get_products", + "activate_signal", + "list_creatives", + "get_media_buy_delivery", + "provide_performance_feedback", + "build_creative", + "buy_products", + "sync_audiences", + "get_adcp_capabilities", + ], +) +def test_synthesized_requests_round_trip_from_a_plain_parent(tool: str) -> None: + """A schema-valid request, grown to reach its optional properties, through a parent.""" + models = _models_for(tool) + if not models: + pytest.skip(f"{tool} exposes no request model under adcp.types") + try: + document = parity.synthesize(tool) + except parity.UnsynthesizableError as exc: + pytest.skip(str(exc)) + request: AdCPBaseModel | None = None + for model in models: + try: + request = model.model_validate(document) + except ValidationError: + continue + break + assert request is not None, f"no model under adcp.types reads the synthesized {tool}" + + class Holder(BaseModel): + request: type(request) # type: ignore[valid-type] + + holder = Holder(request=request) + # ``by_alias``: the wire spelling, so a field such as ``pass_`` reads back + # under its alias. The parent still passes no ``exclude_none``. + body = holder.model_dump_json(by_alias=True) + assert Holder.model_validate_json(body) == holder + # The nested document is the direct dump under the parent's ``exclude_none`` + # (``False``): a schema-admitted ``null`` the synthesizer set is kept, a + # marked field's ``None`` is not. + assert json.loads(body)["request"] == json.loads( + request.model_dump_json(by_alias=True, exclude_none=False) + ) diff --git a/tests/test_no_explicit_null.py b/tests/test_no_explicit_null.py index 63ca04fef..6278c57f4 100644 --- a/tests/test_no_explicit_null.py +++ b/tests/test_no_explicit_null.py @@ -33,7 +33,7 @@ import adcp.types as public_types from adcp.types import base as types_base -from adcp.types.base import NoExplicitNull +from adcp.types.base import NoExplicitNull, is_no_explicit_null from adcp.types.domains.creative.sync_creatives_request import SyncCreativesRequest from adcp.types.domains.media_buy.update_media_buy_request import UpdateMediaBuyRequest from adcp.validation import get_schema, validate_request @@ -109,7 +109,7 @@ class Strict(BaseModel): def test_canonical_subclass_inherits_the_rule_through_inherit() -> None: """The canonical redeclaration (``_inherit``, #1548) keeps the generated marker.""" field = public_types.SyncCreativesRequest.model_fields["creatives"] - assert NoExplicitNull in field.metadata + assert is_no_explicit_null(field) canonical = public_types.SyncCreativesRequest with pytest.raises(ValidationError) as excinfo: canonical.model_validate_json(json.dumps(_SYNC_WITH_NULL_CREATIVES)) @@ -124,7 +124,7 @@ def test_a_redeclaration_that_keeps_the_field_info_keeps_the_marker() -> None: class Narrowed(SyncCreativesRequest): creatives: list[dict[str, object]] | None = parent_field # type: ignore[assignment] - assert NoExplicitNull in Narrowed.model_fields["creatives"].metadata + assert is_no_explicit_null(Narrowed.model_fields["creatives"]) with pytest.raises(ValidationError) as excinfo: Narrowed.model_validate_json(json.dumps(_SYNC_WITH_NULL_CREATIVES)) assert _null_errors(excinfo.value) == [(("creatives",), "list_type")] @@ -149,7 +149,7 @@ def test_omitted_creatives_still_reads_as_none() -> None: def test_constraints_and_description_survive_beside_the_marker() -> None: """The marker is appended to the field's metadata, not substituted for it.""" field = SyncCreativesRequest.model_fields["creatives"] - assert NoExplicitNull in field.metadata + assert is_no_explicit_null(field) assert field.description == "Array of creative assets to sync (create or update)" too_many = { **{k: v for k, v in _SYNC_WITH_NULL_CREATIVES.items() if k != "creatives"}, @@ -192,7 +192,7 @@ def test_genuinely_nullable_property_keeps_accepting_null() -> None: assert request.budget_cap_timezone is None assert "budget_cap_timezone" in request.model_fields_set for name in ("budget_cap_timezone", "daily_budget_cap"): - assert NoExplicitNull not in UpdateMediaBuyRequest.model_fields[name].metadata + assert not is_no_explicit_null(UpdateMediaBuyRequest.model_fields[name]) node = UpdateMediaBuyRequest.model_json_schema()["properties"]["budget_cap_timezone"] assert {"type": "null"} in node["anyOf"] @@ -249,7 +249,7 @@ def test_every_optional_field_either_carries_the_marker_or_admits_null( node = schema["properties"].get(wire_name) if node is None: continue - marked = NoExplicitNull in field.metadata + marked = is_no_explicit_null(field) annotation = repr(field.annotation) if marked: assert wire_name not in required diff --git a/tests/test_reporting_frozen_read_safety.py b/tests/test_reporting_frozen_read_safety.py index 09504695a..b7f3902b9 100644 --- a/tests/test_reporting_frozen_read_safety.py +++ b/tests/test_reporting_frozen_read_safety.py @@ -374,9 +374,12 @@ async def test_every_record_kind_requires_immutable_cross_page_content(kind, fie async def test_optional_field_presence_is_part_of_typed_immutable_content() -> None: + # ``consumer_commit_ref`` admits no ``null`` on the wire, so an explicit + # ``None`` reads as absent (#1587); the presence change a typed page can + # carry is the optional field appearing with a value. pages = _pages(_history()) replay = deepcopy(pages[-1]) - replay["receipts"][0]["consumer_commit_ref"] = None + replay["receipts"][0]["consumer_commit_ref"] = "late-consumer-checkpoint" pages[-1]["pagination"].update(has_more=True, cursor="before-optional-change") pages.append(replay) with pytest.raises(ReportingReconciliationError) as error: diff --git a/tests/test_targeting_overlay_compat.py b/tests/test_targeting_overlay_compat.py index 944b31245..797f92c5d 100644 --- a/tests/test_targeting_overlay_compat.py +++ b/tests/test_targeting_overlay_compat.py @@ -30,6 +30,7 @@ TargetingOverlay, UpdateMediaBuyRequest, ) +from adcp.types.base import is_no_explicit_null from adcp.types.domains.core.targeting_input import TargetingOverlayInput as GeneratedInput from adcp.types.domains.media_buy.package_control import PackageControl from adcp.types.domains.media_buy.package_request import ( @@ -320,7 +321,13 @@ def test_bridge_serialization_keeps_objects_on_the_wire( } expected = overlay.model_dump(mode="json", **options) assert isinstance(expected, dict) - assert expected["geo_countries"] is None + if is_no_explicit_null(overlay_class.model_fields["geo_countries"]): + # The resolved-state shape: ``null`` is not a clear there, so a ``None`` + # is left off even under ``exclude_none=False`` (#1587). The mutation + # input keeps it, and ``preserve_explicit_nulls`` is the client path. + assert "geo_countries" not in expected + else: + assert expected["geo_countries"] is None assert result.model_dump(mode="json", **options)["targeting_overlay"] == expected encoded = result.model_dump_json(**options) assert json.loads(encoded)["targeting_overlay"] == expected From 6c15eb42afcb35c706acc69c8663d0cb8d9e7756 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 06:56:10 -0400 Subject: [PATCH 34/54] chore(main): release adcp-reporting-storage 0.1.0-beta.3 (#1596) * chore(main): release adcp-reporting-storage 0.1.0-beta.3 * chore: normalize prerelease version to PEP 440 --------- Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- packages/adcp-reporting-storage/CHANGELOG.md | 7 +++++++ packages/adcp-reporting-storage/pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 740a8105b..3c9e9d7f2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { ".": "9.0.0", - "packages/adcp-reporting-storage": "0.1.0-beta.2" + "packages/adcp-reporting-storage": "0.1.0-beta.3" } diff --git a/packages/adcp-reporting-storage/CHANGELOG.md b/packages/adcp-reporting-storage/CHANGELOG.md index e8ed9f26f..853e2bd81 100644 --- a/packages/adcp-reporting-storage/CHANGELOG.md +++ b/packages/adcp-reporting-storage/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.1.0-beta.3](https://github.com/adcontextprotocol/adcp-client-python/compare/adcp-reporting-storage-v0.1.0-beta.2...adcp-reporting-storage-v0.1.0-beta.3) (2026-10-25) + + +### Bug Fixes + +* **reporting-storage:** surface ownership migration errors from startup ([51365c9](https://github.com/adcontextprotocol/adcp-client-python/commit/51365c9715c3b74c11b3353991525af93d04f392)) + ## [0.1.0-beta.2](https://github.com/adcontextprotocol/adcp-client-python/compare/adcp-reporting-storage-v0.1.0-beta.1...adcp-reporting-storage-v0.1.0-beta.2) (2026-10-24) diff --git a/packages/adcp-reporting-storage/pyproject.toml b/packages/adcp-reporting-storage/pyproject.toml index 128f4814b..1cf94c347 100644 --- a/packages/adcp-reporting-storage/pyproject.toml +++ b/packages/adcp-reporting-storage/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp-reporting-storage" -version = "0.1.0b2" +version = "0.1.0b3" description = "PostgreSQL and object-storage implementations of the AdCP Reliable Reporting stores" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From d15de535a4c588d15ed6eb9f8798ca0083a6b456 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 08:32:08 -0400 Subject: [PATCH 35/54] ci: map the two rebase-merged commits to their PRs and fix a stale test (#1601) The IPR push policy maps every main commit to the PR whose merge commit it is. #1589 and #1595 were rebase-merged, so their first commits match no PR and main's IPR check has failed since. List those two exact commits with their PR numbers; authorship is still checked. Document squash-only merges in the releasing guide. #1591's test expected ext={'note': None} to dump as {} by default, but #1600 deliberately keeps nulls inside opaque ext and context objects. Expect the null. Co-authored-by: Claude Opus 5.5 (1M context) --- docs/releasing.md | 5 +++- scripts/check_main_policies.py | 18 +++++++++++-- tests/test_main_release_policies.py | 27 +++++++++++++++++++ ...test_nested_no_explicit_null_round_trip.py | 7 +++-- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/docs/releasing.md b/docs/releasing.md index de9500ce1..68deea89a 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -66,7 +66,10 @@ root package lists that path in `exclude-paths` (Release Please otherwise hands the root every commit), so a protocol fix in `adcp` does not release the storage package and a storage fix does not release `adcp`. A commit touching both trees counts for both. Keep storage changes out of a root `Release-As:` commit, or the -storage package takes that version too. Conventional-commit scopes are free text, but +storage package takes that version too. To release only one package, put its +change in its own PR. Always squash-merge: the IPR policy maps each `main` +commit to the PR whose merge commit it is, and a rebase merge leaves earlier +commits with no such PR. Conventional-commit scopes are free text, but `feat(reporting-storage):` keeps the two changelogs readable. On a merge, `release-please.yml` queues `release-publish.yml` with diff --git a/scripts/check_main_policies.py b/scripts/check_main_policies.py index 73a0f4439..2e78f4d4f 100644 --- a/scripts/check_main_policies.py +++ b/scripts/check_main_policies.py @@ -106,6 +106,15 @@ def pages(self, suffix: str, key: str | None = None) -> list[Any]: ), } +# Rebase-merged PRs record only their last commit as merge_commit_sha, so the +# earlier commits they put on main match no PR by merge commit. These two were +# rebase-merged so a storage-only commit would not release adcp; each maps to +# exactly one merged PR, whose authorship is still checked. Squash-merge only. +HISTORICAL_REBASED_COMMITS = { + "dad5e8d9668e0ebd3ced406780e659b42ee22001": 1589, + "a202a3cbeb41ce6f89dcac8db943a0a0f9d72da3": 1595, +} + def git(*arguments: str) -> str: return subprocess.check_output(["git", *arguments], cwd=ROOT, text=True).strip() @@ -257,10 +266,11 @@ def ipr(api: GitHub, kind: str, target: str, event: dict[str, Any]) -> dict[str, contributions = [] if kind == "push": for sha in main_commits(target): + rebased_pr = HISTORICAL_REBASED_COMMITS.get(sha) candidates = [ pr for pr in api.pages(f"commits/{sha}/pulls") - if pr["merge_commit_sha"] == sha + if (pr["merge_commit_sha"] == sha or pr["number"] == rebased_pr) and pr["merged_at"] and pr["base"]["ref"] == "main" and pr["base"]["repo"]["full_name"] == REPOSITORY @@ -268,7 +278,11 @@ def ipr(api: GitHub, kind: str, target: str, event: dict[str, Any]) -> dict[str, require(len(candidates) == 1, f"no unique merged contribution for main commit {sha}") pr = api.repo(f"pulls/{candidates[0]['number']}") validate_pr(pr) - require(pr["merged"] is True and pr["merge_commit_sha"] == sha, "wrong merged PR") + require( + pr["merged"] is True + and (pr["merge_commit_sha"] == sha or pr["number"] == rebased_pr), + "wrong merged PR", + ) require(pr["merged_at"], "missing contribution merge time") contributions.append({"sha": sha, "pr": pr["number"], **author_agreement(pr, signers)}) else: diff --git a/tests/test_main_release_policies.py b/tests/test_main_release_policies.py index 425746444..4fa64cc8f 100644 --- a/tests/test_main_release_policies.py +++ b/tests/test_main_release_policies.py @@ -216,6 +216,33 @@ def test_signer_identity_survives_login_rename_and_real_bots_follow_policy( assert evidence["contributions"][1]["agreement"] == "canonical-bot-exemption" +def _rebase_merge_both_commits_into_one_pr(api: PolicyAPI, history: list[str]) -> None: + pr = {**api.data["pulls/1174"], "number": 1174, "merge_commit_sha": history[2]} + for sha in history[1:]: + api.data[f"commits/{sha}/pulls?per_page=100&page=1"] = [pr] + api.data["pulls/1174"] = pr + + +def test_rebase_merged_earlier_commit_is_rejected_unless_listed(history: list[str]) -> None: + api = PolicyAPI(history) + _rebase_merge_both_commits_into_one_pr(api, history) + with pytest.raises(ReleaseRejectedError, match="no unique merged contribution"): + policies.ipr(api, "push", history[-1], {}) + + +def test_listed_rebased_commit_maps_to_its_exact_pr( + history: list[str], monkeypatch: pytest.MonkeyPatch +) -> None: + api = PolicyAPI(history) + _rebase_merge_both_commits_into_one_pr(api, history) + monkeypatch.setattr(policies, "HISTORICAL_REBASED_COMMITS", {history[1]: 1174}) + evidence = policies.ipr(api, "push", history[-1], {}) + assert [entry["pr"] for entry in evidence["contributions"]] == [1174, 1174] + monkeypatch.setattr(policies, "HISTORICAL_REBASED_COMMITS", {history[1]: 9999}) + with pytest.raises(ReleaseRejectedError): + policies.ipr(api, "push", history[-1], {}) + + def test_all_pages_are_read_before_selecting_merged_pr(history: list[str]) -> None: api = PolicyAPI(history) path = f"commits/{history[1]}/pulls?per_page=100&page=" diff --git a/tests/test_nested_no_explicit_null_round_trip.py b/tests/test_nested_no_explicit_null_round_trip.py index 390911e13..bd93cbb68 100644 --- a/tests/test_nested_no_explicit_null_round_trip.py +++ b/tests/test_nested_no_explicit_null_round_trip.py @@ -209,12 +209,15 @@ def test_exclude_none_false_on_a_direct_dump_keeps_only_schema_admitted_nulls() def test_exclude_none_false_keeps_an_unmarked_none_inside_an_open_dict() -> None: - """Only marked *fields* are affected; a ``None`` value in a dict is still data.""" + """Only marked *fields* are affected; a ``None`` value in an opaque object is data. + + ``ext`` (like ``context``) keeps its nulls on every dump path (#1575/#1600). + """ pricing = CpmVendorPricingOption( pricing_option_id="cpm", model="cpm", cpm=1.0, currency="USD", ext={"note": None} ) assert pricing.model_dump(exclude_none=False)["ext"] == {"note": None} - assert pricing.model_dump()["ext"] == {} + assert pricing.model_dump()["ext"] == {"note": None} # --- the marker carries both halves -------------------------------------------------- From 0479d7d4060302ac06d32ce0f264f3d70b9a4d6f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 09:12:08 -0400 Subject: [PATCH 36/54] docs(reporting-storage): note that 0.1.0b2 and 0.1.0b3 were not published (#1602) Their release commits failed main CI (a rebase-merge IPR mapping, fixed in #1601), which release-publish requires, so neither reached PyPI. Release their changes as 0.1.0b4. Co-Authored-By: Claude Opus 5.5 (1M context) Release-As: 0.1.0-beta.4 --- packages/adcp-reporting-storage/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/adcp-reporting-storage/README.md b/packages/adcp-reporting-storage/README.md index a4b8adf45..d1b2a5c6c 100644 --- a/packages/adcp-reporting-storage/README.md +++ b/packages/adcp-reporting-storage/README.md @@ -63,6 +63,12 @@ major (`adcp>=9.0.0,<10`), and a new `adcp` major means a new release here. [CHANGELOG.md](CHANGELOG.md). The package reaches 1.0 when the storage layout shared with the JavaScript SDK (`@adcp/reporting-storage`) is declared stable. +## Unpublished tags + +`adcp-reporting-storage-v0.1.0-beta.2` and `-beta.3` exist on GitHub but were +never published to PyPI: their release commits failed `main` CI, which the +publish workflow requires. 0.1.0b4 contains both. Install 0.1.0b4 or later. + ## Documentation The guides live in the repository: [Reliable Reporting service](../../docs/reliable-reporting-service.md), From 83da9d82b3b2bb967aabef6985a01e1fc8d32357 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 10:05:08 -0400 Subject: [PATCH 37/54] chore(main): release 9.1.0 (#1585) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 20 ++++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 3c9e9d7f2..ca934c47e 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.0.0", + ".": "9.1.0", "packages/adcp-reporting-storage": "0.1.0-beta.3" } diff --git a/CHANGELOG.md b/CHANGELOG.md index db0218164..9136b1562 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,6 +169,26 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.1.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.0.0...v9.1.0) (2026-10-25) + + +### Features + +* **media-buy:** persist and echo create context on get_media_buys ([#1600](https://github.com/adcontextprotocol/adcp-client-python/issues/1600)) ([4083dc5](https://github.com/adcontextprotocol/adcp-client-python/commit/4083dc52702ddd1d20aa840dd4265bf61dd75ddd)), closes [#1575](https://github.com/adcontextprotocol/adcp-client-python/issues/1575) + + +### Bug Fixes + +* **a2a:** echo request context on unknown-skill and unexpected-error tasks ([#1597](https://github.com/adcontextprotocol/adcp-client-python/issues/1597)) ([7b44d8d](https://github.com/adcontextprotocol/adcp-client-python/commit/7b44d8d5d1627f98fede0ed5660ab3d804f230bd)), closes [#1573](https://github.com/adcontextprotocol/adcp-client-python/issues/1573) +* **a2a:** keep integers integral in echoed context across the protobuf Struct ([#1594](https://github.com/adcontextprotocol/adcp-client-python/issues/1594)) ([bf6d966](https://github.com/adcontextprotocol/adcp-client-python/commit/bf6d96641910ccdaa2e2b95bf7965a564c9e1d9b)), closes [#1572](https://github.com/adcontextprotocol/adcp-client-python/issues/1572) +* **reporting-storage:** surface ownership migration errors from startup ([51365c9](https://github.com/adcontextprotocol/adcp-client-python/commit/51365c9715c3b74c11b3353991525af93d04f392)) +* **reporting:** name the cause of a reporting startup failure ([a202a3c](https://github.com/adcontextprotocol/adcp-client-python/commit/a202a3cbeb41ce6f89dcac8db943a0a0f9d72da3)), closes [#1561](https://github.com/adcontextprotocol/adcp-client-python/issues/1561) +* **server:** mirror a raised AdcpError into the payload errors array ([#1592](https://github.com/adcontextprotocol/adcp-client-python/issues/1592)) ([cd0319b](https://github.com/adcontextprotocol/adcp-client-python/commit/cd0319b57d27ab8bbb06ddb091537645dadf887b)), closes [#1558](https://github.com/adcontextprotocol/adcp-client-python/issues/1558) +* **targeting:** bridge package forecast request inputs ([#1598](https://github.com/adcontextprotocol/adcp-client-python/issues/1598)) ([7cd667f](https://github.com/adcontextprotocol/adcp-client-python/commit/7cd667f9e134d958aadffadef1420d0c91c941ef)) +* **tasks:** echo the poll's own context on tasks/get ([#1599](https://github.com/adcontextprotocol/adcp-client-python/issues/1599)) ([f1f7593](https://github.com/adcontextprotocol/adcp-client-python/commit/f1f75930152510de374d98bd286f2462af2100bb)), closes [#1574](https://github.com/adcontextprotocol/adcp-client-python/issues/1574) +* **types:** inherit the generated creatives ceiling on PackageRequest ([#1579](https://github.com/adcontextprotocol/adcp-client-python/issues/1579)) ([6f13ce0](https://github.com/adcontextprotocol/adcp-client-python/commit/6f13ce07e69bc24de23ce63b97958875f6866cc7)) +* **types:** leave unset optional fields off the wire from any parent ([#1591](https://github.com/adcontextprotocol/adcp-client-python/issues/1591)) ([1086cd9](https://github.com/adcontextprotocol/adcp-client-python/commit/1086cd90f70e760873b445f15db5cdcb0d8aeeed)), closes [#1587](https://github.com/adcontextprotocol/adcp-client-python/issues/1587) + ## [9.0.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.0.0-beta.4...v9.0.0) (2026-10-24) diff --git a/pyproject.toml b/pyproject.toml index 0129587cd..16a8f0db6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.0.0" +version = "9.1.0" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From edeea5853e4e2f242496065c01fa0c85c0abac2a Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:09:49 -0400 Subject: [PATCH 38/54] chore(main): release adcp-reporting-storage 0.1.0-beta.4 (#1603) * chore(main): release adcp-reporting-storage 0.1.0-beta.4 * chore: normalize prerelease version to PEP 440 --------- Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Co-authored-by: Brian O'Kelley --- .release-please-manifest.json | 2 +- packages/adcp-reporting-storage/CHANGELOG.md | 7 +++++++ packages/adcp-reporting-storage/pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ca934c47e..82e144328 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { ".": "9.1.0", - "packages/adcp-reporting-storage": "0.1.0-beta.3" + "packages/adcp-reporting-storage": "0.1.0-beta.4" } diff --git a/packages/adcp-reporting-storage/CHANGELOG.md b/packages/adcp-reporting-storage/CHANGELOG.md index 853e2bd81..18270d67a 100644 --- a/packages/adcp-reporting-storage/CHANGELOG.md +++ b/packages/adcp-reporting-storage/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## [0.1.0-beta.4](https://github.com/adcontextprotocol/adcp-client-python/compare/adcp-reporting-storage-v0.1.0-beta.3...adcp-reporting-storage-v0.1.0-beta.4) (2026-10-25) + + +### docs + +* **reporting-storage:** note that 0.1.0b2 and 0.1.0b3 were not published ([#1602](https://github.com/adcontextprotocol/adcp-client-python/issues/1602)) ([0479d7d](https://github.com/adcontextprotocol/adcp-client-python/commit/0479d7d4060302ac06d32ce0f264f3d70b9a4d6f)) + ## [0.1.0-beta.3](https://github.com/adcontextprotocol/adcp-client-python/compare/adcp-reporting-storage-v0.1.0-beta.2...adcp-reporting-storage-v0.1.0-beta.3) (2026-10-25) diff --git a/packages/adcp-reporting-storage/pyproject.toml b/packages/adcp-reporting-storage/pyproject.toml index 1cf94c347..a99eed8ac 100644 --- a/packages/adcp-reporting-storage/pyproject.toml +++ b/packages/adcp-reporting-storage/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp-reporting-storage" -version = "0.1.0b3" +version = "0.1.0b4" description = "PostgreSQL and object-storage implementations of the AdCP Reliable Reporting stores" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From bc84e1482b1c9d969ec4df139ae53a3807c39e34 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 13:09:50 -0400 Subject: [PATCH 39/54] docs(changelog): list 9.0.0 version conversion as a breaking change (#1608) #1559 shipped in 9.0.0 without a breaking-change entry. Since then a 3.1 request reaches the handler upgraded to adcp_version 3.2, so code that branched on the version in handler params lost that branch. Co-authored-by: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9136b1562..9275eb91a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -197,6 +197,7 @@ * **reporting:** the PostgreSQL reporting stores, the row storage providers, the change feed, the ownership-migration helpers and `ReliableReportingService.postgres` leave `adcp`; install `adcp-reporting-storage[pg]` (plus `gcs`/`s3`/`azure`) and import them from `adcp_reporting_storage`. The `adcp[reporting-gcs]`, `[reporting-s3]` and `[reporting-azure]` extras are removed. See docs/reporting-storage-package-migration.md. * **codegen:** a JSON document carrying `null` for an optional property whose schema does not admit `null` is now refused by `model_validate_json` and `TypeAdapter.validate_json`, and the published JSON Schema of such a property has no null arm. A Python `None` is still accepted. JSON produced with `exclude_none=False` must be read back in Python mode (`model_validate(json.loads(...))`). * **types:** SyncCreativesRequest.creatives is now list[CreativeAsset] | None. Handlers must guard it before iteration or use request.creatives or [] for assignment-only requests. +* **compat:** server handlers receive requests already upgraded to the latest supported minor. A 3.1 buyer's request reaches the handler as `adcp_version: "3.2"`, so reading the version from handler params, including `resolve_requested_adcp_version(params)`, returns the served version, not the buyer's. Read `ToolContext.resolved_adcp_version` for the buyer's negotiated version, and answer in the latest shapes; the SDK converts the response down. Listed after release; see docs/cross-version-conversion.md. ([#1559](https://github.com/adcontextprotocol/adcp-client-python/issues/1559)) ### chore From 65033985ae9bf6358b46d0bb1aa792bdf9d874ce Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 13:47:03 -0400 Subject: [PATCH 40/54] ci(release): refuse an adcp release its companion packages can't install with (#1605) 9.0.0 moved the PostgreSQL reporting stores into adcp-reporting-storage before that package was on PyPI, so the documented install failed and Postgres adopters were stuck on the last beta. Before an adcp release builds, require each companion distribution to have a published, non-yanked release whose adcp requirement accepts the new version. Co-authored-by: Claude Opus 5.5 (1M context) --- .github/workflows/release-publish.yml | 6 ++ scripts/check_companion_releases.py | 97 ++++++++++++++++++++++++++ tests/test_check_companion_releases.py | 74 ++++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 scripts/check_companion_releases.py create mode 100644 tests/test_check_companion_releases.py diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 22105b728..61e9de55f 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -71,6 +71,12 @@ jobs: - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 with: python-version: '3.12' + - name: Require installable companion distributions for an adcp release + if: inputs.package == 'adcp' + run: | + python -m pip install packaging + version="$(python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')" + python scripts/check_companion_releases.py "$version" - name: Build and check distributions env: PACKAGE: ${{ inputs.package }} diff --git a/scripts/check_companion_releases.py b/scripts/check_companion_releases.py new file mode 100644 index 000000000..7df876bce --- /dev/null +++ b/scripts/check_companion_releases.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Refuse to publish an ``adcp`` release its companion distributions can't install with. + +9.0.0 moved the PostgreSQL reporting stores into ``adcp-reporting-storage`` +before that package was on PyPI, so the documented +``pip install "adcp-reporting-storage[pg]"`` failed and Postgres adopters were +stuck on the last beta. Before an ``adcp`` release publishes, every companion +distribution must have a published, non-yanked release whose ``adcp`` +requirement accepts the version being released. +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request +from collections.abc import Callable +from typing import Any + +from packaging.requirements import Requirement +from packaging.utils import canonicalize_name +from packaging.version import InvalidVersion, Version + +#: Distributions maintained in this repository that adopters install with adcp. +COMPANIONS = ("adcp-reporting-storage",) + +Fetch = Callable[[str], Any] + + +def _pypi(url: str) -> Any: + request = urllib.request.Request(url, headers={"Accept": "application/json"}) + try: + with urllib.request.urlopen(request, timeout=30) as response: # noqa: S310 + return json.load(response) + except urllib.error.HTTPError as error: + if error.code == 404: + return None + raise + + +def _adcp_requirement(requires_dist: list[str] | None) -> Requirement | None: + for line in requires_dist or (): + requirement = Requirement(line) + if canonicalize_name(requirement.name) == "adcp" and requirement.marker is None: + return requirement + return None + + +def compatible_release(companion: str, adcp_version: Version, fetch: Fetch = _pypi) -> str | None: + """Return the newest published ``companion`` release that installs with ``adcp_version``.""" + project = fetch(f"https://pypi.org/pypi/{companion}/json") + if not project: + return None + candidates = [] + for raw, files in project.get("releases", {}).items(): + try: + version = Version(raw) + except InvalidVersion: + continue + if files and not all(file.get("yanked") for file in files): + candidates.append(version) + for version in sorted(candidates, reverse=True): + release = fetch(f"https://pypi.org/pypi/{companion}/{version}/json") + requirement = _adcp_requirement((release or {}).get("info", {}).get("requires_dist")) + if requirement is not None and requirement.specifier.contains( + adcp_version, prereleases=True + ): + return str(version) + return None + + +def main(argv: list[str] | None = None, fetch: Fetch = _pypi) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("adcp_version", help="the adcp version about to be published") + args = parser.parse_args(argv) + adcp_version = Version(args.adcp_version) + missing = [] + for companion in COMPANIONS: + found = compatible_release(companion, adcp_version, fetch) + if found is None: + missing.append(companion) + else: + print(f"{companion} {found} installs with adcp {adcp_version}") + if missing: + print( + f"No published release of {', '.join(missing)} accepts adcp {adcp_version}. " + "Publish a compatible companion release first.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_check_companion_releases.py b/tests/test_check_companion_releases.py new file mode 100644 index 000000000..dd1441b88 --- /dev/null +++ b/tests/test_check_companion_releases.py @@ -0,0 +1,74 @@ +"""The adcp release gate requires an installable companion on PyPI.""" + +from __future__ import annotations + +from typing import Any + +import pytest +from packaging.version import Version + +from scripts import check_companion_releases as gate + +_PROJECT = "https://pypi.org/pypi/adcp-reporting-storage/json" + + +def _release(requirement: str | None) -> dict[str, Any]: + requires = ["psycopg>=3; extra == 'pg'"] + ([requirement] if requirement else []) + return {"info": {"requires_dist": requires}} + + +def _fetch(releases: dict[str, tuple[str | None, bool]]) -> gate.Fetch: + data: dict[str, Any] = { + _PROJECT: { + "releases": {version: [{"yanked": yanked}] for version, (_, yanked) in releases.items()} + } + } + for version, (requirement, _) in releases.items(): + data[f"https://pypi.org/pypi/adcp-reporting-storage/{version}/json"] = _release(requirement) + return lambda url: data.get(url) + + +def test_missing_companion_blocks_the_release() -> None: + assert gate.main(["9.0.0"], fetch=lambda url: None) == 1 + + +def test_compatible_companion_beta_allows_the_release() -> None: + fetch = _fetch({"0.1.0b4": ("adcp>=9.0.0,<10", False)}) + assert gate.compatible_release("adcp-reporting-storage", Version("9.1.0"), fetch) == "0.1.0b4" + assert gate.main(["9.1.0"], fetch=fetch) == 0 + + +def test_a_new_adcp_major_needs_a_companion_that_accepts_it() -> None: + fetch = _fetch({"0.1.0b4": ("adcp>=9.0.0,<10", False)}) + assert gate.main(["10.0.0"], fetch=fetch) == 1 + + +def test_prerelease_adcp_versions_match_a_prerelease_floor() -> None: + fetch = _fetch({"0.1.0b1": ("adcp>=9.0.0b4,<10", False)}) + assert gate.compatible_release("adcp-reporting-storage", Version("9.0.0b5"), fetch) + + +@pytest.mark.parametrize( + "releases", + [ + {"0.1.0b4": ("adcp>=9.0.0,<10", True)}, + {"0.1.0b4": (None, False)}, + {"0.1.0b4": ("adcp>=9.2.0,<10", False)}, + ], + ids=["yanked", "no-adcp-requirement", "floor-above"], +) +def test_unusable_companion_releases_do_not_count( + releases: dict[str, tuple[str | None, bool]], +) -> None: + assert gate.main(["9.1.0"], fetch=_fetch(releases)) == 1 + + +def test_newest_compatible_release_is_reported() -> None: + fetch = _fetch( + { + "0.1.0b1": ("adcp>=9.0.0b4,<10", False), + "0.2.0": ("adcp>=10,<11", False), + "0.1.0b4": ("adcp>=9.0.0,<10", False), + } + ) + assert gate.compatible_release("adcp-reporting-storage", Version("9.1.0"), fetch) == "0.1.0b4" From f7e4f3a5f1c8acba40072df0af377927a0af74ec Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 14:58:13 -0400 Subject: [PATCH 41/54] fix(compat): log server-side conversion losses at DEBUG with one INFO summary (#1609) convert_payload logged one WARNING per dropped field on every conversion, on both sides of the wire. A 3.2 seller answering a 3.1 get_products with 50 products wrote 150 WARNING lines, three per product, for drops it must make and already records in ToolContext.metadata. convert_payload now takes loss_log_level (default WARNING) and every caller passes it explicitly: - server dispatch (request upgrade and response downgrade, MCP and A2A): DEBUG per loss, plus one INFO summary per conversion with tool, source -> target, loss count and distinct pointers with array indexes collapsed to *, capped at ten - client outgoing request and incoming response conversion: WARNING - account-key recomputation and catalog evidence reconstruction: DEBUG, since they repeat a conversion that already warned or round-trip the client's own lossless upgrade Embedded polling results are converted through the non-logging core, so their losses are logged once with the prefixed pointer instead of twice. ESA feedback on 9.0 version conversion. Co-authored-by: Claude Opus 5.5 (1M context) --- docs/cross-version-conversion.md | 40 +++- src/adcp/accounts.py | 4 + src/adcp/client.py | 6 + .../compat/negotiated_catalog_purchase.py | 4 + src/adcp/compat/versions.py | 80 ++++++- src/adcp/server/mcp_tools.py | 15 +- tests/test_version_conversion_logging.py | 198 ++++++++++++++++++ 7 files changed, 331 insertions(+), 16 deletions(-) create mode 100644 tests/test_version_conversion_logging.py diff --git a/docs/cross-version-conversion.md b/docs/cross-version-conversion.md index 798c8070e..34fd938e9 100644 --- a/docs/cross-version-conversion.md +++ b/docs/cross-version-conversion.md @@ -93,12 +93,40 @@ Client results expose conversion details in } ``` -Every loss also produces a warning. Reports use JSON Pointers and contain no -discarded values. They remain available when a request fails validation or the -peer returns a failed task. Server reports use the same key in -`ToolContext.metadata`; response losses are appended after the handler returns -and are also logged. These diagnostics do not add unsupported fields to older -wire envelopes. +Reports use JSON Pointers and contain no discarded values. They remain +available when a request fails validation or the peer returns a failed task. +Server reports use the same key in `ToolContext.metadata`; response losses are +appended after the handler returns. These diagnostics do not add unsupported +fields to older wire envelopes. + +## Loss logging + +Losses are logged on the `adcp.compat.versions` logger at a level that depends +on which side of the wire converts: + +| Side | Per-loss record | Summary | +| --- | --- | --- | +| Client (buyer converting its request or a seller's response) | `WARNING` | none | +| Server dispatch (request upgrade, response downgrade) | `DEBUG` | one `INFO` line per conversion with losses | + +On the client, a drop means the buyer lost data it may care about. On the +server, dropping newer fields for an older buyer is the expected cost of serving +it, and `ToolContext.metadata` already records each loss. A 3.2 seller answering +a 3.1 `get_products` with 50 products logs one line instead of 150 warnings: + +```text +INFO adcp.compat.versions: AdCP conversion of get_products 3.2 -> 3.1 dropped 150 field(s): +/products/*/audience_evidence, /products/*/included_signals/*/demographic_predicate, +/products/*/included_signals/*/restricted_attributes +``` + +The summary collapses array indexes to `*` and lists at most ten distinct +pointers. Request upgrades add fields rather than drop them, so a server logs +at most one summary per response. Enable `DEBUG` on `adcp.compat.versions` to +see every exact pointer, or read them from the metadata report. + +Code calling `adcp.compat.versions.convert_payload` directly chooses the level +with `loss_log_level`; it defaults to `WARNING`. ## Account identity diff --git a/src/adcp/accounts.py b/src/adcp/accounts.py index b27738a27..fa4ae76f1 100644 --- a/src/adcp/accounts.py +++ b/src/adcp/accounts.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import logging import uuid from collections.abc import Mapping from copy import deepcopy @@ -377,6 +378,9 @@ async def observe( direction="request", source_version="3.2", target_version=observed_version, + # Recomputes the key the outgoing request already reduced; + # that conversion warned about these losses. + loss_log_level=logging.DEBUG, ).payload["accounts"][0] if account_key(reduced) != account_key(key): wire_key = account_key_payload(reduced) diff --git a/src/adcp/client.py b/src/adcp/client.py index 9c1f610e4..484c429d1 100644 --- a/src/adcp/client.py +++ b/src/adcp/client.py @@ -1166,6 +1166,9 @@ async def _convert_outgoing_request( direction="request", source_version=resolve_adcp_version(None), target_version=target, + # Buyer side: each dropped request field is buyer data the + # seller will never see. + loss_log_level=logging.WARNING, ) return ConversionResult( converted.payload, @@ -1200,6 +1203,9 @@ def _convert_incoming_response( source_version=version, target_version=resolve_adcp_version(None), request=request.payload, + # Buyer side: a dropped response field is seller + # data the buyer may care about. + loss_log_level=logging.WARNING, ) result = result.model_copy(update={"data": converted.payload}) losses.extend(converted.metadata()) diff --git a/src/adcp/compat/negotiated_catalog_purchase.py b/src/adcp/compat/negotiated_catalog_purchase.py index 5b4a3d95c..0896a3278 100644 --- a/src/adcp/compat/negotiated_catalog_purchase.py +++ b/src/adcp/compat/negotiated_catalog_purchase.py @@ -8,6 +8,7 @@ from __future__ import annotations +import logging import re import warnings from collections.abc import Callable, Mapping, Sequence @@ -1010,6 +1011,9 @@ def _completed_payload( direction="sync", source_version=application_version, target_version=wire_version, + # Round-trips the client's own lossless upgrade back to + # the served contract; only upgrade defaults can drop. + loss_log_level=logging.DEBUG, ).payload return payload diff --git a/src/adcp/compat/versions.py b/src/adcp/compat/versions.py index 72f7f82c7..88afac718 100644 --- a/src/adcp/compat/versions.py +++ b/src/adcp/compat/versions.py @@ -25,6 +25,8 @@ logger = logging.getLogger(__name__) CONVERSION_METADATA_KEY = "adcp_version_conversion" _OPAQUE_FIELDS = frozenset({"context", "ext"}) +_ARRAY_INDEX = re.compile(r"/\d+(?=/|$)") +_SUMMARY_POINTER_LIMIT = 10 @dataclass(frozen=True) @@ -536,7 +538,7 @@ def _convert_embedded_results( continue prefix = _pointer(path, "result") try: - converted = convert_payload( + converted = _convert_payload( task_type, result, direction=direction, @@ -567,13 +569,81 @@ def convert_payload( source_version: str, target_version: str, request: dict[str, Any] | None = None, + loss_log_level: int = logging.WARNING, ) -> ConversionResult: """Chain adjacent minor conversions without modifying the caller's data. The 3.1/3.0 lifecycle envelope is translated semantically. Additive changes use the bundled schemas, including nested arrays and unions. New tasks without an older equivalent fail before transport. + + Every loss is logged at ``loss_log_level``. ``direction`` says nothing + about which side of the wire is converting, so callers choose the level: + a buyer losing a seller's data keeps ``WARNING``, while a seller dropping + newer fields for an older buyer expects those losses and logs them at + ``DEBUG`` (see :func:`log_conversion_summary`). + """ + converted = _convert_payload( + tool, + payload, + direction=direction, + source_version=source_version, + target_version=target_version, + request=request, + ) + for loss in converted.losses: + logger.log( + loss_log_level, + "AdCP conversion %s -> %s dropped %s (%s)", + loss.source_version, + loss.target_version, + loss.pointer, + loss.reason, + ) + return converted + + +def log_conversion_summary( + tool: str, + losses: tuple[ConversionLoss, ...], + *, + source_version: str, + target_version: str, + level: int = logging.INFO, +) -> None: + """Log one line summarizing a conversion's losses, if it had any. + + Array indexes collapse to ``*`` so a response repeating the same drop + on every item lists the pointer once, e.g. + ``/products/*/included_signals/*/demographic_predicate``. """ + if not losses: + return + pointers = list(dict.fromkeys(_ARRAY_INDEX.sub("/*", loss.pointer) for loss in losses)) + shown = ", ".join(pointers[:_SUMMARY_POINTER_LIMIT]) + if len(pointers) > _SUMMARY_POINTER_LIMIT: + shown += f", ... (+{len(pointers) - _SUMMARY_POINTER_LIMIT} more)" + logger.log( + level, + "AdCP conversion of %s %s -> %s dropped %d field(s): %s", + tool, + source_version, + target_version, + len(losses), + shown, + ) + + +def _convert_payload( + tool: str, + payload: dict[str, Any], + *, + direction: Direction, + source_version: str, + target_version: str, + request: dict[str, Any] | None = None, +) -> ConversionResult: + """Convert without logging; :func:`convert_payload` logs the final losses once.""" source_version, target_version = _release(source_version), _release(target_version) result = deepcopy(payload) losses: list[ConversionLoss] = [] @@ -659,12 +729,4 @@ def convert_payload( result["adcp_version"] = target_version if direction != "request": _convert_embedded_results(tool, result, source_version, target_version, losses) - for loss in losses: - logger.warning( - "AdCP conversion %s -> %s dropped %s (%s)", - loss.source_version, - loss.target_version, - loss.pointer, - loss.reason, - ) return ConversionResult(result, tuple(losses)) diff --git a/src/adcp/server/mcp_tools.py b/src/adcp/server/mcp_tools.py index 28c6ecc63..26d01f8af 100644 --- a/src/adcp/server/mcp_tools.py +++ b/src/adcp/server/mcp_tools.py @@ -3198,6 +3198,7 @@ def create_tool_caller( ADCPVersionConversionError, ConversionResult, convert_payload, + log_conversion_summary, ) from adcp.exceptions import ADCPFeatureUnsupportedError, ADCPTaskError from adcp.server.helpers import inject_context @@ -3269,12 +3270,17 @@ def convert_version_payload( payload: dict[str, Any], *, direction: Direction, source_version: str, target_version: str ) -> ConversionResult: try: - return convert_payload( + # Seller side: dropping newer fields for an older buyer is the + # expected cost of serving it, and ToolContext.metadata records + # every loss. Log each loss at DEBUG and one INFO summary per + # conversion instead of a WARNING per field per item. + converted = convert_payload( method_name, payload, direction=direction, source_version=source_version, target_version=target_version, + loss_log_level=logging.DEBUG, ) except ADCPFeatureUnsupportedError as exc: details = None @@ -3296,6 +3302,13 @@ def convert_version_payload( ) ], ) from exc + log_conversion_summary( + method_name, + converted.losses, + source_version=source_version, + target_version=target_version, + ) + return converted async def call_tool(params: dict[str, Any], context: ToolContext | None = None) -> Any: ctx = context if context is not None else ToolContext() diff --git a/tests/test_version_conversion_logging.py b/tests/test_version_conversion_logging.py new file mode 100644 index 000000000..b965f6a09 --- /dev/null +++ b/tests/test_version_conversion_logging.py @@ -0,0 +1,198 @@ +"""Conversion losses log quietly on the seller and loudly on the buyer.""" + +from __future__ import annotations + +import json +import logging +from copy import deepcopy +from importlib.resources import files +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from adcp import ADCPClient +from adcp.compat.versions import ( + CONVERSION_METADATA_KEY, + ConversionLoss, + convert_payload, + log_conversion_summary, +) +from adcp.server import ADCPHandler, ToolContext +from adcp.server.mcp_tools import create_tool_caller +from adcp.server.responses import capabilities_response +from adcp.types import GetAdcpCapabilitiesResponse, GetProductsRequest +from adcp.types.core import AgentConfig, Protocol, TaskResult, TaskStatus +from adcp.validation import validate_response + +_LOGGER = "adcp.compat.versions" +_VECTORS = json.loads( + files("adcp") + .joinpath("_compliance/3.2.4/test-vectors/audience-evidence/vectors.json") + .read_text() +) +_PRODUCT_LOSSES = ( + "audience_evidence", + "included_signals/0/demographic_predicate", + "included_signals/0/restricted_attributes", +) + + +def _product(index: int) -> dict[str, Any]: + """A 3.2 product carrying the three fields ESA saw dropped for 3.1 buyers.""" + product = deepcopy(_VECTORS["product"]) + product["product_id"] = f"product_{index}" + product["audience_evidence"] = [deepcopy(_VECTORS["evidence"])] + product["included_signals"] = [ + { + "signal_ref": {"scope": "product", "signal_id": "adults_25_34"}, + "name": "Adults 25-34", + "value_type": "binary", + "demographic_predicate": {"age": {"min": 25, "max": 34, "include_unknown": False}}, + "restricted_attributes": ["age"], + } + ] + return product + + +def _records(caplog: pytest.LogCaptureFixture, level: int) -> list[logging.LogRecord]: + return [r for r in caplog.records if r.name == _LOGGER and r.levelno == level] + + +async def test_seller_answering_an_older_buyer_logs_one_summary_not_a_warning_per_field( + caplog: pytest.LogCaptureFixture, +) -> None: + response = { + "status": "completed", + "cache_scope": "public", + "products": [_product(index) for index in range(50)], + } + assert validate_response("get_products", response, version="3.2").valid + + class Seller(ADCPHandler): + adcp_capabilities = {"media_buy": {"features": {"canonical_creatives": True}}} + + async def get_products(self, params: dict[str, Any], context: ToolContext): + return deepcopy(response) + + context = ToolContext() + caplog.set_level(logging.DEBUG, logger=_LOGGER) + served = await create_tool_caller(Seller(), "get_products")( + {"adcp_version": "3.1", "buying_mode": "brief", "brief": "video"}, context + ) + + assert validate_response("get_products", served, version="3.1").valid + assert _records(caplog, logging.WARNING) == [] + debug = _records(caplog, logging.DEBUG) + assert len(debug) == 150 + (summary,) = _records(caplog, logging.INFO) + message = summary.getMessage() + assert "get_products 3.2 -> 3.1 dropped 150 field(s)" in message + assert message.endswith( + "/products/*/audience_evidence, " + "/products/*/included_signals/*/demographic_predicate, " + "/products/*/included_signals/*/restricted_attributes" + ) + # The metadata report keeps every exact, uncollapsed pointer. + losses = context.metadata[CONVERSION_METADATA_KEY]["losses"] + assert [loss["pointer"] for loss in losses] == [ + f"/products/{index}/{suffix}" for index in range(50) for suffix in _PRODUCT_LOSSES + ] + assert {(loss["source_version"], loss["target_version"]) for loss in losses} == {("3.2", "3.1")} + + +async def test_lossless_server_conversion_logs_nothing( + caplog: pytest.LogCaptureFixture, +) -> None: + class Seller(ADCPHandler): + adcp_capabilities = {"media_buy": {"features": {"canonical_creatives": True}}} + + async def get_products(self, params: dict[str, Any], context: ToolContext): + return {"products": [], "status": "completed", "cache_scope": "public"} + + caplog.set_level(logging.DEBUG, logger=_LOGGER) + await create_tool_caller(Seller(), "get_products")( + {"adcp_version": "3.1", "buying_mode": "brief", "brief": "video"} + ) + assert [r for r in caplog.records if r.name == _LOGGER] == [] + + +def test_summary_caps_the_distinct_pointers() -> None: + losses = tuple(ConversionLoss(f"/field_{index}", "3.2", "3.1") for index in range(12)) + logger = logging.getLogger(_LOGGER) + records: list[logging.LogRecord] = [] + + class Capture(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + records.append(record) + + handler = Capture() + logger.addHandler(handler) + previous = logger.level + logger.setLevel(logging.INFO) + try: + log_conversion_summary("sync_accounts", losses, source_version="3.2", target_version="3.1") + log_conversion_summary("sync_accounts", (), source_version="3.2", target_version="3.1") + finally: + logger.removeHandler(handler) + logger.setLevel(previous) + (record,) = records + assert record.getMessage().endswith("/field_8, /field_9, ... (+2 more)") + + +def test_direct_conversion_defaults_to_a_warning_per_loss( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level(logging.DEBUG, logger=_LOGGER) + result = convert_payload( + "get_products", + {"status": "completed", "cache_scope": "public", "products": [_product(0)]}, + direction="sync", + source_version="3.2", + target_version="3.1", + ) + assert len(result.losses) == 3 + assert len(_records(caplog, logging.WARNING)) == 3 + assert _records(caplog, logging.INFO) == [] + + +async def test_buyer_side_conversion_keeps_a_warning_per_loss( + caplog: pytest.LogCaptureFixture, +) -> None: + client = ADCPClient( + AgentConfig(id="seller", agent_uri="https://seller.example/mcp/", protocol=Protocol.MCP) + ) + declaration = capabilities_response( + ["media_buy"], adcp_version="3.1", supported_versions=["3.0", "3.1"] + ) + declaration["adcp"]["idempotency"] = {"supported": False} + client.fetch_capabilities = AsyncMock( + return_value=GetAdcpCapabilitiesResponse.model_validate(declaration) + ) + client.adapter._call_mcp_tool_wire = AsyncMock( + return_value=TaskResult( + status=TaskStatus.COMPLETED, + success=True, + data={ + "products": [], + "status": "completed", + "cache_scope": "public", + "adcp_version": "3.1", + }, + ) + ) + caplog.set_level(logging.DEBUG, logger=_LOGGER) + result = await client.get_products( + GetProductsRequest.model_validate( + { + "buying_mode": "brief", + "brief": "video", + "brand": {"domain": "example.com", "countries": ["US"]}, + } + ) + ) + assert result.success, result.error + (warning,) = _records(caplog, logging.WARNING) + assert "dropped /brand/countries" in warning.getMessage() + assert _records(caplog, logging.INFO) == [] + assert result.metadata[CONVERSION_METADATA_KEY]["losses"][0]["pointer"] == "/brand/countries" From fbb1930820ce32faf42ec003d8e19b89d4ee314b Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 15:49:18 -0400 Subject: [PATCH 42/54] docs(compat): handlers receive requests upgraded to the latest minor (#1611) Since 9.0.0 (#1559) server dispatch upgrades an older buyer's request before the handler runs, so resolve_requested_adcp_version(params) inside a handler returns the served version ("3.2" for an explicit 3.1 claim). Adopters branching on it lost those branches silently. Document the behaviour as the breaking change it is and point handlers at ToolContext.resolved_adcp_version for the buyer's negotiated contract. - expand resolve_requested_adcp_version's docstring - rewrite the ToolContext.resolved_adcp_version docstring (wire contract, server pin, MCP 3.0 / A2A None for unversioned traffic, capabilities) - add a section to docs/cross-version-conversion.md and a row to MIGRATION_v8_to_v9.md - pin the documented contract in the existing server dispatch test ESA feedback on 9.0 version conversion. Co-authored-by: Claude Opus 5.5 (1M context) --- MIGRATION_v8_to_v9.md | 1 + docs/cross-version-conversion.md | 36 ++++++++++++++++++++++++++ src/adcp/server/base.py | 20 +++++++++----- src/adcp/validation/envelope.py | 10 +++++++ tests/test_cross_version_conversion.py | 4 +++ 5 files changed, 65 insertions(+), 6 deletions(-) diff --git a/MIGRATION_v8_to_v9.md b/MIGRATION_v8_to_v9.md index b8b304869..0bf2642bb 100644 --- a/MIGRATION_v8_to_v9.md +++ b/MIGRATION_v8_to_v9.md @@ -14,6 +14,7 @@ changes below before upgrading each client, server and reporting worker. | 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. A validated declaration is published on `Product.format_options` as-is. `Format` stays the lenient path for reading and publishing stored declarations; `ProductFormatDeclaration` is the authoring check. See [product declarations](docs/types-9-migration.md#product-declarations-are-graded-against-their-root-schema) and [strict authoring and lenient publishing](docs/types-9-migration.md#strict-authoring-lenient-reading-and-publishing). | | 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). | +| Handlers receive requests upgraded to the latest minor | A 3.1 buyer's request reaches the handler as 3.2, so `resolve_requested_adcp_version(params)` and `params["adcp_version"]` return `"3.2"` inside a handler. Read the buyer's negotiated version from `ToolContext.resolved_adcp_version` (or `RequestContext.resolved_adcp_version` on decisioning platforms) and keep answering in the latest shapes; the SDK converts the response down. See [handlers and upgraded requests](docs/cross-version-conversion.md#handlers-receive-requests-upgraded-to-the-latest-minor). | | 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). | | The PostgreSQL reporting stores and row storage providers ship as `adcp-reporting-storage` | Install `adcp-reporting-storage[pg]` (and `gcs`/`s3`/`azure`) and import from `adcp_reporting_storage`; `ReliableReportingService.postgres` is `adcp_reporting_storage.postgres_reliable_reporting_service`. `adcp` keeps the protocols, the in-memory stores and the digest code. See [the storage package migration](docs/reporting-storage-package-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/cross-version-conversion.md b/docs/cross-version-conversion.md index 34fd938e9..f287ba1aa 100644 --- a/docs/cross-version-conversion.md +++ b/docs/cross-version-conversion.md @@ -10,6 +10,42 @@ For A2A, discovery uses a separate conversation and never changes a retained business context or task ID. Refreshing capabilities while approval is pending therefore preserves the original task for resumption. +## Handlers receive requests upgraded to the latest minor + +Since SDK 9.0.0, server dispatch upgrades an older buyer's request to the +latest AdCP minor packaged with the SDK before the handler runs. Handlers +always answer in the latest shapes; the SDK converts the response down to the +buyer's version. This is a breaking change from SDK 8, where handlers saw the +buyer's own envelope. + +A handler that tailors a response per version must read the buyer's version +from the context, not from `params`: + +```python +from adcp.server import ADCPHandler, ToolContext + + +class Seller(ADCPHandler): + async def get_products(self, params, context: ToolContext): + # A 3.1 buyer: params["adcp_version"] == "3.2" after the upgrade, + # and resolve_requested_adcp_version(params) also returns "3.2". + buyer_version = context.resolved_adcp_version # "3.1" + ... +``` + +`resolve_requested_adcp_version(params)` inside a handler returns the +converted (served) version for any explicit `adcp_version` claim, so branches +keyed on it silently stop matching older buyers. It still describes a raw wire +payload correctly, before dispatch. `ToolContext.resolved_adcp_version` is the +buyer's negotiated wire contract. A2A requests without any version envelope +leave it `None` and are served as the current SDK shape. Decisioning platforms +read the same value from `RequestContext.resolved_adcp_version`. + +Most handlers need no version logic: return the latest shapes and let the SDK +drop the fields an older buyer cannot represent. Branch on +`context.resolved_adcp_version` only when the business answer itself differs, +for example when withholding a product that an older buyer could not book. + `adcp_version` sets a negotiation ceiling. A request's explicit `adcp_version` overrides the constructor ceiling for that call. `server_version` declares a known peer version and bypasses automatic capability discovery. Application diff --git a/src/adcp/server/base.py b/src/adcp/server/base.py index 94fad3a99..a832bdb06 100644 --- a/src/adcp/server/base.py +++ b/src/adcp/server/base.py @@ -148,12 +148,20 @@ class ToolContext: headers, testing hooks). Downstream agents may subclass :class:`ToolContext` for typed fields; ``metadata`` is the escape hatch when subclassing isn't worth it. - :param resolved_adcp_version: Release-precision AdCP version resolved by - the transport dispatcher for this request. MCP unversioned native - traffic is pinned to ``"3.0"`` for compatibility; A2A unversioned - traffic leaves this as ``None`` and is served as the current SDK - shape. Handlers should read this only when business logic truly - depends on the buyer's wire contract. + :param resolved_adcp_version: The buyer's wire contract: the + release-precision AdCP version the transport dispatcher negotiated + for this request, before the SDK upgraded the request. A buyer + claiming ``adcp_version: "3.1"`` reads ``"3.1"`` here even though the + handler's params arrive upgraded to the latest minor, so this, not + :func:`adcp.validation.envelope.resolve_requested_adcp_version` on + those params, is the source for version-specific business logic. + A major-only envelope resolves to ``"3.0"``. Without any version + envelope, the server's pinned ``adcp_version`` applies if it has one; + otherwise MCP pins ``"3.0"`` for compatibility, while A2A leaves this + ``None`` and serves the current SDK shape. Unversioned + ``get_adcp_capabilities`` resolves to the highest supported release. + Handlers must answer in the latest shapes regardless of this value; + the SDK converts the response down to this version. """ request_id: str | None = None diff --git a/src/adcp/validation/envelope.py b/src/adcp/validation/envelope.py index 5911d0045..8f79f3a6d 100644 --- a/src/adcp/validation/envelope.py +++ b/src/adcp/validation/envelope.py @@ -166,6 +166,16 @@ def resolve_requested_adcp_version( Unsupported explicit claims, or an unnegotiated request whose default is not in ``supported``, raise :class:`UnsupportedVersionError`, just like :func:`detect_wire_version`. + + Call it on the raw wire payload, before server dispatch. Inside a server + handler, ``params`` have already been upgraded to the SDK's latest minor, + so this returns the converted (served) version: a buyer's explicit + ``adcp_version: "3.1"`` reads back as ``"3.2"``. Major-only and + unversioned payloads still resolve to ``"3.0"`` here, but their shapes + are upgraded too. To branch on the version the buyer negotiated, read + :attr:`adcp.server.ToolContext.resolved_adcp_version` instead. Handlers + answer in the latest shapes either way; the SDK converts the response + down to the buyer's version. See ``docs/cross-version-conversion.md``. """ resolved = detect_wire_version(payload, supported=supported) if resolved is not None: diff --git a/tests/test_cross_version_conversion.py b/tests/test_cross_version_conversion.py index ac53526b9..453ddafcd 100644 --- a/tests/test_cross_version_conversion.py +++ b/tests/test_cross_version_conversion.py @@ -22,6 +22,7 @@ from adcp.types import GetAdcpCapabilitiesResponse, GetProductsRequest, SyncAccountsRequest from adcp.types.core import AgentConfig, Protocol, TaskResult, TaskStatus from adcp.validation import ValidationHookConfig, validate_request, validate_response +from adcp.validation.envelope import resolve_requested_adcp_version def test_highest_common_version_and_call_ceiling() -> None: @@ -550,6 +551,9 @@ class Seller(ADCPHandler): async def get_products(self, params: dict[str, Any], context: ToolContext): assert params.get("adcp_version", "3.2") == "3.2" assert context.resolved_adcp_version == version + # Documented 9.0 contract: params read back as the served + # version; only the context carries an explicit 3.1 claim. + assert resolve_requested_adcp_version(params) == ("3.0" if version == "3.0" else "3.2") contexts.append(context) return {"products": [], "status": "completed", "cache_scope": "public"} From 5aea50b5a578c11907023d2df4fdb7403cb1c7af Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:52:02 -0400 Subject: [PATCH 43/54] chore(main): release 9.1.1 (#1612) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 7 +++++++ pyproject.toml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 82e144328..b97d97e1f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.1.0", + ".": "9.1.1", "packages/adcp-reporting-storage": "0.1.0-beta.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 9275eb91a..d179d4b2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,6 +169,13 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.1.1](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.1.0...v9.1.1) (2026-10-25) + + +### Bug Fixes + +* **compat:** log server-side conversion losses at DEBUG with one INFO summary ([#1609](https://github.com/adcontextprotocol/adcp-client-python/issues/1609)) ([f7e4f3a](https://github.com/adcontextprotocol/adcp-client-python/commit/f7e4f3a5f1c8acba40072df0af377927a0af74ec)) + ## [9.1.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.0.0...v9.1.0) (2026-10-25) diff --git a/pyproject.toml b/pyproject.toml index 16a8f0db6..03f5cc304 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.1.0" +version = "9.1.1" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From 6f61234cc2717e5e9cff2a824b28d3eb480af6f8 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 20:49:45 -0400 Subject: [PATCH 44/54] fix(testing): stop build_all_models hanging after a generated_poc alias import (#1626) The deprecated adcp.types.generated_poc.* alias registers the canonical adcp.types.domains.* module object under a second sys.modules key. _realize_lazy_exports filtered its snapshot on the sys.modules key but marked module.__name__ visited, so the alias key was never retired and the walk never terminated. Mark the key the snapshot filtered on and walk each module object once by identity, so an alias and its target are the same module. As a backstop the walk is capped at 100 passes (the SDK surface stabilizes in about 4); if it still finds newly imported modules after that it raises RuntimeError naming them instead of spinning. Fixes #1619 Co-authored-by: Claude Opus 5.5 (1M context) --- src/adcp/testing/models.py | 51 ++++++++++++++++++++------ tests/test_freezegun_models.py | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 11 deletions(-) diff --git a/src/adcp/testing/models.py b/src/adcp/testing/models.py index 5093493c2..6b01606b0 100644 --- a/src/adcp/testing/models.py +++ b/src/adcp/testing/models.py @@ -10,29 +10,58 @@ from adcp._deferred_adapters import build_registered_adapters +#: Passes the lazy-export walk may take before it is declared non-terminating. +#: A pass only follows one that imported new modules; the SDK's own surface +#: stabilizes in a handful, so this is a backstop, not a tuning knob. +_MAX_REALIZE_PASSES = 100 + def _realize_lazy_exports() -> None: # Freezegun enumerates dir(module), including unbound lazy names. Resolve the # same advertised surfaces before it patches datetime. Imports can introduce # additional lazy facades, so take fresh snapshots until the walk stabilizes. + # + # A module can sit in sys.modules under more than one name: the deprecated + # ``adcp.types.generated_poc.*`` alias registers the canonical + # ``adcp.types.domains.*`` object under its old name, so the KEY is not the + # object's ``__name__``. Mark the key the snapshot filtered on (so every pass + # retires what it found) and walk each module object once. importlib.import_module("adcp.types") - visited: set[str] = set() - while True: - modules = [ - module + seen_names: set[str] = set() + walked: dict[int, ModuleType] = {} + + def unseen() -> list[tuple[str, ModuleType]]: + return [ + (name, module) for name, module in tuple(sys.modules.items()) if (name == "adcp" or name.startswith("adcp.")) and isinstance(module, ModuleType) - and name not in visited + and name not in seen_names ] - if not modules: + + for _ in range(_MAX_REALIZE_PASSES): + pending = unseen() + if not pending: return - for module in modules: - visited.add(module.__name__) + for name, module in pending: + seen_names.add(name) + if id(module) in walked: + continue + # Holding the object keeps its id from being reused by another. + walked[id(module)] = module if "__getattr__" in vars(module): - for name in dir(module): - if not name.startswith("_"): - getattr(module, name) + for attr in dir(module): + if not attr.startswith("_"): + getattr(module, attr) + remaining = sorted(name for name, _ in unseen()) + if not remaining: + return + raise RuntimeError( + f"build_all_models(): realizing lazy exports did not stabilize after " + f"{_MAX_REALIZE_PASSES} passes; the last pass still imported " + f"{', '.join(remaining[:5])}" + + (f" and {len(remaining) - 5} more" if len(remaining) > 5 else "") + ) def build_all_models() -> None: diff --git a/tests/test_freezegun_models.py b/tests/test_freezegun_models.py index 7667d7f74..0fe01e197 100644 --- a/tests/test_freezegun_models.py +++ b/tests/test_freezegun_models.py @@ -7,6 +7,7 @@ import subprocess import sys from pathlib import Path +from types import ModuleType import pytest @@ -130,3 +131,69 @@ def test_sdk_has_no_module_level_type_adapter_construction() -> None: ): offenders.append(f"{path.relative_to(root)}:{node.lineno}") assert not offenders + + +def test_build_all_models_returns_after_a_generated_poc_alias_import() -> None: + # #1619: the deprecated alias registers the canonical module object under a + # second sys.modules key, which used to make the lazy-export walk spin + # forever. A subprocess with a timeout turns a regression into a failure + # instead of a hung CI job. + program = r""" +import sys +import warnings + +warnings.simplefilter("ignore", DeprecationWarning) +import adcp.types.generated_poc.core.format_id as alias +import adcp.types.domains.core.format_id as canonical +assert alias is canonical +# A split name is served by its own shim module rather than the canonical object. +from adcp.types.generated_poc.brand import Brand # noqa: F401 +assert "adcp.types.generated_poc.brand" in sys.modules + +from adcp.testing import build_all_models +build_all_models() +build_all_models() +print("ok") +""" + try: + result = subprocess.run( + [sys.executable, "-c", program], + capture_output=True, + text=True, + timeout=120, + ) + except subprocess.TimeoutExpired: + pytest.fail("build_all_models() did not return after a generated_poc alias import") + assert result.returncode == 0, result.stdout + result.stderr + assert "ok" in result.stdout + + +def test_lazy_export_walk_that_never_stabilizes_raises_naming_the_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from adcp.testing import models + + created: list[str] = [] + + def facade(index: int) -> ModuleType: + name = f"adcp._test_endless_facade_{index}" + module = ModuleType(name) + + def realize(attr: str) -> object: + # Every realized facade imports a brand-new facade. + child = facade(index + 1) + monkeypatch.setitem(sys.modules, child.__name__, child) + created.append(child.__name__) + return None + + module.__getattr__ = realize # type: ignore[method-assign] + module.__dir__ = lambda: ["export"] # type: ignore[method-assign] + return module + + monkeypatch.setattr(models, "_MAX_REALIZE_PASSES", 5) + first = facade(0) + monkeypatch.setitem(sys.modules, first.__name__, first) + + with pytest.raises(RuntimeError, match="did not stabilize") as excinfo: + models._realize_lazy_exports() + assert created[-1] in str(excinfo.value) From 4e2ae8fc153a662ec67bbb24ef478621b37570db Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 21:37:40 -0400 Subject: [PATCH 45/54] fix(adagents): strip surrounding whitespace in normalize_url (#1629) normalize_url(" https://interchange.io ") returned "interchange.io " because urlparse drops leading whitespace but keeps trailing whitespace in the path, so an adagents.json entry with a padded url did not match its agent. Strip before parsing; _normalize_domain already does the same. Signing paths keep byte-exact canonicalization: canonicalize_target_uri is a signature-base canonicalizer, and the brand.json / adagents entry matches in signing select keys or the signer identity and fail closed. Fixes #1624 Co-authored-by: Claude Opus 5.5 (1M context) --- src/adcp/adagents.py | 5 +++-- tests/test_adagents_matching.py | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/adcp/adagents.py b/src/adcp/adagents.py index ef487d46a..54a9101b8 100644 --- a/src/adcp/adagents.py +++ b/src/adcp/adagents.py @@ -475,9 +475,10 @@ def normalize_url(url: str) -> str: Paths remain case-sensitive. Query strings and fragments are ignored, and trailing path slashes are removed, preserving the existing matching rules. Only the default port for the supplied scheme is removed (80 for HTTP, - 443 for HTTPS); non-default ports remain significant. + 443 for HTTPS); non-default ports remain significant. Surrounding + whitespace is stripped, so a padded adagents.json ``url`` still matches. """ - parsed = urlparse(url) + parsed = urlparse(url.strip()) authority = parsed.netloc if parsed.hostname is not None: host = parsed.hostname.lower() diff --git a/tests/test_adagents_matching.py b/tests/test_adagents_matching.py index 0fe2559c5..ffa80eb30 100644 --- a/tests/test_adagents_matching.py +++ b/tests/test_adagents_matching.py @@ -45,6 +45,9 @@ def document(url: Any = AGENT, tags: list[str] | None = None) -> dict[str, Any]: "https://SALES.Example.com:443/mcp/", "http://sales.example.com:80/mcp", "https://sales.example.com/mcp/?q=x#fragment", + # #1624: stray whitespace around a listed url must not hide the agent. + " https://sales.example.com/mcp ", + "\thttps://sales.example.com/mcp/\n", ], ) def test_normalization_retains_protocol_and_path_contract(url: str) -> None: @@ -98,3 +101,9 @@ def test_listing_distinguishes_unbound_and_unlisted_and_retains_duplicates() -> matches = find_authorized_agent_entries(unbound, AGENT) assert matches == unbound["authorized_agents"] assert matches[0] is unbound["authorized_agents"][0] + + +def test_normalization_strips_surrounding_whitespace_on_either_side() -> None: + assert normalize_url(" https://interchange.io ") == "interchange.io" + assert normalize_url(" https://interchange.io ") == normalize_url("https://interchange.io") + assert find_authorized_agent_entries(document(), f" {AGENT} ") From 88cf04a1cd684595b6ae58712cec42e01590c58f Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 21:47:36 -0400 Subject: [PATCH 46/54] fix(compat): infer display_tag and every 3.2 asset type from the asset key (#1628) coerce_legacy_assets() / infer_asset_type() let an exact type-name key win over field presence, but the type names came from a hand-kept list of 14 that predated AdCP 3.2. An untyped asset under `display_tag` was inferred as text, url or image from its other keys. Derive the vocabulary from the generated AssetVariant union instead, so the key list is the 21 asset types the pinned schema declares (adds display_tag, zip, card, published_post, pixel_tracker, vast_tracker, daast_tracker) and a schema bump extends it in the same regeneration. Imported lazily on first use. Fixes #1624 Co-authored-by: Claude Opus 5.5 (1M context) --- src/adcp/types/_legacy_assets.py | 51 ++++++++++++++++++------------ tests/test_legacy_asset_helpers.py | 23 ++++++++++++++ 2 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/adcp/types/_legacy_assets.py b/src/adcp/types/_legacy_assets.py index 89631ac3f..f1f5ef43a 100644 --- a/src/adcp/types/_legacy_assets.py +++ b/src/adcp/types/_legacy_assets.py @@ -1,37 +1,46 @@ """Pure, opt-in normalization of pre-v3 creative asset dictionaries.""" from collections.abc import Mapping -from typing import Any +from functools import cache +from typing import Any, get_args -_KNOWN_ASSET_TYPES = frozenset( - { - "image", - "video", - "audio", - "vast", - "text", - "url", - "html", - "javascript", - "webhook", - "css", - "daast", - "markdown", - "brief", - "catalog", - } -) +from pydantic import RootModel + + +@cache +def _known_asset_types() -> frozenset[str]: + """Every ``asset_type`` the pinned schema's canonical asset union routes. + + Derived from the generated ``AssetVariant`` arms rather than listed here, + so a schema bump that adds an asset type (as 3.2 added ``display_tag``) + extends key-name inference in the same regeneration. Imported on first use: + the generated asset graph is not needed to import the legacy helpers. + """ + from adcp.types.domains.core.assets.asset_union import AssetVariant + + union = get_args(AssetVariant)[0] + types: set[str] = set() + for arm in get_args(union): + # A multi-shape asset (``vast``, ``display_tag``, ...) is a RootModel + # over per-shape classes that share one ``asset_type`` literal. + shapes = ( + get_args(arm.model_fields["root"].annotation) if issubclass(arm, RootModel) else (arm,) + ) + for shape in shapes: + types.update(get_args(shape.model_fields["asset_type"].annotation)) + return frozenset(types) def infer_asset_type(asset_key: str, asset: Mapping[str, Any]) -> str | None: """Infer a missing discriminator from an exact key, then field presence. - Type-name keys take precedence over fields. Otherwise URL plus both + Keys naming any asset_type the pinned schema declares (``display_tag``, + ``card``, ...) take precedence over fields. Otherwise URL plus both dimensions implies image, URL alone implies url, and content implies text. Unknown keys without those fields return None for validation to diagnose. This function does not inspect or replace an existing discriminator. """ - if asset_key in _KNOWN_ASSET_TYPES: + if asset_key in _known_asset_types(): return asset_key if "url" in asset: return "image" if "width" in asset and "height" in asset else "url" diff --git a/tests/test_legacy_asset_helpers.py b/tests/test_legacy_asset_helpers.py index b6b4c13bd..8c488d47d 100644 --- a/tests/test_legacy_asset_helpers.py +++ b/tests/test_legacy_asset_helpers.py @@ -66,6 +66,29 @@ def test_rules_match_both_adapters(key, asset, inferred, normalized): assert asset == original +def test_display_tag_key_is_inferred_as_display_tag(): + # #1624: a 3.2 display_tag asset was inferred as text from its content. + tag = "" + assert coerce_legacy_assets({"display_tag": {"content": tag}}) == { + "display_tag": {"content": tag, "asset_type": "display_tag"} + } + assert coerce_legacy_assets({"display_tag": {"url": "https://ads.example/tag"}}) == { + "display_tag": {"url": "https://ads.example/tag", "asset_type": "display_tag"} + } + + +def test_key_inference_covers_every_asset_type_the_schema_declares(): + from adcp.types.domains.core import format as format_module + + # The format slot union derives its own vocabulary from the same pin; the + # creative-asset vocabulary key inference uses must not lag behind it. + declared = format_module._KNOWN_BASE_INDIVIDUAL_ASSET_TYPES + assert "display_tag" in declared + for asset_type in declared: + assert infer_asset_type(asset_type, {"content": "x"}) == asset_type + assert infer_asset_type(asset_type, {"url": "https://cdn.example/a"}) == asset_type + + def test_public_reexports_and_mapping_immutability(): assert legacy.infer_asset_type is infer_asset_type assert legacy.coerce_legacy_asset is coerce_legacy_asset From b5234e221b539abb7c6797aa278e9c2874ea38fa Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 22:06:52 -0400 Subject: [PATCH 47/54] fix(compat): never downgrade into a payload the older schema refuses (#1630) * fix(compat): never downgrade into a payload the older schema refuses Projection dropped what the target release could not hold but never checked that the parent it left behind was still valid. A notification config whose event types were all 3.2-only kept event_types: [] below 3.1's minItems: 1, and a structured 3.2 geo_regions capability became {} where 3.1 allows only a boolean. When a node loses or coarsens a direct child, its own shape keywords (required, minItems, minProperties, unions, if/then) are now checked against the older schema. If the newer schema accepted the original and the older one refuses the remainder: - a boolean | object capability whose older contract is boolean maps to true when the structure declares any support (coarsened_to_boolean) - an optional field is omitted (unrepresentable_in_target) - a response array item is omitted (unrepresentable_in_target) - a request array item is never dropped: a shortened sync list can read as removal under delete_missing, so the nearest optional field holding it is omitted instead - a required chain to the root raises ADCPVersionConversionError at the refused value Every downgrade then validates against the older bundled schema and raises a structured error for anything still refused, such as a required enum member the older release cannot spell. Payloads already invalid in their own release are projected but neither repaired nor rejected. Also fixes a crash resolving $ref pointers that index into a list (#/oneOf/N, comply_test_controller). A new sweep converts every bundled 3.2 schema example, the canonical parity cases, the audience-evidence vectors and new fixtures down to 3.1 and 3.0. Before: 21 of 168 conversions failed (19 refused by the target schema, 2 crashes). After: 0; 10 are listed, intentional structured errors. Fixes #1621 Co-Authored-By: Claude Opus 5.5 (1M context) * fix(compat): blame only drops that cause a refusal; never repair requests Review follow-ups: - A node is dropped only when the drop caused the refusal: the target must accept it with the dropped children restored. Otherwise the final check raises, so a 3.1 delivery row missing spend is not silently discarded just because a 3.2-only sibling was also dropped. - Requests are never repaired by omission: an absent filter widens the search and a shortened sync list can read as removal. They fail with a structured error instead. - The final check validates against the same portable schemas the projection used, without the validator's payload size cap. - Structured-error reasons are value-free; pointers from the final check locate the value in the converted payload. - Chained $ref nodes resolve fully for the shape checks. - convert_payload(require_valid_target=False) for evidence reconstruction, used by the negotiated catalog purchase round-trip. Co-Authored-By: Claude Opus 5.5 (1M context) * fix(compat): keep the final check on for status-less responses The SDK validators default a missing status envelope; the final check now does the same, so a direct caller converting a status-less 3.2 response still gets the guarantee. Also restore only removed (not coarsened) list items when attributing a refusal, and word false-schema refusals. Co-Authored-By: Claude Opus 5.5 (1M context) --------- Co-authored-by: Claude Opus 5.5 (1M context) --- docs/cross-version-conversion.md | 45 ++ .../compat/negotiated_catalog_purchase.py | 3 + src/adcp/compat/versions.py | 410 ++++++++++++++++-- tests/fixtures/version_conversion_sweep.json | 105 +++++ tests/test_cross_version_conversion.py | 185 ++++++++ tests/test_version_conversion_sweep.py | 189 ++++++++ 6 files changed, 890 insertions(+), 47 deletions(-) create mode 100644 tests/fixtures/version_conversion_sweep.json create mode 100644 tests/test_version_conversion_sweep.py diff --git a/docs/cross-version-conversion.md b/docs/cross-version-conversion.md index f287ba1aa..2fadf6b4c 100644 --- a/docs/cross-version-conversion.md +++ b/docs/cross-version-conversion.md @@ -110,6 +110,51 @@ results supplied on `list_tasks` entries. Loss pointers include `/result` or `/tasks//result`. Unknown vendor task results remain opaque. Nested polling wrappers remain opaque as well, bounding conversion to one wrapper level. +## Downgrades never emit a payload the older schema refuses + +Dropping a newer field or enum member can leave its parent in a shape the +older release refuses: a notification config whose `event_types` were all +3.2-only becomes an empty list below 3.1's `minItems: 1`. When a node loses or +coarsens a direct child, the SDK checks the node's own constraints in the older +schema (`required`, `minItems`, `minProperties`, unions, `if`/`then`). A +refusal counts as caused by the drop only when the newer schema accepted the +original and the older schema accepts the node with the dropped children put +back. Such a node is resolved by one rule: + +| Situation | Result | Loss `reason` | +| --- | --- | --- | +| Newer `boolean \| object` capability, older boolean only (3.2 `geo_regions`) | `true` when the structure declares any support, otherwise `false` | `coarsened_to_boolean` | +| Response: refused node is an optional field | Field omitted | `unrepresentable_in_target` | +| Response: refused node is an array item | Item omitted | `unrepresentable_in_target` | +| Request: any refused node | `ADCPVersionConversionError` | none | +| Response: only required fields lead to the root | `ADCPVersionConversionError` | none | + +Requests are never repaired by omission, because omitting a request field +changes its meaning: a shortened sync list can read as removal under +`delete_missing`, and an absent filter matches everything. A request whose +`filters.required_metrics` were all 3.2-only therefore fails rather than +widening the search. Optional newer members are still dropped one at a time +(`enum_unavailable`) when others remain, as before. Losses already recorded +inside a dropped response node stay in the report, explaining why it went. + +Every downgrade then validates the result against the older release's bundled +schema. A refusal the projection did not cause fails with +`ADCPVersionConversionError` instead of reaching the peer invalid. Examples are +a field the older release requires and the payload never had, such as 3.1 +delivery `totals.spend`, and a required enum member the older release cannot +spell. That error's pointer locates the value in the converted payload. Its +reason never echoes payload values. A payload already invalid in its own +release is projected but neither repaired nor rejected; the caller's +validation policy decides. The server returns these errors as +`VERSION_UNSUPPORTED`, including after a handler has run. The client returns a +failed `TaskResult` with `adcp_error.code="UNSUPPORTED_FEATURE"`. Code that +reconstructs a peer's own evidence, rather than sending a payload, can pass +`require_valid_target=False` to `convert_payload` to skip the final check. + +`tests/test_version_conversion_sweep.py` converts every bundled 3.2 schema +example and fixture down to 3.1 and 3.0 and requires each result to validate, +or to fail with one of the structured errors it lists. + ## Conversion reports Client results expose conversion details in diff --git a/src/adcp/compat/negotiated_catalog_purchase.py b/src/adcp/compat/negotiated_catalog_purchase.py index 0896a3278..ea30ac0a9 100644 --- a/src/adcp/compat/negotiated_catalog_purchase.py +++ b/src/adcp/compat/negotiated_catalog_purchase.py @@ -1014,6 +1014,9 @@ def _completed_payload( # Round-trips the client's own lossless upgrade back to # the served contract; only upgrade defaults can drop. loss_log_level=logging.DEBUG, + # Reconstructs the seller's evidence faithfully, even + # where the seller's own response was invalid. + require_valid_target=False, ).payload return payload diff --git a/src/adcp/compat/versions.py b/src/adcp/compat/versions.py index 88afac718..65aae532e 100644 --- a/src/adcp/compat/versions.py +++ b/src/adcp/compat/versions.py @@ -14,6 +14,9 @@ from functools import lru_cache from typing import Any +from jsonschema.exceptions import best_match +from jsonschema.validators import validator_for + from adcp._version import ( get_supported_adcp_versions, normalize_to_release_precision, @@ -27,6 +30,9 @@ _OPAQUE_FIELDS = frozenset({"context", "ext"}) _ARRAY_INDEX = re.compile(r"/\d+(?=/|$)") _SUMMARY_POINTER_LIMIT = 10 +#: Compiled node validators keyed by schema identity; see :func:`_refusal`. +_NODE_VALIDATORS: dict[tuple[int, int, bool], tuple[Any, Any, Any]] = {} +_NODE_VALIDATOR_LIMIT = 4096 @dataclass(frozen=True) @@ -50,6 +56,19 @@ def metadata(self) -> list[dict[str, Any]]: return [asdict(loss) for loss in self.losses] +class _UnrepresentableError(Exception): + """A projected node the target schema refuses; its parent decides what survives. + + ``pointer`` locates the refused value for the structured error; the + parent drops the whole child it was projecting, not just that value. + """ + + def __init__(self, pointer: str, message: str) -> None: + super().__init__(message) + self.pointer = pointer + self.message = message + + class ADCPVersionConversionError(ADCPFeatureUnsupportedError): """A payload cannot be represented without inventing state or changing a clear.""" @@ -131,7 +150,8 @@ def _resolve(schema: Any, root: dict[str, Any]) -> dict[str, Any]: if isinstance(ref, str) and ref.startswith("#/"): resolved: Any = root for segment in ref[2:].split("/"): - resolved = resolved[segment.replace("~1", "/").replace("~0", "~")] + segment = segment.replace("~1", "/").replace("~0", "~") + resolved = resolved[int(segment) if isinstance(resolved, list) else segment] return {**resolved, **{key: value for key, value in schema.items() if key != "$ref"}} return schema @@ -209,6 +229,143 @@ def _property(schema: dict[str, Any], key: str) -> Any: return additional if isinstance(additional, dict) else None +#: Keywords a node's remaining shape can fail once a direct child is dropped or +#: coarsened, e.g. a required identity whose only newer spelling was removed. +_SHAPE_KEYWORDS = frozenset( + { + "anyOf", + "contains", + "dependencies", + "if", + "minItems", + "minProperties", + "not", + "oneOf", + "required", + } +) + + +def _resolve_chain(schema: Any, root: dict[str, Any]) -> dict[str, Any]: + """Follow ``$ref`` until it reaches a node with its own keywords.""" + resolved = _resolve(schema, root) + for _ in range(32): + if "$ref" not in resolved: + break + resolved = _resolve(resolved, root) + return resolved + + +def _shape_keywords(schema: Any, root: dict[str, Any]) -> frozenset[str]: + """The node's own constraints that removing children could violate.""" + resolved = _resolve_chain(schema, root) + found = _SHAPE_KEYWORDS & resolved.keys() + for branch in resolved.get("allOf", []): + found |= _shape_keywords(branch, root) + return frozenset(found) + + +def _shape_schema(schema: Any, root: dict[str, Any]) -> dict[str, Any]: + """The node's shape keywords alone, without ``properties`` or ``items``.""" + resolved = _resolve_chain(schema, root) + own = {key: resolved[key] for key in _SHAPE_KEYWORDS if key in resolved} + if "if" in own: + own.update({key: resolved[key] for key in ("then", "else") if key in resolved}) + branches = [_shape_schema(branch, root) for branch in resolved.get("allOf", [])] + return {**own, "allOf": [branch for branch in branches if branch]} if branches else own + + +def _refusal( + schema: Any, root: dict[str, Any], value: Any, *, shape: bool = True +) -> tuple[str, str] | None: + """Return where and why ``schema`` refuses ``value``, or ``None``. + + With ``shape`` only the node's own shape keywords run (see + :func:`_shape_schema`); its children are checked where they were + projected. Otherwise the whole subtree is validated. The pointer is + relative to ``value``. Draft-07 ignores ``$ref`` siblings, so the + wrapper validates only the node while every ``#/...`` pointer in it + still resolves against the original document. + """ + if not isinstance(schema, dict) or not schema: + return None + key = (id(schema), id(root), shape) + cached = _NODE_VALIDATORS.get(key) + if cached is None or cached[0] is not schema or cached[1] is not root: + if len(_NODE_VALIDATORS) >= _NODE_VALIDATOR_LIMIT: + _NODE_VALIDATORS.clear() + document = { + **root, + "$ref": "#/$defs/__adcp_conversion_node", + "$defs": { + **root.get("$defs", {}), + "__adcp_conversion_node": _shape_schema(schema, root) if shape else schema, + }, + } + cached = (schema, root, validator_for(root)(document)) + _NODE_VALIDATORS[key] = cached + try: + error = best_match(cached[2].iter_errors(value)) + except Exception: # noqa: BLE001 - an unresolvable node is checked whole later + return None + if error is None: + return None + pointer = "" + for key in error.absolute_path: + pointer = _pointer(pointer, key) + return pointer, _describe(error) + + +def _describe(error: Any) -> str: + """A value-free reason: diagnostics never echo payload contents.""" + keyword = error.validator + if keyword == "required": + missing = [name for name in error.validator_value if name not in error.instance] + return f"missing required field(s): {', '.join(map(str, missing))}" + if keyword in {"minItems", "minProperties"}: + unit = "item" if keyword == "minItems" else "field" + return f"needs at least {error.validator_value} {unit}(s)" + if keyword in {"enum", "const"}: + return "value has no spelling in the older release" + if keyword is None: + return "the older schema allows no value here" + return f"fails the older schema's {keyword} constraint" + + +def _coarsens_to_boolean(source: Any, target: dict[str, Any], root: dict[str, Any]) -> bool: + """A ``boolean | object`` capability whose older contract kept only the boolean. + + The union itself says how to map down: the boolean is the coarse spelling + of the structured declaration (e.g. 3.2 ``geo_regions``). + """ + types = target.get("type") + if types != "boolean" and not ( + isinstance(types, list) and "boolean" in types and "object" not in types + ): + return False + resolved = _resolve(source, root) + arms = resolved.get("oneOf", resolved.get("anyOf", [])) + return any(_resolve(arm, root).get("type") == "boolean" for arm in arms) and any( + _resolve(arm, root).get("type") == "object" or "properties" in _resolve(arm, root) + for arm in arms + ) + + +def _declares_support(value: Any) -> bool: + """Whether a structured capability declares anything supported.""" + if isinstance(value, dict): + return any(_declares_support(child) for child in value.values()) + if isinstance(value, list): + return any(_declares_support(child) for child in value) + if isinstance(value, bool): + return value + return value is not None + + +def _parent_pointer(pointer: str) -> str: + return pointer.rsplit("/", 1)[0] + + def _project( value: Any, source: Any, @@ -220,10 +377,15 @@ def _project( target_version: str, losses: list[ConversionLoss], *, - drop_unavailable_nulls: bool = False, + response: bool = False, ) -> Any: + raw_source, raw_target = source, target + first_loss = len(losses) source = _view(source, value, source_root) target = _view(target, value, target_root) + if isinstance(value, dict) and _coarsens_to_boolean(raw_source, target, source_root): + losses.append(ConversionLoss(path, source_version, target_version, "coarsened_to_boolean")) + return _declares_support(value) if isinstance(value, dict): if target_version == "3.0" and source.get("title") in { "Postal Area", @@ -266,7 +428,7 @@ def _project( target_child = _property(target, key) if source_child is not None and target_child is None: if ( - drop_unavailable_nulls + response and not path and key == "message" and target.get("additionalProperties", True) is not False @@ -276,7 +438,7 @@ def _project( projected[key] = child continue if ( - not drop_unavailable_nulls + not response and child is None and _allows_null(_view(source_child, child, source_root)) ): @@ -299,7 +461,7 @@ def _project( source_view = _view(source_child, child, source_root) target_view = _view(target_child, child, target_root) if ( - (not drop_unavailable_nulls or key in target["required"]) + (not response or key in target["required"]) and child is None and _allows_null(source_view) and _rejects_null(target_view) @@ -310,12 +472,12 @@ def _project( target_version=target_version, reason=( "the older contract cannot express an explicit null clear" - if not drop_unavailable_nulls + if not response else "the older contract requires a value unavailable in this response" ), ) if ( - drop_unavailable_nulls + response and child is None and key not in target["required"] and _allows_null(source_view) @@ -326,8 +488,8 @@ def _project( ) continue # An optional enum member introduced by the newer release cannot - # be sent to the old peer. Required values remain for validation - # to reject rather than manufacturing a different meaning. + # be sent to the old peer. A required value is never replaced by + # a different meaning; the target check fails it structurally. if ( not isinstance(child, (dict, list)) and "enum" in source_view @@ -340,18 +502,40 @@ def _project( ConversionLoss(child_path, source_version, target_version, "enum_unavailable") ) continue - projected[key] = _project( - child, - source_child, - target_child, - source_root, - target_root, - child_path, - source_version, - target_version, - losses, - drop_unavailable_nulls=drop_unavailable_nulls, - ) + try: + projected[key] = _project( + child, + source_child, + target_child, + source_root, + target_root, + child_path, + source_version, + target_version, + losses, + response=response, + ) + except _UnrepresentableError: + if key in target["required"] or not response: + # Omitting a request field changes its meaning, e.g. an + # absent filter matches everything; the caller decides. + raise + losses.append( + ConversionLoss( + child_path, source_version, target_version, "unrepresentable_in_target" + ) + ) + _check_representable( + projected, + value, + raw_source, + raw_target, + source_root, + target_root, + path, + losses, + first_loss, + ) return projected if isinstance(value, list): projected_items = [] @@ -371,24 +555,94 @@ def _project( ) ) continue - projected_items.append( - _project( - child, - source.get("items", {}), - target.get("items", {}), - source_root, - target_root, - _pointer(path, index), - source_version, - target_version, - losses, - drop_unavailable_nulls=drop_unavailable_nulls, + try: + projected_items.append( + _project( + child, + source.get("items", {}), + target.get("items", {}), + source_root, + target_root, + _pointer(path, index), + source_version, + target_version, + losses, + response=response, + ) ) - ) + except _UnrepresentableError: + if not response: + # A shortened request list can read as removal, e.g. a + # sync with delete_missing. + raise + losses.append( + ConversionLoss( + _pointer(path, index), + source_version, + target_version, + "unrepresentable_in_target", + ) + ) + _check_representable( + projected_items, + value, + raw_source, + raw_target, + source_root, + target_root, + path, + losses, + first_loss, + ) return projected_items return value +def _check_representable( + projected: Any, + value: Any, + source: Any, + target: Any, + source_root: dict[str, Any], + target_root: dict[str, Any], + path: str, + losses: list[ConversionLoss], + first_loss: int, +) -> None: + """Refuse a node whose own projection left a shape the target rejects. + + Only nodes that lost or coarsened a direct child are checked, and only + when the drop caused the refusal: the source accepted the original and + the target accepts the node with those children put back. A refusal + the drop did not cause, such as a field the older release requires and + the payload never had, is left to :func:`_require_target_valid`. + Projection must not turn valid data invalid, but it is not a validator + for data that arrived invalid. + """ + dropped = [ + loss.pointer.rsplit("/", 1)[1].replace("~1", "/").replace("~0", "~") + for loss in losses[first_loss:] + if _parent_pointer(loss.pointer) == path + ] + if not dropped or not _shape_keywords(target, target_root): + return + refusal = _refusal(target, target_root, projected) + if refusal is None or _refusal(source, source_root, value) is not None: + return + if isinstance(value, dict): + restored: Any = {**projected, **{key: value[key] for key in dropped if key in value}} + else: + # Only removed items are restored; a coarsened item is still present. + removed = { + int(loss.pointer.rsplit("/", 1)[1]) + for loss in losses[first_loss:] + if _parent_pointer(loss.pointer) == path and loss.reason != "coarsened_to_boolean" + } + restored = [*projected, *(value[index] for index in sorted(removed))] + if _refusal(target, target_root, restored) is None: + raise _UnrepresentableError(path + refusal[0], refusal[1]) + + def _allows_null(schema: dict[str, Any]) -> bool: types = schema.get("type", []) return ( @@ -506,6 +760,7 @@ def _convert_embedded_results( source_version: str, target_version: str, losses: list[ConversionLoss], + require_valid_target: bool, ) -> None: """Use task identity for opaque polling artifacts, bounded to one wrapper level.""" if tool == "get_task_status": @@ -544,6 +799,7 @@ def _convert_embedded_results( direction=direction, source_version=source_version, target_version=target_version, + require_valid_target=require_valid_target, ) except ADCPVersionConversionError as exc: raise ADCPVersionConversionError( @@ -570,6 +826,7 @@ def convert_payload( target_version: str, request: dict[str, Any] | None = None, loss_log_level: int = logging.WARNING, + require_valid_target: bool = True, ) -> ConversionResult: """Chain adjacent minor conversions without modifying the caller's data. @@ -582,6 +839,11 @@ def convert_payload( a buyer losing a seller's data keeps ``WARNING``, while a seller dropping newer fields for an older buyer expects those losses and logs them at ``DEBUG`` (see :func:`log_conversion_summary`). + + A downgrade raises :class:`ADCPVersionConversionError` rather than return + a payload the older schema refuses. ``require_valid_target=False`` skips + only that final check, for callers reconstructing a peer's own evidence + rather than sending a payload; projection repairs still apply. """ converted = _convert_payload( tool, @@ -590,6 +852,7 @@ def convert_payload( source_version=source_version, target_version=target_version, request=request, + require_valid_target=require_valid_target, ) for loss in converted.losses: logger.log( @@ -642,6 +905,7 @@ def _convert_payload( source_version: str, target_version: str, request: dict[str, Any] | None = None, + require_valid_target: bool = True, ) -> ConversionResult: """Convert without logging; :func:`convert_payload` logs the final losses once.""" source_version, target_version = _release(source_version), _release(target_version) @@ -706,18 +970,28 @@ def _convert_payload( ) result.setdefault("cache_scope", scope) if step < 0: - result = _project( - result, - source_schema, - target_schema, - source_schema, - target_schema, - "", - source, - target, - losses, - drop_unavailable_nulls=direction != "request", - ) + try: + result = _project( + result, + source_schema, + target_schema, + source_schema, + target_schema, + "", + source, + target, + losses, + response=direction != "request", + ) + except _UnrepresentableError as exc: + raise ADCPVersionConversionError( + pointer=exc.pointer, + source_version=source, + target_version=target, + reason=( + f"the older contract cannot represent the remaining value: {exc.message}" + ), + ) from None minor += step # The version envelope describes the boundary's contract, including # upgrades before application code. 3.0 has only the major envelope. @@ -728,5 +1002,47 @@ def _convert_payload( elif direction != "request" or "adcp_version" in payload: result["adcp_version"] = target_version if direction != "request": - _convert_embedded_results(tool, result, source_version, target_version, losses) + _convert_embedded_results( + tool, result, source_version, target_version, losses, require_valid_target + ) + if step < 0 and require_valid_target: + _require_target_valid(tool, direction, payload, result, source_version, target_version) return ConversionResult(result, tuple(losses)) + + +def _require_target_valid( + tool: str, + direction: Direction, + payload: dict[str, Any], + result: dict[str, Any], + source_version: str, + target_version: str, +) -> None: + """A downgrade never emits a payload the older schema refuses. + + Projection repairs what it removed. Anything else the older contract + requires and the payload cannot supply, such as a newly optional field + or a required enum member it lacks, fails as a structured error rather + than reaching the peer invalid. Payloads that arrived invalid are left + to the caller's validation policy. Validation uses the same bundled + schemas the projection used, with no payload size cap. + """ + from adcp.validation._response_envelope import uses_task_status_envelope + + if direction != "request" and uses_task_status_envelope(tool): + # Match the SDK validators, which default the status envelope. + payload = {"status": "completed", **payload} + result = {"status": "completed", **result} + target_schema = _schema(tool, direction, target_version) + refusal = _refusal(target_schema, target_schema, result, shape=False) + if refusal is None: + return + source_schema = _schema(tool, direction, source_version) + if _refusal(source_schema, source_schema, payload, shape=False) is not None: + return + raise ADCPVersionConversionError( + pointer=refusal[0], + source_version=source_version, + target_version=target_version, + reason=f"the older contract refuses the converted payload at that pointer: {refusal[1]}", + ) diff --git a/tests/fixtures/version_conversion_sweep.json b/tests/fixtures/version_conversion_sweep.json new file mode 100644 index 000000000..a5322dfa0 --- /dev/null +++ b/tests/fixtures/version_conversion_sweep.json @@ -0,0 +1,105 @@ +{ + "description": "Valid AdCP 3.2 payloads converted down by tests/test_version_conversion_sweep.py, in addition to every bundled schema example. Each covers a shape the bundled examples do not.", + "cases": [ + { + "id": "issue1621.list_accounts.notification_configs", + "tool": "list_accounts", + "direction": "sync", + "payload": { + "status": "completed", + "accounts": [ + { + "account_id": "a1", + "name": "Acct", + "status": "active", + "notification_configs": [ + { + "subscriber_id": "only_new_events", + "url": "https://buyer.example/hooks/a", + "event_types": ["account.change_recorded"] + }, + { + "subscriber_id": "mixed_events", + "url": "https://buyer.example/hooks/b", + "event_types": ["account.change_recorded", "product.updated"] + } + ] + } + ] + } + }, + { + "id": "issue1621.get_adcp_capabilities.geo_regions", + "tool": "get_adcp_capabilities", + "direction": "sync", + "payload": { + "status": "completed", + "adcp": { + "major_versions": [3], + "supported_versions": ["3.1", "3.2"], + "idempotency": {"supported": false} + }, + "supported_protocols": ["media_buy"], + "media_buy": { + "execution": { + "targeting": { + "geo_countries": true, + "geo_regions": {"countries": {"US": {"values": ["US-NY", "US-CA"]}}}, + "geo_postal_areas": {"US": ["zip"]} + } + } + } + } + }, + { + "id": "get_adcp_capabilities.geo_regions.with_account", + "tool": "get_adcp_capabilities", + "direction": "sync", + "payload": { + "status": "completed", + "adcp": { + "major_versions": [3], + "supported_versions": ["3.0", "3.1", "3.2"], + "idempotency": {"supported": false} + }, + "supported_protocols": ["media_buy"], + "account": {"supported_billing": ["operator"]}, + "media_buy": { + "execution": { + "targeting": { + "geo_countries": true, + "geo_regions": {"countries": {"US": {"all_values": true}}} + } + } + } + } + }, + { + "id": "sync_accounts.request.notification_configs", + "tool": "sync_accounts", + "direction": "request", + "payload": { + "idempotency_key": "sync-accounts-notification-configs-0001", + "accounts": [ + { + "brand": {"domain": "acme.example"}, + "operator": "agency.example", + "billing": "operator", + "notification_configs": [ + { + "subscriber_id": "only_new_events", + "url": "https://buyer.example/hooks/a", + "event_types": ["account.change_recorded"] + }, + { + "subscriber_id": "mixed_events", + "url": "https://buyer.example/hooks/b", + "event_types": ["account.change_recorded", "product.updated"] + } + ] + } + ] + } + } + ] +} diff --git a/tests/test_cross_version_conversion.py b/tests/test_cross_version_conversion.py index 453ddafcd..df1d3dec2 100644 --- a/tests/test_cross_version_conversion.py +++ b/tests/test_cross_version_conversion.py @@ -793,3 +793,188 @@ async def test_in_flight_creative_dialect_survives_capability_refresh() -> None: ) assert client._creative_dialect(request) is CreativeDialect.CANONICAL assert client._creative_dialect(request, result=result) is CreativeDialect.LEGACY + + +def _notification_accounts(*event_types: list[str]) -> dict[str, Any]: + return { + "status": "completed", + "accounts": [ + { + "account_id": "a1", + "name": "Acct", + "status": "active", + "notification_configs": [ + { + "subscriber_id": f"s{index}", + "url": f"https://buyer.example/hooks/{index}", + "event_types": events, + } + for index, events in enumerate(event_types) + ], + } + ], + } + + +def test_response_drops_a_notification_config_left_without_older_events() -> None: + payload = _notification_accounts( + ["account.change_recorded"], ["account.change_recorded", "product.updated"] + ) + result = convert_payload( + "list_accounts", payload, direction="sync", source_version="3.2", target_version="3.1" + ) + assert result.payload["accounts"][0]["notification_configs"] == [ + { + "subscriber_id": "s1", + "url": "https://buyer.example/hooks/1", + "event_types": ["product.updated"], + } + ] + assert [(loss.pointer, loss.reason) for loss in result.losses] == [ + ("/accounts/0/notification_configs/0/event_types/0", "enum_unavailable"), + ("/accounts/0/notification_configs/0", "unrepresentable_in_target"), + ("/accounts/0/notification_configs/1/event_types/0", "enum_unavailable"), + ] + assert validate_response("list_accounts", result.payload, version="3.1").valid + + +def test_request_is_never_repaired_by_omission() -> None: + # Omitting a request field changes its meaning: a shortened sync list can + # read as removal, an absent filter matches everything. + request = { + "idempotency_key": "sync-accounts-notification-configs-0001", + "accounts": [ + { + "brand": {"domain": "acme.example"}, + "operator": "agency.example", + "billing": "operator", + "notification_configs": _notification_accounts( + ["account.change_recorded"], ["product.updated"] + )["accounts"][0]["notification_configs"], + } + ], + } + assert validate_request("sync_accounts", request, version="3.2").valid + with pytest.raises(ADCPVersionConversionError) as caught: + convert_payload( + "sync_accounts", + request, + direction="request", + source_version="3.2", + target_version="3.1", + ) + assert caught.value.pointer == "/accounts/0/notification_configs/0/event_types" + + +def test_request_filter_is_not_widened_by_omission() -> None: + request = { + "buying_mode": "brief", + "brief": "video", + "filters": {"required_metrics": ["viewable_impressions"], "channels": ["olv"]}, + } + assert validate_request("get_products", request, version="3.2").valid + with pytest.raises(ADCPVersionConversionError) as caught: + convert_payload( + "get_products", + request, + direction="request", + source_version="3.2", + target_version="3.1", + ) + assert caught.value.pointer == "/filters/required_metrics" + + +def test_a_drop_does_not_take_the_blame_for_an_unrelated_refusal() -> None: + # spend became optional in 3.2. Dropping the 3.2-only sibling did not + # cause its absence, so the delivery row is not silently discarded. + def delivery(**totals: Any) -> dict[str, Any]: + return { + "status": "completed", + "reporting_period": {"start": "2026-10-01T00:00:00Z", "end": "2026-10-02T00:00:00Z"}, + "currency": "USD", + "media_buy_deliveries": [ + { + "media_buy_id": "mb1", + "status": "active", + "totals": {"impressions": 100, **totals}, + "by_package": [], + } + ], + } + + for payload in (delivery(), delivery(commissionable_value=5)): + assert validate_response("get_media_buy_delivery", payload, version="3.2").valid + with pytest.raises(ADCPVersionConversionError) as caught: + convert_payload( + "get_media_buy_delivery", + payload, + direction="sync", + source_version="3.2", + target_version="3.1", + ) + assert caught.value.pointer == "/media_buy_deliveries/0/totals" + + +def test_request_without_an_optional_holder_fails_structurally() -> None: + request = { + "idempotency_key": "sync-accounts-by-reference-0001", + "accounts": [{"account": {"account_id": "acct_1"}, "name": "Acme"}], + } + with pytest.raises(ADCPVersionConversionError) as caught: + convert_payload( + "sync_accounts", + request, + direction="request", + source_version="3.2", + target_version="3.0", + ) + assert caught.value.pointer == "/accounts/0" + + +@pytest.mark.parametrize( + ("declaration", "expected"), + [ + ({"countries": {"US": {"values": ["US-NY", "US-CA"]}}}, True), + ({"countries": {"US": {"all_values": True}}}, True), + ({"countries": {}}, False), + ], +) +def test_structured_capability_coarsens_to_the_older_boolean(declaration, expected) -> None: + capabilities = { + "status": "completed", + "adcp": { + "major_versions": [3], + "supported_versions": ["3.1", "3.2"], + "idempotency": {"supported": False}, + }, + "supported_protocols": ["media_buy"], + "media_buy": {"execution": {"targeting": {"geo_regions": declaration}}}, + } + result = convert_payload( + "get_adcp_capabilities", + capabilities, + direction="sync", + source_version="3.2", + target_version="3.1", + ) + assert result.payload["media_buy"]["execution"]["targeting"]["geo_regions"] is expected + assert [(loss.pointer, loss.reason) for loss in result.losses] == [ + ("/media_buy/execution/targeting/geo_regions", "coarsened_to_boolean") + ] + assert validate_response("get_adcp_capabilities", result.payload, version="3.1").valid + + +def test_payloads_that_arrive_invalid_are_projected_not_repaired() -> None: + # Missing required product fields are the sender's problem: projection + # still drops the 3.2-only field but neither discards the product nor + # turns the response into a conversion error. + product = {"product_id": "p1", "audience_evidence": [{"evidence_id": "e1"}]} + result = convert_payload( + "get_products", + {"status": "completed", "cache_scope": "public", "products": [product]}, + direction="sync", + source_version="3.2", + target_version="3.1", + ) + assert result.payload["products"] == [{"product_id": "p1"}] + assert [loss.pointer for loss in result.losses] == ["/products/0/audience_evidence"] diff --git a/tests/test_version_conversion_sweep.py b/tests/test_version_conversion_sweep.py new file mode 100644 index 000000000..24c548f4a --- /dev/null +++ b/tests/test_version_conversion_sweep.py @@ -0,0 +1,189 @@ +"""Every valid 3.2 payload converts down to a valid older payload, or fails structurally. + +Sources: each bundled 3.2 schema example, the canonical-valid validation +parity cases, the ESA audience-evidence product and capabilities vectors, and +``tests/fixtures/version_conversion_sweep.json``. A conversion may raise +``ADCPVersionConversionError`` only where ``EXPECTED_STRUCTURED_ERRORS`` says +so; any other result must validate against the target release's bundled schema. +""" + +from __future__ import annotations + +import json +import logging +from copy import deepcopy +from importlib.resources import files +from pathlib import Path +from typing import Any + +import pytest + +from adcp.compat.versions import ADCPVersionConversionError, convert_payload +from adcp.validation import validate_request, validate_response +from adcp.validation.schema_loader import get_portable_schema, list_validator_keys + +SOURCE = "3.2" +TARGETS = ("3.1", "3.0") +FIXTURES = Path(__file__).parent / "fixtures" + +#: (case id, target) -> pointer of the intentional structured error. +EXPECTED_STRUCTURED_ERRORS = { + # Test-controller scenarios added after 3.0 have no 3.0 spelling. + ("comply_test_controller::request::example-10", "3.0"): "/scenario", + ("comply_test_controller::sync::example-0", "3.0"): "/scenarios/1", + # convert_payload cannot invent a legacy format_id; the creative dialect + # adapters supply one before conversion on real 3.0 boundaries. + ("list_creatives::sync::example-0", "3.0"): "/creatives/1", + ("list_creatives::sync::example-1", "3.0"): "/creatives/1", + ("sync_creatives::request::example-0", "3.0"): "/creatives/0", + ("sync_creatives::request::example-1", "3.0"): "/creatives/0", + ("sync_creatives::request::example-2", "3.0"): "/creatives/1", + # Older accounts need a brand/operator key; an account_id reference has none. + ("sync_accounts::request::example-5", "3.0"): "/accounts/0", + ("sync_accounts::request::example-6", "3.0"): "/accounts/0", + ("sync_accounts::sync::example-5", "3.1"): "", + ("sync_accounts::sync::example-5", "3.0"): "", + # Wholesale discovery does not exist in 3.0. + ("parity::get_signals.valid_wholesale", "3.0"): "", + # 3.0 media_buy sellers must declare account capabilities. + ("fixture::issue1621.get_adcp_capabilities.geo_regions", "3.0"): "", + ("vector::audience_evidence.capabilities", "3.0"): "", + # Requests are never repaired: a config left without older event types + # fails instead of silently changing the subscription. + ( + "fixture::sync_accounts.request.notification_configs", + "3.1", + ): "/accounts/0/notification_configs/0/event_types", + ( + "fixture::sync_accounts.request.notification_configs", + "3.0", + ): "/accounts/0/notification_configs/0/event_types", +} + + +def _validate(tool: str, direction: str, payload: dict[str, Any], version: str) -> Any: + validator = validate_request if direction == "request" else validate_response + return validator(tool, payload, version=version) + + +def _cases() -> list[tuple[str, str, str, dict[str, Any]]]: + previous = logging.root.manager.disable + logging.disable(logging.CRITICAL) + try: + cases = [] + for key in list_validator_keys(version=SOURCE): + tool, direction = key.split("::") + schema = get_portable_schema(tool, direction, version=SOURCE) or {} + for index, example in enumerate(schema.get("examples") or []): + data = example.get("data") if isinstance(example, dict) else None + if isinstance(data, dict): + cases.append((f"{key}::example-{index}", tool, direction, data)) + finally: + logging.disable(previous) + for case in json.loads((FIXTURES / "validation-parity-cases.json").read_text()): + if case["canonical_valid"]: + cases.append( + (f"parity::{case['id']}", case["tool"], case["direction"], case["payload"]) + ) + vectors = json.loads( + files("adcp") + .joinpath("_compliance/3.2.4/test-vectors/audience-evidence/vectors.json") + .read_text() + ) + product = deepcopy(vectors["product"]) + product["audience_evidence"] = [vectors["evidence"]] + product["included_signals"] = [ + { + "signal_ref": {"scope": "product", "signal_id": "adults_25_34"}, + "name": "Adults 25-34", + "value_type": "binary", + "demographic_predicate": {"age": {"min": 25, "max": 34, "include_unknown": False}}, + "restricted_attributes": ["age"], + } + ] + cases.append( + ( + "vector::audience_evidence.get_products", + "get_products", + "sync", + {"status": "completed", "cache_scope": "public", "products": [product]}, + ) + ) + cases.append( + ( + "vector::audience_evidence.capabilities", + "get_adcp_capabilities", + "sync", + vectors["capability_response"], + ) + ) + for case in json.loads((FIXTURES / "version_conversion_sweep.json").read_text())["cases"]: + cases.append((f"fixture::{case['id']}", case["tool"], case["direction"], case["payload"])) + return cases + + +CASES = _cases() + + +def test_sweep_covers_every_bundled_example_and_fixture() -> None: + assert len({case_id for case_id, *_ in CASES}) == len(CASES) + assert sum(case_id.startswith("fixture::") for case_id, *_ in CASES) == 4 + assert len(CASES) > 90 + + +@pytest.mark.parametrize("target", TARGETS) +@pytest.mark.parametrize( + ("case_id", "tool", "direction", "payload"), CASES, ids=[case[0] for case in CASES] +) +def test_downgrade_is_valid_in_the_target_or_a_structured_error( + case_id: str, tool: str, direction: str, payload: dict[str, Any], target: str +) -> None: + assert _validate(tool, direction, payload, SOURCE).valid, "sweep inputs must be valid 3.2" + if get_portable_schema(tool, direction, version=target) is None: + pytest.skip(f"{tool} {direction} does not exist in AdCP {target}") + expected = EXPECTED_STRUCTURED_ERRORS.get((case_id, target)) + try: + result = convert_payload( + tool, + payload, + direction=direction, + source_version=SOURCE, + target_version=target, + ) + except ADCPVersionConversionError as exc: + assert expected is not None, f"unexpected structured error: {exc}" + assert exc.pointer == expected + return + assert expected is None, "expected a structured error; update EXPECTED_STRUCTURED_ERRORS" + outcome = _validate(tool, direction, result.payload, target) + assert outcome.valid, outcome.issues + + +STATUSLESS = [case for case in CASES if case[2] != "request" and "status" in case[3]] + + +@pytest.mark.parametrize("target", TARGETS) +@pytest.mark.parametrize( + ("case_id", "tool", "direction", "payload"), + STATUSLESS, + ids=[case[0] for case in STATUSLESS], +) +def test_status_defaulted_responses_get_the_same_guarantee( + case_id: str, tool: str, direction: str, payload: dict[str, Any], target: str +) -> None: + # The SDK validators default a missing status envelope; so does the + # downgrade's final check, which must not switch off without it. + payload = {key: value for key, value in payload.items() if key != "status"} + if not _validate(tool, direction, payload, SOURCE).valid: + pytest.skip("not valid 3.2 without its status") + if get_portable_schema(tool, direction, version=target) is None: + pytest.skip(f"{tool} {direction} does not exist in AdCP {target}") + try: + result = convert_payload( + tool, payload, direction=direction, source_version=SOURCE, target_version=target + ) + except ADCPVersionConversionError: + assert (case_id, target) in EXPECTED_STRUCTURED_ERRORS + return + assert (case_id, target) not in EXPECTED_STRUCTURED_ERRORS + assert _validate(tool, direction, result.payload, target).valid From cf96d573ba2240fb5b666dc0f891378f253dfbe4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 23:00:10 -0400 Subject: [PATCH 48/54] feat(server): let PrincipalResolverError carry a stable error code (#1633) PrincipalResolverError(403, code="identity_org_mismatch") answers 403 {"error": "identity_org_mismatch"} on MCP and A2A. Codes must match ^[a-z][a-z0-9_]{0,63}$; anything else falls back to the generic body. Exception text is never sent, and the default bodies are unchanged. Fixes #1623 Co-authored-by: Claude Opus 5.5 (1M context) --- docs/principal-resolution.md | 17 +++- src/adcp/server/auth.py | 25 ++++- tests/test_auth_principal_resolver.py | 122 ++++++++++++++++++++++++ tests/type_checks/principal_resolver.py | 14 +++ 4 files changed, 171 insertions(+), 7 deletions(-) diff --git a/docs/principal-resolution.md b/docs/principal-resolution.md index a5879008d..90aad1e94 100644 --- a/docs/principal-resolution.md +++ b/docs/principal-resolution.md @@ -56,8 +56,21 @@ Returning `Principal` authenticates the request. Returning `None` applies the usual missing-bearer policy, including explicitly configured discovery and `allow_unauthenticated` behavior. Raising `PrincipalResolverError(401)` or `PrincipalResolverError(403)` rejects regardless of that opt-in. Responses contain -only generic `unauthenticated`/`forbidden` error bodies. A 401 carries the shared -Bearer challenge; 403 does not. Other resolver exceptions and invalid return +only generic `unauthenticated`/`forbidden` error bodies unless the resolver +supplies a stable code. A 401 carries the shared Bearer challenge; 403 does not. + +When your integrators branch on documented rejection codes, pass one as `code`: + +```python +raise PrincipalResolverError(403, code="identity_org_mismatch") +# -> 403 {"error": "identity_org_mismatch"} +``` + +`code` must match `^[a-z][a-z0-9_]{0,63}$`. It is a fixed identifier from your +documented set, never a message or a value derived from the request. A code that +doesn't match falls back to the generic body for that status, and the auth log +records only the coarse reason `invalid_code`, never the value. Exception text +is never sent. Without `code` the bodies are unchanged. Other resolver exceptions and invalid return values fail closed with generic 401; auth logs contain only coarse reason codes, never callback exception text or credential values. diff --git a/src/adcp/server/auth.py b/src/adcp/server/auth.py index a13a5c0db..c3bafeeeb 100644 --- a/src/adcp/server/auth.py +++ b/src/adcp/server/auth.py @@ -74,6 +74,7 @@ async def validate_token(token: str) -> Principal | None: import inspect import json import logging +import re import warnings from collections.abc import Awaitable, Callable, Collection, Mapping, Sequence from contextvars import ContextVar @@ -267,17 +268,24 @@ def __call__(self, request: AuthRequest) -> Awaitable[Principal | None]: ... """ +_RESOLVER_ERROR_CODE = re.compile(r"[a-z][a-z0-9_]{0,63}") + + class PrincipalResolverError(Exception): - """Intentional resolver rejection. Only the status is sent to the caller. + """Intentional resolver rejection. Only the status and code reach the caller. 401 means identity was not established; 403 means it is forbidden by the - resolver's policy. Responses use fixed generic bodies, never exception text. + resolver's policy. Responses never include exception text. The body is + ``{"error": code}`` when ``code`` is a stable machine-readable identifier + matching ``^[a-z][a-z0-9_]{0,63}$`` (for example ``identity_org_mismatch``), + otherwise the generic ``unauthenticated``/``forbidden`` body. """ - def __init__(self, status_code: Literal[401, 403] = 401) -> None: + def __init__(self, status_code: Literal[401, 403] = 401, *, code: str | None = None) -> None: if status_code not in (401, 403): raise ValueError("PrincipalResolverError status_code must be 401 or 403") self.status_code = status_code + self.code = code super().__init__("Principal resolution rejected") @@ -313,10 +321,17 @@ async def _resolve_request_principal( def _resolver_rejection(error: PrincipalResolverError) -> JSONResponse: + code = error.code + if code is not None and not ( + isinstance(code, str) and _RESOLVER_ERROR_CODE.fullmatch(code) is not None + ): + # Never echo a code that could carry free text; log no value either. + logger.warning("principal resolver code rejected", extra={"reason": "invalid_code"}) + code = None if error.status_code == 403: - return JSONResponse({"error": "forbidden"}, status_code=403) + return JSONResponse({"error": code or "forbidden"}, status_code=403) return JSONResponse( - {"error": "unauthenticated"}, + {"error": code or "unauthenticated"}, status_code=401, headers={"WWW-Authenticate": _WWW_AUTHENTICATE_CHALLENGE}, ) diff --git a/tests/test_auth_principal_resolver.py b/tests/test_auth_principal_resolver.py index 8e8c31901..36a190d23 100644 --- a/tests/test_auth_principal_resolver.py +++ b/tests/test_auth_principal_resolver.py @@ -268,6 +268,128 @@ def resolve(request: AuthRequest) -> Any: assert not errors +@pytest.mark.asyncio +@pytest.mark.parametrize("leg", ["mcp", "a2a"]) +@pytest.mark.parametrize("status", [401, 403]) +@pytest.mark.parametrize("code", ["identity_org_mismatch", "network_policy_denied", "x"]) +async def test_resolver_rejection_carries_a_stable_code( + leg: str, status: Any, code: str, caplog: pytest.LogCaptureFixture +) -> None: + def resolve(request: AuthRequest) -> Principal | None: + error = PrincipalResolverError(status, code=code) + error.args = ("private-token",) + raise error + + config = BearerTokenAuth(validate_token=lambda token: None, resolve_principal=resolve) + with caplog.at_level(logging.DEBUG, logger="adcp.server.auth"): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=resolver_app(leg, config)), base_url="http://test" + ) as client: + response = await client.post("/", json=CALL) + assert response.status_code == status + assert response.json() == {"error": code} + assert ("www-authenticate" in response.headers) == (status == 401) + assert "private-token" not in caplog.text + response.text + + +@pytest.mark.asyncio +@pytest.mark.parametrize("leg", ["mcp", "a2a"]) +@pytest.mark.parametrize("status", [401, 403]) +@pytest.mark.parametrize( + "code", + [ + "", + "Identity_org_mismatch", + "1dentity", + "_identity", + "identity-org-mismatch", + "identity org mismatch", + "identity_org_mismatch\n", + "a" * 65, + "token=private-token", + 42, + ], +) +async def test_resolver_rejection_falls_back_on_an_unsafe_code( + leg: str, status: Any, code: Any, caplog: pytest.LogCaptureFixture +) -> None: + def resolve(request: AuthRequest) -> Principal | None: + raise PrincipalResolverError(status, code=code) + + config = BearerTokenAuth(validate_token=lambda token: None, resolve_principal=resolve) + with caplog.at_level(logging.DEBUG, logger="adcp.server.auth"): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=resolver_app(leg, config)), base_url="http://test" + ) as client: + response = await client.post("/", json=CALL) + assert response.status_code == status + assert response.json() == {"error": "forbidden" if status == 403 else "unauthenticated"} + assert ("www-authenticate" in response.headers) == (status == 401) + rejected = [r for r in caplog.records if getattr(r, "reason", None) == "invalid_code"] + assert len(rejected) == 1 + assert rejected[0].args == () + if code not in ("", "a" * 65): + assert str(code) not in caplog.text + response.text + + +@pytest.mark.asyncio +async def test_combined_server_resolver_code_on_both_transports() -> None: + class Seller(ADCPHandler): + advertised_tools = {"get_products"} + + async def get_products(self, params: Any, context: Any = None) -> dict[str, Any]: + raise AssertionError("a rejected caller must not reach the handler") + + async def resolve(request: AuthRequest) -> Principal | None: + raise PrincipalResolverError(403, code="identity_org_mismatch") + + app = _build_mcp_and_a2a_app( + Seller(), + name="seller", + port=3001, + host="127.0.0.1", + instructions=None, + test_controller=None, + validation=None, + stateless_http=True, + enable_dns_rebinding_protection=False, + context_factory=auth_context_factory, + auth=BearerTokenAuth(validate_token=lambda token: None, resolve_principal=resolve), + ) + params = {"buying_mode": "brief", "brief": "video"} + mcp = { + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "get_products", "arguments": params}, + } + a2a = { + "jsonrpc": "2.0", + "id": 1, + "method": "SendMessage", + "params": { + "message": { + "messageId": "m1", + "role": "ROLE_USER", + "parts": [{"data": {"skill": "get_products", "parameters": params}}], + } + }, + } + async with LifespanManager(app): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://test", follow_redirects=True + ) as client: + for path, body in [("/mcp", mcp), ("/", a2a)]: + response = await client.post( + path, + json=body, + headers={"accept": "application/json, text/event-stream", "A2A-Version": "1.0"}, + ) + assert response.status_code == 403, response.text + assert response.json() == {"error": "identity_org_mismatch"} + assert "www-authenticate" not in response.headers + + @pytest.mark.asyncio @pytest.mark.parametrize("leg", ["mcp", "a2a"]) @pytest.mark.parametrize("allow", [False, True]) diff --git a/tests/type_checks/principal_resolver.py b/tests/type_checks/principal_resolver.py index d10b99283..60ce847a8 100644 --- a/tests/type_checks/principal_resolver.py +++ b/tests/type_checks/principal_resolver.py @@ -42,3 +42,17 @@ async def __call__(self, request: AuthRequest) -> Principal | None: unauthorized = PrincipalResolverError(401) forbidden = PrincipalResolverError(403) + +# A stable machine-readable code answers 403 {"error": "identity_org_mismatch"}. +coded = PrincipalResolverError(403, code="identity_org_mismatch") +coded_unauthenticated = PrincipalResolverError(401, code="identity_required") +code: str | None = coded.code + + +def resolve_with_code(request: AuthRequest) -> Principal | None: + if request.state.get("verified_proxy_org") != request.headers.get("x-org"): + raise PrincipalResolverError(403, code="identity_org_mismatch") + return None + + +BearerTokenAuth(validate_token=lambda token: None, resolve_principal=resolve_with_code) From a2ec81c0679042c0a3cb0a0779d03df53311e174 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 23:08:30 -0400 Subject: [PATCH 49/54] fix(reporting): classify pending status and owed history as transient, unresolved references as REFERENCE_NOT_FOUND (#1631) installed_reporting_call projected STATUS_PROJECTION_UNAVAILABLE, HISTORY_UNAVAILABLE and LOOKUP_UNAVAILABLE as terminal. A pending status lifecycle and retained history the seller still owes resolve on the seller side, so they are transient. A reporting reference that does not resolve for the caller answers the standard, nondisclosing REFERENCE_NOT_FOUND (correctable) with a generic message. Fixes #1618 Co-authored-by: Claude Opus 5.5 (1M context) --- docs/reliable-reporting-service.md | 10 ++++- src/adcp/reporting/_errors.py | 12 ++++++ tests/test_reporting_installed_errors.py | 51 +++++++++++++++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/docs/reliable-reporting-service.md b/docs/reliable-reporting-service.md index dffee6fd7..df9ec9fd4 100644 --- a/docs/reliable-reporting-service.md +++ b/docs/reliable-reporting-service.md @@ -394,8 +394,14 @@ returns `AUTH_REQUIRED`; explicit buyer configuration validation can raise Composition errors retain `INTERNAL_ERROR` and terminal recovery, with internal configuration details omitted from buyer responses. Ledger codes are preserved and receive the SDK's recovery classification for that code. Reporting cursor/ -input extensions are correctable, storage-unavailability extensions are transient, -and unknown extension or integrity failures default to terminal. Disabled optional methods and aggregate delivery +input extensions are correctable. Storage-unavailability extensions are transient, +as are a pending status lifecycle (`STATUS_PROJECTION_UNAVAILABLE`) and retained +history the seller still owes but has not yet materialized or repaired +(`HISTORY_UNAVAILABLE`): the request is valid, and the same retry succeeds once +the seller catches up. A reporting reference that does not resolve for the caller +(`LOOKUP_UNAVAILABLE`) answers the standard, nondisclosing `REFERENCE_NOT_FOUND` +with correctable recovery. Unknown extension or integrity failures default to +terminal. Disabled optional methods and aggregate delivery continue to delegate to the application. ## Source identity and automatic read timing diff --git a/src/adcp/reporting/_errors.py b/src/adcp/reporting/_errors.py index eb5105aa3..dd5c6e017 100644 --- a/src/adcp/reporting/_errors.py +++ b/src/adcp/reporting/_errors.py @@ -16,6 +16,10 @@ # Reporting extensions have local meanings. Normative SDK codes always take # precedence; unlisted extension/invariant failures retain terminal recovery. +# Transient covers seller-side state the seller still owes the buyer: a pending +# status lifecycle, and retained history that is not yet materialized or awaits +# repair. The buyer's request is valid and an identical retry succeeds once the +# seller catches up, as for unreadable revision rows (SERVICE_UNAVAILABLE). _REPORTING_CORRECTABLE = frozenset( { "CURSOR_REVISION_MISMATCH", @@ -49,6 +53,8 @@ "REPORTING_CONTENT_UNAVAILABLE", "REPORTING_FEED_STORAGE_UNAVAILABLE", "RECEIPT_STORAGE_UNAVAILABLE", + "STATUS_PROJECTION_UNAVAILABLE", + "HISTORY_UNAVAILABLE", } ) @@ -77,6 +83,12 @@ def reporting_error(code: str, message: str) -> Error: # nondisclosing answer is the standard reference error. code = "REFERENCE_NOT_FOUND" message = "Reporting revision not found" + elif code == "LOOKUP_UNAVAILABLE": + # A buyer-supplied reporting reference that does not resolve for this + # caller. Typed parameters without a dedicated code use the standard, + # nondisclosing reference error with a generic message. + code = "REFERENCE_NOT_FOUND" + message = "Reporting reference not found" recovery = None if code not in STANDARD_ERROR_CODES: if code in _REPORTING_CORRECTABLE: diff --git a/tests/test_reporting_installed_errors.py b/tests/test_reporting_installed_errors.py index 93214f50f..5d4bbc762 100644 --- a/tests/test_reporting_installed_errors.py +++ b/tests/test_reporting_installed_errors.py @@ -97,7 +97,8 @@ async def get_media_buy_delivery(self, params, context=None): ("CURSOR_REVISION_MISMATCH", "correctable"), ("INVALID_PAGE_SIZE", "correctable"), ("CONFIGURATION_GENERATION_IMMUTABLE", "correctable"), - ("HISTORY_UNAVAILABLE", "terminal"), + ("HISTORY_UNAVAILABLE", "transient"), + ("STATUS_PROJECTION_UNAVAILABLE", "transient"), ("REVISION_CONTENT_MISMATCH", "terminal"), ("REPORTING_STATUS_UNAVAILABLE", "transient"), ("RECEIPT_STORAGE_UNAVAILABLE", "transient"), @@ -112,3 +113,51 @@ async def test_reporting_extension_codes_retain_their_meaning(code, recovery): assert caught.value.code == code assert caught.value.recovery == recovery await service.close() + + +@pytest.mark.parametrize("decisioning", [False, True]) +@pytest.mark.parametrize( + "ledger_code,message,code,wire_message,recovery", + [ + ( + "STATUS_PROJECTION_UNAVAILABLE", + "status lifecycle is pending", + "STATUS_PROJECTION_UNAVAILABLE", + "status lifecycle is pending", + "transient", + ), + ( + "HISTORY_UNAVAILABLE", + "the revision history requires repair", + "HISTORY_UNAVAILABLE", + "the revision history requires repair", + "transient", + ), + ( + "LOOKUP_UNAVAILABLE", + "reporting_obligation_id obl_secret does not resolve for this caller and account", + "REFERENCE_NOT_FOUND", + "Reporting reference not found", + "correctable", + ), + ], +) +async def test_owed_state_retries_and_unresolved_references_are_correctable( + decisioning, ledger_code, message, code, wire_message, recovery +): + from adcp.reporting._errors import installed_reporting_call + + async def call(): + raise LedgerConflictError(ledger_code, message) + + with pytest.raises(AdcpError if decisioning else ADCPTaskError) as caught: + await installed_reporting_call("sync_reporting_status", call, decisioning=decisioning) + error = caught.value + if decisioning: + assert (error.code, error.recovery) == (code, recovery) + assert wire_message in str(error) + else: + wire = error.error_info[0] + assert (wire.code, wire.message, wire.recovery) == (code, wire_message, recovery) + assert "obl_secret" not in str(error) + assert STANDARD_ERROR_CODES.get(code, {}).get("recovery", recovery) == recovery From 7b95b26f0d61b1b35edaae1d192f8f74adc40289 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 23:12:41 -0400 Subject: [PATCH 50/54] fix(decisioning): project ADCPTaskError from platform methods to its own wire error (#1632) _invoke_platform_method caught only AdcpError and pydantic ValidationError, so IdempotencyConflictError raised by IdempotencyStore.wrap reached the buyer as INTERNAL_ERROR / terminal. ADCPTaskError subclasses now keep their code and recovery through the same field extraction serve() uses, on the sync path and in handoff tasks. Fixes #1622 Co-authored-by: Claude Opus 5.5 (1M context) --- docs/handler-authoring.md | 6 + src/adcp/decisioning/dispatch.py | 40 ++++- tests/test_decisioning_dispatch.py | 65 ++++++++ .../test_decisioning_idempotency_conflict.py | 140 ++++++++++++++++++ 4 files changed, 250 insertions(+), 1 deletion(-) create mode 100644 tests/test_decisioning_idempotency_conflict.py diff --git a/docs/handler-authoring.md b/docs/handler-authoring.md index 91e35e0ec..181f5bbee 100644 --- a/docs/handler-authoring.md +++ b/docs/handler-authoring.md @@ -708,6 +708,12 @@ re-execute and can double-allocate. Always wire `context_factory` on MCP servers so the auth middleware populates these fields before the handler runs. +The same decorator works on `DecisioningPlatform` methods. A key reused with +a different payload raises `IdempotencyConflictError`; the dispatcher +projects it, like any other `ADCPTaskError`, to its own code and recovery +(`IDEMPOTENCY_CONFLICT` / `correctable`) on MCP and A2A rather than +`INTERNAL_ERROR`. You don't need your own translator. + ## Error handling **Handler methods return error dicts — they do not raise.** Use diff --git a/src/adcp/decisioning/dispatch.py b/src/adcp/decisioning/dispatch.py index eb56ddd0b..f40c9873e 100644 --- a/src/adcp/decisioning/dispatch.py +++ b/src/adcp/decisioning/dispatch.py @@ -80,6 +80,7 @@ _extract_push_operation_id, emit_terminal_completion_webhook, ) +from adcp.exceptions import ADCPTaskError if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -711,6 +712,30 @@ def _exception_cause_details(exc: BaseException) -> dict[str, Any]: return {"caused_by": {"type": type(exc).__name__}} +def _task_error_to_adcp_error(exc: ADCPTaskError) -> AdcpError: + """Project an AdCP-typed task error to its own wire error. + + ``IdempotencyStore.wrap`` raises + :class:`~adcp.exceptions.IdempotencyConflictError` (an + ``ADCPTaskError``) for a reused key. It is a protocol answer, not an + internal failure, so it keeps its code and recovery. The field + extraction is the one ``serve()`` applies to the same exceptions, so + both paths answer with the same code, message, recovery, field, + suggestion and sanitized details. + """ + from adcp.server.translate import _extract_structured_fields + + code, message, recovery, field, suggestion, details, _errors = _extract_structured_fields(exc) + return AdcpError( + code, + message=message, + recovery=typing.cast(Any, recovery), + field=field, + suggestion=suggestion, + details=details, + ) + + def _internal_error_details(exc: BaseException) -> dict[str, Any]: """Build the wire-side ``details`` payload for an INTERNAL_ERROR wrap. @@ -1511,7 +1536,9 @@ async def _invoke_platform_method( allocate a task_id, kick off the handoff fn, and project the Submitted envelope. - Wraps any non-:class:`AdcpError` exception to + Projects an :class:`~adcp.exceptions.ADCPTaskError` (for example + ``IdempotencyConflictError`` from ``IdempotencyStore.wrap``) to its own + code and recovery. Wraps any other non-:class:`AdcpError` exception to ``AdcpError("INTERNAL_ERROR", recovery="terminal")`` so the wire response never leaks a stack trace. Adopters get the original exception logged via the framework's observability hooks (the @@ -1655,6 +1682,14 @@ async def _invoke_platform_method( if on_failure is not None: await _safe_on_failure_call(on_failure, exc, method_name) raise + except ADCPTaskError as exc: + # AdCP-typed task errors (IdempotencyConflictError from + # ``IdempotencyStore.wrap``, account errors) carry their own wire + # code and recovery; never collapse them to INTERNAL_ERROR. + wrapped = _task_error_to_adcp_error(exc) + if on_failure is not None: + await _safe_on_failure_call(on_failure, wrapped, method_name) + raise wrapped from exc except TypeError as exc: # Most likely an arg_projector or extra_kwargs signature-drift bug. # Bare INTERNAL_ERROR would hide the cause; project to @@ -2301,6 +2336,9 @@ async def _run() -> None: except AdcpError as exc: await _fail(exc) return + except ADCPTaskError as exc: + await _fail(_task_error_to_adcp_error(exc)) + return except Exception as exc: logger.exception( "Unhandled exception in handoff fn for task %s — wrapping", diff --git a/tests/test_decisioning_dispatch.py b/tests/test_decisioning_dispatch.py index 045c77297..c4cda4f0b 100644 --- a/tests/test_decisioning_dispatch.py +++ b/tests/test_decisioning_dispatch.py @@ -803,6 +803,44 @@ async def get_products(self, req, ctx): assert exc_info.value.recovery == "correctable" +@pytest.mark.asyncio +async def test_invoke_projects_adcp_task_errors_to_their_own_code( + executor: ThreadPoolExecutor, +) -> None: + """An ``ADCPTaskError`` (``IdempotencyStore.wrap``'s conflict) keeps its + code and recovery instead of collapsing to INTERNAL_ERROR (#1622).""" + from adcp.exceptions import IdempotencyConflictError + + class _ConflictPlatform(DecisioningPlatform): + capabilities = DecisioningCapabilities() + accounts = SingletonAccounts(account_id="x") + + async def get_products(self, req, ctx): + raise IdempotencyConflictError(operation="get_products", errors=[]) + + failures: list[BaseException] = [] + + async def _on_failure(exc: BaseException) -> None: + failures.append(exc) + + ctx = _build_request_context(ToolContext(), Account(id="x"), None) + with pytest.raises(AdcpError) as exc_info: + await _invoke_platform_method( + _ConflictPlatform(), + "get_products", + _ProductsRequest(), + ctx, + executor=executor, + registry=InMemoryTaskRegistry(), + on_failure=_on_failure, + ) + assert exc_info.value.code == "IDEMPOTENCY_CONFLICT" + assert exc_info.value.recovery == "correctable" + assert exc_info.value.suggestion is not None + assert isinstance(exc_info.value.__cause__, IdempotencyConflictError) + assert failures == [exc_info.value] + + @pytest.mark.asyncio async def test_invoke_wraps_unexpected_exceptions_to_internal_error( executor: ThreadPoolExecutor, @@ -1335,6 +1373,33 @@ async def _handoff_fn(task_ctx): assert "internal bug" not in rec["error"].get("message", "") +@pytest.mark.asyncio +async def test_handoff_adcp_task_error_keeps_its_code( + executor: ThreadPoolExecutor, +) -> None: + from adcp.exceptions import IdempotencyConflictError + + registry = InMemoryTaskRegistry() + ctx = _build_request_context(ToolContext(), Account(id="acct_a"), None) + + async def _handoff_fn(task_ctx): + raise IdempotencyConflictError(operation="create_media_buy", errors=[]) + + envelope = await _project_handoff( + TaskHandoff(_handoff_fn), + ctx, + method_name="create_media_buy", + registry=registry, + executor=executor, + ) + await asyncio.sleep(0.1) + rec = await registry.get(envelope["task_id"], expected_account_id="acct_a") + assert rec is not None + assert rec["state"] == "failed" + assert rec["error"]["code"] == "IDEMPOTENCY_CONFLICT" + assert rec["error"]["recovery"] == "correctable" + + @pytest.mark.asyncio async def test_handoff_request_context_echoes_into_completed_task( executor: ThreadPoolExecutor, diff --git a/tests/test_decisioning_idempotency_conflict.py b/tests/test_decisioning_idempotency_conflict.py new file mode 100644 index 000000000..957cccf95 --- /dev/null +++ b/tests/test_decisioning_idempotency_conflict.py @@ -0,0 +1,140 @@ +"""A decisioning method wrapped by ``IdempotencyStore.wrap`` answers a reused +key with ``IDEMPOTENCY_CONFLICT``, not ``INTERNAL_ERROR`` (#1622). + +The conflict is an :class:`~adcp.exceptions.ADCPTaskError` raised inside the +platform method, so the decisioning dispatcher must project it to its own wire +error, as the handler ``serve()`` path does. Both requests run through the real +MCP and A2A servers. +""" + +from __future__ import annotations + +import secrets +from typing import Any + +import pytest + +from adcp.decisioning import DecisioningCapabilities, DecisioningPlatform, SingletonAccounts +from adcp.server import IdempotencyStore, MemoryBackend, ToolContext +from adcp.server.helpers import STANDARD_ERROR_CODES +from adcp.testing import build_test_client +from adcp.types import CreateMediaBuySuccessResponse, Package + +store = IdempotencyStore(backend=MemoryBackend(), ttl_seconds=86_400) + + +class Seller(DecisioningPlatform): + capabilities = DecisioningCapabilities( + specialisms=["sales-non-guaranteed"], channels=["display"], pricing_models=["cpm"] + ) + accounts = SingletonAccounts(account_id="acct_1") + + def __init__(self) -> None: + self.created = 0 + + @store.wrap + async def create_media_buy(self, req: Any, ctx: Any) -> CreateMediaBuySuccessResponse: + self.created += 1 + return CreateMediaBuySuccessResponse( + media_buy_id=f"mb_{self.created}", + confirmed_at="2026-05-01T00:00:00Z", + revision=1, + status="active", + packages=[Package(package_id="pkg_1", product_id="prod_1")], + ) + + def get_products(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + def update_media_buy(self, media_buy_id: Any, patch: Any, ctx: Any) -> Any: + raise NotImplementedError + + def sync_creatives(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + def get_media_buy_delivery(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + def get_media_buys(self, req: Any, ctx: Any) -> Any: + raise NotImplementedError + + +def _create_request(key: str, budget: int) -> dict[str, Any]: + return { + "idempotency_key": key, + "account": {"account_id": "acct_1"}, + "brand": {"domain": "brand.example"}, + "start_time": "2026-10-01T00:00:00Z", + "end_time": "2026-10-31T00:00:00Z", + "packages": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_1", + "budget": budget, + } + ], + } + + +async def _call(client: Any, transport: str, arguments: dict[str, Any]) -> dict[str, Any]: + if transport == "a2a": + response = await client.post( + "/", + json={ + "jsonrpc": "2.0", + "id": secrets.token_hex(4), + "method": "message/send", + "params": { + "message": { + "messageId": secrets.token_hex(8), + "role": "user", + "parts": [ + { + "kind": "data", + "data": {"skill": "create_media_buy", "parameters": arguments}, + } + ], + } + }, + }, + ) + assert response.status_code == 200, response.text + result = response.json()["result"] + return next(p["data"] for p in result["artifacts"][0]["parts"] if "data" in p) + response = await client.post( + "/mcp/", + headers={"accept": "application/json, text/event-stream"}, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": {"name": "create_media_buy", "arguments": arguments}, + }, + ) + assert response.status_code == 200, response.text + return response.json()["result"]["structuredContent"] + + +def _caller(meta: Any) -> ToolContext: + return ToolContext(caller_identity="buyer_1") + + +@pytest.mark.parametrize("transport", ["mcp", "a2a"]) +async def test_reused_key_with_different_payload_returns_idempotency_conflict( + transport: str, +) -> None: + seller = Seller() + key = f"idem-{secrets.token_hex(8)}" + async with build_test_client( + seller, transport=transport, stateless_http=True, context_factory=_caller + ) as client: + first = await _call(client, transport, _create_request(key, 1000)) + assert "adcp_error" not in first, first + second = await _call(client, transport, _create_request(key, 2000)) + + error = second["adcp_error"] + assert error["code"] == "IDEMPOTENCY_CONFLICT", second + assert error["recovery"] == STANDARD_ERROR_CODES["IDEMPOTENCY_CONFLICT"]["recovery"] + assert error["recovery"] == "correctable" + assert "caused_by" not in error.get("details", {}) + assert seller.created == 1 From 6e0ed688e16c3be94fb117fef5157b4cda08a136 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sun, 25 Oct 2026 23:28:08 -0400 Subject: [PATCH 51/54] fix(types): inherit the generated field on every hand-written redeclaration (#1625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1620. Same class as #1587/#1610; a mechanical extension of #1548, #1579 and #1591. ## The defect Optional fields that `adcp.types.canonical_creative` redeclares did not carry the never-null marker (`_RefuseJsonNull` plus `exclude_if`) their generated counterparts do, so a nested dump through a plain Pydantic parent emitted `null` for them (`Product.placements`, `Placement.format_options`, `CreateMediaBuyRequest.packages`, `UpdateMediaBuyRequest.packages` / `new_packages`, `GetProductsResponse.products`, `GetProductsRequest.filters`, `ListCreativesRequest.filters`). Root cause: hand redeclarations that narrow the annotation used a bare `Field(...)` instead of inheriting the generated `FieldInfo`. The same redeclarations had also dropped descriptions and, on `CreateMediaBuyRequest.packages` / `UpdateMediaBuyRequest.packages`, the generated `min_length=1`. ## The fix - `adcp.types.base.inherit_field(source, name, *, required=False)` — the former `canonical_creative._inherit`, moved to `base` so every hand-written layer can use it; `required=True` drops the default for a subclass that makes an optional generated field mandatory (`Product.format_options`, `CreativeAsset.format_kind`, `Creative.format_kind`). `_inherit` remains as the alias `canonical_creative` has used since #1548. - Every redeclaration of a generated field now goes through it: 17 in `canonical_creative.py`, `capabilities.MediaBuy.reporting_delivery`, and `projections` (`AccountResponse.billing_entity`, `AccountResponse.notification_configs` — whose `max_length=16` is now inherited rather than restated — and `_NotificationConfigResponse.authentication`). - `_forward_compat.py` was checked: its only redeclarations (`adcp_version`, `adcp_major_version` on the readback models) already match their generated parent. ## Guard test `tests/test_redeclared_fields_inherit.py` imports every `adcp` module, finds every hand-written subclass of a generated model, and asserts each redeclared field keeps its generated parent's never-null marker, constraints, description and alias. Defaults may differ (narrowing to required is the point). Two exemptions, each with a reason: `BusinessEntityResponse.bank` and `_NotificationAuthenticationResponse.credentials` are write-only projections — excluded from every dump, any value refused — so the marker and the generated `min_length` have nothing to apply to. A stale exemption fails the suite. The issue's reproduction and the eight listed fields are tested directly. `make lint`, `make typecheck-all` pass; the related suites (canonical subclasses, capabilities, composability, account projections, public API) pass. Pre-commit cannot build its python3.11 hook environment in this sandbox, so the commit skipped hooks; black, ruff and mypy were run directly. Squash-merge only. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- [Open workspace in Conductor](https://app.conductor.build/pro-user-232ad008-36c5-45c0-b430-d68f028abd4b/w/851) --- src/adcp/types/base.py | 36 ++++- src/adcp/types/canonical_creative.py | 62 ++++---- src/adcp/types/capabilities.py | 5 +- src/adcp/types/projections.py | 16 +- tests/test_redeclared_fields_inherit.py | 203 ++++++++++++++++++++++++ tests/test_targeting_overlay_schema.py | 13 +- 6 files changed, 289 insertions(+), 46 deletions(-) create mode 100644 tests/test_redeclared_fields_inherit.py diff --git a/src/adcp/types/base.py b/src/adcp/types/base.py index 81ae15e28..d24462e91 100644 --- a/src/adcp/types/base.py +++ b/src/adcp/types/base.py @@ -9,6 +9,7 @@ every JSON Schema conditional or ``x-adcp-validation`` behavioral rule. """ +import copy import os from collections.abc import Callable from typing import TYPE_CHECKING, Annotated, Any, Final, Literal, cast @@ -29,7 +30,7 @@ model_serializer, ) from pydantic.fields import FieldInfo -from pydantic_core import PydanticSerializationError, core_schema +from pydantic_core import PydanticSerializationError, PydanticUndefined, core_schema from adcp._deferred_adapters import deferred_adapter from adcp.types._geo_place_keys import GeoPlaceSystemKey as GeoPlaceSystemKey @@ -452,6 +453,39 @@ def __hash__(self) -> int: NoExplicitNull.metadata.append(_REFUSE_JSON_NULL) +def inherit_field(source: type[BaseModel], name: str, *, required: bool = False) -> Any: + """A copy of ``source``'s ``name`` field, for a redeclaration that retypes it. + + A hand-written subclass that narrows an inherited field's *annotation* + (``list[Format]`` for the generated ``list[FormatOption]``) still wants + everything else the generated field carries: its description, its + constraints (``min_length``, ``max_length``), its default, and the + never-null marker (:data:`NoExplicitNull`) that leaves an unset ``None`` + off the wire from any parent (#1591). A bare ``Field(...)`` on the + redeclaration silently drops all of them -- which is how a nested dump of + a canonical ``Product`` came to write ``"placements": null`` (#1620), the + same class of defect as #1548 and #1579. + + Assign the result on the right-hand side of the annotated redeclaration. + ``required=True`` drops the default and the never-null marker, for a + subclass that makes an optional generated field mandatory: a required + field has no "omitted" for the marker to spell. The return type is + ``Any`` because a ``FieldInfo`` is what pydantic expects there -- the same + reason ``Field()`` itself is ``Any``. + """ + field = copy.deepcopy(source.model_fields[name]) + if required: + field.default = PydanticUndefined + field.default_factory = None + # The marker belongs to an optional property (omit it, or supply a + # value); a required field has no "omitted" to spell, and the tree-wide + # invariant is that every marked field is optional. + field.metadata = [item for item in field.metadata if item is not _REFUSE_JSON_NULL] + if field.exclude_if is NoExplicitNull.exclude_if: + field.exclude_if = None + return field + + def is_no_explicit_null(field: FieldInfo) -> bool: """Whether ``field`` carries :data:`NoExplicitNull`. diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 0312a02ff..7bde84963 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -101,7 +101,7 @@ PRODUCT_FORMAT_DECLARATION_SCHEMA, check_declaration_rules, ) -from adcp.types.base import _REFUSE_JSON_NULL, AdCPBaseModel, NoExplicitNull +from adcp.types.base import _REFUSE_JSON_NULL, AdCPBaseModel, NoExplicitNull, inherit_field from adcp.types.domains.core.canonical_format_kind import CanonicalFormatKind from adcp.types.domains.core.creative_asset import CreativeAsset as _CanonicalCreativeWire from adcp.types.domains.core.creative_filters import CreativeFilters as _LegacyCreativeFilters @@ -754,18 +754,12 @@ def __get_pydantic_json_schema__( return schema -def _inherit(source: type[AdCPBaseModel], name: str) -> Any: - """Return a copy of ``source``'s ``name`` field for a retyped redeclaration. - - A canonical model that narrows an inherited field's *annotation* still wants - the generated field's constraints, description and default. Redeclaring with - this as the assigned value keeps every one of them, where a bare ``Field()`` - would silently drop ``min_length`` and friends. The return type is ``Any`` - because a ``FieldInfo`` is what pydantic expects on the right-hand side of an - annotated field declaration — the same reason ``Field()`` itself is ``Any``. - """ - - return copy.deepcopy(source.model_fields[name]) +#: :func:`adcp.types.base.inherit_field`, under the name this module has used +#: since #1548. Every redeclaration of a generated field below goes through it: +#: a bare ``Field(...)`` drops the generated description, constraints and +#: never-null marker (#1620), and ``tests/test_redeclared_fields_inherit.py`` +#: fails on one that does. +_inherit = inherit_field _CanonicalParamsT = TypeVar("_CanonicalParamsT", bound=AdCPBaseModel) @@ -924,7 +918,7 @@ class Placement(_LegacyPlacement, CanonicalBoundaryModel): if TYPE_CHECKING: # the removed field, hidden from the constructor too format_ids: _RemovedFormatIdSequence = Field(default=None, init=False) - format_options: list[Format] | None = Field(default=None, min_length=1) # type: ignore[assignment] + format_options: list[Format] | None = _inherit(_LegacyPlacement, "format_options") # type: ignore[assignment] class Product(_LegacyProduct, CanonicalBoundaryModel): @@ -933,11 +927,11 @@ class Product(_LegacyProduct, CanonicalBoundaryModel): if TYPE_CHECKING: # the removed field, hidden from the constructor too format_ids: _RemovedFormatIds = Field(default=None, init=False) - format_options: list[Format] = Field( # type: ignore[assignment] - min_length=1, description="Canonical creative formats accepted by this product." + format_options: list[Format] = _inherit( # type: ignore[assignment] + _LegacyProduct, "format_options", required=True ) - placements: list[Placement] | None = Field(default=None, min_length=1) # type: ignore[assignment] - pricing_options: list[CanonicalPricingOption] = Field(min_length=1) + placements: list[Placement] | None = _inherit(_LegacyProduct, "placements") # type: ignore[assignment] + pricing_options: list[CanonicalPricingOption] = _inherit(_LegacyProduct, "pricing_options") class CreativeAsset(_CanonicalCreativeWire, CanonicalBoundaryModel): @@ -953,7 +947,7 @@ class CreativeAsset(_CanonicalCreativeWire, CanonicalBoundaryModel): if TYPE_CHECKING: # the removed field, hidden from the constructor too format_id: _RemovedFormatId = Field(default=None, init=False) - format_kind: str + format_kind: str = _inherit(_CanonicalCreativeWire, "format_kind", required=True) class Creative(_CanonicalListedCreative, CanonicalBoundaryModel): @@ -967,7 +961,7 @@ class Creative(_CanonicalListedCreative, CanonicalBoundaryModel): if TYPE_CHECKING: # the removed field, hidden from the constructor too format_id: _RemovedFormatId = Field(default=None, init=False) - format_kind: str + format_kind: str = _inherit(_CanonicalListedCreative, "format_kind", required=True) class CreativeManifest(_CanonicalCreativeManifestWire, CanonicalBoundaryModel): @@ -1005,7 +999,7 @@ def wire_value(value: Any) -> Any: class CreativeVariant(_LegacyCreativeVariant, CanonicalBoundaryModel): """Canonical creative variant whose manifest is the canonical manifest.""" - manifest: CreativeManifest | None = None + manifest: CreativeManifest | None = _inherit(_LegacyCreativeVariant, "manifest") class _DeliveryCreativeManifest(_CanonicalCreativeManifestWire, CanonicalBoundaryModel): @@ -1132,7 +1126,7 @@ class Package(_LegacyPackage, CanonicalBoundaryModel): class GetProductsRequest(_LegacyGetProductsRequest, CanonicalBoundaryModel): """Canonical discovery request with legacy response-field selection rejected.""" - filters: ProductFilters | None = None + filters: ProductFilters | None = _inherit(_LegacyGetProductsRequest, "filters") @field_validator("fields") @classmethod @@ -1149,21 +1143,23 @@ def _reject_legacy_fields(cls, value: Any) -> Any: class GetProductsResponse(_LegacyGetProductsResponse, CanonicalBoundaryModel): """Canonical discovery response; products are canonical products.""" - products: list[Product] | None = None # type: ignore[assignment] + products: list[Product] | None = _inherit( # type: ignore[assignment] + _LegacyGetProductsResponse, "products" + ) class CreateMediaBuyRequest(_LegacyCreateMediaBuyRequest, CanonicalBoundaryModel): """Canonical create request; packages are canonical package requests.""" - packages: list[PackageRequest] | None = None + packages: list[PackageRequest] | None = _inherit(_LegacyCreateMediaBuyRequest, "packages") class UpdateMediaBuyRequest(_LegacyUpdateMediaBuyRequest, CanonicalBoundaryModel): """Canonical update request; both package lists are canonical.""" - packages: list[PackageUpdate] | None = None - new_packages: list[PackageRequest] | None = Field( # type: ignore[assignment] - default=None, min_length=1 + packages: list[PackageUpdate] | None = _inherit(_LegacyUpdateMediaBuyRequest, "packages") + new_packages: list[PackageRequest] | None = _inherit( # type: ignore[assignment] + _LegacyUpdateMediaBuyRequest, "new_packages" ) @@ -1242,7 +1238,7 @@ class SyncCreativesRequest(_LegacySyncCreativesRequest, CanonicalBoundaryModel): class ListCreativesRequest(_LegacyListCreativesRequest, CanonicalBoundaryModel): """Canonical creative read request with legacy field selection rejected.""" - filters: CreativeFilters | None = None + filters: CreativeFilters | None = _inherit(_LegacyListCreativesRequest, "filters") @field_validator("fields") @classmethod @@ -1259,7 +1255,7 @@ def _reject_legacy_fields(cls, value: Any) -> Any: class ListCreativesResponse(_LegacyListCreativesResponse, CanonicalBoundaryModel): """Canonical creative listing; rows are canonical listed creatives.""" - creatives: list[Creative] + creatives: list[Creative] = _inherit(_LegacyListCreativesResponse, "creatives") class MediaBuyPackage(_LegacyMediaBuyPackage, CanonicalBoundaryModel): @@ -1274,13 +1270,13 @@ class MediaBuyPackage(_LegacyMediaBuyPackage, CanonicalBoundaryModel): class MediaBuy(_LegacyMediaBuy, CanonicalBoundaryModel): """Canonical media buy; packages are canonical package rows.""" - packages: Sequence[MediaBuyPackage] + packages: Sequence[MediaBuyPackage] = _inherit(_LegacyMediaBuy, "packages") class GetMediaBuysResponse(_LegacyGetMediaBuysResponse, CanonicalBoundaryModel): """Canonical media-buy listing; rows are canonical media buys.""" - media_buys: Sequence[MediaBuy] + media_buys: Sequence[MediaBuy] = _inherit(_LegacyGetMediaBuysResponse, "media_buys") class GetMediaBuyDeliveryResponse(_LegacyGetMediaBuyDeliveryResponse, CanonicalBoundaryModel): @@ -1290,7 +1286,9 @@ class GetMediaBuyDeliveryResponse(_LegacyGetMediaBuyDeliveryResponse, CanonicalB class GetCreativeDeliveryResponse(_LegacyGetCreativeDeliveryResponse, CanonicalBoundaryModel): """Canonical creative delivery response; rows are the read-back delivery creatives.""" - creatives: Sequence[DeliveryCreative] + creatives: Sequence[DeliveryCreative] = _inherit( + _LegacyGetCreativeDeliveryResponse, "creatives" + ) PRIMARY_CANONICAL_MODELS: tuple[type[CanonicalBoundaryModel], ...] = ( diff --git a/src/adcp/types/capabilities.py b/src/adcp/types/capabilities.py index 1c4a2b487..dfca1b03b 100644 --- a/src/adcp/types/capabilities.py +++ b/src/adcp/types/capabilities.py @@ -41,6 +41,7 @@ from adcp.types.base import AdCPBaseModel from adcp.types._generated import CountryFusedPostalCodeSystem as LegacyPostalCodeSystem +from adcp.types.base import inherit_field from adcp.types.domains.bundled.protocol.get_adcp_capabilities_response import ( A2ui, Accreditation, @@ -152,7 +153,9 @@ class MediaBuy(_MediaBuy): """Media-buy capabilities using the canonical reporting model identity.""" - reporting_delivery: ReportingDeliveryCapabilities | None = None # type: ignore[assignment] + reporting_delivery: ReportingDeliveryCapabilities | None = inherit_field( # type: ignore[assignment] + _MediaBuy, "reporting_delivery" + ) CapabilitiesMediaBuy = MediaBuy diff --git a/src/adcp/types/projections.py b/src/adcp/types/projections.py index 4a6018f3d..b7c155430 100644 --- a/src/adcp/types/projections.py +++ b/src/adcp/types/projections.py @@ -25,12 +25,13 @@ import re from collections.abc import Iterator, Mapping -from typing import Annotated, Any +from typing import Any from pydantic import ConfigDict, Field, field_validator from adcp._version import normalize_to_release_precision from adcp.types import Account, BusinessEntity, NotificationAuthentication, NotificationConfig +from adcp.types.base import inherit_field from adcp.types.capabilities import GeoPostalAreas, LegacyPostalCodeSystem from adcp.types.variants import SchemaVariant @@ -188,7 +189,9 @@ def _reject_credentials(cls, v: Any) -> None: class _NotificationConfigResponse(NotificationConfig): """Account notification config with write-only credentials stripped.""" - authentication: SchemaVariant[_NotificationAuthenticationResponse | None] = None + authentication: SchemaVariant[_NotificationAuthenticationResponse | None] = inherit_field( + NotificationConfig, "authentication" + ) class AccountResponse(Account): @@ -202,10 +205,11 @@ class AccountResponse(Account): to an ``AccountResponse`` and drops bank along the way. """ - billing_entity: BusinessEntityResponse | None = None - notification_configs: SchemaVariant[ - Annotated[list[_NotificationConfigResponse], Field(max_length=16)] | None - ] = None + billing_entity: BusinessEntityResponse | None = inherit_field(Account, "billing_entity") + # The generated field carries ``max_length=16``; it is inherited, not restated. + notification_configs: SchemaVariant[list[_NotificationConfigResponse] | None] = inherit_field( + Account, "notification_configs" + ) def to_account_response(account: Account) -> AccountResponse: diff --git a/tests/test_redeclared_fields_inherit.py b/tests/test_redeclared_fields_inherit.py new file mode 100644 index 000000000..8903da360 --- /dev/null +++ b/tests/test_redeclared_fields_inherit.py @@ -0,0 +1,203 @@ +"""A hand-written redeclaration keeps its generated field's metadata (#1620). + +A canonical model narrows an inherited field's annotation — ``list[Format]`` +for the generated ``list[FormatOption]`` — and that is all it means to change. +A bare ``Field(...)`` on the redeclaration silently dropped the generated +description, the constraints (``min_length``, ``max_length``) and the +never-null marker that leaves an unset ``None`` off the wire from any parent +(#1591), so a plain-pydantic parent nesting a canonical ``Product`` wrote +``"placements": null`` where the schema admits no null. #1548 and #1579 fixed +single fields the same way; this guard walks every hand-written subclass of a +generated model and holds every redeclared field to its parent, so the next +redeclaration cannot regress it. +""" + +from __future__ import annotations + +import importlib +import pkgutil +import sys +import warnings +from typing import Any + +import pytest +from pydantic import BaseModel +from pydantic.fields import FieldInfo + +import adcp +from adcp.types import ( + CreateMediaBuyRequest, + GetProductsRequest, + GetProductsResponse, + ListCreativesRequest, + Placement, + Product, + UpdateMediaBuyRequest, +) +from adcp.types.base import is_no_explicit_null + +#: Redeclarations that deliberately do not mirror the generated field, each +#: with the reason. Nothing else is exempt. +ALLOWED: dict[tuple[str, str], str] = { + ("adcp.types.projections.BusinessEntityResponse", "bank"): ( + "write-only projection: the field is excluded from every dump and a validator " + "refuses any value, so the marker and description have nothing to apply to" + ), + ("adcp.types.projections._NotificationAuthenticationResponse", "credentials"): ( + "write-only projection: excluded from every dump, any value refused; the generated " + "min_length would raise on the only value it ever holds (None)" + ), +} + + +def _is_generated(cls: type) -> bool: + return cls.__module__.startswith("adcp.types.domains.") + + +def _generated_parent_field(cls: type[BaseModel], name: str) -> tuple[type, FieldInfo] | None: + for base in cls.__mro__[1:]: + if ( + isinstance(base, type) + and issubclass(base, BaseModel) + and _is_generated(base) + and name in base.model_fields + ): + return base, base.model_fields[name] + return None + + +def _constraints(field: FieldInfo) -> list[str]: + """The field's constraint metadata, as text, without the never-null marker.""" + return sorted( + repr(item) + for item in field.metadata + if type(item).__name__ != "_RefuseJsonNull" and item is not None + ) + + +def _hand_written_subclasses() -> list[type[BaseModel]]: + """Every class under ``adcp`` that subclasses a generated model and is not one.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + importlib.import_module("adcp.types._eager") + for info in pkgutil.walk_packages(adcp.__path__, "adcp."): + if ".domains." in info.name or info.name.endswith("._generated"): + continue + try: + importlib.import_module(info.name) + except ImportError: # an optional dependency this environment lacks + continue + found: list[type[BaseModel]] = [] + seen: set[type] = set() + for module_name in sorted(sys.modules): + if not module_name.startswith("adcp.") or ".domains." in module_name: + continue + for value in list(vars(sys.modules[module_name]).values()): + if not (isinstance(value, type) and issubclass(value, BaseModel)): + continue + if value in seen or value.__module__ != module_name or _is_generated(value): + continue + if any(_is_generated(base) for base in value.__mro__[1:]): + seen.add(value) + found.append(value) + return found + + +def _redeclarations() -> list[tuple[str, str, FieldInfo, FieldInfo]]: + out = [] + for cls in _hand_written_subclasses(): + for name in cls.__dict__.get("__annotations__", {}): + if name.startswith("_") or name not in cls.model_fields: + continue + parent = _generated_parent_field(cls, name) + if parent is None: + continue + out.append( + (f"{cls.__module__}.{cls.__qualname__}", name, parent[1], cls.model_fields[name]) + ) + return out + + +REDECLARED = sorted(_redeclarations(), key=lambda item: (item[0], item[1])) + + +def test_the_walk_finds_the_canonical_layer() -> None: + names = {(owner, name) for owner, name, _, _ in REDECLARED} + assert ("adcp.types.canonical_creative.Product", "placements") in names + assert ("adcp.types.capabilities.MediaBuy", "reporting_delivery") in names + assert ("adcp.types.projections.AccountResponse", "notification_configs") in names + + +@pytest.mark.parametrize( + "owner, name, parent, child", + REDECLARED, + ids=[f"{owner.rsplit('.', 1)[1]}.{name}" for owner, name, _, _ in REDECLARED], +) +def test_redeclared_field_keeps_its_generated_metadata( + owner: str, name: str, parent: FieldInfo, child: FieldInfo +) -> None: + if (owner, name) in ALLOWED: + pytest.skip(ALLOWED[(owner, name)]) + problems = [] + narrowed_to_required = child.is_required() and not parent.is_required() + # A field made required drops the marker (``inherit_field(required=True)``): + # every marked field in the tree is optional, and a required one has no + # "omitted" for it to spell. + if not narrowed_to_required and is_no_explicit_null(parent) != is_no_explicit_null(child): + problems.append("never-null marker") + if (parent.description or None) != (child.description or None): + problems.append("description") + if _constraints(parent) != _constraints(child): + problems.append(f"constraints {_constraints(parent)} -> {_constraints(child)}") + if parent.alias != child.alias: + problems.append("alias") + assert not problems, ( + f"{owner}.{name} redeclares the generated field without its {', '.join(problems)}; " + "assign adcp.types.base.inherit_field(, ) on the redeclaration" + ) + + +def test_every_allowed_exemption_is_still_a_redeclaration() -> None: + names = {(owner, name) for owner, name, _, _ in REDECLARED} + stale = sorted(set(ALLOWED) - names) + assert not stale, f"ALLOWED names redeclarations that no longer exist: {stale}" + + +# --------------------------------------------------------------------------- +# The issue's reproduction: no null for an unset optional field from a parent. +# --------------------------------------------------------------------------- + + +class _Envelope(BaseModel): + products: list[Product] + + +def test_a_nested_canonical_product_leaves_unset_placements_off() -> None: + product = Product.model_construct(product_id="p", name="n", description="d") + assert [k for k, v in product.model_dump(mode="json").items() if v is None] == [] + body = _Envelope.model_construct(products=[product]).model_dump(mode="json") + assert [k for k, v in body["products"][0].items() if v is None] == [] + + +@pytest.mark.parametrize( + "model, field", + [ + (Product, "placements"), + (Placement, "format_options"), + (CreateMediaBuyRequest, "packages"), + (UpdateMediaBuyRequest, "packages"), + (UpdateMediaBuyRequest, "new_packages"), + (GetProductsResponse, "products"), + (GetProductsRequest, "filters"), + (ListCreativesRequest, "filters"), + ], +) +def test_listed_fields_carry_the_never_null_marker(model: type[BaseModel], field: str) -> None: + info = model.model_fields[field] + assert is_no_explicit_null(info), f"{model.__name__}.{field}" + + class Parent(BaseModel): + item: Any + + instance = model.model_construct(**{field: None}) + assert field not in Parent(item=instance).model_dump(mode="json")["item"] diff --git a/tests/test_targeting_overlay_schema.py b/tests/test_targeting_overlay_schema.py index e735ca680..aa5abeeb0 100644 --- a/tests/test_targeting_overlay_schema.py +++ b/tests/test_targeting_overlay_schema.py @@ -16,17 +16,18 @@ from adcp.types import TargetingOverlayInput from adcp.types.canonical_creative import sanitize_canonical_schema -_PACKAGE_PATH = ("properties", "packages", "anyOf", 0, "items", "properties", "targeting_overlay") +# ``packages`` is optional but not nullable on every request, so the +# advertised node is the array itself rather than an ``anyOf`` with a null arm +# (#1547; the canonical create/update redeclarations inherit the generated +# never-null marker since #1620). +_PACKAGE_PATH = ("properties", "packages", "items", "properties", "targeting_overlay") _TARGETING_PATHS = { "create_media_buy": {_PACKAGE_PATH}, "update_media_buy": { _PACKAGE_PATH, - ("properties", "new_packages", "anyOf", 0, "items", "properties", "targeting_overlay"), + ("properties", "new_packages", "items", "properties", "targeting_overlay"), }, - # ``packages`` is optional but not nullable on this request, so its - # advertised node is the array itself rather than an ``anyOf`` with a null - # arm (#1547). - "control_media_buy": {("properties", "packages", "items", "properties", "targeting_overlay")}, + "control_media_buy": {_PACKAGE_PATH}, "buy_products": {("properties", "purchases", "items", "properties", "targeting_overlay")}, } From a7f5acdbf58fcba5c2e590976c00dba4b84dbbf2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 26 Oct 2026 14:05:47 -0400 Subject: [PATCH 52/54] feat(types): keep wire bytes on parsed URLs, datetimes, UUIDs and emails so digests round-trip (#1617) AnyUrl, AwareDatetime, UUID and EmailStr rewrite what they parse (a trailing slash on a bare origin, +00:00 to Z, dropped .000), so a digest-covered object such as AudienceEvidence could not reproduce its own content_digest after parse and dump. Generated uri, date-time, uuid and email fields now use wire-preserving subclasses that keep the input text when it is strictly valid wire syntax naming the same host, and normalize otherwise. AudienceEvidence and its pin, selection and canonical variants are exported from adcp.types, and AdCPBaseModel.model_dump_as_received() dumps an object as received. Behaviour change: parsed URLs, datetimes, UUIDs and emails keep their received spelling in str() and JSON output (no added trailing slash, +00:00 kept). Runtime values are subclasses, so type(x) is datetime is false while isinstance holds, and str(url)-keyed caches see different spellings of the same URL as different. Strings that are not strict wire syntax are still normalized. The JSON Schema is unchanged. Fixes #1610 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/validation-contract.md | 94 ++ scripts/post_generate_fixes.py | 96 +++ scripts/post_generation_manifest.json | 3 +- src/adcp/_formats.py | 122 +++ src/adcp/reporting/adjustment_evidence.py | 6 +- src/adcp/types/__init__.py | 10 + src/adcp/types/_eager.py | 10 + src/adcp/types/_wire.py | 384 +++++++++ src/adcp/types/base.py | 63 +- src/adcp/types/canonical_creative.py | 9 +- src/adcp/types/domains/a2ui/si_catalog.py | 8 +- src/adcp/types/domains/a2ui/user_action.py | 8 +- .../types/domains/aao/agent_publishers.py | 12 +- .../account/list_account_changes_response.py | 16 +- .../domains/account/report_usage_request.py | 8 +- .../domains/account/sync_accounts_response.py | 7 +- .../account/sync_governance_request.py | 8 +- .../account/sync_governance_response.py | 8 +- src/adcp/types/domains/adagents.py | 42 +- .../domains/brand/acquire_rights_response.py | 5 +- .../brand/creative_approval_request.py | 8 +- .../brand/creative_approval_response.py | 16 +- .../brand/get_brand_identity_response.py | 7 +- .../domains/brand/get_rights_response.py | 5 +- .../domains/brand/revocation_notification.py | 8 +- .../domains/brand/search_brands_response.py | 10 +- .../domains/brand/update_rights_response.py | 5 +- .../brand/verify_brand_claim_response.py | 6 +- .../brand/verify_brand_claims_request.py | 10 +- .../brand/verify_brand_claims_response.py | 6 +- src/adcp/types/domains/brand_discovery.py | 93 +- .../get_adcp_capabilities_response.py | 149 ++-- .../domains/collection/collection_list.py | 12 +- .../collection_list_changed_webhook.py | 10 +- .../get_collection_list_response.py | 10 +- .../update_collection_list_request.py | 8 +- .../comply_test_controller_request.py | 8 +- .../comply_test_controller_response.py | 16 +- .../domains/content_standards/artifact.py | 24 +- .../artifact_webhook_payload.py | 10 +- .../create_content_standards_request.py | 10 +- .../get_media_buy_artifacts_request.py | 10 +- .../get_media_buy_artifacts_response.py | 5 +- .../update_content_standards_request.py | 10 +- .../validate_content_delivery_request.py | 8 +- src/adcp/types/domains/core/account.py | 14 +- src/adcp/types/domains/core/account_change.py | 10 +- .../core/account_change_recorded_webhook.py | 10 +- .../domains/core/account_identity_change.py | 10 +- .../core/account_status_changed_webhook.py | 12 +- .../domains/core/agent_notification_config.py | 8 +- .../core/agent_notification_config_state.py | 8 +- .../core/agent_reporting_destination_state.py | 10 +- .../types/domains/core/agent_signing_key.py | 8 +- .../domains/core/agent_webhook_challenge.py | 8 +- src/adcp/types/domains/core/app_item.py | 16 +- .../types/domains/core/assets/asset_union.py | 102 ++- .../types/domains/core/assets/audio_asset.py | 10 +- .../types/domains/core/assets/daast_asset.py | 14 +- .../domains/core/assets/display_tag_asset.py | 12 +- .../types/domains/core/assets/image_asset.py | 8 +- .../core/assets/published_post_asset.py | 18 +- .../types/domains/core/assets/vast_asset.py | 18 +- .../types/domains/core/assets/video_asset.py | 14 +- .../domains/core/assets/webhook_asset.py | 8 +- .../types/domains/core/assets/zip_asset.py | 10 +- .../get_products_async_response_submitted.py | 8 +- .../get_signals_async_response_submitted.py | 8 +- .../domains/core/attestation_capabilities.py | 26 +- .../domains/core/attestation_evaluation.py | 20 +- .../types/domains/core/attestation_issuer.py | 10 +- .../domains/core/attestation_reference.py | 18 +- .../types/domains/core/attestation_subject.py | 12 +- .../core/audience_activation_method.py | 8 +- .../domains/core/audience_characteristic.py | 12 +- .../types/domains/core/audience_evidence.py | 32 +- .../core/audience_evidence_requirements.py | 8 +- .../types/domains/core/audience_source.py | 8 +- .../domains/core/authorized_agent_base.py | 10 +- src/adcp/types/domains/core/brand_ref.py | 10 +- .../brand_response_authorization_result.py | 10 +- .../types/domains/core/business_entity.py | 8 +- .../core/canonical_audience_evidence.py | 12 +- .../core/canonical_delivery_forecast.py | 10 +- .../domains/core/canonical_format_option.py | 8 +- .../types/domains/core/canonical_product.py | 8 +- .../types/domains/core/canonical_proposal.py | 10 +- .../core/canonical_reporting_commitment.py | 10 +- .../core/capabilities_changed_webhook.py | 12 +- src/adcp/types/domains/core/catalog.py | 8 +- .../core/catalog_item_availability_state.py | 10 +- .../core/catalog_item_availability_update.py | 8 +- ...catalog_item_availability_update_result.py | 10 +- .../types/domains/core/collection_list_ref.py | 8 +- .../types/domains/core/committed_metric.py | 10 +- .../types/domains/core/creative_filters.py | 14 +- src/adcp/types/domains/core/creative_item.py | 8 +- .../creative_operation_format_declaration.py | 8 +- .../types/domains/core/creative_policy.py | 8 +- src/adcp/types/domains/core/datetime_range.py | 10 +- .../types/domains/core/delivery_forecast.py | 10 +- src/adcp/types/domains/core/deployment.py | 12 +- src/adcp/types/domains/core/destination.py | 8 +- .../types/domains/core/destination_item.py | 10 +- .../core/downstream_connection_requirement.py | 14 +- src/adcp/types/domains/core/education_item.py | 10 +- src/adcp/types/domains/core/evaluator_spec.py | 10 +- src/adcp/types/domains/core/event.py | 10 +- .../types/domains/core/event_source_health.py | 10 +- src/adcp/types/domains/core/flight_item.py | 14 +- .../domains/core/forecast_dimension_time.py | 10 +- src/adcp/types/domains/core/format.py | 40 +- .../domains/core/generation_credential.py | 10 +- .../core/geo_place_catalog_capability.py | 8 +- .../domains/core/geo_place_catalog_entry.py | 8 +- .../types/domains/core/geo_place_resolver.py | 8 +- .../types/domains/core/geo_place_system.py | 9 +- src/adcp/types/domains/core/geo_place_type.py | 9 +- src/adcp/types/domains/core/hotel_item.py | 10 +- src/adcp/types/domains/core/impairment.py | 8 +- src/adcp/types/domains/core/indicator.py | 8 +- .../types/domains/core/indicator_bearing.py | 8 +- .../core/indicators_changed_webhook.py | 10 +- .../types/domains/core/insertion_order.py | 14 +- src/adcp/types/domains/core/installment.py | 10 +- .../domains/core/installment_deadlines.py | 10 +- .../core/installment_delivery_metrics.py | 8 +- .../installment_property_delivery_metrics.py | 8 +- .../core/inventory_list_application.py | 18 +- src/adcp/types/domains/core/job_item.py | 8 +- src/adcp/types/domains/core/limited_series.py | 10 +- .../types/domains/core/macro_bearing_url.py | 9 +- .../types/domains/core/macro_declaration.py | 8 +- .../core/macro_resolution_capability.py | 8 +- .../domains/core/macro_resolution_result.py | 8 +- .../domains/core/macro_translation_target.py | 8 +- .../types/domains/core/material_deadline.py | 8 +- .../types/domains/core/mcp_webhook_payload.py | 8 +- src/adcp/types/domains/core/media_buy.py | 16 +- .../types/domains/core/notification_config.py | 8 +- src/adcp/types/domains/core/offering.py | 14 +- .../types/domains/core/opportunity_context.py | 8 +- src/adcp/types/domains/core/overlay.py | 12 +- src/adcp/types/domains/core/package.py | 16 +- .../domains/core/package_format_snapshot.py | 38 +- .../domains/core/performance_feedback.py | 14 +- .../core/performance_feedback_assertion.py | 10 +- .../types/domains/core/placement_evidence.py | 10 +- .../domains/core/placement_presentation.py | 10 +- .../types/domains/core/planned_delivery.py | 10 +- .../domains/core/platform_extension_ref.py | 8 +- .../types/domains/core/presentation_ref.py | 10 +- .../types/domains/core/preview_provider.py | 10 +- .../domains/core/principal_changed_webhook.py | 12 +- .../types/domains/core/principal_state.py | 8 +- src/adcp/types/domains/core/product.py | 14 +- .../types/domains/core/product_allocation.py | 10 +- .../product_audience_evidence_requirements.py | 12 +- .../types/domains/core/product_filters.py | 12 +- .../core/product_format_declaration.py | 38 +- .../domains/core/product_offer_filters.py | 12 +- .../types/domains/core/property_list_ref.py | 8 +- src/adcp/types/domains/core/proposal.py | 8 +- .../types/domains/core/protocol_envelope.py | 8 +- src/adcp/types/domains/core/provenance.py | 26 +- .../domains/core/push_notification_config.py | 8 +- .../types/domains/core/real_estate_item.py | 10 +- .../types/domains/core/reference_asset.py | 8 +- .../types/domains/core/reference_renderer.py | 10 +- src/adcp/types/domains/core/registry_event.py | 141 ++- .../domains/core/registry_feed_response.py | 13 +- .../domains/core/reporting_adjustment.py | 14 +- .../core/reporting_adjustment_receipt.py | 10 +- .../reporting_canonical_content_digest.py | 8 +- .../domains/core/reporting_consumer_status.py | 16 +- .../types/domains/core/reporting_coverage.py | 8 +- .../core/reporting_delivery_capabilities.py | 10 +- .../domains/core/reporting_delivery_config.py | 8 +- .../core/reporting_delivery_config_state.py | 20 +- .../core/reporting_delivery_offering.py | 12 +- .../core/reporting_delivery_ready_webhook.py | 10 +- .../domains/core/reporting_file_manifest.py | 12 +- .../core/reporting_ledger_changed_webhook.py | 8 +- .../domains/core/reporting_materialization.py | 12 +- .../domains/core/reporting_obligation.py | 16 +- .../types/domains/core/reporting_receipt.py | 10 +- .../core/reporting_reliability_statistics.py | 14 +- .../types/domains/core/reporting_resource.py | 8 +- .../types/domains/core/reporting_revision.py | 22 +- .../types/domains/core/reporting_schedule.py | 8 +- .../core/reporting_schedule_offering.py | 8 +- .../core/reporting_status_changed_webhook.py | 8 +- .../domains/core/reporting_status_issue.py | 14 +- .../domains/core/reporting_verification.py | 8 +- .../types/domains/core/reporting_webhook.py | 8 +- .../core/response_payload_jws_envelope.py | 8 +- .../core/rights_attestation_evaluation.py | 14 +- .../types/domains/core/rights_constraint.py | 22 +- .../types/domains/core/seller_agent_ref.py | 8 +- .../domains/core/signal_coverage_forecast.py | 10 +- .../types/domains/core/signal_definition.py | 18 +- .../core/signal_definition_enrichment.py | 18 +- src/adcp/types/domains/core/signal_id.py | 8 +- src/adcp/types/domains/core/signal_listing.py | 10 +- .../core/signal_modeling_disclosure.py | 8 +- src/adcp/types/domains/core/signal_ref.py | 8 +- src/adcp/types/domains/core/special.py | 10 +- src/adcp/types/domains/core/start_timing.py | 7 +- src/adcp/types/domains/core/store_item.py | 8 +- src/adcp/types/domains/core/talent.py | 8 +- .../types/domains/core/tasks_get_response.py | 14 +- .../types/domains/core/tasks_list_request.py | 14 +- .../types/domains/core/tasks_list_response.py | 12 +- src/adcp/types/domains/core/transformer.py | 40 +- src/adcp/types/domains/core/vehicle_item.py | 10 +- .../domains/core/verification_token_claims.py | 12 +- .../domains/core/webhook_activity_record.py | 12 +- .../types/domains/core/webhook_challenge.py | 8 +- .../domains/core/wholesale_feed_event.py | 51 +- .../domains/core/wholesale_feed_webhook.py | 11 +- .../domains/creative/audit_observation.py | 10 +- .../creative_assignment_changed_webhook.py | 10 +- .../creative/creative_feature_result.py | 10 +- .../creative/creative_purged_webhook.py | 10 +- .../creative_status_changed_webhook.py | 10 +- .../get_creative_delivery_response.py | 10 +- .../get_creative_features_terminal_success.py | 8 +- .../list_creative_formats_response.py | 8 +- .../creative/list_creatives_response.py | 22 +- .../creative/preview_creative_response.py | 13 +- .../types/domains/creative/preview_render.py | 10 +- .../creative/sync_creatives_response.py | 7 +- .../creative/validate_input_request.py | 8 +- .../error_details/account_setup_required.py | 8 +- .../error_details/authorization_required.py | 8 +- .../error_details/creative_rejected.py | 8 +- .../governance_agent_not_accepted.py | 8 +- .../domains/error_details/policy_violation.py | 8 +- .../domains/error_details/stale_response.py | 8 +- .../error_details/vendor_error_codes.py | 8 +- .../domains/extensions/extension_meta.py | 8 +- .../governance/accepted_governance_agents.py | 10 +- .../governance/check_governance_request.py | 26 +- .../governance/check_governance_response.py | 16 +- .../get_plan_audit_logs_response.py | 28 +- .../types/domains/governance/policy_entry.py | 8 +- .../report_plan_adjustment_request.py | 10 +- .../governance/report_plan_outcome_request.py | 14 +- .../domains/governance/sync_plans_request.py | 16 +- src/adcp/types/domains/manifest_schema.py | 8 +- .../media_buy/accept_proposal_request.py | 8 +- .../media_buy/accept_proposal_response.py | 8 +- .../media_buy/acceptance_policy_catalog.py | 8 +- .../acceptance_policy_requirement.py | 10 +- .../media_buy/acceptance_policy_rule.py | 10 +- .../media_buy/build_creative_response.py | 19 +- .../domains/media_buy/buy_products_request.py | 8 +- .../media_buy/buy_products_response.py | 8 +- .../media_buy/change_term_constraints.py | 14 +- .../domains/media_buy/commercial_terms.py | 10 +- .../media_buy/control_media_buy_response.py | 8 +- .../media_buy/create_media_buy_request.py | 12 +- .../media_buy/create_media_buy_response.py | 7 +- .../get_media_buy_delivery_response.py | 24 +- .../media_buy/get_media_buys_response.py | 42 +- .../get_products_async_response_submitted.py | 8 +- .../media_buy/get_reporting_status_request.py | 10 +- .../get_reporting_status_response.py | 18 +- .../legacy_purchase_continuation_input.py | 7 +- .../list_creative_formats_response.py | 8 +- .../media_buy_commitment_response.py | 8 +- .../media_buy_delivery_webhook_result.py | 18 +- .../domains/media_buy/package_request.py | 10 +- .../types/domains/media_buy/package_update.py | 10 +- .../domains/media_buy/product_purchase.py | 10 +- .../media_buy/product_purchase_input.py | 10 +- .../domains/media_buy/proposal_refinement.py | 10 +- .../provide_performance_feedback_response.py | 7 +- .../media_buy/refine_proposals_response.py | 14 +- .../media_buy/request_proposals_response.py | 10 +- .../media_buy/sync_audiences_response.py | 5 +- .../media_buy/sync_catalogs_response.py | 7 +- .../media_buy/update_media_buy_request.py | 8 +- .../media_buy/update_media_buy_response.py | 5 +- .../domains/property/authorization_result.py | 8 +- .../types/domains/property/delivery_record.py | 8 +- .../property/get_property_list_response.py | 10 +- .../property/property_feature_definition.py | 8 +- .../property/property_feature_result.py | 8 +- .../property/property_feature_value.py | 10 +- .../types/domains/property/property_list.py | 12 +- .../property/property_list_changed_webhook.py | 10 +- .../property/update_property_list_request.py | 8 +- .../validate_property_delivery_response.py | 12 +- .../get_adcp_capabilities_response.py | 42 +- .../protocol/get_task_status_response.py | 14 +- .../domains/protocol/list_tasks_request.py | 14 +- .../domains/protocol/list_tasks_response.py | 12 +- .../get_signals_async_response_submitted.py | 8 +- .../domains/signals/get_signals_response.py | 20 +- .../si_get_offering_response.py | 18 +- .../sponsored_intelligence/si_identity.py | 12 +- .../si_sponsored_context.py | 10 +- .../si_sponsored_context_receipt.py | 8 +- .../si_terminate_session_response.py | 10 +- .../trusted_match/context_match_request.py | 13 +- .../trusted_match/identity_match_request.py | 12 +- .../trusted_match/provider_registration.py | 19 +- src/adcp/validation/schema_loader.py | 55 +- tests/conformance/_round_trip.py | 815 ++++++++++++++++++ tests/conformance/test_wire_round_trip.py | 252 ++++++ tests/fixtures/public_api_snapshot.json | 9 +- tests/fixtures/round_trip/corpus.json | 265 ++++++ tests/test_code_generation.py | 3 +- tests/test_composability_invariant.py | 2 + tests/test_governance_authorization.py | 2 +- tests/test_post_generate_wire_types.py | 50 ++ tests/test_reporting_adjustment_evidence.py | 12 +- tests/test_type_aliases.py | 4 +- tests/test_wire_types.py | 429 +++++++++ 320 files changed, 4584 insertions(+), 1996 deletions(-) create mode 100644 src/adcp/_formats.py create mode 100644 src/adcp/types/_wire.py create mode 100644 tests/conformance/_round_trip.py create mode 100644 tests/conformance/test_wire_round_trip.py create mode 100644 tests/fixtures/round_trip/corpus.json create mode 100644 tests/test_post_generate_wire_types.py create mode 100644 tests/test_wire_types.py diff --git a/docs/validation-contract.md b/docs/validation-contract.md index 738c8331e..792bb4ca4 100644 --- a/docs/validation-contract.md +++ b/docs/validation-contract.md @@ -43,6 +43,100 @@ directly. One consequence for subclasses: a `field_serializer` that substitutes a default for `None` no longer runs for a `None` value on a marked field, because `exclude_if` drops the field before the serializer is reached. +## Wire round trip + +AdCP binds bytes to several objects. `core/audience-evidence.json` defines +`content_digest` as SHA-256 over the RFC 8785 (JCS) form of the entry *as +declared*; `core/canonical-proposal.json` binds `terms_digest` to +`commercial_terms` the same way; `core/rights-constraint.json`, +`core/creative-representation-set.json`, `media-buy/acceptance-policy-profile.json`, +the attestation schemas and the governance check carry their own; a `context` +is "echoed unchanged". A model that changes a value between parse and dump +breaks every one of those contracts without failing anything, and until 9.1 +the SDK had no invariant saying it must not. + +### Root cause + +The generator maps each JSON Schema `format` to the pydantic type that +*parses* it best — `AnyUrl`, `AwareDatetime`, `UUID`, `EmailStr` — and those +types re-serialize the value they parsed rather than the text they were given: +a bare origin gains a trailing slash, `+00:00` becomes `Z`, `.000` is dropped, +a UUID is lower-cased, an email's domain is lower-cased. The runtime then adds +rewrites of its own: `model_dump` emits every field, so a schema `default` the +generator wrote into the field appears in the output though the document +omitted it; `exclude_none` (the default) drops an explicit `null` the schema +admits; `by_alias` is off, so `$schema` dumps as `field_schema`; and +`extra='ignore'` drops a property the pinned model does not declare. Each of +these is reasonable for an application type and wrong for a document whose +bytes are an identity. + +Four earlier fixes were point patches of this one gap: #1384 typed one field +(`agent_url`) as a wire string because an option ID hashed it; #1449 reported +that dumping a validated `ProductFormatDeclaration` "adds every branch default +to the published format"; #1587 made nested models leave unset fields off from +any parent; #1575 kept a `ContextObject`'s null keys. #1610 found the next +instance in `AudienceEvidence`. + +### The invariant + +**A value the model did not change is dumped as it was received.** Two +mechanisms carry it, and a conformance suite measures it: + +- **Wire-preserving scalar types.** `scripts/post_generate_fixes.py` + (`preserve_wire_text_on_normalizing_fields`) points every generated + `format: date-time`, `uri`, `uuid` and `email` field at + `adcp.types._wire` — `WireDatetime`, `WireAnyUrl`, `WireUuid`, `WireEmail`. + Each validates exactly as the pydantic type did, keeps the attribute API + (`.year`, `.tzinfo`, `.host`, `.hex`), compares and hashes as the same + value, advertises the same JSON Schema, and in JSON mode serializes the text + it was parsed from — when that text passes the strict wire check the bundled + validator applies (`adcp._formats`: RFC 3339 for a date-time, RFC 3986 + syntax with an agreeing host for a URI, the hyphenated 8-4-4-4-12 form for a + UUID, a trimmed address for an email). A string pydantic's lenient parser + accepts but the schema rejects (`"2026-10-01 00:00:00+00:00"`, + `"https:x.example"`, `"{...}"`) is normalized, as it always was. A + `datetime`, `AnyUrl` or `UUID` *object* passed to a constructor has no wire + text and serializes as before: RFC 3339 with `Z` for UTC and `±HH:MM` + otherwise, fractional seconds only when non-zero; pydantic's normalized URL + string; a lower-case hyphenated UUID. Every such field is covered, not only the + digest-covered ones: a shared class (`AttestationReference`, `Provenance`, + `Targeting`) sits inside digested and undigested objects alike, so a + per-subtree rule would have to split classes. The rule covers the generated + AdCP schema types (`adcp.types.domains`); `adcp.types.registry`, the + OpenAPI-generated registry client, keeps pydantic's `AnyUrl` and + `AwareDatetime` — no registry object is digest-covered. +- **An explicit "as received" dump.** `AdCPBaseModel.model_dump_as_received()` + is `model_dump(mode="json", exclude_unset=True, exclude_none=False, + by_alias=True)`: it leaves off every field the document did not set (where + a generated default would otherwise appear), keeps an explicit `null` the + schema admits, and writes wire names. Recompute a digest over this dump, not + the default one. For a model built in Python it emits exactly the fields the + caller set. + +`tests/conformance/test_wire_round_trip.py` runs every bundle schema that has +a model — the bundle's `examples`, a synthesized document, the differential +fixtures, a checked-in corpus for the digest-covered schemas, and variants of +each exercising every form a normalizing parser rewrites — through +`model_validate_json(doc).model_dump(mode="json")` and compares the result to +the input under JCS. A rewritten URL, datetime, UUID, email, value or shape +fails anywhere. The digest- and echo-covered schemas must round-trip with no +difference of any class under `model_dump_as_received`. Under the default +dump the suite names the classes that remain, each with the issue that tracks +it, and the list only shrinks: + +| class | what happens | where it stands | +| --- | --- | --- | +| `default_emitted` | the dump materializes a schema default the document omitted | #1613; `model_dump_as_received` recovers the bytes. Two digest-covered schemas (`canonical-proposal`, `commercial-terms`) are affected under the default dump and are listed in the suite. | +| `field_dropped` | `extra='ignore'` drops an unknown or sibling-arm property | #1614; not recoverable by any dump. No digest-covered schema is affected at pin 3.2.4. | +| `alias_emitted` | `model_dump()` writes `pass_` for `pass`, `field_schema` for `$schema` | #1615; `model_dump_as_received` writes the wire name. | +| `null_dropped` | `exclude_none` drops an admitted explicit `null` | #1615; `model_dump_as_received` keeps it. | + +Why the default dump was not changed in 9.x: leaving unset defaults off every +`model_dump()` is wire-equivalent for a schema-applying reader but visible to +any code that indexes a dumped dict (`payload["sandbox"]`), including a +counterparty that does not apply schema defaults. That is a 10.0 decision, +written up in #1613. + ## Wire-boundary behavior - SDK clients validate requests before sending and responses after receiving, diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index ca322f248..9f5f43d56 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -25,9 +25,11 @@ import ast import hashlib import importlib.util +import io import json import re import sys +import tokenize from collections.abc import Mapping, Sequence from copy import deepcopy from pathlib import Path @@ -1007,6 +1009,97 @@ def preserve_format_reference_agent_url_wire_string() -> None: print(" core/format_id.py: agent_url keeps the wire string (WireUrl)") +#: The pydantic type the generator renders for each normalizing ``format`` and +#: the wire-preserving type from ``adcp.types._wire`` that replaces it. Each +#: replacement validates identically, keeps the attribute API (a ``datetime``, +#: an ``AnyUrl``, a ``UUID``, a ``str``) and advertises the same JSON Schema; +#: the only change is that JSON-mode serialization writes the text the wire +#: delivered instead of the parser's normalized form. +_WIRE_PRESERVING_TYPES: dict[tuple[str, str], str] = { + ("pydantic", "AwareDatetime"): "WireDatetime", + ("pydantic", "AnyUrl"): "WireAnyUrl", + ("pydantic", "EmailStr"): "WireEmail", + ("uuid", "UUID"): "WireUuid", +} + + +def _rename_code_names(source: str, renames: Mapping[str, str]) -> str: + """Rename identifier tokens, leaving strings and comments untouched. + + A description such as "Use a fresh UUID v4 for each request" must keep + its wording; only a ``NAME`` token the tokenizer reports is a reference. + """ + lines = source.splitlines(keepends=True) + edits: list[tuple[int, int, int, str]] = [] + previous = "" + for token in tokenize.generate_tokens(io.StringIO(source).readline): + # ``uuid.UUID`` is an attribute, not a reference to the imported name. + if token.type == tokenize.NAME and token.string in renames and previous != ".": + (row, col), (_, end_col) = token.start, token.end + edits.append((row - 1, col, end_col, renames[token.string])) + if token.type not in (tokenize.NL, tokenize.NEWLINE, tokenize.COMMENT): + previous = token.string + for row, col, end_col, replacement in reversed(edits): + line = lines[row] + lines[row] = line[:col] + replacement + line[end_col:] + return "".join(lines) + + +def preserve_wire_text_on_normalizing_fields() -> None: + """Point every normalizing scalar field at its wire-preserving type. + + ``format: date-time``, ``format: uri``, ``format: uuid`` and + ``format: email`` fields are rendered ``AwareDatetime``, ``AnyUrl``, + ``UUID`` and ``EmailStr``, each of which re-serializes the value it parsed: + ``https://measurement.example`` comes back with a trailing slash, + ``2026-10-01T00:00:00.000+00:00`` as ``2026-10-01T00:00:00Z``, an + upper-case UUID lower-cased, an email with its domain lower-cased. AdCP + binds bytes to several of these objects -- ``content_digest`` is SHA-256 + over the JCS form of an entry's declared fields -- so a parsed-then-dumped + entry stopped matching its own digest (#1610). The types in + ``adcp.types._wire`` validate the same way and serialize the text they + were parsed from; a value built in Python serializes as before. + + Every field is pointed there, not only the digest-covered ones: a shared + class (``AttestationReference``, ``Provenance``) sits inside digested and + undigested objects alike, so a per-subtree rule would have to split + classes, and the invariant worth having is that the SDK never rewrites a + wire value it did not change. + """ + renames = {name: wire for (_module, name), wire in _WIRE_PRESERVING_TYPES.items()} + modified = 0 + for path in sorted(OUTPUT_DIR.rglob("*.py")): + source = path.read_text() + if not any(re.search(rf"\b{name}\b", source) for name in renames): + continue + tree = ast.parse(source) + referenced = { + node.id for node in ast.walk(tree) if isinstance(node, ast.Name) and node.id in renames + } + if not referenced: + continue + for (module, name), wire in _WIRE_PRESERVING_TYPES.items(): + if name in referenced: + source = drop_from_imports(source, module, name) + source = _rename_code_names(source, {name: renames[name] for name in referenced}) + wires = sorted({renames[name] for name in referenced}) + if re.search(r"^from adcp\.types\.base import ", source, re.MULTILINE): + source = add_to_import(source, "adcp.types.base", *wires) + else: + anchor = _import_pattern("pydantic").search(source) + if anchor is None: + anchor = re.search(r"^from |^import ", source, re.MULTILINE) + assert anchor is not None, path + source = ( + source[: anchor.start()] + + f"from adcp.types.base import {', '.join(wires)}\n" + + source[anchor.start() :] + ) + path.write_text(source) + modified += 1 + print(f" Pointed normalizing fields at wire-preserving types in {modified} file(s)") + + def point_integer_fields_at_the_schema_integer_type() -> None: """Rewrite the generator's ``StrictInt`` marker to ``SchemaInt``. @@ -8360,6 +8453,9 @@ def main(argv: list[str] | None = None) -> int: annotate_registry_track_verdict, preserve_geo_place_system_map_keys, preserve_format_reference_agent_url_wire_string, + # After every pass that adds a validator typed ``AnyUrl`` and before + # the import cleanups, which would read the names this pass drops. + preserve_wire_text_on_normalizing_fields, insert_task_message_markers, point_integer_fields_at_the_schema_integer_type, remove_imports_shadowed_by_a_local_class, diff --git a/scripts/post_generation_manifest.json b/scripts/post_generation_manifest.json index 3fdf39234..ec9a571ce 100644 --- a/scripts/post_generation_manifest.json +++ b/scripts/post_generation_manifest.json @@ -1,6 +1,7 @@ { "_comment": "Effect manifest for scripts/post_generate_fixes.py. ``true`` records a fix that changes the generated tree; a string records a fix that changes nothing on the pinned schema set, and states why. Regenerate with `python scripts/post_generate_fixes.py --update-manifest`.", "fixes": { + "point_omittable_fields_at_no_explicit_null": true, "add_model_validator_to_product": "No-op since #213 added an explicit discriminator to publisher_properties: Pydantic validates the union, and the body only prints.", "fix_preview_render_self_reference": "datamodel-code-generator 0.64.0 emits PreviewRender1..3 unqualified in creative/preview_render.py; the module-qualified form this fix rewrites appears nowhere.", "fix_brand_manifest_references": "Its target core/promoted_offerings.py is not generated \u2014 brand.json is in GENERATED_SCHEMA_EXCLUDE_FILES \u2014 and the unaliased brand_manifest.BrandManifest reference appears nowhere in the tree.", @@ -69,9 +70,9 @@ "annotate_registry_track_verdict": true, "preserve_geo_place_system_map_keys": true, "preserve_format_reference_agent_url_wire_string": true, + "preserve_wire_text_on_normalizing_fields": true, "insert_task_message_markers": true, "point_integer_fields_at_the_schema_integer_type": true, - "point_omittable_fields_at_no_explicit_null": true, "remove_imports_shadowed_by_a_local_class": true, "remove_unused_pydantic_field_imports": true, "emit_open_asset_unions": true, diff --git a/src/adcp/_formats.py b/src/adcp/_formats.py new file mode 100644 index 000000000..9c910f8a8 --- /dev/null +++ b/src/adcp/_formats.py @@ -0,0 +1,122 @@ +"""Strict wire-format checks shared by schema validation and the wire types. + +The bundled JSON Schema validator and the generated models must agree on +which strings are a ``format: date-time`` or a ``format: uri`` +(``adcp.validation.schema_loader``), and the wire-preserving scalar types +(``adcp.types._wire``) keep a string's spelling only when it passes the same +check -- a string pydantic's lenient parser accepts but the schema rejects +(``"2026-10-01 00:00:00+00:00"``, ``"https:x.example"``) is normalized +instead, as it always was. This module is a leaf: it imports nothing from +``adcp.types`` or ``adcp.validation``, so both can import it. +""" + +from __future__ import annotations + +import re +from datetime import date +from urllib.parse import quote + +__all__ = [ + "RFC3339_DATE_TIME", + "UUID_TEXT", + "is_plain_uri", + "is_rfc3339_date_time", + "is_uri_syntax", + "is_uuid_text", + "uri_host", +] + +RFC3339_DATE_TIME = re.compile( + r"^\d{4}-\d{2}-\d{2}[Tt]" + r"(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d" + r"(?:\.\d+)?" + r"(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$", + re.ASCII, +) + +#: The hyphenated 8-4-4-4-12 form, either case: the only spelling +#: ``format: uuid`` admits. Braces, ``urn:uuid:`` and the bare 32 hex digits +#: are accepted by ``uuid.UUID`` and normalized. +UUID_TEXT = re.compile( + r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" +) + +# scheme "://" [userinfo "@"] host [":" port] -- enough to read the host a +# URI's own text names, for comparison with what the URL parser reports. +_URI_AUTHORITY = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*://(?:[^/?#@]*@)?(\[[^\]]*\]|[^/?#:]*)") + +# A strict SUBSET of RFC 3986: scheme, a plain ASCII reg-name host, an +# optional port, and path/query/fragment made of unreserved, sub-delimiter +# and well-formed percent-escape characters only. No userinfo, IP literal, +# non-ASCII or stray ``%``, so anything it matches is a valid URI whose host +# every parser reads the same way; everything else takes the full check. +_PCHAR = r"(?:[A-Za-z0-9._~!$&'()*+,;=:@/-]|%[0-9A-Fa-f]{2})" +_PLAIN_URI = re.compile( + r"^[A-Za-z][A-Za-z0-9+.\-]*://[A-Za-z0-9.\-]+(?::[0-9]*)?" + rf"(?:/{_PCHAR}*)?(?:\?(?:{_PCHAR}|\?)*)?(?:#(?:{_PCHAR}|\?)*)?$", + re.ASCII, +) + + +def is_rfc3339_date_time(text: str) -> bool: + """Whether ``text`` is an RFC 3339 date-time. + + The grammar checks the time and offset ranges; only the calendar is + checked here, because Python 3.10's parser accepts three or six + fractional digits where RFC 3339 permits any positive number, and the + check must neither coerce nor truncate the value. + """ + if RFC3339_DATE_TIME.fullmatch(text) is None: + return False + try: + date.fromisoformat(text[:10]) + except ValueError: + return False + return True + + +def is_uri_syntax(text: str) -> bool: + """Whether ``text`` is RFC 3986 URI syntax, with non-ASCII escaped first. + + Whitespace, C1 controls and a backslash fail here: pydantic's ``AnyUrl`` + trims or rewrites them, which is exactly what must not be hidden. Only + ASCII is checked as written, so a malformed percent escape stays visible; + ``AnyUrl`` still checks internationalized hosts. + """ + from rfc3986_validator import validate_rfc3986 + + if any(char.isspace() or 0x7F <= ord(char) <= 0x9F for char in text): + return False + try: + syntax = ( + text + if text.isascii() + else "".join(char if char.isascii() else quote(char, safe="") for char in text) + ) + except UnicodeEncodeError: + return False + return bool(validate_rfc3986(syntax)) + + +def is_plain_uri(text: str) -> bool: + """Whether ``text`` is a URI the fast subset grammar accepts. + + True implies :func:`is_uri_syntax` is true and that the host the text + names is the host a URL parser reports (lower-cased). The common case, + checked in a fraction of the full RFC 3986 validator's time. + """ + # fullmatch: ``$`` alone would also accept a single trailing newline. + return _PLAIN_URI.fullmatch(text) is not None + + +def is_uuid_text(text: str) -> bool: + """Whether ``text`` is the hyphenated 8-4-4-4-12 UUID spelling.""" + return UUID_TEXT.fullmatch(text) is not None + + +def uri_host(text: str) -> str | None: + """The host ``text`` names in its authority, lower-cased, or ``None``.""" + match = _URI_AUTHORITY.match(text) + if match is None: + return None + return match.group(1).lower() diff --git a/src/adcp/reporting/adjustment_evidence.py b/src/adcp/reporting/adjustment_evidence.py index 406e9262e..fc63a80f9 100644 --- a/src/adcp/reporting/adjustment_evidence.py +++ b/src/adcp/reporting/adjustment_evidence.py @@ -32,6 +32,7 @@ ReportingObligation, ReportingRevision, ) +from adcp.types._wire import rfc3339 from adcp.validation.schema_loader import get_named_validator __all__ = [ @@ -463,7 +464,10 @@ def build_reporting_adjustment_receipt( moment = aware_utc(observed_at) if _instant(moment) < _instant(adjustment.created_at, raw["created_at"]): _fail("INVALID_RECEIPT") - payload["observed_at"] = moment.isoformat() + # The receipt's own spelling, written here: a parsed datetime keeps the + # text it was given (#1610), so ``isoformat()``'s ``+00:00`` would be + # emitted as such; the receipt has always carried ``Z``. + payload["observed_at"] = rfc3339(moment) if failures: payload["rejection_codes"] = sorted(failures) if current_receipt is not None: diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 98b67e3f6..e18489287 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -83,6 +83,11 @@ class name, or a numbered variant has no semantic alias — import it by its "AcceptancePolicyProfileIds", "AcceptancePolicyRequirement", "AcceptancePolicyRule", + "AudienceEvidence", + "AudienceEvidencePin", + "AudienceEvidenceSelection", + "CanonicalAudienceEvidence", + "CanonicalAudienceEvidenceSelection", "CanonicalMediaBuyAction", "CanonicalMediaBuyActionMode", "CanonicalMediaBuyActionName", @@ -1290,6 +1295,9 @@ def __dir__() -> list[str]: AssetVariant, AssignedPackage, Assignments, + AudienceEvidence, + AudienceEvidencePin, + AudienceEvidenceSelection, AudienceSource, AudienceTooSmallDetails, AudioContent, @@ -1333,6 +1341,8 @@ def __dir__() -> list[str]: CalibrateContentResponse1, CalibrateContentSuccessResponse, CanonicalAssetSource, + CanonicalAudienceEvidence, + CanonicalAudienceEvidenceSelection, CanonicalCompositionModel, CanonicalFormatAgentPlacement, CanonicalFormatBase, diff --git a/src/adcp/types/_eager.py b/src/adcp/types/_eager.py index 9e854ca10..c34626a1d 100644 --- a/src/adcp/types/_eager.py +++ b/src/adcp/types/_eager.py @@ -95,6 +95,9 @@ AssetContentType, AssignedPackage, Assignments, + AudienceEvidence, + AudienceEvidencePin, + AudienceEvidenceSelection, AudienceSource, AudienceTooSmallDetails, Authentication, @@ -112,6 +115,8 @@ ByPackageItem, CalibrateContentRequest, CalibrateContentResponse, + CanonicalAudienceEvidence, + CanonicalAudienceEvidenceSelection, CanonicalMediaBuyAction, CanonicalMediaBuyActionMode, CanonicalMediaBuyActionName, @@ -1152,6 +1157,11 @@ def __init__(self, *args: object, **kwargs: object) -> None: "AcceptancePolicyProfileIds", "AcceptancePolicyRequirement", "AcceptancePolicyRule", + "AudienceEvidence", + "AudienceEvidencePin", + "AudienceEvidenceSelection", + "CanonicalAudienceEvidence", + "CanonicalAudienceEvidenceSelection", "CanonicalMediaBuyAction", "CanonicalMediaBuyActionMode", "CanonicalMediaBuyActionName", diff --git a/src/adcp/types/_wire.py b/src/adcp/types/_wire.py new file mode 100644 index 000000000..d57a232e7 --- /dev/null +++ b/src/adcp/types/_wire.py @@ -0,0 +1,384 @@ +"""Scalar types that keep the wire text they were parsed from. + +AdCP binds bytes to several objects: ``content_digest`` is SHA-256 over the +RFC 8785 (JCS) form of an entry's declared fields, a ``context`` is "echoed +unchanged", and a buyer verifies a seller's digest over what it received. A +model that parses ``https://measurement.example`` into pydantic's ``AnyUrl`` +re-serializes it as ``https://measurement.example/``; one that parses +``2026-10-01T00:00:00.000+00:00`` into ``AwareDatetime`` writes +``2026-10-01T00:00:00Z``. Either rewrite changes the digested bytes, so a +parsed-then-dumped entry no longer matches its own digest (#1610). + +The types here validate exactly as the pydantic types the generator would +have used, expose the same attribute API (``.year``, ``.tzinfo``, ``.host``, +``.hex``), compare and hash as the same values, and differ in one thing: in +JSON mode they serialize the text the wire delivered -- when that text passes +the strict wire check the bundled schema validator applies +(:mod:`adcp._formats`). A string pydantic's lenient parser accepts but the +schema rejects (``"2026-10-01 00:00:00+00:00"``, ``"https:x.example"``, a +braced UUID, ``"Bob "``) is normalized exactly as before, so +nothing invalid is ever echoed. A ``datetime``, ``AnyUrl`` or ``UUID`` +*object* has no wire text and serializes in the pydantic form it always did: + +* :class:`WireDatetime`: RFC 3339 with ``Z`` for UTC and ``±HH:MM`` otherwise, + fractional seconds written only when non-zero (six digits); +* :class:`WireAnyUrl`: pydantic's normalized URL string; +* :class:`WireUuid`: lower-case hyphenated. + +``scripts/post_generate_fixes.py`` points every generated ``format: date-time``, +``format: uri``, ``format: uuid`` and ``format: email`` field here +(``preserve_wire_text_on_normalizing_fields``). The published JSON Schema is +unchanged: each type advertises the same ``type``/``format`` pair the pydantic +type did. ``adcp.types.base.WireUrl`` (#1384) is the ``str``-typed sibling for +a URL that was already a plain string to adopters. + +To a type checker the names re-exported from ``adcp.types.base`` are the +pydantic types they replace (``datetime``, ``AnyUrl``, ``UUID``), so nothing +an adopter wrote against the generated annotations changes its verdict; the +``.wire`` property is a runtime attribute, and :func:`wire_text` is the typed +way to read it. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime +from typing import Annotated, Any + +from pydantic import AnyUrl, EmailStr, GetCoreSchemaHandler, StrictStr, TypeAdapter +from pydantic.functional_validators import AfterValidator +from pydantic.json_schema import WithJsonSchema +from pydantic_core import CoreSchema, SchemaSerializer, core_schema +from typing_extensions import Self + +from adcp import _formats +from adcp._deferred_adapters import deferred_adapter + +__all__ = ["WireAnyUrl", "WireDatetime", "WireEmail", "WireUuid", "rfc3339", "wire_text"] + + +def rfc3339(value: datetime) -> str: + """The RFC 3339 text pydantic writes for an aware ``datetime``. + + ``Z`` for a zero offset, ``±HH:MM`` otherwise; fractional seconds only + when the microsecond is non-zero, and then all six digits. This is the + form every generated model has always emitted for a datetime built in + Python, and it stays the form for one. + """ + text = value.isoformat() + if text.endswith("+00:00"): + return text[:-6] + "Z" + return text + + +class WireDatetime(datetime): + """An aware ``datetime`` that remembers the RFC 3339 text it was parsed from. + + A ``datetime`` in every respect -- attributes, arithmetic, comparison, + hashing, ``isinstance`` -- plus :attr:`wire`, the text it serializes to in + JSON mode. A value parsed from strictly valid text serializes to that + text; one built in Python, parsed from a lenient spelling, or derived by + ``replace()``, ``astimezone()`` or arithmetic, serializes in + :func:`rfc3339` form. + + The slot is left unset on a value that carries no wire text: ``datetime`` + builds derived values and unpickles without calling ``__new__``, and a + constructor override costs every parse three times as much. + """ + + __slots__ = ("_wire",) + + _wire: str + + @classmethod + def from_datetime(cls, value: datetime, wire: str | None = None) -> Self: + """``value`` as a :class:`WireDatetime` carrying ``wire`` as its text.""" + # The pickle state (packed bytes plus tzinfo) is the cheapest way + # through ``datetime.__new__``: one C call instead of nine arguments. + self = cls(*datetime.__reduce_ex__(value, 2)[1]) + if wire is not None: + self._wire = wire + return self + + @property + def wire(self) -> str: + """The text this value serializes to in JSON mode.""" + wire = getattr(self, "_wire", None) + return wire if wire is not None else rfc3339(self) + + def __reduce_ex__(self, protocol: Any) -> Any: + # ``datetime`` pickles as ``(cls, (bytes[, tzinfo]))``; ``copy`` and + # ``pickle`` both go through here, so the wire text travels with the + # value through ``deepcopy`` and ``model_copy(deep=True)``. + reduced = datetime.__reduce_ex__(self, protocol) + return (_rebuild_datetime, (reduced, getattr(self, "_wire", None))) + + @classmethod + def __get_pydantic_core_schema__(cls, source: Any, handler: GetCoreSchemaHandler) -> CoreSchema: + inner = core_schema.datetime_schema(tz_constraint="aware") + is_strict = _formats.is_rfc3339_date_time + + def validate(value: Any, nested: core_schema.ValidatorFunctionWrapHandler) -> Any: + if isinstance(value, cls): + return value + parsed = nested(value) + if isinstance(value, str) and is_strict(value): + return cls.from_datetime(parsed, value) + return cls.from_datetime(parsed) + + return core_schema.no_info_wrap_validator_function( + validate, inner, serialization=_DATETIME_SERIALIZATION + ) + + +def _rebuild_datetime(reduced: Any, wire: str | None) -> WireDatetime: + cls, args = reduced[0], reduced[1] + value: WireDatetime = cls(*args) + if wire is not None: + value._wire = wire + return value + + +def _serialize_datetime(value: Any) -> str: + wire = getattr(value, "_wire", None) + return wire if wire is not None else rfc3339(value) + + +# JSON mode only: a python-mode dump returns the value itself, with no +# Python call per value, exactly as pydantic serializes a datetime. +_DATETIME_SERIALIZATION = core_schema.plain_serializer_function_ser_schema( + _serialize_datetime, when_used="json" +) + +# ``AdCPBaseModel.model_dump`` passes ``serialize_as_any=True``, under which +# pydantic-core serializes a value by its runtime type rather than the field's +# schema -- and infers "datetime" for a ``datetime`` subclass, which would +# write the normalized form. A ``__pydantic_serializer__`` on the class is what +# the inference consults first (``AnyUrl`` carries one for the same reason). +WireDatetime.__pydantic_serializer__ = SchemaSerializer( # type: ignore[attr-defined] + core_schema.any_schema(serialization=_DATETIME_SERIALIZATION) +) + + +class WireAnyUrl(AnyUrl): + """An ``AnyUrl`` that remembers the text it was parsed from. + + Validated by the same parser as ``AnyUrl`` and exposing the same parsed + view (``.scheme``, ``.host``, ``.path``, ...). ``str()`` and JSON-mode + serialization return the text as received when it is strict RFC 3986 + syntax naming the same host the parser reports; otherwise, and for a + value built from an ``AnyUrl`` or by :meth:`build`, pydantic's normalized + form -- so ``str(url)`` can never hand a client a host ``.host`` did not + name. Compares equal to any ``AnyUrl`` for the same parsed URL, so + ``model.url == AnyUrl("https://x.example/")`` keeps holding. + """ + + __slots__ = ("_wire",) + + _wire: str + + def __init__(self, url: Any) -> None: + super().__init__(url) + if isinstance(url, str): + _keep_url_text(self, url) + + @property + def wire(self) -> str: + """The text this value serializes to in JSON mode.""" + wire = getattr(self, "_wire", None) + return wire if wire is not None else str(self._url) + + def __str__(self) -> str: + return self.wire + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.wire!r})" + + def __eq__(self, other: Any) -> bool: + return isinstance(other, AnyUrl) and self._url == other._url + + def __hash__(self) -> int: + return hash(self._url) + + def __deepcopy__(self, memo: dict[int, Any]) -> Self: + clone = type(self).__new__(type(self)) + clone._url = self._url + wire = getattr(self, "_wire", None) + if wire is not None: + clone._wire = wire + return clone + + def __reduce__(self) -> Any: + return (_rebuild_url, (type(self), str(self._url), getattr(self, "_wire", None))) + + @classmethod + def __get_pydantic_core_schema__(cls, source: Any, handler: GetCoreSchemaHandler) -> CoreSchema: + # One Python layer, not ``AnyUrl``'s wrap plus another: the core URL + # parser does the work and the instance is assembled here. + inner = core_schema.url_schema() + + def validate(value: Any, nested: core_schema.ValidatorFunctionWrapHandler) -> Any: + if isinstance(value, cls): + return value + if isinstance(value, AnyUrl): + value = str(value) + instance = cls.__new__(cls) + instance._url = nested(value) + if isinstance(value, str): + _keep_url_text(instance, value) + return instance + + return core_schema.no_info_wrap_validator_function( + validate, inner, serialization=_URL_SERIALIZATION + ) + + +def _serialize_url(value: Any, info: core_schema.SerializationInfo) -> Any: + return str(value) if info.mode == "json" else value + + +# ``always``, as ``AnyUrl``'s own serializer is: the core URL serializer the +# python-mode fallback would use expects the Rust URL, not the instance. +_URL_SERIALIZATION = core_schema.plain_serializer_function_ser_schema( + _serialize_url, info_arg=True, when_used="always" +) + + +def _keep_url_text(instance: WireAnyUrl, text: str) -> None: + """Attach ``text`` to ``instance`` when it is strict syntax for the parsed host. + + The fast grammar (:func:`adcp._formats.is_plain_uri`) settles the common + case and guarantees the host agrees; anything else takes the full RFC 3986 + check and an explicit host comparison, so ``https://good.example\\@evil.example`` + (a backslash the WHATWG parser reads as a slash) falls back to the + normalized text whose host is the one ``.host`` reports. + """ + if not (_formats.is_plain_uri(text) or _formats.is_uri_syntax(text)): + return + # Compare hosts on every path: the WHATWG parser rewrites numeric hosts + # (``2130706433``, ``0x7f.1``, ``127.1`` all become ``127.0.0.1``), so text + # that is valid syntax can still name a host other than ``.host``. + named = _formats.uri_host(text) + if named is None: + return + url = instance._url + if named == (url.host or "").lower() or named == (url.unicode_host() or "").lower(): + instance._wire = text + + +def _rebuild_url(cls: type[WireAnyUrl], parsed: str, wire: str | None) -> WireAnyUrl: + value = cls.__new__(cls) + value._url = AnyUrl(parsed)._url + if wire is not None: + value._wire = wire + return value + + +class WireUuid(uuid.UUID): + """A ``UUID`` that remembers the text it was parsed from. + + ``uuid.UUID`` serializes lower-case; RFC 4122 reads hex case-insensitively + and the bundled ``format: uuid`` validator accepts either case of the + hyphenated 8-4-4-4-12 form, which is the only spelling kept. A braced, + ``urn:uuid:`` or unhyphenated input ``uuid.UUID`` accepts is normalized + as before, and so is a value built in Python. + """ + + __slots__ = ("_wire",) + + _wire: str + + @property + def wire(self) -> str: + """The text this value serializes to in JSON mode.""" + wire = getattr(self, "_wire", None) + return wire if wire is not None else str(uuid.UUID(int=self.int)) + + def __reduce__(self) -> Any: + return (_rebuild_uuid, (type(self), self.int, getattr(self, "_wire", None))) + + def __deepcopy__(self, memo: dict[int, Any]) -> Self: + return self + + @classmethod + def __get_pydantic_core_schema__(cls, source: Any, handler: GetCoreSchemaHandler) -> CoreSchema: + inner = core_schema.uuid_schema() + is_strict = _formats.is_uuid_text + + def validate(value: Any, nested: core_schema.ValidatorFunctionWrapHandler) -> Any: + if isinstance(value, cls): + return value + parsed = nested(value) + instance = cls(int=parsed.int) + if isinstance(value, str) and is_strict(value): + object.__setattr__(instance, "_wire", value) + return instance + + return core_schema.no_info_wrap_validator_function( + validate, inner, serialization=_UUID_SERIALIZATION + ) + + +def _rebuild_uuid(cls: type[WireUuid], value: int, wire: str | None) -> WireUuid: + instance = cls(int=value) + if wire is not None: + object.__setattr__(instance, "_wire", wire) + return instance + + +def _serialize_uuid(value: Any) -> str: + wire = getattr(value, "_wire", None) + return wire if wire is not None else str(value) + + +_UUID_SERIALIZATION = core_schema.plain_serializer_function_ser_schema( + _serialize_uuid, when_used="json" +) + +# See the note on ``WireDatetime.__pydantic_serializer__``. +WireUuid.__pydantic_serializer__ = SchemaSerializer( # type: ignore[attr-defined] + core_schema.any_schema(serialization=_UUID_SERIALIZATION) +) + + +def wire_text(value: datetime | AnyUrl | uuid.UUID) -> str: + """The text ``value`` serializes to in JSON mode. + + The text as received for a value parsed from strictly valid text; the + deterministic form the module docstring lists otherwise. + """ + if isinstance(value, (WireDatetime, WireAnyUrl, WireUuid)): + return value.wire + if isinstance(value, datetime): + return rfc3339(value) + return str(value) + + +def _email_as_received(value: str) -> str: + """``value`` when it is the trimmed address ``EmailStr`` normalizes it to. + + ``EmailStr`` lower-cases the domain, which rewrites a digested + ``provider.data_subject_contestation.email``; the address as written is + kept when it differs only by case. A display-name form + (``"Bob "``) or surrounding whitespace is not an address + the schema admits, and comes back as the normalized address, as before. + """ + normalized: str = _email_adapter().validate_python(value) + if value.strip() == value and value.lower() == normalized.lower(): + return value + return normalized + + +@deferred_adapter +def _email_adapter() -> TypeAdapter[EmailStr]: + return TypeAdapter(EmailStr) + + +#: A ``format: email`` string, validated as ``EmailStr`` and carried as the +#: ``str`` the wire delivered when that is the address itself. ``EmailStr`` is +#: itself a ``str`` to adopters, so nothing about the attribute changes. +WireEmail = Annotated[ + StrictStr, + AfterValidator(_email_as_received), + WithJsonSchema({"type": "string", "format": "email"}), +] diff --git a/src/adcp/types/base.py b/src/adcp/types/base.py index d24462e91..089bc7221 100644 --- a/src/adcp/types/base.py +++ b/src/adcp/types/base.py @@ -36,6 +36,30 @@ from adcp.types._geo_place_keys import GeoPlaceSystemKey as GeoPlaceSystemKey from adcp.types._scalar import as_json_schema_integer +# Re-exported for the generated modules, beside ``WireUrl`` (#1384): the +# wire-preserving types ``scripts/post_generate_fixes.py`` points format fields +# at. To a type checker each IS the pydantic type it replaces -- a generated +# ``last_updated`` reads as ``datetime``, a ``url`` as ``AnyUrl`` -- so an +# adopter's ``assert_type``, assignment or constructor call type-checks exactly +# as it did; at runtime the value is the subclass that remembers its wire text. +# ``wire_text`` is the typed way to read that text. +from adcp.types._wire import WireEmail as WireEmail +from adcp.types._wire import wire_text as wire_text + +if TYPE_CHECKING: + import uuid as _uuid + from typing import TypeAlias + + from pydantic import AwareDatetime as _AwareDatetime + + WireDatetime: TypeAlias = _AwareDatetime + WireAnyUrl: TypeAlias = AnyUrl + WireUuid: TypeAlias = _uuid.UUID +else: + from adcp.types._wire import WireAnyUrl as WireAnyUrl + from adcp.types._wire import WireDatetime as WireDatetime + from adcp.types._wire import WireUuid as WireUuid + if TYPE_CHECKING: from typing_extensions import Self @@ -306,7 +330,10 @@ def _url_adapter() -> TypeAdapter[AnyUrl]: #: A ``format: uri`` string whose bytes are part of an identity. Validated as a #: URL, carried as the ``str`` the wire delivered. ``scripts/post_generate_fixes.py`` -#: points the format-reference ``agent_url`` here. +#: points the format-reference ``agent_url`` here. Its siblings for a field that +#: keeps the parsed type -- :class:`WireAnyUrl`, :class:`WireDatetime`, +#: :class:`WireUuid` -- and the ``str``-typed :data:`WireEmail` live in +#: :mod:`adcp.types._wire` and are re-exported here for the generated modules. WireUrl = Annotated[ StrictStr, AfterValidator(_require_absolute_url), @@ -609,6 +636,40 @@ def model_dump(self, **kwargs: Any) -> dict[str, Any]: _build_deferred_serializers(self, set()) return super().model_dump(**kwargs) + def model_dump_as_received(self, **kwargs: Any) -> dict[str, Any]: + """The document this model was parsed from, as JSON values. + + ``model_dump(mode="json")`` emits every field, so a property the wire + omitted comes back carrying the schema default the generator wrote + into the field (``sandbox: false``, ``timezone: "inventory_local"``), + and an explicit ``null`` the schema admits is dropped by + ``exclude_none``. A reader treats those as the same document; a + digest does not. ``core/canonical-proposal.json`` binds + ``terms_digest`` to the JCS form of ``commercial_terms`` *as declared*, + and a buyer recomputing it over a default dump gets a mismatch whenever + the seller left a defaulted property out (#1613). + + This dump leaves off every field the document did not set + (``exclude_unset``), keeps an explicit ``null`` on a property whose + schema admits one (``exclude_none=False``; a property the schema + refuses ``null`` on is left off by its own :data:`NoExplicitNull` + exclusion either way), and writes wire names (``by_alias``). With the + wire-preserving scalar types (:mod:`adcp.types._wire`), a schema-valid + document that reached the model round-trips byte-for-byte under RFC + 8785 -- except for a property the model never held, which + ``extra='ignore'`` dropped on input (#1614). + ``tests/conformance/test_wire_round_trip.py`` holds every digest- and + echo-covered schema to that, with no allowlist. + + For a model built in Python the result is exactly the fields the + caller set, in the deterministic forms the wire types document. + """ + kwargs.setdefault("mode", "json") + kwargs.setdefault("exclude_unset", True) + kwargs.setdefault("exclude_none", False) + kwargs.setdefault("by_alias", True) + return self.model_dump(**kwargs) + def model_dump_json(self, **kwargs: Any) -> str: if "exclude_none" not in kwargs: kwargs["exclude_none"] = True diff --git a/src/adcp/types/canonical_creative.py b/src/adcp/types/canonical_creative.py index 7bde84963..8e3fe787a 100644 --- a/src/adcp/types/canonical_creative.py +++ b/src/adcp/types/canonical_creative.py @@ -327,7 +327,14 @@ def _has_boundary_serialization_metadata(annotation: Any) -> bool: return False if hasattr(annotation, "__get_pydantic_core_schema__"): is_adcp_model = isinstance(annotation, type) and issubclass(annotation, AdCPBaseModel) - if not is_adcp_model and getattr(annotation, "__module__", "").split(".")[0] != "pydantic": + module = getattr(annotation, "__module__", "") + # The wire-preserving scalars (#1610) serialize to themselves in + # python mode, so a stored value is what the default dump reads. + if ( + not is_adcp_model + and module.split(".")[0] != "pydantic" + and module != "adcp.types._wire" + ): return True return any(_has_boundary_serialization_metadata(item) for item in get_args(annotation)) diff --git a/src/adcp/types/domains/a2ui/si_catalog.py b/src/adcp/types/domains/a2ui/si_catalog.py index 455b52dde..5788eee08 100644 --- a/src/adcp/types/domains/a2ui/si_catalog.py +++ b/src/adcp/types/domains/a2ui/si_catalog.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: a2ui/si_catalog.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import Field, StrictBool from . import bound_value @@ -205,7 +205,7 @@ class IntegrationAction(AdCPBaseModel): class Apps(AdCPBaseModel): appId: Annotated[str | None, NoExplicitNull] = None deepLink: Annotated[str | None, NoExplicitNull] = None - url: Annotated[AnyUrl | None, NoExplicitNull] = None + url: Annotated[WireAnyUrl | None, NoExplicitNull] = None class AppHandoff(AdCPBaseModel): diff --git a/src/adcp/types/domains/a2ui/user_action.py b/src/adcp/types/domains/a2ui/user_action.py index 00494a516..51e969836 100644 --- a/src/adcp/types/domains/a2ui/user_action.py +++ b/src/adcp/types/domains/a2ui/user_action.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: a2ui/user_action.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field class Action(AdCPBaseModel): @@ -32,5 +32,5 @@ class A2UiUserAction(AdCPBaseModel): componentId: Annotated[str, Field(description='ID of the component that triggered the action')] action: Annotated[Action, Field(description='The action that was triggered')] timestamp: Annotated[ - AwareDatetime | None, Field(description='When the action occurred'), NoExplicitNull + WireDatetime | None, Field(description='When the action occurred'), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/aao/agent_publishers.py b/src/adcp/types/domains/aao/agent_publishers.py index a3c6b9485..69a0a53b1 100644 --- a/src/adcp/types/domains/aao/agent_publishers.py +++ b/src/adcp/types/domains/aao/agent_publishers.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: aao/agent_publishers.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool class DiscoveryMethod(StrEnum): @@ -82,7 +82,7 @@ class PublisherEntry(AdCPBaseModel): ), ] last_verified_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description="When the directory last fetched and validated this publisher's adagents.json. Distinct from the envelope's `directory_indexed_at` — `last_verified_at` is per-publisher freshness, the envelope value is the most recent refresh in the result set." ), @@ -94,13 +94,13 @@ class AaoDirectoryAgentPublishersInverseLookupResponse(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonicalized echo of the agent_url that was looked up. Lowercase host, default port stripped, trailing slash on path component normalized — matches the canonicalization the SDK applies in `verify_agent_authorization`.' ), ] directory_indexed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="When the directory last completed a refresh of any publisher in this result. Provenance anchor for the consumer's own cache. NULL on empty pages — there's no per-publisher anchor to report; consumers SHOULD treat a null value as 'no freshness assertion for this response' and not advance their own cache freshness." ), diff --git a/src/adcp/types/domains/account/list_account_changes_response.py b/src/adcp/types/domains/account/list_account_changes_response.py index 5024f54e0..430bdc9c4 100644 --- a/src/adcp/types/domains/account/list_account_changes_response.py +++ b/src/adcp/types/domains/account/list_account_changes_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/list_account_changes_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,9 +9,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import account_change from ..core import context as context_1 @@ -56,18 +56,18 @@ class SourceCoverageItem(AdCPBaseModel): ), ] coverage_start: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Earliest upstream time covered by this source after connection or retention limits.' ), NoExplicitNull, ] = None observed_through: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Latest upstream point the seller has successfully observed.'), NoExplicitNull, ] = None - last_successful_sync_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + last_successful_sync_at: Annotated[WireDatetime | None, NoExplicitNull] = None stale_after_seconds: Annotated[ SchemaInt | None, Field( @@ -114,14 +114,14 @@ class ListAccountChangesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve NoExplicitNull, ] = None available_since: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Oldest time for which the seller currently retains change records for this account and caller. The capability guarantees at least 90 days after adoption; pre-adoption history is not fabricated.' ), NoExplicitNull, ] = None generated_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Seller time when this page and its source-coverage watermarks were generated.' ), diff --git a/src/adcp/types/domains/account/report_usage_request.py b/src/adcp/types/domains/account/report_usage_request.py index 4d4569273..b630ed996 100644 --- a/src/adcp/types/domains/account/report_usage_request.py +++ b/src/adcp/types/domains/account/report_usage_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/report_usage_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import account_ref from ..core import context as context_1 @@ -132,7 +132,7 @@ class UsageItem(AdCPBaseModel): NoExplicitNull, ] = None finalized_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="ISO 8601 timestamp at which the reporter considered these numbers final. Present only when `final: true`. Anchors any deadline declared in the buy's `measurement_terms.billing_measurement.finalization_deadline_hours`." ), diff --git a/src/adcp/types/domains/account/sync_accounts_response.py b/src/adcp/types/domains/account/sync_accounts_response.py index 3431a8f9c..c886070c0 100644 --- a/src/adcp/types/domains/account/sync_accounts_response.py +++ b/src/adcp/types/domains/account/sync_accounts_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Any, Literal, TypeAlias -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StringConstraints +from adcp.types.base import WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StringConstraints from ..core.version_envelope import AdcpVersionEnvelope from ..core import account_authorization as account_authorization_1 @@ -31,9 +32,9 @@ class Setup(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') - url: AnyUrl | None = None + url: WireAnyUrl | None = None message: str - expires_at: AwareDatetime | None = None + expires_at: WireDatetime | None = None class CreditLimit(AdcpVersionEnvelope): diff --git a/src/adcp/types/domains/account/sync_governance_request.py b/src/adcp/types/domains/account/sync_governance_request.py index 06909f781..3ad942b3b 100644 --- a/src/adcp/types/domains/account/sync_governance_request.py +++ b/src/adcp/types/domains/account/sync_governance_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/sync_governance_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import account_ref from ..core import context as context_1 @@ -38,7 +38,7 @@ class GovernanceAgent(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - url: Annotated[AnyUrl, Field(description='Governance agent endpoint URL. Must use HTTPS.')] + url: Annotated[WireAnyUrl, Field(description='Governance agent endpoint URL. Must use HTTPS.')] authentication: Annotated[ Authentication, Field(description='Authentication the seller presents when calling this governance agent.'), diff --git a/src/adcp/types/domains/account/sync_governance_response.py b/src/adcp/types/domains/account/sync_governance_response.py index fd533d4bd..bba5947d8 100644 --- a/src/adcp/types/domains/account/sync_governance_response.py +++ b/src/adcp/types/domains/account/sync_governance_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: account/sync_governance_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -11,8 +11,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import account_ref from ..core import context as context_1 @@ -31,7 +31,7 @@ class GovernanceAgent(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - url: Annotated[AnyUrl, Field(description='Governance agent endpoint URL.')] + url: Annotated[WireAnyUrl, Field(description='Governance agent endpoint URL.')] class SyncGovernanceResponse(AdcpResponse, ResponseArmDispatchMixin, AdcpVersionEnvelope, ProtocolEnvelope): diff --git a/src/adcp/types/domains/adagents.py b/src/adcp/types/domains/adagents.py index 4bd2d200b..ae05a6294 100644 --- a/src/adcp/types/domains/adagents.py +++ b/src/adcp/types/domains/adagents.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: adagents.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, RootModel, StrictBool, model_validator +from pydantic import ConfigDict, Field, RootModel, StrictBool, model_validator from .core import ( collection, @@ -35,13 +35,13 @@ class AdcpAgentsAuthorization1(AdCPBaseModel): NoExplicitNull, ] = None authoritative_location: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="HTTPS URL of the authoritative adagents.json file. When present, this file is a reference and the authoritative location contains the actual agent authorization data. Because one deploy can change authorization across every publisher in the network, validators MUST cap response size, refuse redirects on the fetch, enforce short timeouts, and serve the previously cached file on transient 5xx. Two-tier size cap: pointer files served at `/.well-known/adagents.json` use the general 5 MB SSRF cap; dereferenced authoritative files (this URL's response, after the indirection) use a recommended 20 MB cap because the origin has explicitly opted in to fanning out across a publisher network. See docs/governance/property/managed-networks#security-considerations." ), ] last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='ISO 8601 timestamp indicating when this reference was last updated'), NoExplicitNull, ] = None @@ -84,7 +84,7 @@ class Contact(AdCPBaseModel): ), ] email: Annotated[ - EmailStr | None, + WireEmail | None, Field( description='Contact email for questions or issues with this authorization file', max_length=255, @@ -119,7 +119,7 @@ class Contact(AdCPBaseModel): NoExplicitNull, ] = None privacy_policy_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL to the entity's privacy policy. Used for consumer consent flows when interacting with this sales agent." ), @@ -146,7 +146,7 @@ class RevokedPublisherDomain(AdCPBaseModel): ), ] revoked_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when this publisher was revoked. Validators MAY use this to order revocations against their own cached state.' ), @@ -204,7 +204,7 @@ class PropertyFeature(AdCPBaseModel): extra='allow', ) url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="The agent's API endpoint URL. Callers comparing this URL against a feature-provider registry MUST canonicalize both sides per the AdCP URL canonicalization rules, not byte-equality. See docs/reference/url-canonicalization." ), @@ -313,12 +313,12 @@ class AuthorizedAgents1(AuthorizedAgentBaseFields): NoExplicitNull, ] = None effective_from: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Optional start time for this authorization window.'), NoExplicitNull, ] = None effective_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Optional end time for this authorization window.'), NoExplicitNull, ] = None @@ -386,12 +386,12 @@ class AuthorizedAgents2(AuthorizedAgentBaseFields): NoExplicitNull, ] = None effective_from: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Optional start time for this authorization window.'), NoExplicitNull, ] = None effective_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Optional end time for this authorization window.'), NoExplicitNull, ] = None @@ -461,12 +461,12 @@ class AuthorizedAgents4(AuthorizedAgentBaseFields): NoExplicitNull, ] = None effective_from: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Optional start time for this authorization window.'), NoExplicitNull, ] = None effective_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Optional end time for this authorization window.'), NoExplicitNull, ] = None @@ -690,12 +690,12 @@ class AuthorizedAgents3(AuthorizedAgentBaseFields): NoExplicitNull, ] = None effective_from: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Optional start time for this authorization window.'), NoExplicitNull, ] = None effective_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Optional end time for this authorization window.'), NoExplicitNull, ] = None @@ -864,7 +864,7 @@ class AdcpAgentsAuthorization27(AdCPBaseModel): NoExplicitNull, ] = None superseded_by: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional pointer indicating this adagents.json file has been superseded by another adagents.json at a different URL. Used by the AAO community-mirror lifecycle: when a platform (e.g., Meta) adopts AdCP and publishes its own adagents.json at `/.well-known/adagents.json`, the AAO mirror file at `creative.adcontextprotocol.org/translated//adagents.json` sets `superseded_by` to the platform-hosted URL. Buyer SDKs encountering a file with `superseded_by` SHOULD short-circuit and re-fetch from the named URL rather than serving stale content from the mirror. The mirror SHOULD continue serving with `superseded_by` set for ≥1 minor release after platform adoption so buyer caches keyed on the mirror URL get an explicit migration signal rather than a silent break.' ), @@ -891,7 +891,7 @@ class AdcpAgentsAuthorization27(AdCPBaseModel): ), ] last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='ISO 8601 timestamp indicating when this file was last updated'), NoExplicitNull, ] = None @@ -988,7 +988,7 @@ class AdcpAgentsAuthorization29(AdcpAgentsAuthorization22, AdcpAgentsAuthorizati NoExplicitNull, ] = None superseded_by: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional pointer indicating this adagents.json file has been superseded by another adagents.json at a different URL. Used by the AAO community-mirror lifecycle: when a platform (e.g., Meta) adopts AdCP and publishes its own adagents.json at `/.well-known/adagents.json`, the AAO mirror file at `creative.adcontextprotocol.org/translated//adagents.json` sets `superseded_by` to the platform-hosted URL. Buyer SDKs encountering a file with `superseded_by` SHOULD short-circuit and re-fetch from the named URL rather than serving stale content from the mirror. The mirror SHOULD continue serving with `superseded_by` set for ≥1 minor release after platform adoption so buyer caches keyed on the mirror URL get an explicit migration signal rather than a silent break.' ), @@ -1015,7 +1015,7 @@ class AdcpAgentsAuthorization29(AdcpAgentsAuthorization22, AdcpAgentsAuthorizati ), ] last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='ISO 8601 timestamp indicating when this file was last updated'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/brand/acquire_rights_response.py b/src/adcp/types/domains/brand/acquire_rights_response.py index c49b22355..42977929f 100644 --- a/src/adcp/types/domains/brand/acquire_rights_response.py +++ b/src/adcp/types/domains/brand/acquire_rights_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Any, Literal, TypeAlias -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import WireAnyUrl +from pydantic import ConfigDict, Field from ..core.version_envelope import AdcpVersionEnvelope from . import rights_terms as rights_terms_1 @@ -36,7 +37,7 @@ class AcquireRightsResponse1(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope restrictions: list[str] | None = None disclosure: Disclosure | None = None approval_webhook: push_notification_config_1.PushNotificationConfig | None = None - usage_reporting_url: AnyUrl | None = None + usage_reporting_url: WireAnyUrl | None = None rights_constraint: Any context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None diff --git a/src/adcp/types/domains/brand/creative_approval_request.py b/src/adcp/types/domains/brand/creative_approval_request.py index b7468825c..72adb4656 100644 --- a/src/adcp/types/domains/brand/creative_approval_request.py +++ b/src/adcp/types/domains/brand/creative_approval_request.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: brand/creative_approval_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import ext as ext_1 @@ -28,7 +28,7 @@ class CreativeApprovalRequest(AdcpVersionEnvelope): NoExplicitNull, ] = None creative_url: Annotated[ - AnyUrl, Field(description='URL where the creative asset can be retrieved for review') + WireAnyUrl, Field(description='URL where the creative asset can be retrieved for review') ] creative_format: Annotated[ format_id.FormatReferenceStructuredObject | None, diff --git a/src/adcp/types/domains/brand/creative_approval_response.py b/src/adcp/types/domains/brand/creative_approval_response.py index f0da03818..e6c4c97d0 100644 --- a/src/adcp/types/domains/brand/creative_approval_response.py +++ b/src/adcp/types/domains/brand/creative_approval_response.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: brand/creative_approval_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import error @@ -30,8 +30,8 @@ class CreativeApprovalResponse1(AdcpVersionEnvelope, ProtocolEnvelope): creative_id: Annotated[ str | None, Field(description="Echo of the buyer's creative identifier"), NoExplicitNull ] = None - creative_url: Annotated[AnyUrl | None, NoExplicitNull] = None - approved_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + creative_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None + approved_at: Annotated[WireDatetime | None, NoExplicitNull] = None conditions: Annotated[ list[str] | None, Field(description="Conditions on the approval (e.g., 'approved for NL market only')"), @@ -50,7 +50,7 @@ class CreativeApprovalResponse2(AdcpVersionEnvelope, ProtocolEnvelope): creative_id: Annotated[ str | None, Field(description="Echo of the buyer's creative identifier"), NoExplicitNull ] = None - creative_url: Annotated[AnyUrl | None, NoExplicitNull] = None + creative_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None reason: Annotated[str, Field(description='Why the creative was rejected')] suggestions: Annotated[ list[str] | None, @@ -74,14 +74,14 @@ class CreativeApprovalResponse3(AdcpVersionEnvelope, ProtocolEnvelope): creative_id: Annotated[ str | None, Field(description="Echo of the buyer's creative identifier"), NoExplicitNull ] = None - creative_url: Annotated[AnyUrl | None, NoExplicitNull] = None + creative_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None estimated_response_time: Annotated[ str | None, Field(description="Expected time for review (e.g., '24h', '2 business days')"), NoExplicitNull, ] = None status_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to poll for updated approval status. GET this URL to receive a creative-approval-response. Poll at reasonable intervals (suggested: every 5 minutes, back off after 1 hour to every 30 minutes). Stop polling after estimated_response_time has elapsed and the approval_status is still pending_review.' ), diff --git a/src/adcp/types/domains/brand/get_brand_identity_response.py b/src/adcp/types/domains/brand/get_brand_identity_response.py index 0af6a4ff8..1b81fc70c 100644 --- a/src/adcp/types/domains/brand/get_brand_identity_response.py +++ b/src/adcp/types/domains/brand/get_brand_identity_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Any, Literal, TypeAlias -from pydantic import AnyUrl, ConfigDict, Field, StringConstraints +from adcp.types.base import WireAnyUrl +from pydantic import ConfigDict, Field, StringConstraints from ..core.version_envelope import AdcpVersionEnvelope from ..core import context as context_1 @@ -27,7 +28,7 @@ class House(AdcpVersionEnvelope): class Logo(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') - url: AnyUrl + url: WireAnyUrl orientation: Literal['square', 'horizontal', 'vertical', 'stacked'] | None = None background: Literal['dark-bg', 'light-bg', 'transparent-bg'] | None = None variant: Literal['primary', 'secondary', 'icon', 'wordmark', 'full-lockup'] | None = None @@ -88,7 +89,7 @@ class Asset(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') asset_id: str asset_type: asset_content_type_1.AssetContentType - url: AnyUrl + url: WireAnyUrl tags: list[str] | None = None name: str | None = None description: str | None = None diff --git a/src/adcp/types/domains/brand/get_rights_response.py b/src/adcp/types/domains/brand/get_rights_response.py index 65bc299fc..ed9d193aa 100644 --- a/src/adcp/types/domains/brand/get_rights_response.py +++ b/src/adcp/types/domains/brand/get_rights_response.py @@ -8,7 +8,8 @@ from typing import Annotated, TypeAlias -from pydantic import AnyUrl, ConfigDict, Field, StringConstraints +from adcp.types.base import WireAnyUrl +from pydantic import ConfigDict, Field, StringConstraints from ..core.version_envelope import AdcpVersionEnvelope from . import rights_pricing_option as rights_pricing_option_1 @@ -28,7 +29,7 @@ class ExclusivityStatus(AdcpVersionEnvelope): class PreviewAsset(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') - url: AnyUrl + url: WireAnyUrl usage: str | None = None diff --git a/src/adcp/types/domains/brand/revocation_notification.py b/src/adcp/types/domains/brand/revocation_notification.py index e501e9023..75656bf59 100644 --- a/src/adcp/types/domains/brand/revocation_notification.py +++ b/src/adcp/types/domains/brand/revocation_notification.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: brand/revocation_notification.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import ext as ext_1 @@ -31,7 +31,7 @@ class RevocationNotification(AdCPBaseModel): brand_id: Annotated[str, Field(description='Brand identifier of the rights subject')] reason: Annotated[str, Field(description='Human-readable reason for revocation')] effective_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='When the revocation takes effect. Immediate revocations use current time. Grace periods use a future time. The buyer must stop serving creative using these rights by this time.' ), diff --git a/src/adcp/types/domains/brand/search_brands_response.py b/src/adcp/types/domains/brand/search_brands_response.py index c17283dbe..85de39e87 100644 --- a/src/adcp/types/domains/brand/search_brands_response.py +++ b/src/adcp/types/domains/brand/search_brands_response.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: brand/search_brands_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator +from pydantic import ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import error @@ -77,7 +77,7 @@ class Logo(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - url: Annotated[AnyUrl, Field(description='URL to the logo asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the logo asset')] orientation: Annotated[ Orientation | None, Field(description='Logo aspect ratio orientation'), NoExplicitNull ] = None @@ -142,7 +142,7 @@ class SearchBrandResult(AdCPBaseModel): NoExplicitNull, ] = None relationship_verified_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the mutual-assertion edge was last confirmed by both sides. Present only when relationship_trust is mutual.' ), diff --git a/src/adcp/types/domains/brand/update_rights_response.py b/src/adcp/types/domains/brand/update_rights_response.py index f4dad3910..ed6214eaa 100644 --- a/src/adcp/types/domains/brand/update_rights_response.py +++ b/src/adcp/types/domains/brand/update_rights_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Any, TypeAlias -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import WireDatetime +from pydantic import ConfigDict, Field from ..core.version_envelope import AdcpVersionEnvelope from . import rights_terms as rights_terms_1 @@ -28,7 +29,7 @@ class UpdateRightsResponse1(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) ) rights_constraint: Any | None = None paused: bool | None = None - implementation_date: AwareDatetime | None = None + implementation_date: WireDatetime | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None diff --git a/src/adcp/types/domains/brand/verify_brand_claim_response.py b/src/adcp/types/domains/brand/verify_brand_claim_response.py index 1037943f6..676476bbc 100644 --- a/src/adcp/types/domains/brand/verify_brand_claim_response.py +++ b/src/adcp/types/domains/brand/verify_brand_claim_response.py @@ -8,8 +8,8 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import error as error_1 @@ -58,7 +58,7 @@ class VerifyBrandClaimPayload(AdCPBaseModel): ), ] agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical URL of the responding brand agent entry whose response-signing key verifies this envelope.' ), diff --git a/src/adcp/types/domains/brand/verify_brand_claims_request.py b/src/adcp/types/domains/brand/verify_brand_claims_request.py index 160679181..baacba516 100644 --- a/src/adcp/types/domains/brand/verify_brand_claims_request.py +++ b/src/adcp/types/domains/brand/verify_brand_claims_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: brand/verify_brand_claims_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,9 +9,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from ..core.version_envelope import AdcpVersionEnvelope from ..enums import property_type @@ -37,7 +37,7 @@ class Claim(AdCPBaseModel): NoExplicitNull, ] = None observed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description="When the caller observed the leaf's claim."), NoExplicitNull, ] = None @@ -67,7 +67,7 @@ class Claim1(AdCPBaseModel): Field(description='Optional context — what the claimant published or said.'), NoExplicitNull, ] = None - observed_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + observed_at: Annotated[WireDatetime | None, NoExplicitNull] = None class ClaimEntry2(AdCPBaseModel): diff --git a/src/adcp/types/domains/brand/verify_brand_claims_response.py b/src/adcp/types/domains/brand/verify_brand_claims_response.py index 31e8b68e2..f81beb053 100644 --- a/src/adcp/types/domains/brand/verify_brand_claims_response.py +++ b/src/adcp/types/domains/brand/verify_brand_claims_response.py @@ -8,8 +8,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, WireAnyUrl +from pydantic import ConfigDict, Field, RootModel from ..core import context as context_1 from ..core import error as error_1 @@ -103,7 +103,7 @@ class VerifyBrandClaimsPayload(AdCPBaseModel): ), ] agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical URL of the responding brand agent entry whose response-signing key verifies this envelope.' ), diff --git a/src/adcp/types/domains/brand_discovery.py b/src/adcp/types/domains/brand_discovery.py index 6a61e1ab3..10a964dc3 100644 --- a/src/adcp/types/domains/brand_discovery.py +++ b/src/adcp/types/domains/brand_discovery.py @@ -1,19 +1,16 @@ # generated by datamodel-codegen: # filename: _brand_discovery.json -# timestamp: 2026-10-21T11:05:59+00:00 +# timestamp: 2026-10-25T19:32:57+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Dict, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarFloat, ScalarInt, ScalarStr from pydantic import ( - AnyUrl, - AwareDatetime, ConfigDict, - EmailStr, Field, RootModel, StrictBool, @@ -37,7 +34,7 @@ class BrandDiscovery1(AdCPBaseModel): ) field_schema: Annotated[str | None, Field(alias='$schema')] = None authoritative_location: Annotated[ - AnyUrl, Field(description='HTTPS URL of the authoritative brand.json file') + WireAnyUrl, Field(description='HTTPS URL of the authoritative brand.json file') ] redirect_reason: Annotated[ RedirectReason | None, @@ -46,7 +43,7 @@ class BrandDiscovery1(AdCPBaseModel): ), ] = None redirect_effective_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Optional timestamp when this redirect became effective. Caches MUST treat any entry cached before this timestamp as stale and re-fetch through the redirect.' ), @@ -54,7 +51,7 @@ class BrandDiscovery1(AdCPBaseModel): note: Annotated[ str | None, Field(description='Optional human-readable rationale for the redirect.') ] = None - last_updated: AwareDatetime | None = None + last_updated: WireDatetime | None = None class Domain(ScalarStr): @@ -182,7 +179,7 @@ class PortfolioEntry(AdCPBaseModel): ), ] = None effective_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when the house established this ownership claim. Consumers age mutual-assertion edges from this date for TTL purposes and MUST NOT extend relationship trust before a future effective_at is reached. Optional; absent means the consumer ages from its own durable first observation.' ), @@ -453,7 +450,7 @@ class FontFile(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - url: Annotated[AnyUrl, Field(description='HTTPS URL to the font file (WOFF2, TTF, or OTF)')] + url: Annotated[WireAnyUrl, Field(description='HTTPS URL to the font file (WOFF2, TTF, or OTF)')] weight: Annotated[ SchemaInt | None, Field(description='CSS numeric font-weight for static fonts (100-900)', ge=100, le=900), @@ -587,7 +584,7 @@ class UpdateFrequency(StrEnum): class AgenticCheckout(AdCPBaseModel): - endpoint: Annotated[AnyUrl, Field(description='Base URL for checkout session API')] + endpoint: Annotated[WireAnyUrl, Field(description='Base URL for checkout session API')] spec: Annotated[ str, Field( @@ -615,11 +612,11 @@ class DataSubjectContestation(AdCPBaseModel): extra='forbid', ) url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='HTTPS URL to a human-accessible contestation form or information page.'), ] = None email: Annotated[ - EmailStr | None, + WireEmail | None, Field( description='Email address for contestation requests. Deployer MUST monitor and respond within the timelines set by applicable law (e.g., 1 month under GDPR Art. 12(3)).' ), @@ -691,7 +688,7 @@ class Avatar(AdCPBaseModel): class Contact1(AdCPBaseModel): - email: Annotated[EmailStr | None, Field(description='Contact email')] = None + email: Annotated[WireEmail | None, Field(description='Contact email')] = None phone: Annotated[str | None, Field(description='Contact phone number')] = None @@ -727,8 +724,8 @@ class DataSubjectContestation1(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - url: Annotated[AnyUrl | None, NoExplicitNull] = None - email: Annotated[EmailStr | None, NoExplicitNull] = None + url: Annotated[WireAnyUrl | None, NoExplicitNull] = None + email: Annotated[WireEmail | None, NoExplicitNull] = None languages: Annotated[list[str] | None, NoExplicitNull] = None @@ -797,7 +794,7 @@ class Role(StrEnum): class DeclaredBy(AdCPBaseModel): agent_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL of the agent or service that declared this provenance'), NoExplicitNull, ] = None @@ -806,7 +803,7 @@ class DeclaredBy(AdCPBaseModel): class C2pa(AdCPBaseModel): manifest_url: Annotated[ - AnyUrl, Field(description='URL to the C2PA manifest store for this content') + WireAnyUrl, Field(description='URL to the C2PA manifest store for this content') ] @@ -815,7 +812,7 @@ class VerifyAgent(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the governance agent the buyer represents was used to embed/verify this layer. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." ), @@ -834,7 +831,7 @@ class VerifyAgent1(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the governance agent the buyer represents was used to apply/detect this watermark. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." ), @@ -863,7 +860,7 @@ class VerificationItem(AdCPBaseModel): ), ] verified_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the verification was performed (ISO 8601)'), NoExplicitNull, ] = None @@ -876,7 +873,7 @@ class VerificationItem(AdCPBaseModel): NoExplicitNull, ] = None details_url: Annotated[ - AnyUrl | None, Field(description='URL to the full verification report'), NoExplicitNull + WireAnyUrl | None, Field(description='URL to the full verification report'), NoExplicitNull ] = None @@ -937,7 +934,7 @@ class BrandAgent(AdCPBaseModel): extra='allow', ) url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Brand agent MCP endpoint URL. Callers comparing this URL against another value (e.g., resolving 'is this the brand's declared agent?' against a discovery cache) MUST canonicalize both sides per the AdCP URL canonicalization rules, not byte-equality. See docs/reference/url-canonicalization." ), @@ -1028,13 +1025,13 @@ class AuthorizedOperator(AdCPBaseModel): ), ] = None valid_from: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when this operator authorization starts. Omit when authorization is already active or the start date is not tracked.' ), ] = None valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when this operator authorization expires. Consumers MUST treat entries at or after this timestamp as inactive for authorization decisions.' ), @@ -1776,7 +1773,7 @@ class AssetLibrary(AdCPBaseModel): ) name: Annotated[str, Field(description='Display name of the asset library')] type: Annotated[Type2 | None, Field(description='Type of asset library')] = None - url: Annotated[AnyUrl, Field(description='URL to the asset library (for human access)')] + url: Annotated[WireAnyUrl, Field(description='URL to the asset library (for human access)')] description: Annotated[ str | None, Field(description='Description of the library contents and usage') ] = None @@ -1811,7 +1808,7 @@ class Contact(AdCPBaseModel): extra='allow', ) name: Annotated[str, Field(max_length=255, min_length=1)] - email: Annotated[EmailStr | None, Field(max_length=255)] = None + email: Annotated[WireEmail | None, Field(max_length=255)] = None domain: Domain | None = None @@ -1835,7 +1832,7 @@ class BrandDiscovery2(AdCPBaseModel): ), ] = None redirect_effective_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Optional timestamp when this redirect became effective. Caches MUST treat any entry cached before this timestamp as stale and re-fetch through the redirect.' ), @@ -1843,7 +1840,7 @@ class BrandDiscovery2(AdCPBaseModel): note: Annotated[ str | None, Field(description='Optional human-readable rationale for the redirect.') ] = None - last_updated: AwareDatetime | None = None + last_updated: WireDatetime | None = None class DefaultLanguage(AdCPBaseModel): @@ -1860,7 +1857,7 @@ class Logo(AdCPBaseModel): description='Stable identifier for this logo entry. Recommended when logo usage rules or mark lockups need to bind to a specific logo asset.' ), ] = None - url: Annotated[AnyUrl, Field(description='URL to the logo asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the logo asset')] orientation: Annotated[ Orientation | None, Field( @@ -1907,7 +1904,7 @@ class Asset(AdCPBaseModel): ) asset_id: Annotated[str, Field(description='Unique identifier')] asset_type: Annotated[AssetContentType, Field(description='Type of asset content')] - url: Annotated[AnyUrl, Field(description='URL to CDN-hosted asset file')] + url: Annotated[WireAnyUrl, Field(description='URL to CDN-hosted asset file')] tags: Annotated[ list[str] | None, Field(description="Tags for discovery (e.g., 'hero', 'lifestyle', 'product', 'holiday')"), @@ -1970,7 +1967,7 @@ class ProductCatalog(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - feed_url: Annotated[AnyUrl, Field(description='URL to product catalog feed')] + feed_url: Annotated[WireAnyUrl, Field(description='URL to product catalog feed')] feed_format: Annotated[FeedFormat | None, Field(description='Format of the product feed')] = ( None ) @@ -1978,7 +1975,7 @@ class ProductCatalog(AdCPBaseModel): list[str] | None, Field(description='Product categories available in the catalog') ] = None last_updated: Annotated[ - AwareDatetime | None, Field(description='When the product catalog was last updated') + WireDatetime | None, Field(description='When the product catalog was last updated') ] = None update_frequency: Annotated[ UpdateFrequency | None, Field(description='How frequently the product catalog is updated') @@ -2001,7 +1998,7 @@ class Collection(AdCPBaseModel): TalentRole | None, Field(description="This person's role on the collection") ] = None seller_agent_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL of the sales agent that sells inventory for this collection. Buyer agents can query this agent for collection products.' ), @@ -2034,7 +2031,7 @@ class EmbeddedProvenanceItem(AdCPBaseModel): NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the provenance data was embedded (ISO 8601)'), NoExplicitNull, ] = None @@ -2063,7 +2060,7 @@ class Watermark(AdCPBaseModel): NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the watermark was applied (ISO 8601)'), NoExplicitNull, ] = None @@ -2178,14 +2175,14 @@ class Provenance(AdCPBaseModel): NoExplicitNull, ] = None declared_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this provenance claim was made (ISO 8601). Distinct from created_time, which records when the content itself was produced. A provenance claim may be attached well after content creation, for example when retroactively declaring AI involvement for regulatory compliance.' ), NoExplicitNull, ] = None created_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this content was created or generated (ISO 8601)'), NoExplicitNull, ] = None @@ -2235,7 +2232,7 @@ class RightsAgent(AdCPBaseModel): extra='allow', ) url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Rights agent MCP endpoint URL. Callers comparing this URL against another value (e.g., matching against a brand's declared rights endpoint) MUST canonicalize both sides per the AdCP URL canonicalization rules, not byte-equality. See docs/reference/url-canonicalization." ), @@ -2260,7 +2257,7 @@ class BrandAgentEntry(AdCPBaseModel): ) type: Annotated[BrandAgentType, Field(description='Functional role of this agent')] url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Agent endpoint URL (MCP or A2A). Callers comparing a brand's declared agent URL against another value (e.g., resolving 'is this the agent that signed this artifact?' or matching against a discovery cache) MUST canonicalize both sides per the AdCP URL canonicalization rules, not byte-equality. After the publication chain and an authorized request-signing key verify, this exact entry's canonical URL is the stable Agent identity input for principal mapping; the domain, JWKS URI, kid, and key material are credential or trust facts beneath it, not the Agent identity. See docs/reference/url-canonicalization." ), @@ -2281,7 +2278,7 @@ class BrandAgentEntry(AdCPBaseModel): ), ] = None jwks_uri: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="HTTPS URL of the agent's JWKS (RFC 7517) containing public keys used to verify artifacts this agent signs or requests it sends. Verified artifacts include signed governance_context tokens (for governance agents) and RFC 9421 HTTP Signatures on outgoing requests (for any agent). When absent, verifiers MUST default to /.well-known/jwks.json on the origin of `url`. Keys are identified by `kid` in the JWS header or RFC 9421 `keyid` parameter; JWKS MAY contain multiple keys to support rotation and per-purpose separation via `key_ops` and `use`." ), @@ -2316,7 +2313,7 @@ class LogoUsageRule(AdCPBaseModel): extra='allow', ) logo_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Specific logo asset URL this rule applies to. Omit when the rule applies by variant or tags.' ), @@ -2449,7 +2446,7 @@ class BrandDiscovery3(AdCPBaseModel): brand_agent: Annotated[BrandAgent | None, Field(deprecated=True)] = None contact: Contact | None = None data_subject_contestation: DataSubjectContestation | None = None - last_updated: AwareDatetime | None = None + last_updated: WireDatetime | None = None class ImageAsset(AdCPBaseModel): @@ -2462,7 +2459,7 @@ class ImageAsset(AdCPBaseModel): description='Discriminator identifying this as an image asset. See /schemas/creative/asset-types for the registry.' ), ] = 'image' - url: Annotated[AnyUrl, Field(description='URL to the image asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the image asset')] width: Annotated[SchemaInt, Field(description='Width in pixels', ge=1)] height: Annotated[SchemaInt, Field(description='Height in pixels', ge=1)] file_size_bytes: Annotated[ @@ -2625,7 +2622,7 @@ class Brand(AdCPBaseModel): BrandId, Field(description='Brand identifier within the house. House chooses this ID.') ] url: Annotated[ - AnyUrl | None, Field(description='Primary brand URL for context and asset discovery') + WireAnyUrl | None, Field(description='Primary brand URL for context and asset discovery') ] = None identity_relying_parties: Annotated[ list[IdentityRelyingParty] | None, @@ -2674,7 +2671,7 @@ class Brand(AdCPBaseModel): ] = None product_catalog: ProductCatalog | None = None privacy_policy_url: Annotated[ - AnyUrl | None, Field(description="URL to the brand's privacy policy") + WireAnyUrl | None, Field(description="URL to the brand's privacy policy") ] = None data_subject_contestation: DataSubjectContestation | None = None disclaimers: Annotated[ @@ -2795,7 +2792,7 @@ class BrandDiscovery4(AdCPBaseModel): description='House-level (corporate) registered trademarks. Brand-level marks live on individual brand entries; resolution is union.' ), ] = None - last_updated: AwareDatetime | None = None + last_updated: WireDatetime | None = None class BrandDiscovery5(Brand): @@ -2808,7 +2805,7 @@ class BrandDiscovery5(Brand): description="Optional pointer to the corporate house this brand belongs to. The named house's brand_refs[] MUST reciprocate for mutual-assertion trust. Single-hop only — a brand cannot itself declare brand_refs[]. Omit for standalone brands (no house)." ), ] = None - last_updated: AwareDatetime | None = None + last_updated: WireDatetime | None = None BrandDiscovery = Annotated[ diff --git a/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py b/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py index 9ccc22545..898fb65c7 100644 --- a/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py +++ b/src/adcp/types/domains/bundled/protocol/get_adcp_capabilities_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: bundled/protocol/get_adcp_capabilities_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -11,13 +11,10 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Dict, Literal -from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarFloat, ScalarInt, ScalarStr from pydantic import ( - AnyUrl, - AwareDatetime, ConfigDict, - EmailStr, Field, RootModel, StrictBool, @@ -235,7 +232,7 @@ class Authentication(AdCPBaseModel): class PushNotificationConfig(AdCPBaseModel): url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Webhook endpoint URL for task status notifications. The wire contract is unconstrained beyond `format: "uri"` — in particular, publishers SHOULD NOT enforce a destination-port allowlist by default, since buyers legitimately host receivers on non-standard TLS ports (`:9443`, `:4443`, path-routed multi-tenant gateways). The SSRF guard the protocol relies on is the IP-range check + DNS-rebinding-resistant connect pin defined in [Webhook URL validation (SSRF)](/docs/building/by-layer/L1/security#webhook-url-validation-ssrf), not port filtering. Operators who want a hardened destination-port allowlist as defense-in-depth (e.g., locked-down enterprise egress) opt in explicitly — see [Destination port: permissive by default](/docs/building/by-layer/L1/security#destination-port-permissive-by-default).' ), @@ -583,7 +580,7 @@ class CapabilityChanges(AdCPBaseModel): NoExplicitNull, ] = None last_modified: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when the seller last changed the advertised capability document. This is the scoped replacement for legacy top-level `last_updated` when buyers need cache validation semantics.' ), @@ -696,7 +693,7 @@ class AnyOf(AdCPBaseModel): ) kind: Literal['agent_url'] = 'agent_url' agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Exact canonical agent endpoint without userinfo, query, or fragment. Redirect targets, DNS aliases, and URLs asserted by the candidate do not satisfy this matcher.' ), @@ -714,7 +711,7 @@ class AnyOf5(AdCPBaseModel): ) kind: Literal['verification'] = 'verification' registry: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Seller-configured trusted verification registry without userinfo, query, or fragment. The candidate cannot supply or override this URL; fetches use the registry-resolution security contract.' ), @@ -792,8 +789,8 @@ class DataSubjectContestation(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - url: Annotated[AnyUrl | None, NoExplicitNull] = None - email: Annotated[EmailStr | None, NoExplicitNull] = None + url: Annotated[WireAnyUrl | None, NoExplicitNull] = None + email: Annotated[WireEmail | None, NoExplicitNull] = None languages: Annotated[list[str] | None, NoExplicitNull] = None @@ -822,7 +819,7 @@ class Issuer1(AdCPBaseModel): ), ] = 'agent' agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical HTTPS endpoint of the issuing agent. Evaluators compare it using AdCP URL canonicalization rules.' ), @@ -848,7 +845,7 @@ class Issuer2(AdCPBaseModel): ), ] = 'origin' origin: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical HTTPS origin with no path, query, fragment, or userinfo. This identifies the issuer; it does not authorize a fetch.' ), @@ -863,8 +860,8 @@ class Issuer2(AdCPBaseModel): ] = None -class CredentialOrigin(RootModel[AnyUrl]): - root: AnyUrl +class CredentialOrigin(RootModel[WireAnyUrl]): + root: WireAnyUrl class Authentication5(StrEnum): @@ -878,7 +875,7 @@ class Resolver(AdCPBaseModel): ) resolver_id: Annotated[str, Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9._:-]+$')] url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Evaluator-configured HTTPS resolver endpoint. Calls use POST with Content-Type application/json and a body containing only credential_id; query-string and path interpolation are forbidden. The evaluator still applies the attestation fetch contract before every call.' ), @@ -895,9 +892,9 @@ class AcceptedVerifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - agent_url: AnyUrl - claim_types: Annotated[list[AnyUrl] | None, Field(min_length=1), NoExplicitNull] = None - proof_formats: Annotated[list[AnyUrl] | None, Field(min_length=1), NoExplicitNull] = None + agent_url: WireAnyUrl + claim_types: Annotated[list[WireAnyUrl] | None, Field(min_length=1), NoExplicitNull] = None + proof_formats: Annotated[list[WireAnyUrl] | None, Field(min_length=1), NoExplicitNull] = None ext: Annotated[ dict[str, Any] | None, Field( @@ -1108,7 +1105,7 @@ class Account(AdCPBaseModel): NoExplicitNull, ] = False authorization_endpoint: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='OAuth authorization endpoint for obtaining operator-level credentials. Present when the seller supports OAuth for operator authentication. The agent directs the operator to this URL to authenticate and obtain a bearer token. If absent and require_operator_auth is true, operators obtain credentials out-of-band (e.g., seller portal, API key).' ), @@ -1190,7 +1187,7 @@ class AcceptancePolicyDiscovery(AdCPBaseModel): extra='forbid', ) catalog_url: Annotated[ - AnyUrl, + WireAnyUrl, Field(description='HTTPS document that validates against acceptance-policy-catalog.json.'), ] catalog_digest: Annotated[ @@ -1297,7 +1294,7 @@ class ReportingProfile(AdCPBaseModel): id: Annotated[str, Field(max_length=128, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,128}$')] version: Annotated[str, Field(max_length=64, min_length=1)] schema_uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Authenticated seller/provider or AdCP-registry HTTPS origin only; never an IP literal, userinfo URL, redirect target, or mutable validation authority.' ), @@ -1344,7 +1341,7 @@ class ReportingProfile(AdCPBaseModel): 'application/vnd.adcp.reporting-canonicalization+json' ) canonicalization_uri: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Retrievable exact canonicalization contract on the authenticated seller/provider or AdCP-registry origin. SDKs apply the same bounded, redirect-free SSRF controls as schema_uri and verify canonicalization_sha256 before use.' ), @@ -1409,7 +1406,7 @@ class Schedule(AdCPBaseModel): ), NoExplicitNull, ] = None - period_anchor: Annotated[AwareDatetime | None, NoExplicitNull] = None + period_anchor: Annotated[WireDatetime | None, NoExplicitNull] = None period_timezone: Annotated[str | None, Field(max_length=255, min_length=1), NoExplicitNull] = ( None ) @@ -1478,14 +1475,14 @@ class OperationsContact(AdCPBaseModel): regex_engine="python-re", ) url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='HTTPS page a human uses to open or track a reporting issue, such as a support portal or status page. Same hardened origin shape as the offering document URIs: never an IP literal, userinfo URL, loopback host, AdCP task endpoint, webhook target, or credentialed link.' ), NoExplicitNull, ] = None email: Annotated[ - EmailStr | None, + WireEmail | None, Field( description='Monitored operations mailbox for reporting escalations. A role address, not an individual.', max_length=254, @@ -1516,8 +1513,8 @@ class Evidence(AdCPBaseModel): regex_engine="python-re", ) basis: Basis - generated_at: AwareDatetime - attestation_uri: Annotated[AnyUrl | None, NoExplicitNull] = None + generated_at: WireDatetime + attestation_uri: Annotated[WireAnyUrl | None, NoExplicitNull] = None attestation_sha256: Annotated[ str | None, Field(pattern='^[A-Fa-f0-9]{64}$'), NoExplicitNull ] = None @@ -1888,9 +1885,9 @@ class Countries8(StrEnum): ward = 'ward' -class Countries9(RootModel[AnyUrl]): +class Countries9(RootModel[WireAnyUrl]): root: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical place classification. Registered tokens have protocol-defined meanings. Catalog-specific classifications without a registered mapping MUST use an absolute HTTPS URI controlled by the vocabulary owner; consumers compare URI types as exact opaque strings.', examples=[ @@ -1922,7 +1919,7 @@ class Resolver2(AdCPBaseModel): extra='forbid', ) url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='HTTPS endpoint accepting the standard geo-place resolution query parameters.' ), @@ -1944,7 +1941,7 @@ class Catalog(AdCPBaseModel): extra='forbid', ) source: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional catalog dataset or derivative-source identifier. This records provenance/coverage and does not change the identifier namespace in system.' ), @@ -1999,9 +1996,9 @@ class GeoPlaces1(StrEnum): microsoft_ads = 'microsoft_ads' -class GeoPlaces2(RootModel[AnyUrl]): +class GeoPlaces2(RootModel[WireAnyUrl]): root: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Collision-safe identifier namespace for geographic places. Registered tokens have protocol-defined semantics. Unregistered systems MUST use an absolute HTTPS URI controlled by the catalog owner; consumers compare URI systems as exact opaque strings.', examples=[ @@ -2183,7 +2180,7 @@ class BuyerAgent(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Buyer agent's registered agent URL. Canonical identifier, mirroring trusted_match.providers[].agent_url." ), @@ -2398,7 +2395,7 @@ class SignalActivation(AdCPBaseModel): ), ] claim_types: Annotated[ - list[AnyUrl], + list[WireAnyUrl], Field( description='Signal-quality claim identifiers accepted for this governed action. Values are open absolute URIs and MUST be a subset of adcp.attestations.accepted_claim_types.', min_length=1, @@ -2453,7 +2450,7 @@ class PropertyFeature(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to documentation explaining how this feature is calculated or measured. Helps buyers understand and compare methodologies across vendors.' ), @@ -2490,7 +2487,7 @@ class CreativeFeature(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to documentation explaining how this feature is calculated or measured.' ), @@ -2541,7 +2538,7 @@ class Transport3(AdCPBaseModel): extra='allow', ) type: Annotated[Type9, Field(description='Protocol transport type')] - url: Annotated[AnyUrl, Field(description='Agent endpoint URL for this transport')] + url: Annotated[WireAnyUrl, Field(description='Agent endpoint URL for this transport')] class Endpoint(AdCPBaseModel): @@ -2705,7 +2702,7 @@ class SponsoredIntelligence(AdCPBaseModel): ), ] brand_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to brand.json with colors, fonts, logos, tone'), NoExplicitNull, ] = None @@ -2904,7 +2901,7 @@ class V1FormatRefItem(AdCPBaseModel): extra='allow', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the agent that defines this format (e.g., 'https://creative.adcontextprotocol.org' for standard formats, or 'https://publisher.com/.well-known/adcp/sales' for custom formats). Callers comparing two `format-id` values MUST canonicalize `agent_url` per the AdCP URL canonicalization rules before treating two formats as the same. See docs/reference/url-canonicalization." ), @@ -2955,7 +2952,7 @@ class FormatSchema(AdCPBaseModel): extra='allow', ) uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="HTTPS URL identifying the extension. `https://` is mandatory — `http://`, `file://`, `data:`, and other schemes are rejected at the schema layer (defense-in-depth on top of the fetch-contract normative rules). The URI base is the owning agent's URL; the path identifies the extension within that agent. Example: 'https://creative.adcontextprotocol.org/translated/meta/extensions/meta_pixel'. The full fetch contract — SSRF allowlist, response-size cap, $ref sandbox, schema-compile bounds — is documented on `product-format-declaration.json#format_schema` and applies to ALL fetches of this reference shape regardless of whether the field is named `format_schema` (load-bearing for validation) or `platform_extensions` (informational); the *transport* rules are identical, only the *consumption* semantics differ." ), @@ -3211,24 +3208,24 @@ class KeyOrigins(AdCPBaseModel): extra='forbid', ) governance_signing: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Origin (scheme + host) serving the governance-signing JWKS.'), NoExplicitNull, ] = None request_signing: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Origin (scheme + host) serving the request-signing JWKS.'), NoExplicitNull, ] = None webhook_signing: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Origin (scheme + host) serving the JWKS used for webhook delivery. Webhooks are signed with `adcp_use: "request-signing"` keys; the deprecated `adcp_use: "webhook-signing"` value remains accepted during the backward-compatibility window.' ), NoExplicitNull, ] = None tmp_signing: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Origin (scheme + host) serving the TMP-signing JWKS, when this operator participates in TMP.' ), @@ -3259,7 +3256,7 @@ class Identity(AdCPBaseModel): extra='allow', ) brand_json_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="HTTPS URL of the operator's brand.json (typically `https://{operator-domain}/.well-known/brand.json`). Trust-root pointer for this agent's signing keys. See [security.mdx §Discovering an agent's signing keys via `brand_json_url`](https://adcontextprotocol.org/docs/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url) for the verifier algorithm and `x-adcp-validation` for structured constraints. Distinct from `sponsored_intelligence.brand_url`, which is a rendering pointer for SI agent visuals — verifiers MUST use this field for key discovery and MUST NOT fall back to `sponsored_intelligence.brand_url` as a trust-root pointer." ), @@ -3332,7 +3329,7 @@ class Accreditation(AdCPBaseModel): NoExplicitNull, ] = None evidence_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="Optional URL pointing at the accrediting body's public listing for this certification (the buyer's path to verify the claim independently)." ), @@ -3362,7 +3359,7 @@ class Metric(AdCPBaseModel): ), ] standard_reference: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional URI pointing at the published standard this metric IMPLEMENTS (e.g., IAB Attention Measurement Guidelines, MRC Viewable Impression Measurement, GARM emissions framework). Distinct from `accreditations[]` — `standard_reference` is what the metric is built against; `accreditations[]` is third-party certification that the implementation actually conforms. Buyer agents normalizing across vendors SHOULD apply the AdCP URL canonicalization rules before comparing — vendors implementing the same standard MAY use different URL forms for the same canonical document.' ), @@ -3391,7 +3388,7 @@ class Metric(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL to the vendor's full methodology documentation for this metric. Buyers SHOULD link or fetch this when human review of the methodology is in scope (compliance, RFP review, accreditation audit). Field name mirrors `governance.property_features[].methodology_url`." ), @@ -3670,8 +3667,8 @@ class ReportingReliabilityMeasurementPeriod(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime class Mode17(StrEnum): @@ -3898,7 +3895,7 @@ class Role(StrEnum): class DeclaredBy(AdCPBaseModel): agent_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL of the agent or service that declared this provenance'), NoExplicitNull, ] = None @@ -3907,7 +3904,7 @@ class DeclaredBy(AdCPBaseModel): class C2pa(AdCPBaseModel): manifest_url: Annotated[ - AnyUrl, Field(description='URL to the C2PA manifest store for this content') + WireAnyUrl, Field(description='URL to the C2PA manifest store for this content') ] @@ -3921,7 +3918,7 @@ class VerifyAgent(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the governance agent the buyer represents was used to embed/verify this layer. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." ), @@ -3964,7 +3961,7 @@ class EmbeddedProvenanceItem(AdCPBaseModel): NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the provenance data was embedded (ISO 8601)'), NoExplicitNull, ] = None @@ -3982,7 +3979,7 @@ class VerifyAgent4(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the governance agent the buyer represents was used to apply/detect this watermark. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." ), @@ -4030,7 +4027,7 @@ class Watermark(AdCPBaseModel): NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the watermark was applied (ISO 8601)'), NoExplicitNull, ] = None @@ -4148,7 +4145,7 @@ class VerificationItem(AdCPBaseModel): ), ] verified_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the verification was performed (ISO 8601)'), NoExplicitNull, ] = None @@ -4161,7 +4158,7 @@ class VerificationItem(AdCPBaseModel): NoExplicitNull, ] = None details_url: Annotated[ - AnyUrl | None, Field(description='URL to the full verification report'), NoExplicitNull + WireAnyUrl | None, Field(description='URL to the full verification report'), NoExplicitNull ] = None @@ -4203,14 +4200,14 @@ class Provenance(AdCPBaseModel): NoExplicitNull, ] = None declared_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this provenance claim was made (ISO 8601). Distinct from created_time, which records when the content itself was produced. A provenance claim may be attached well after content creation, for example when retroactively declaring AI involvement for regulatory compliance.' ), NoExplicitNull, ] = None created_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this content was created or generated (ISO 8601)'), NoExplicitNull, ] = None @@ -4377,7 +4374,7 @@ class Logo(AdCPBaseModel): description='Discriminator identifying this as an image asset. See /schemas/creative/asset-types for the registry.' ), ] = 'image' - url: Annotated[AnyUrl, Field(description='URL to the image asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the image asset')] width: Annotated[SchemaInt, Field(description='Width in pixels', ge=1)] height: Annotated[SchemaInt, Field(description='Height in pixels', ge=1)] file_size_bytes: Annotated[ @@ -4559,7 +4556,7 @@ class AcceptedIssuer(AdCPBaseModel): ), ] claim_types: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field( description='Optional subset of accepted_claim_types this issuer may assert. Omit to allow any globally accepted claim type for this issuer.', min_length=1, @@ -4567,7 +4564,7 @@ class AcceptedIssuer(AdCPBaseModel): NoExplicitNull, ] = None proof_formats: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field( description='Optional subset of accepted_proof_formats allowed for this issuer. Omit to allow any globally accepted proof format for this issuer.', min_length=1, @@ -4605,14 +4602,14 @@ class Attestations(AdCPBaseModel): extra='forbid', ) accepted_claim_types: Annotated[ - list[AnyUrl], + list[WireAnyUrl], Field( description='Open claim identifiers the evaluator is prepared to evaluate. Each value is an absolute URI. Absence means the evaluator has not advertised portable-attestation support; an empty list is not permitted.', min_length=1, ), ] accepted_proof_formats: Annotated[ - list[AnyUrl], + list[WireAnyUrl], Field( description='Open credential/proof format identifiers the evaluator can verify. Values are absolute URIs rather than a protocol enum so issuers can adopt new formats without AdCP endorsement.', min_length=1, @@ -4801,7 +4798,7 @@ class Offering(AdCPBaseModel): ), ] report_definition_uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Retrievable immutable reporting-report-definition.json document on the authenticated seller/provider or AdCP-registry origin.' ), @@ -5047,7 +5044,7 @@ class TranslationTarget(AdCPBaseModel): ) token: Annotated[str, Field(min_length=1)] dialect: MacroDialectFamily - dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_namespace: Annotated[WireAnyUrl | None, NoExplicitNull] = None dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MacroMappingStatus @@ -5068,7 +5065,7 @@ class MacroResolutionCapability(AdCPBaseModel): extra='forbid', ) dialect: MacroDialectFamily - dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_namespace: Annotated[WireAnyUrl | None, NoExplicitNull] = None dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MappingStatus @@ -5284,7 +5281,7 @@ class Execution(AdCPBaseModel): NoExplicitNull, ] = None axe_integrations: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field( deprecated=True, description='Deprecated. Legacy AXE integrations. Use trusted_match for new integrations.', @@ -5995,7 +5992,7 @@ class TranslationTarget1(AdCPBaseModel): ) token: Annotated[str, Field(min_length=1)] dialect: MacroDialectFamily - dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_namespace: Annotated[WireAnyUrl | None, NoExplicitNull] = None dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MacroMappingStatus @@ -6016,7 +6013,7 @@ class MacroResolutionCapability2(AdCPBaseModel): extra='forbid', ) dialect: MacroDialectFamily - dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_namespace: Annotated[WireAnyUrl | None, NoExplicitNull] = None dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MappingStatus @@ -6068,7 +6065,7 @@ class Format5(AdCPBaseModel): ] = None technical_requirements_complete: Annotated[StrictBool | None, NoExplicitNull] = None display_name: Annotated[str | None, NoExplicitNull] = None - sample_render_url: Annotated[AnyUrl | None, NoExplicitNull] = None + sample_render_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None applies_to_channels: Annotated[list[MediaChannel] | None, NoExplicitNull] = None seller_preference: Annotated[SellerPreference | None, NoExplicitNull] = None locale_policy: Annotated[ @@ -6798,7 +6795,7 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): NoExplicitNull, ] = None timestamp: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when the response was generated. Useful for debugging, logging, cache validation, and tracking async operation progress.' ), @@ -6979,7 +6976,7 @@ class GetAdcpCapabilitiesResponse(AdCPBaseModel): NoExplicitNull, ] = None last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp of when capabilities were last updated. Buyers can use this for cache invalidation.' ), diff --git a/src/adcp/types/domains/collection/collection_list.py b/src/adcp/types/domains/collection/collection_list.py index 38da09cb0..b9689f6b8 100644 --- a/src/adcp/types/domains/collection/collection_list.py +++ b/src/adcp/types/domains/collection/collection_list.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: collection/collection_list.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from ..core import account_ref, brand_ref from . import base_collection_source, collection_list_filters @@ -49,7 +49,7 @@ class CollectionList(AdCPBaseModel): NoExplicitNull, ] = None webhook_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to receive notifications when the resolved list changes'), NoExplicitNull, ] = None @@ -62,10 +62,10 @@ class CollectionList(AdCPBaseModel): NoExplicitNull, ] = 168 created_at: Annotated[ - AwareDatetime | None, Field(description='When the list was created'), NoExplicitNull + WireDatetime | None, Field(description='When the list was created'), NoExplicitNull ] = None updated_at: Annotated[ - AwareDatetime | None, Field(description='When the list was last modified'), NoExplicitNull + WireDatetime | None, Field(description='When the list was last modified'), NoExplicitNull ] = None collection_count: Annotated[ SchemaInt | None, diff --git a/src/adcp/types/domains/collection/collection_list_changed_webhook.py b/src/adcp/types/domains/collection/collection_list_changed_webhook.py index 7199c70a3..4d1eda04a 100644 --- a/src/adcp/types/domains/collection/collection_list_changed_webhook.py +++ b/src/adcp/types/domains/collection/collection_list_changed_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: collection/collection_list_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from ..core import ext as ext_1 @@ -56,9 +56,9 @@ class CollectionListChangedWebhook(AdCPBaseModel): Field(description='Summary of changes to the resolved list'), NoExplicitNull, ] = None - resolved_at: Annotated[AwareDatetime, Field(description='When the list was re-resolved')] + resolved_at: Annotated[WireDatetime, Field(description='When the list was re-resolved')] cache_valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the consumer should refresh from the governance agent'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/collection/get_collection_list_response.py b/src/adcp/types/domains/collection/get_collection_list_response.py index 3957e7b8f..023bd820c 100644 --- a/src/adcp/types/domains/collection/get_collection_list_response.py +++ b/src/adcp/types/domains/collection/get_collection_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/get_collection_list_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import content_rating as content_rating_1 from ..core import context as context_1 @@ -101,10 +101,10 @@ class GetCollectionListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvel ] = None pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None resolved_at: Annotated[ - AwareDatetime | None, Field(description='When the list was resolved'), NoExplicitNull + WireDatetime | None, Field(description='When the list was resolved'), NoExplicitNull ] = None cache_valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Cache expiration timestamp. Re-fetch the list after this time to get updated collections.' ), diff --git a/src/adcp/types/domains/collection/update_collection_list_request.py b/src/adcp/types/domains/collection/update_collection_list_request.py index a220c12e1..e0ac54417 100644 --- a/src/adcp/types/domains/collection/update_collection_list_request.py +++ b/src/adcp/types/domains/collection/update_collection_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: collection/update_collection_list_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import account_ref, brand_ref from ..core import context as context_1 @@ -52,7 +52,7 @@ class UpdateCollectionListRequest(AdcpRequest, AdcpVersionEnvelope): NoExplicitNull, ] = None webhook_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Update the webhook URL for list change notifications (set to empty string to remove). Governance agents MUST validate this URL against SSRF per docs/building/by-layer/L1/security#webhook-url-validation-ssrf.' ), diff --git a/src/adcp/types/domains/compliance/comply_test_controller_request.py b/src/adcp/types/domains/compliance/comply_test_controller_request.py index 67fad2e4e..d3c736d68 100644 --- a/src/adcp/types/domains/compliance/comply_test_controller_request.py +++ b/src/adcp/types/domains/compliance/comply_test_controller_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: compliance/comply_test_controller_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -10,9 +10,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat from ..core import brand_ref, committed_metric from ..core import context as context_1 @@ -268,7 +268,7 @@ class Params(AdCPBaseModel): NoExplicitNull, ] = None target_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Deterministic sandbox clock target used by catalog_item_availability_probe advance_time.' ), diff --git a/src/adcp/types/domains/compliance/comply_test_controller_response.py b/src/adcp/types/domains/compliance/comply_test_controller_response.py index 24481ec89..0d38f9a40 100644 --- a/src/adcp/types/domains/compliance/comply_test_controller_response.py +++ b/src/adcp/types/domains/compliance/comply_test_controller_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: compliance/comply_test_controller_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -11,9 +11,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 from ..core import ext as ext_1 @@ -133,7 +133,7 @@ class RecordedCalls1(AdCPBaseModel): ), ] url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Full URL of the outbound call (scheme + host + path + query). Treated as untrusted agent-controlled input by report renderers — see runner-output-contract.yaml > security.rendered_output_fencing.' ), @@ -200,7 +200,7 @@ class RecordedCalls1(AdCPBaseModel): NoExplicitNull, ] = None timestamp: Annotated[ - AwareDatetime, + WireDatetime, Field( description="ISO 8601 timestamp the adopter recorded the outbound call. MUST reflect the adopter's wall clock at the moment the outbound request was sent (not log-flush time), and MUST be monotonically non-decreasing across recorded_calls of a single response. Used by runners to scope assertions to a specific storyboard step's window — see runner-output-contract.yaml > validation_result for the timestamp boundary semantics." ), @@ -228,7 +228,7 @@ class RecordedCalls2(AdCPBaseModel): ), ] url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Full URL of the outbound call (scheme + host + path + query). Treated as untrusted agent-controlled input by report renderers — see runner-output-contract.yaml > security.rendered_output_fencing.' ), @@ -294,7 +294,7 @@ class RecordedCalls2(AdCPBaseModel): NoExplicitNull, ] = None timestamp: Annotated[ - AwareDatetime, + WireDatetime, Field( description="ISO 8601 timestamp the adopter recorded the outbound call. MUST reflect the adopter's wall clock at the moment the outbound request was sent (not log-flush time), and MUST be monotonically non-decreasing across recorded_calls of a single response. Used by runners to scope assertions to a specific storyboard step's window — see runner-output-contract.yaml > validation_result for the timestamp boundary semantics." ), @@ -466,7 +466,7 @@ class ComplyTestControllerResponse7(ComplyTestControllerResponse): NoExplicitNull, ] = None since_timestamp: Annotated[ - AwareDatetime, + WireDatetime, Field( description="Echo of the `since_timestamp` the runner requested (or the session-start timestamp the adopter substituted when the runner omitted it). Informational — the runner SHOULD use its own clock-bracket of when it issued the AdCP step request, not this echo, when attributing recorded_calls to a specific step. The controller is part of the adopter's claimed conformance; the echo is not adversarially trustworthy." ), diff --git a/src/adcp/types/domains/content_standards/artifact.py b/src/adcp/types/domains/content_standards/artifact.py index a74834062..4e5f2d695 100644 --- a/src/adcp/types/domains/content_standards/artifact.py +++ b/src/adcp/types/domains/content_standards/artifact.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: content_standards/artifact.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, RootModel from ..core import format_id as format_id_1 from ..core import provenance as provenance_1 @@ -55,7 +55,7 @@ class Metadata(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - canonical: Annotated[AnyUrl | None, Field(description='Canonical URL'), NoExplicitNull] = None + canonical: Annotated[WireAnyUrl | None, Field(description='Canonical URL'), NoExplicitNull] = None author: Annotated[str | None, Field(description='Artifact author name'), NoExplicitNull] = None keywords: Annotated[str | None, Field(description='Artifact keywords'), NoExplicitNull] = None open_graph: Annotated[ @@ -87,7 +87,7 @@ class Identifiers(AdCPBaseModel): youtube_video_id: Annotated[ str | None, Field(description='YouTube video ID'), NoExplicitNull ] = None - rss_url: Annotated[AnyUrl | None, Field(description='RSS feed URL'), NoExplicitNull] = None + rss_url: Annotated[WireAnyUrl | None, Field(description='RSS feed URL'), NoExplicitNull] = None class AssetAccess1(AdCPBaseModel): @@ -182,7 +182,7 @@ class Assets1(AdCPBaseModel): class Assets2(AdCPBaseModel): type: Literal['image'] = 'image' - url: Annotated[AnyUrl, Field(description='Image URL')] + url: Annotated[WireAnyUrl, Field(description='Image URL')] access: Annotated[ AssetAccess | None, Field(description='Authentication for secured URLs'), NoExplicitNull ] = None @@ -205,7 +205,7 @@ class Assets2(AdCPBaseModel): class Assets3(AdCPBaseModel): type: Literal['video'] = 'video' - url: Annotated[AnyUrl, Field(description='Video URL')] + url: Annotated[WireAnyUrl, Field(description='Video URL')] access: Annotated[ AssetAccess | None, Field(description='Authentication for secured URLs'), NoExplicitNull ] = None @@ -233,7 +233,7 @@ class Assets3(AdCPBaseModel): NoExplicitNull, ] = None thumbnail_url: Annotated[ - AnyUrl | None, Field(description='Video thumbnail URL'), NoExplicitNull + WireAnyUrl | None, Field(description='Video thumbnail URL'), NoExplicitNull ] = None provenance: Annotated[ provenance_1.Provenance | None, @@ -244,7 +244,7 @@ class Assets3(AdCPBaseModel): class Assets4(AdCPBaseModel): type: Literal['audio'] = 'audio' - url: Annotated[AnyUrl, Field(description='Audio URL')] + url: Annotated[WireAnyUrl, Field(description='Audio URL')] access: Annotated[ AssetAccess | None, Field(description='Authentication for secured URLs'), NoExplicitNull ] = None @@ -321,19 +321,19 @@ class Artifact(AdCPBaseModel): NoExplicitNull, ] = None url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional URL for this artifact (web page, podcast feed, video page). Not all artifacts have URLs (e.g., Instagram content, podcast segments, TV scenes).' ), NoExplicitNull, ] = None published_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the artifact was published (ISO 8601 format)'), NoExplicitNull, ] = None last_update_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the artifact was last modified (ISO 8601 format)'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/content_standards/artifact_webhook_payload.py b/src/adcp/types/domains/content_standards/artifact_webhook_payload.py index 668cc3e35..b89f49bed 100644 --- a/src/adcp/types/domains/content_standards/artifact_webhook_payload.py +++ b/src/adcp/types/domains/content_standards/artifact_webhook_payload.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: content_standards/artifact_webhook_payload.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from ..core import ext as ext_1 from . import artifact as artifact_1 @@ -32,7 +32,7 @@ class Pagination(AdCPBaseModel): class Artifact(AdCPBaseModel): artifact: Annotated[artifact_1.Artifact, Field(description='The content artifact')] delivered_at: Annotated[ - AwareDatetime, Field(description='When the impression was delivered (ISO 8601)') + WireDatetime, Field(description='When the impression was delivered (ISO 8601)') ] impression_id: Annotated[ str | None, @@ -69,7 +69,7 @@ class ArtifactWebhookPayload(AdCPBaseModel): ), ] timestamp: Annotated[ - AwareDatetime, Field(description='When this batch was generated (ISO 8601)') + WireDatetime, Field(description='When this batch was generated (ISO 8601)') ] artifacts: Annotated[ list[Artifact], Field(description='Content artifacts from delivered impressions') diff --git a/src/adcp/types/domains/content_standards/create_content_standards_request.py b/src/adcp/types/domains/content_standards/create_content_standards_request.py index 4f6cc84e3..bd65f84e7 100644 --- a/src/adcp/types/domains/content_standards/create_content_standards_request.py +++ b/src/adcp/types/domains/content_standards/create_content_standards_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/create_content_standards_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, model_validator from ..core import context as context_1 from ..core import ext as ext_1 @@ -54,7 +54,7 @@ class Scope(AdCPBaseModel): class Pass(AdCPBaseModel): type: Annotated[Literal['url'], Field(description='Indicates this is a URL reference')] = 'url' value: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Full URL to a specific page (e.g., 'https://espn.com/nba/story/_/id/12345/lakers-win')" ), @@ -67,7 +67,7 @@ class Pass(AdCPBaseModel): class Fail(AdCPBaseModel): type: Annotated[Literal['url'], Field(description='Indicates this is a URL reference')] = 'url' value: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Full URL to a specific page (e.g., 'https://news.example.com/controversial-article')" ), diff --git a/src/adcp/types/domains/content_standards/get_media_buy_artifacts_request.py b/src/adcp/types/domains/content_standards/get_media_buy_artifacts_request.py index 032313abd..cdd039be1 100644 --- a/src/adcp/types/domains/content_standards/get_media_buy_artifacts_request.py +++ b/src/adcp/types/domains/content_standards/get_media_buy_artifacts_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/get_media_buy_artifacts_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref from ..core import context as context_1 @@ -19,10 +19,10 @@ class TimeRange(AdCPBaseModel): start: Annotated[ - AwareDatetime | None, Field(description='Start of time range (inclusive)'), NoExplicitNull + WireDatetime | None, Field(description='Start of time range (inclusive)'), NoExplicitNull ] = None end: Annotated[ - AwareDatetime | None, Field(description='End of time range (exclusive)'), NoExplicitNull + WireDatetime | None, Field(description='End of time range (exclusive)'), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/content_standards/get_media_buy_artifacts_response.py b/src/adcp/types/domains/content_standards/get_media_buy_artifacts_response.py index 598849802..75f1eb860 100644 --- a/src/adcp/types/domains/content_standards/get_media_buy_artifacts_response.py +++ b/src/adcp/types/domains/content_standards/get_media_buy_artifacts_response.py @@ -8,7 +8,8 @@ from typing import Literal, TypeAlias -from pydantic import AwareDatetime, ConfigDict +from adcp.types.base import WireDatetime +from pydantic import ConfigDict from ..core.version_envelope import AdcpVersionEnvelope from . import artifact as artifact_1 @@ -28,7 +29,7 @@ class BrandContext(AdcpVersionEnvelope): class Artifact(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') record_id: str - timestamp: AwareDatetime | None = None + timestamp: WireDatetime | None = None package_id: str | None = None artifact: artifact_1.Artifact country: str | None = None diff --git a/src/adcp/types/domains/content_standards/update_content_standards_request.py b/src/adcp/types/domains/content_standards/update_content_standards_request.py index 7a5d215f1..14ec6aec7 100644 --- a/src/adcp/types/domains/content_standards/update_content_standards_request.py +++ b/src/adcp/types/domains/content_standards/update_content_standards_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/update_content_standards_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import ext as ext_1 @@ -52,7 +52,7 @@ class Scope(AdCPBaseModel): class Pass(AdCPBaseModel): type: Annotated[Literal['url'], Field(description='Indicates this is a URL reference')] = 'url' value: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Full URL to a specific page (e.g., 'https://espn.com/nba/story/_/id/12345/lakers-win')" ), @@ -65,7 +65,7 @@ class Pass(AdCPBaseModel): class Fail(AdCPBaseModel): type: Annotated[Literal['url'], Field(description='Indicates this is a URL reference')] = 'url' value: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Full URL to a specific page (e.g., 'https://news.example.com/controversial-article')" ), diff --git a/src/adcp/types/domains/content_standards/validate_content_delivery_request.py b/src/adcp/types/domains/content_standards/validate_content_delivery_request.py index 3ab3a7476..367bbd864 100644 --- a/src/adcp/types/domains/content_standards/validate_content_delivery_request.py +++ b/src/adcp/types/domains/content_standards/validate_content_delivery_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: content_standards/validate_content_delivery_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import Field, StrictBool from ..core import context as context_1 from ..core import ext as ext_1 @@ -32,7 +32,7 @@ class Record(AdCPBaseModel): NoExplicitNull, ] = None timestamp: Annotated[ - AwareDatetime | None, Field(description='When the delivery occurred'), NoExplicitNull + WireDatetime | None, Field(description='When the delivery occurred'), NoExplicitNull ] = None artifact: Annotated[artifact_1.Artifact, Field(description='Artifact where ad was delivered')] country: Annotated[ diff --git a/src/adcp/types/domains/core/account.py b/src/adcp/types/domains/core/account.py index 3a5bd7caa..dd5253785 100644 --- a/src/adcp/types/domains/core/account.py +++ b/src/adcp/types/domains/core/account.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/account.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import account_scope as account_scope_1 from ..enums import account_status, billing_party, cloud_storage_protocol @@ -30,7 +30,7 @@ class Setup(AdCPBaseModel): extra='allow', ) url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL where the human can complete the required action (credit application, legal agreement, add funds).' ), @@ -38,7 +38,7 @@ class Setup(AdCPBaseModel): ] = None message: Annotated[str, Field(description="Human-readable description of what's needed.")] expires_at: Annotated[ - AwareDatetime | None, Field(description='When this setup link expires.'), NoExplicitNull + WireDatetime | None, Field(description='When this setup link expires.'), NoExplicitNull ] = None @@ -46,7 +46,7 @@ class GovernanceAgent(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - url: Annotated[AnyUrl, Field(description='Governance agent endpoint URL. Must use HTTPS.')] + url: Annotated[WireAnyUrl, Field(description='Governance agent endpoint URL. Must use HTTPS.')] class Format(StrEnum): @@ -119,7 +119,7 @@ class ReportingBucket(AdCPBaseModel): ), ] setup_instructions: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to documentation for configuring buyer read access to this bucket (IAM role, service account, etc.). Operator-facing documentation — buyer agents MUST NOT auto-fetch this URL; surface it to a human operator. If an implementation fetches it (for preview), apply webhook URL SSRF validation and do not pass the fetched content into an LLM context without indirect-prompt-injection guarding. See docs/media-buy/media-buys/optimization-reporting#security-considerations-for-offline-delivery.' ), diff --git a/src/adcp/types/domains/core/account_change.py b/src/adcp/types/domains/core/account_change.py index 4d3570ff1..2ff6cb3f0 100644 --- a/src/adcp/types/domains/core/account_change.py +++ b/src/adcp/types/domains/core/account_change.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/account_change.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool from . import ext as ext_1 @@ -156,13 +156,13 @@ class AccountChange(AdCPBaseModel): ), ] recorded_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='Time the seller committed or durably observed the change. Feed order is defined by the cursor, not by this timestamp.' ), ] occurred_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Upstream business time when the change occurred, only when the seller can establish it reliably.' ), diff --git a/src/adcp/types/domains/core/account_change_recorded_webhook.py b/src/adcp/types/domains/core/account_change_recorded_webhook.py index d4570f8c7..74721c4e9 100644 --- a/src/adcp/types/domains/core/account_change_recorded_webhook.py +++ b/src/adcp/types/domains/core/account_change_recorded_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/account_change_recorded_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -44,7 +44,7 @@ class AccountChangeRecordedWebhook(AdCPBaseModel): ), ] notification_type: Literal['account.change_recorded'] = 'account.change_recorded' - fired_at: AwareDatetime + fired_at: WireDatetime subscriber_id: Annotated[ str, Field(max_length=64, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$') ] @@ -52,7 +52,7 @@ class AccountChangeRecordedWebhook(AdCPBaseModel): change_id: Annotated[ str, Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,255}$') ] - recorded_at: AwareDatetime + recorded_at: WireDatetime resource: Annotated[ Resource, Field(description='Resource identity copied from the corresponding account change record.'), diff --git a/src/adcp/types/domains/core/account_identity_change.py b/src/adcp/types/domains/core/account_identity_change.py index 69ea474ae..9fc567ead 100644 --- a/src/adcp/types/domains/core/account_identity_change.py +++ b/src/adcp/types/domains/core/account_identity_change.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/account_identity_change.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from . import operator_identity @@ -25,7 +25,7 @@ class AccountIdentityChange1(AdCPBaseModel): Field(description='Complete desired operator identity submitted by the buyer.'), ] requested_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the seller recorded the request.'), NoExplicitNull, ] = None @@ -46,7 +46,7 @@ class AccountIdentityChange2(AdCPBaseModel): Field(description='Complete desired operator identity submitted by the buyer.'), ] requested_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the seller recorded the request.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/account_status_changed_webhook.py b/src/adcp/types/domains/core/account_status_changed_webhook.py index 7e9504653..fdb1ac7e2 100644 --- a/src/adcp/types/domains/core/account_status_changed_webhook.py +++ b/src/adcp/types/domains/core/account_status_changed_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/account_status_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import account_status from . import ext as ext_1 @@ -41,7 +41,7 @@ class Setup(AdCPBaseModel): ), ] expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the setup action or hint expires, if known.'), NoExplicitNull, ] = None @@ -76,7 +76,7 @@ class AccountStatusChangedWebhook(AdCPBaseModel): ), ] = 'account.status_changed' fired_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller initiated this fire. Distinct from observed_at, which is when the seller recorded the account transition.' ), @@ -107,7 +107,7 @@ class AccountStatusChangedWebhook(AdCPBaseModel): ), ] observed_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller recorded the account status transition. Used in the notification_id stability key; this is seller wall time for the transition, not webhook fire time.' ), diff --git a/src/adcp/types/domains/core/agent_notification_config.py b/src/adcp/types/domains/core/agent_notification_config.py index a800e3bec..e7c119b9d 100644 --- a/src/adcp/types/domains/core/agent_notification_config.py +++ b/src/adcp/types/domains/core/agent_notification_config.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/agent_notification_config.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ..enums import auth_scheme, notification_type from . import ext as ext_1 @@ -42,7 +42,7 @@ class AgentNotificationConfig(AdCPBaseModel): ), ] url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Webhook endpoint URL. Same wire contract as `push-notification-config.url` and account-level `notification-config.url`: `format: "uri"`, no destination-port allowlist enforced by the protocol, SSRF protection via the IP-range check defined in docs/building/by-layer/L1/security.mdx#webhook-url-validation-ssrf. Sellers MUST validate URL syntax, HTTPS usage, hostname normalization, and reserved-range rejection when writing any config, including `active: false` configs. Sellers MUST complete an activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active.' ), diff --git a/src/adcp/types/domains/core/agent_notification_config_state.py b/src/adcp/types/domains/core/agent_notification_config_state.py index 6eda58e1c..2dbf51b20 100644 --- a/src/adcp/types/domains/core/agent_notification_config_state.py +++ b/src/adcp/types/domains/core/agent_notification_config_state.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/agent_notification_config_state.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ..enums import auth_scheme, notification_type from . import ext as ext_1 @@ -27,7 +27,7 @@ class AgentNotificationConfigState(AdCPBaseModel): subscriber_id: Annotated[ str, Field(max_length=64, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$') ] - url: AnyUrl + url: WireAnyUrl event_types: Annotated[list[notification_type.NotificationType], Field(min_length=1)] all_authorized_accounts: Annotated[ StrictBool | None, diff --git a/src/adcp/types/domains/core/agent_reporting_destination_state.py b/src/adcp/types/domains/core/agent_reporting_destination_state.py index e213fb699..546f03108 100644 --- a/src/adcp/types/domains/core/agent_reporting_destination_state.py +++ b/src/adcp/types/domains/core/agent_reporting_destination_state.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/agent_reporting_destination_state.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from ..enums import reporting_destination_setup_state from . import agent_reporting_destination, error @@ -33,14 +33,14 @@ class Setup(AdCPBaseModel): ) action: Action setup_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='HTTPS page for completing provider-native setup. Printable ASCII without whitespace, \'?\', \'#\', \'%\', \'"\', \'<\', or \'>\'. It MUST NOT carry a credential, userinfo authority, or signed component and MUST be rendered as an untrusted link, never executed as agent instructions.' ), NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Optional expiry of this setup action. A new sync obtains a fresh action after expiry.' ), diff --git a/src/adcp/types/domains/core/agent_signing_key.py b/src/adcp/types/domains/core/agent_signing_key.py index 2608be649..0135bbd92 100644 --- a/src/adcp/types/domains/core/agent_signing_key.py +++ b/src/adcp/types/domains/core/agent_signing_key.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/agent_signing_key.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field class AgentSigningKey(AdCPBaseModel): @@ -50,7 +50,7 @@ class AgentSigningKey(AdCPBaseModel): str | None, Field(description='Base64url-encoded RSA public exponent.'), NoExplicitNull ] = None revoked_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Optional revocation timestamp. When present, verifiers MUST reject any signature produced with this key whose signing epoch (or equivalent time reference) is at or after this timestamp. The key may continue to appear in the trust anchor during a grace period so caches that have not yet refreshed still find the key and can evaluate the revocation marker. Keys past their revocation can be removed once the cache TTL (recommended: 5 minutes) has elapsed across all verifiers.' ), diff --git a/src/adcp/types/domains/core/agent_webhook_challenge.py b/src/adcp/types/domains/core/agent_webhook_challenge.py index 49ae89e97..24fe627ee 100644 --- a/src/adcp/types/domains/core/agent_webhook_challenge.py +++ b/src/adcp/types/domains/core/agent_webhook_challenge.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/agent_webhook_challenge.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class Mode(StrEnum): @@ -68,7 +68,7 @@ class AgentWebhookChallenge(AdCPBaseModel): ), ] seller_agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Exact seller agent URL whose RFC 9421 webhook profile key signs this challenge and that will send subsequent webhooks.' ), diff --git a/src/adcp/types/domains/core/app_item.py b/src/adcp/types/domains/core/app_item.py index 126a56b5b..f4c0f552e 100644 --- a/src/adcp/types/domains/core/app_item.py +++ b/src/adcp/types/domains/core/app_item.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/app_item.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 from . import offering_asset_group @@ -79,12 +79,12 @@ class AppItem(AdCPBaseModel): NoExplicitNull, ] = None icon_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='App icon image URL. Typically 1024×1024 px.'), NoExplicitNull, ] = None screenshots: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field( description='App store screenshot URLs. Used by platforms for creative generation when native store assets are not available.', min_length=1, @@ -92,19 +92,19 @@ class AppItem(AdCPBaseModel): NoExplicitNull, ] = None preview_video_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='App preview or gameplay video URL for use in video ad creatives.'), NoExplicitNull, ] = None store_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="Direct link to the app's store listing (Apple App Store or Google Play)." ), NoExplicitNull, ] = None deep_link_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="Deep link URI for re-engagement campaigns targeting existing users. Use Universal Links (iOS) or App Links (Android) where available (e.g., 'https://acmegames.com/app/level/5'). Falls back to URI scheme (e.g., 'acmegames://level/5') when universal links are not configured." ), diff --git a/src/adcp/types/domains/core/assets/asset_union.py b/src/adcp/types/domains/core/assets/asset_union.py index 7c03eab18..c491405ab 100644 --- a/src/adcp/types/domains/core/assets/asset_union.py +++ b/src/adcp/types/domains/core/assets/asset_union.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/assets/asset_union.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,11 +8,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt +from adcp.types.base import AdCPBaseModel, SchemaInt, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarFloat, ScalarStr from pydantic import ( - AnyUrl, - AwareDatetime, ConfigDict, Field, RootModel, @@ -65,7 +63,7 @@ class Role(StrEnum): class DeclaredBy(AdCPBaseModel): agent_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL of the agent or service that declared this provenance'), ] = None role: Annotated[Role, Field(description='Role of the declaring party in the supply chain')] @@ -73,7 +71,7 @@ class DeclaredBy(AdCPBaseModel): class C2pa(AdCPBaseModel): manifest_url: Annotated[ - AnyUrl, Field(description='URL to the C2PA manifest store for this content') + WireAnyUrl, Field(description='URL to the C2PA manifest store for this content') ] @@ -82,7 +80,7 @@ class VerifyAgent(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the governance agent the buyer represents was used to embed/verify this layer. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." ), @@ -100,7 +98,7 @@ class VerifyAgent1(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the governance agent the buyer represents was used to apply/detect this watermark. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." ), @@ -128,7 +126,7 @@ class VerificationItem(AdCPBaseModel): ), ] verified_time: Annotated[ - AwareDatetime | None, Field(description='When the verification was performed (ISO 8601)') + WireDatetime | None, Field(description='When the verification was performed (ISO 8601)') ] = None result: Annotated[Result, Field(description='Verification outcome')] confidence: Annotated[ @@ -138,7 +136,7 @@ class VerificationItem(AdCPBaseModel): ), ] = None details_url: Annotated[ - AnyUrl | None, Field(description='URL to the full verification report') + WireAnyUrl | None, Field(description='URL to the full verification report') ] = None @@ -270,9 +268,9 @@ class UnavailableBehavior(StrEnum): reject = 'reject' -class MacroBearingUrl1(RootModel[AnyUrl]): +class MacroBearingUrl1(RootModel[WireAnyUrl]): root: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Backward-compatible URL value that preserves the existing `uri-template` branch (including deep links, protocol-relative references, and RFC 6570 host variables) while additionally accepting absolute HTTP(S) URLs with byte-preserved macro delimiters not legal in a strict URI, including `%%...%%`, `[...]`, and `${...}`. Macro-bearing HTTP(S) URLs require a valid lexical authority and valid percent triplets; an AdCP verifier replaces each recognized token with an unreserved sentinel and validates the resulting absolute URI. Token semantics are established only by `macro_declarations`.', title='Macro-bearing URL', @@ -724,7 +722,7 @@ class ReferenceAsset(AdCPBaseModel): extra='allow', ) url: Annotated[ - AnyUrl, Field(description='URL to the reference asset (image, video, or document)') + WireAnyUrl, Field(description='URL to the reference asset (image, video, or document)') ] role: Annotated[ Role2, @@ -911,7 +909,7 @@ class IdentityRef(AdCPBaseModel): str | None, Field(description='Human-readable handle or username when available.') ] = None profile_url: Annotated[ - AnyUrl | None, Field(description='URL for the authoring profile, page, or channel.') + WireAnyUrl | None, Field(description='URL for the authoring profile, page, or channel.') ] = None platform_identity_id: Annotated[ str | None, Field(description='Opaque platform-native identity identifier.') @@ -938,15 +936,15 @@ class ReferenceAuthorization(AdCPBaseModel): ), ] checked_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the seller last checked the authorization state.'), ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the authorization expires, if the platform exposes an expiry.'), ] = None authorization_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Optional URL the buyer or creator can use to complete authorization.'), ] = None authorization_instructions: Annotated[ @@ -964,7 +962,7 @@ class PlatformExtensionRef(AdCPBaseModel): extra='allow', ) uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="HTTPS URL identifying the extension. `https://` is mandatory — `http://`, `file://`, `data:`, and other schemes are rejected at the schema layer (defense-in-depth on top of the fetch-contract normative rules). The URI base is the owning agent's URL; the path identifies the extension within that agent. Example: 'https://creative.adcontextprotocol.org/translated/meta/extensions/meta_pixel'. The full fetch contract — SSRF allowlist, response-size cap, $ref sandbox, schema-compile bounds — is documented on `product-format-declaration.json#format_schema` and applies to ALL fetches of this reference shape regardless of whether the field is named `format_schema` (load-bearing for validation) or `platform_extensions` (informational); the *transport* rules are identical, only the *consumption* semantics differ." ), @@ -1032,7 +1030,7 @@ class EmbeddedProvenanceItem(AdCPBaseModel): ), ] = None embedded_at: Annotated[ - AwareDatetime | None, Field(description='When the provenance data was embedded (ISO 8601)') + WireDatetime | None, Field(description='When the provenance data was embedded (ISO 8601)') ] = None @@ -1057,7 +1055,7 @@ class Watermark(AdCPBaseModel): Field(description='C2PA action classification for this watermark'), ] = None embedded_at: Annotated[ - AwareDatetime | None, Field(description='When the watermark was applied (ISO 8601)') + WireDatetime | None, Field(description='When the watermark was applied (ISO 8601)') ] = None @@ -1158,13 +1156,13 @@ class Provenance(AdCPBaseModel): ), ] = None declared_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this provenance claim was made (ISO 8601). Distinct from created_time, which records when the content itself was produced. A provenance claim may be attached well after content creation, for example when retroactively declaring AI involvement for regulatory compliance.' ), ] = None created_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this content was created or generated (ISO 8601)'), ] = None c2pa: Annotated[ @@ -1213,7 +1211,7 @@ class VideoAsset(AdCPBaseModel): description='Discriminator identifying this as a video asset. See /schemas/creative/asset-types for the registry.' ), ] = 'video' - url: Annotated[AnyUrl, Field(description='URL to the video asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the video asset')] width: Annotated[ SchemaInt, Field( @@ -1289,13 +1287,13 @@ class VideoAsset(AdCPBaseModel): StrictFloat | None, Field(description='True peak level in dBFS') ] = None captions_url: Annotated[ - AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)') + WireAnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)') ] = None transcript_url: Annotated[ - AnyUrl | None, Field(description='URL to text transcript of the video content') + WireAnyUrl | None, Field(description='URL to text transcript of the video content') ] = None audio_description_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to audio description track for visually impaired users'), ] = None provenance: Annotated[ @@ -1316,7 +1314,7 @@ class AudioAsset(AdCPBaseModel): description='Discriminator identifying this as an audio asset. See /schemas/creative/asset-types for the registry.' ), ] = 'audio' - url: Annotated[AnyUrl, Field(description='URL to the audio asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the audio asset')] duration_ms: Annotated[ SchemaInt | None, Field(description='Audio duration in milliseconds', ge=0) ] = None @@ -1350,7 +1348,7 @@ class AudioAsset(AdCPBaseModel): None ) transcript_url: Annotated[ - AnyUrl | None, Field(description='URL to text transcript of the audio content') + WireAnyUrl | None, Field(description='URL to text transcript of the audio content') ] = None provenance: Annotated[ Provenance | None, @@ -1374,7 +1372,7 @@ class MacroTranslationTarget(AdCPBaseModel): ) token: Annotated[str, Field(min_length=1)] dialect: MacroDialect - dialect_namespace: AnyUrl | None = None + dialect_namespace: WireAnyUrl | None = None dialect_revision: Annotated[str | None, Field(min_length=1)] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MacroMappingStatus @@ -1469,7 +1467,7 @@ class ZipAsset(AdCPBaseModel): description='Discriminator identifying this as a zip-bundled asset. See /schemas/creative/asset-types for the registry.' ), ] = 'zip' - url: Annotated[AnyUrl, Field(description='URL where the zip archive is hosted. Must be HTTPS.')] + url: Annotated[WireAnyUrl, Field(description='URL where the zip archive is hosted. Must be HTTPS.')] max_file_size_kb: Annotated[ SchemaInt | None, Field( @@ -1490,7 +1488,7 @@ class ZipAsset(AdCPBaseModel): ), ] = None backup_image_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Fallback image URL for environments that cannot render the bundled creative (e.g., non-HTML5 endpoints, ad blockers). Recommended for HTML5 banners.' ), @@ -1534,7 +1532,7 @@ class WebhookAsset(AdCPBaseModel): description='Discriminator identifying this as a webhook asset. See /schemas/creative/asset-types for the registry.' ), ] = 'webhook' - url: Annotated[AnyUrl, Field(description='Webhook URL to call for dynamic content')] + url: Annotated[WireAnyUrl, Field(description='Webhook URL to call for dynamic content')] method: Annotated[HttpMethod | None, Field(description='HTTP method')] = HttpMethod.POST timeout_ms: Annotated[ SchemaInt | None, @@ -1605,7 +1603,7 @@ class MacroDeclaration6(AdCPBaseModel): ] dialect: MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -1690,7 +1688,7 @@ class DaastAsset1(AdCPBaseModel): StrictBool | None, Field(description='Whether companion display ads are included') ] = None transcript_url: Annotated[ - AnyUrl | None, Field(description='URL to text transcript of the audio content') + WireAnyUrl | None, Field(description='URL to text transcript of the audio content') ] = None provenance: Annotated[ Provenance | None, @@ -1738,7 +1736,7 @@ class MacroDeclaration7(AdCPBaseModel): ] dialect: MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -1823,7 +1821,7 @@ class DaastAsset2(AdCPBaseModel): StrictBool | None, Field(description='Whether companion display ads are included') ] = None transcript_url: Annotated[ - AnyUrl | None, Field(description='URL to text transcript of the audio content') + WireAnyUrl | None, Field(description='URL to text transcript of the audio content') ] = None provenance: Annotated[ Provenance | None, @@ -1962,7 +1960,7 @@ class Catalog(AdCPBaseModel): ), ] url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL to an external catalog feed. The platform fetches and resolves items from this URL. For offering-type catalogs, the feed contains an array of Offering objects. For other types, the feed format is determined by feed_format. When omitted with type 'product', the platform uses its synced copy of the brand's product catalog." ), @@ -2052,7 +2050,7 @@ class PublishedPostAsset1(AdCPBaseModel): ), ] = 'published_post' post_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical URL for the published post. Preferred when the platform exposes a stable public or authenticated URL.' ), @@ -2076,7 +2074,7 @@ class PublishedPostAsset1(AdCPBaseModel): ), ] = None published_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the referenced post was originally published, if known.'), ] = None reference_authorization: Annotated[ @@ -2104,7 +2102,7 @@ class PublishedPostAsset2(AdCPBaseModel): ), ] = 'published_post' post_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Canonical URL for the published post. Preferred when the platform exposes a stable public or authenticated URL.' ), @@ -2128,7 +2126,7 @@ class PublishedPostAsset2(AdCPBaseModel): ), ] = None published_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the referenced post was originally published, if known.'), ] = None reference_authorization: Annotated[ @@ -2171,7 +2169,7 @@ class ImageAsset(AdCPBaseModel): description='Discriminator identifying this as an image asset. See /schemas/creative/asset-types for the registry.' ), ] = 'image' - url: Annotated[AnyUrl, Field(description='URL to the image asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the image asset')] width: Annotated[SchemaInt, Field(description='Width in pixels', ge=1)] height: Annotated[SchemaInt, Field(description='Height in pixels', ge=1)] file_size_bytes: Annotated[ @@ -2241,7 +2239,7 @@ class MacroDeclaration(AdCPBaseModel): ] dialect: MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -2337,10 +2335,10 @@ class VastAsset1(AdCPBaseModel): Field(description='Tracking events supported by this VAST tag'), ] = None captions_url: Annotated[ - AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)') + WireAnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)') ] = None audio_description_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to audio description track for visually impaired users'), ] = None provenance: Annotated[ @@ -2382,7 +2380,7 @@ class MacroDeclaration1(AdCPBaseModel): ] dialect: MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -2478,10 +2476,10 @@ class VastAsset2(AdCPBaseModel): Field(description='Tracking events supported by this VAST tag'), ] = None captions_url: Annotated[ - AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)') + WireAnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)') ] = None audio_description_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to audio description track for visually impaired users'), ] = None provenance: Annotated[ @@ -2535,7 +2533,7 @@ class MacroDeclarationModel(AdCPBaseModel): ] dialect: MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -2617,7 +2615,7 @@ class MacroDeclaration2(MacroDeclarationModel): ] dialect: MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -2730,7 +2728,7 @@ class MacroDeclaration3(MacroDeclarationModel): ] dialect: MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -2848,7 +2846,7 @@ class MacroDeclaration4(MacroDeclarationModel): ] dialect: MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), diff --git a/src/adcp/types/domains/core/assets/audio_asset.py b/src/adcp/types/domains/core/assets/audio_asset.py index 01ca0c875..fea6c1d6d 100644 --- a/src/adcp/types/domains/core/assets/audio_asset.py +++ b/src/adcp/types/domains/core/assets/audio_asset.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/assets/audio_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from enum import IntEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from ...enums import audio_channel_layout from .. import provenance as provenance_1 @@ -30,7 +30,7 @@ class AudioAsset(AdCPBaseModel): description='Discriminator identifying this as an audio asset. See /schemas/creative/asset-types for the registry.' ), ] = 'audio' - url: Annotated[AnyUrl, Field(description='URL to the audio asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the audio asset')] duration_ms: Annotated[ SchemaInt | None, Field(description='Audio duration in milliseconds', ge=0), NoExplicitNull ] = None @@ -70,7 +70,7 @@ class AudioAsset(AdCPBaseModel): StrictFloat | None, Field(description='True peak level in dBFS'), NoExplicitNull ] = None transcript_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to text transcript of the audio content'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/daast_asset.py b/src/adcp/types/domains/core/assets/daast_asset.py index 5823c9807..9c50c486e 100644 --- a/src/adcp/types/domains/core/assets/daast_asset.py +++ b/src/adcp/types/domains/core/assets/daast_asset.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/assets/daast_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, RootModel, StrictBool from ...enums import daast_tracking_event from ...enums import daast_version as daast_version_1 @@ -83,7 +83,7 @@ class MacroDeclaration(AdCPBaseModel): ] dialect: asset_union.MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -169,7 +169,7 @@ class MacroDeclaration12(AdCPBaseModel): ] dialect: asset_union.MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -265,7 +265,7 @@ class DaastAsset3(AdCPBaseModel): NoExplicitNull, ] = None transcript_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to text transcript of the audio content'), NoExplicitNull, ] = None @@ -327,7 +327,7 @@ class DaastAsset4(AdCPBaseModel): NoExplicitNull, ] = None transcript_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to text transcript of the audio content'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/display_tag_asset.py b/src/adcp/types/domains/core/assets/display_tag_asset.py index 4423f1a7f..4ef590ca1 100644 --- a/src/adcp/types/domains/core/assets/display_tag_asset.py +++ b/src/adcp/types/domains/core/assets/display_tag_asset.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/assets/display_tag_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, RootModel, StrictBool from .. import macro_bearing_url from .. import provenance as provenance_1 @@ -81,7 +81,7 @@ class MacroDeclaration(MacroDeclaration_1): ] dialect: asset_union.MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -201,7 +201,7 @@ class MacroDeclaration15(MacroDeclaration_1): ] dialect: asset_union.MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -326,7 +326,7 @@ class MacroDeclaration16(MacroDeclaration_1): ] dialect: asset_union.MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), diff --git a/src/adcp/types/domains/core/assets/image_asset.py b/src/adcp/types/domains/core/assets/image_asset.py index 15130540d..239b07a5d 100644 --- a/src/adcp/types/domains/core/assets/image_asset.py +++ b/src/adcp/types/domains/core/assets/image_asset.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/assets/image_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl from adcp.types._scalar import ScalarFloat -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat from .. import provenance as provenance_1 @@ -28,7 +28,7 @@ class ImageAsset(AdCPBaseModel): description='Discriminator identifying this as an image asset. See /schemas/creative/asset-types for the registry.' ), ] = 'image' - url: Annotated[AnyUrl, Field(description='URL to the image asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the image asset')] width: Annotated[SchemaInt, Field(description='Width in pixels', ge=1)] height: Annotated[SchemaInt, Field(description='Height in pixels', ge=1)] file_size_bytes: Annotated[ diff --git a/src/adcp/types/domains/core/assets/published_post_asset.py b/src/adcp/types/domains/core/assets/published_post_asset.py index 20e9df09f..f10c442a1 100644 --- a/src/adcp/types/domains/core/assets/published_post_asset.py +++ b/src/adcp/types/domains/core/assets/published_post_asset.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/assets/published_post_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, model_validator from .. import provenance as provenance_1 @@ -26,7 +26,7 @@ class IdentityRef(AdCPBaseModel): NoExplicitNull, ] = None profile_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL for the authoring profile, page, or channel.'), NoExplicitNull, ] = None @@ -55,17 +55,17 @@ class ReferenceAuthorization(AdCPBaseModel): ), ] checked_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the seller last checked the authorization state.'), NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the authorization expires, if the platform exposes an expiry.'), NoExplicitNull, ] = None authorization_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Optional URL the buyer or creator can use to complete authorization.'), NoExplicitNull, ] = None @@ -87,7 +87,7 @@ class PublishedPostAsset(AdCPBaseModel): ), ] = 'published_post' post_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Canonical URL for the published post. Preferred when the platform exposes a stable public or authenticated URL.' ), @@ -115,7 +115,7 @@ class PublishedPostAsset(AdCPBaseModel): NoExplicitNull, ] = None published_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the referenced post was originally published, if known.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/vast_asset.py b/src/adcp/types/domains/core/assets/vast_asset.py index 6f85ec7a9..194b293a3 100644 --- a/src/adcp/types/domains/core/assets/vast_asset.py +++ b/src/adcp/types/domains/core/assets/vast_asset.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/assets/vast_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, RootModel, StrictBool from ...enums import vast_tracking_event from ...enums import vast_version as vast_version_1 @@ -83,7 +83,7 @@ class MacroDeclaration(AdCPBaseModel): ] dialect: asset_union.MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -189,10 +189,10 @@ class VastAsset3(AdCPBaseModel): NoExplicitNull, ] = None captions_url: Annotated[ - AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)'), NoExplicitNull + WireAnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)'), NoExplicitNull ] = None audio_description_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to audio description track for visually impaired users'), NoExplicitNull, ] = None @@ -236,7 +236,7 @@ class MacroDeclaration20(AdCPBaseModel): ] dialect: asset_union.MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), @@ -342,10 +342,10 @@ class VastAsset4(AdCPBaseModel): NoExplicitNull, ] = None captions_url: Annotated[ - AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)'), NoExplicitNull + WireAnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)'), NoExplicitNull ] = None audio_description_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to audio description track for visually impaired users'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/video_asset.py b/src/adcp/types/domains/core/assets/video_asset.py index eba2a43cc..fc32b8db4 100644 --- a/src/adcp/types/domains/core/assets/video_asset.py +++ b/src/adcp/types/domains/core/assets/video_asset.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/assets/video_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ...enums import audio_channel_layout from ...enums import frame_rate_type as frame_rate_type_1 @@ -63,7 +63,7 @@ class VideoAsset(AdCPBaseModel): description='Discriminator identifying this as a video asset. See /schemas/creative/asset-types for the registry.' ), ] = 'video' - url: Annotated[AnyUrl, Field(description='URL to the video asset')] + url: Annotated[WireAnyUrl, Field(description='URL to the video asset')] width: Annotated[ SchemaInt, Field( @@ -164,15 +164,15 @@ class VideoAsset(AdCPBaseModel): StrictFloat | None, Field(description='True peak level in dBFS'), NoExplicitNull ] = None captions_url: Annotated[ - AnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)'), NoExplicitNull + WireAnyUrl | None, Field(description='URL to captions file (WebVTT, SRT, etc.)'), NoExplicitNull ] = None transcript_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to text transcript of the video content'), NoExplicitNull, ] = None audio_description_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to audio description track for visually impaired users'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/assets/webhook_asset.py b/src/adcp/types/domains/core/assets/webhook_asset.py index e30b32d79..5e6424488 100644 --- a/src/adcp/types/domains/core/assets/webhook_asset.py +++ b/src/adcp/types/domains/core/assets/webhook_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/webhook_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field from ...enums import http_method, universal_macro, webhook_response_type, webhook_security_method from .. import provenance as provenance_1 @@ -37,7 +37,7 @@ class WebhookAsset(AdCPBaseModel): description='Discriminator identifying this as a webhook asset. See /schemas/creative/asset-types for the registry.' ), ] = 'webhook' - url: Annotated[AnyUrl, Field(description='Webhook URL to call for dynamic content')] + url: Annotated[WireAnyUrl, Field(description='Webhook URL to call for dynamic content')] method: Annotated[ http_method.HttpMethod | None, Field(description='HTTP method'), NoExplicitNull ] = http_method.HttpMethod.POST diff --git a/src/adcp/types/domains/core/assets/zip_asset.py b/src/adcp/types/domains/core/assets/zip_asset.py index a3c7fe7cf..c70c5aa6e 100644 --- a/src/adcp/types/domains/core/assets/zip_asset.py +++ b/src/adcp/types/domains/core/assets/zip_asset.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/assets/zip_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from .. import provenance as provenance_1 @@ -47,7 +47,7 @@ class ZipAsset(AdCPBaseModel): description='Discriminator identifying this as a zip-bundled asset. See /schemas/creative/asset-types for the registry.' ), ] = 'zip' - url: Annotated[AnyUrl, Field(description='URL where the zip archive is hosted. Must be HTTPS.')] + url: Annotated[WireAnyUrl, Field(description='URL where the zip archive is hosted. Must be HTTPS.')] max_file_size_kb: Annotated[ SchemaInt | None, Field( @@ -71,7 +71,7 @@ class ZipAsset(AdCPBaseModel): NoExplicitNull, ] = None backup_image_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Fallback image URL for environments that cannot render the bundled creative (e.g., non-HTML5 endpoints, ad blockers). Recommended for HTML5 banners.' ), diff --git a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py index b76ef7ad8..f11eea3e6 100644 --- a/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/media_buy/get_products_async_response_submitted.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/media_buy/get_products_async_response_submitted.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ... import context as context_1 from ... import error @@ -39,7 +39,7 @@ class GetProductsSubmitted(AdCPBaseModel): NoExplicitNull, ] = None estimated_completion: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Estimated completion time for the search'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py b/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py index de2e72d89..dc8535824 100644 --- a/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py +++ b/src/adcp/types/domains/core/async_response_refs/signals/get_signals_async_response_submitted.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/async_response_refs/signals/get_signals_async_response_submitted.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ... import context as context_1 from ... import error @@ -39,7 +39,7 @@ class GetSignalsSubmitted(AdCPBaseModel): NoExplicitNull, ] = None estimated_completion: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Estimated completion time for the signal discovery task.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/attestation_capabilities.py b/src/adcp/types/domains/core/attestation_capabilities.py index e72a95e99..8f61fae29 100644 --- a/src/adcp/types/domains/core/attestation_capabilities.py +++ b/src/adcp/types/domains/core/attestation_capabilities.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/attestation_capabilities.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, RootModel from . import attestation_issuer from . import ext as ext_1 @@ -20,8 +20,8 @@ class SupportedDeliveryMethod(StrEnum): embedded = 'embedded' -class CredentialOrigin(RootModel[AnyUrl]): - root: AnyUrl +class CredentialOrigin(RootModel[WireAnyUrl]): + root: WireAnyUrl class Authentication(StrEnum): @@ -35,7 +35,7 @@ class Resolver(AdCPBaseModel): ) resolver_id: Annotated[str, Field(max_length=255, min_length=1, pattern='^[A-Za-z0-9._:-]+$')] url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Evaluator-configured HTTPS resolver endpoint. Calls use POST with Content-Type application/json and a body containing only credential_id; query-string and path interpolation are forbidden. The evaluator still applies the attestation fetch contract before every call.' ), @@ -52,9 +52,9 @@ class AcceptedVerifier(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - agent_url: AnyUrl - claim_types: Annotated[list[AnyUrl] | None, Field(min_length=1), NoExplicitNull] = None - proof_formats: Annotated[list[AnyUrl] | None, Field(min_length=1), NoExplicitNull] = None + agent_url: WireAnyUrl + claim_types: Annotated[list[WireAnyUrl] | None, Field(min_length=1), NoExplicitNull] = None + proof_formats: Annotated[list[WireAnyUrl] | None, Field(min_length=1), NoExplicitNull] = None ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @@ -64,7 +64,7 @@ class AcceptedIssuer(AdCPBaseModel): ) issuer: attestation_issuer.AttestationIssuer claim_types: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field( description='Optional subset of accepted_claim_types this issuer may assert. Omit to allow any globally accepted claim type for this issuer.', min_length=1, @@ -72,7 +72,7 @@ class AcceptedIssuer(AdCPBaseModel): NoExplicitNull, ] = None proof_formats: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field( description='Optional subset of accepted_proof_formats allowed for this issuer. Omit to allow any globally accepted proof format for this issuer.', min_length=1, @@ -103,14 +103,14 @@ class AttestationCapabilities(AdCPBaseModel): extra='forbid', ) accepted_claim_types: Annotated[ - list[AnyUrl], + list[WireAnyUrl], Field( description='Open claim identifiers the evaluator is prepared to evaluate. Each value is an absolute URI. Absence means the evaluator has not advertised portable-attestation support; an empty list is not permitted.', min_length=1, ), ] accepted_proof_formats: Annotated[ - list[AnyUrl], + list[WireAnyUrl], Field( description='Open credential/proof format identifiers the evaluator can verify. Values are absolute URIs rather than a protocol enum so issuers can adopt new formats without AdCP endorsement.', min_length=1, diff --git a/src/adcp/types/domains/core/attestation_evaluation.py b/src/adcp/types/domains/core/attestation_evaluation.py index 5521a0fa8..a3c248255 100644 --- a/src/adcp/types/domains/core/attestation_evaluation.py +++ b/src/adcp/types/domains/core/attestation_evaluation.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/attestation_evaluation.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 @@ -39,7 +39,7 @@ class ActionBinding(AdCPBaseModel): extra='forbid', ) action_type: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Absolute URI naming the action vocabulary, such as an AdCP governance-check or audience-evidence snapshot type.' ), @@ -83,7 +83,7 @@ class AttestationEvaluation(AdCPBaseModel): NoExplicitNull, ] = None proof_format: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Absolute URI identifying the credential/proof format used to interpret and hash obtained credential bytes. REQUIRED whenever credential_digest is present.' ), @@ -95,15 +95,15 @@ class AttestationEvaluation(AdCPBaseModel): description='Stable evaluation outcome. verified means all required issuer, signature, claim type, subject, consuming-action scope, validity, revocation, and digest checks passed under evaluator policy. subject_mismatch covers either a credential/presentation subject mismatch or a presentation/consuming-action subject mismatch. Other values are non-authoritative failures and MUST NOT be treated as verified.' ), ] - evaluated_at: AwareDatetime + evaluated_at: WireDatetime evaluated_by: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical agent URL of the evaluator-of-record that produced this result, even when it delegated proof checking to an accepted verifier.' ), ] verifier_agent_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Accepted verifier agent actually used, when verification was delegated. Omit for local verification or when no verifier was called.' ), @@ -127,14 +127,14 @@ class AttestationEvaluation(AdCPBaseModel): NoExplicitNull, ] = None revocation_checked_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the evaluator last checked authoritative revocation state. Its freshness is governed by proof format and evaluator policy.' ), NoExplicitNull, ] = None valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Upper bound on reuse of this evaluation, considering credential expiry, revocation-cache TTL, and evaluator policy. Consumers MUST re-evaluate after this time.' ), diff --git a/src/adcp/types/domains/core/attestation_issuer.py b/src/adcp/types/domains/core/attestation_issuer.py index 2009ac580..85cd9f008 100644 --- a/src/adcp/types/domains/core/attestation_issuer.py +++ b/src/adcp/types/domains/core/attestation_issuer.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/attestation_issuer.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from . import brand_ref from . import ext as ext_1 @@ -24,7 +24,7 @@ class AttestationIssuer2(AdCPBaseModel): ), ] = 'agent' agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical HTTPS endpoint of the issuing agent. Evaluators compare it using AdCP URL canonicalization rules.' ), @@ -43,7 +43,7 @@ class AttestationIssuer3(AdCPBaseModel): ), ] = 'origin' origin: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical HTTPS origin with no path, query, fragment, or userinfo. This identifies the issuer; it does not authorize a fetch.' ), diff --git a/src/adcp/types/domains/core/attestation_reference.py b/src/adcp/types/domains/core/attestation_reference.py index 74760a7a7..b70537394 100644 --- a/src/adcp/types/domains/core/attestation_reference.py +++ b/src/adcp/types/domains/core/attestation_reference.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/attestation_reference.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, model_validator from . import attestation_issuer, attestation_subject from . import ext as ext_1 @@ -20,15 +20,15 @@ class ValidityHint(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - not_before: Annotated[AwareDatetime | None, NoExplicitNull] = None - expires_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + not_before: Annotated[WireDatetime | None, NoExplicitNull] = None + expires_at: Annotated[WireDatetime | None, NoExplicitNull] = None class VerifyAgent(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - agent_url: AnyUrl + agent_url: WireAnyUrl class Locator(AdCPBaseModel): @@ -37,7 +37,7 @@ class Locator(AdCPBaseModel): ) type: Literal['credential_uri'] = 'credential_uri' credential_uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='HTTPS URI of the credential. Before any fetch, evaluators MUST match its canonical origin to credential_origins on the accepted issuer capability and then apply the attestation fetch contract.' ), @@ -75,7 +75,7 @@ class EmbeddedCredential(AdCPBaseModel): extra='forbid', ) format: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Open, absolute URI identifying the credential/proof format. AdCP does not impose a universal issuer payload schema.' ), @@ -96,7 +96,7 @@ class AttestationReference(AdCPBaseModel): ) issuer: attestation_issuer.AttestationIssuer claim_type: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Open, absolute URI identifying the claim vocabulary. The URI is an identifier and need not be dereferenceable. AdCP does not maintain an enum of approved claims.' ), diff --git a/src/adcp/types/domains/core/attestation_subject.py b/src/adcp/types/domains/core/attestation_subject.py index 1519bf545..9b74d9dbf 100644 --- a/src/adcp/types/domains/core/attestation_subject.py +++ b/src/adcp/types/domains/core/attestation_subject.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/attestation_subject.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from . import brand_ref from . import ext as ext_1 @@ -19,7 +19,7 @@ class AttestationSubject2(AdCPBaseModel): ) type: Literal['agent'] = 'agent' agent_url: Annotated[ - AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') + WireAnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @@ -30,13 +30,13 @@ class AttestationSubject3(AdCPBaseModel): ) type: Literal['resource'] = 'resource' resource_type: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Open, absolute URI naming the subject vocabulary, such as https://adcontextprotocol.org/claims/subjects/signal. AdCP does not maintain an exhaustive enum.' ), ] namespace: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Absolute URI identifying the namespace in which id is unique. This may be an AdCP agent endpoint, a catalog origin, or a domain-specific namespace URI.' ), diff --git a/src/adcp/types/domains/core/audience_activation_method.py b/src/adcp/types/domains/core/audience_activation_method.py index 35393c94e..f60a0370d 100644 --- a/src/adcp/types/domains/core/audience_activation_method.py +++ b/src/adcp/types/domains/core/audience_activation_method.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/audience_activation_method.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field from ..enums import cloud_storage_protocol from . import brand_ref @@ -26,7 +26,7 @@ class BuyerAgent(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Buyer agent's registered agent URL. Canonical identifier, mirroring trusted_match.providers[].agent_url." ), diff --git a/src/adcp/types/domains/core/audience_characteristic.py b/src/adcp/types/domains/core/audience_characteristic.py index e2ca29480..bd6bb2eb6 100644 --- a/src/adcp/types/domains/core/audience_characteristic.py +++ b/src/adcp/types/domains/core/audience_characteristic.py @@ -1,21 +1,21 @@ # generated by datamodel-codegen: # filename: core/audience_characteristic.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, RootModel, StrictBool, StrictFloat, model_validator from pydantic_core import PydanticCustomError -class Dimension(RootModel[AnyUrl]): +class Dimension(RootModel[WireAnyUrl]): root: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical dimension name or HTTPS URI. AdCP 3.2 defines `age`; other values remain provider- or taxonomy-scoped.' ), @@ -47,7 +47,7 @@ class Taxonomy(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - system: AnyUrl + system: WireAnyUrl version: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None value_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/audience_evidence.py b/src/adcp/types/domains/core/audience_evidence.py index a6a263e09..f29545a1f 100644 --- a/src/adcp/types/domains/core/audience_evidence.py +++ b/src/adcp/types/domains/core/audience_evidence.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/audience_evidence.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, RootModel, StrictFloat from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type from . import audience_characteristic, brand_ref, date_range @@ -32,7 +32,7 @@ class Baseline(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - system: AnyUrl + system: WireAnyUrl population_id: Annotated[str, Field(min_length=1)] version: Annotated[str, Field(min_length=1)] description: Annotated[str | None, NoExplicitNull] = None @@ -50,7 +50,7 @@ class Subject12(AdCPBaseModel): ) type: Literal['agent'] = 'agent' agent_url: Annotated[ - AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') + WireAnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @@ -61,13 +61,13 @@ class Subject13(AdCPBaseModel): ) type: Literal['resource'] = 'resource' resource_type: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Open, absolute URI naming the subject vocabulary, such as https://adcontextprotocol.org/claims/subjects/signal. AdCP does not maintain an exhaustive enum.' ), ] namespace: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Absolute URI identifying the namespace in which id is unique. This may be an AdCP agent endpoint, a catalog origin, or a domain-specific namespace URI.' ), @@ -107,7 +107,7 @@ class Subject24(AdCPBaseModel): resource_type: Literal['https://adcontextprotocol.org/claims/subjects/audience-evidence'] = 'https://adcontextprotocol.org/claims/subjects/audience-evidence' content_digest: Annotated[str, Field(pattern='^sha256:[a-f0-9]{64}$')] agent_url: Annotated[ - AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') + WireAnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @@ -140,7 +140,7 @@ class Subject34(AdCPBaseModel): resource_type: Literal['https://adcontextprotocol.org/claims/subjects/audience-evidence'] = 'https://adcontextprotocol.org/claims/subjects/audience-evidence' content_digest: Annotated[str, Field(pattern='^sha256:[a-f0-9]{64}$')] namespace: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Absolute URI identifying the namespace in which id is unique. This may be an AdCP agent endpoint, a catalog origin, or a domain-specific namespace URI.' ), @@ -161,13 +161,13 @@ class Subject36(Subject32): extra='forbid', ) resource_type: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Open, absolute URI naming the subject vocabulary, such as https://adcontextprotocol.org/claims/subjects/signal. AdCP does not maintain an exhaustive enum.' ), ] namespace: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Absolute URI identifying the namespace in which id is unique. This may be an AdCP agent endpoint, a catalog origin, or a domain-specific namespace URI.' ), @@ -273,7 +273,7 @@ class Subject25(Subject21): extra='forbid', ) agent_url: Annotated[ - AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') + WireAnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] @@ -315,13 +315,13 @@ class Subject35(Subject31): extra='forbid', ) resource_type: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Open, absolute URI naming the subject vocabulary, such as https://adcontextprotocol.org/claims/subjects/signal. AdCP does not maintain an exhaustive enum.' ), ] namespace: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Absolute URI identifying the namespace in which id is unique. This may be an AdCP agent endpoint, a catalog origin, or a domain-specific namespace URI.' ), @@ -462,11 +462,11 @@ class AudienceEvidence(AdCPBaseModel): NoExplicitNull, ] = None last_updated: Annotated[ - AwareDatetime, + WireDatetime, Field(description='When the provider last produced or refreshed this immutable snapshot.'), ] methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Documentation for interpreting the methodology. It is disclosure, not executable routing.' ), diff --git a/src/adcp/types/domains/core/audience_evidence_requirements.py b/src/adcp/types/domains/core/audience_evidence_requirements.py index d06b227d8..f409c29f0 100644 --- a/src/adcp/types/domains/core/audience_evidence_requirements.py +++ b/src/adcp/types/domains/core/audience_evidence_requirements.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/audience_evidence_requirements.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type from . import attestation_issuer, brand_ref @@ -137,7 +137,7 @@ class AudienceEvidenceRequirements(AdCPBaseModel): NoExplicitNull, ] = None accepted_attestation_claim_types: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field( description='Optional acceptable claim types when attestation is used. Buyer-supplied claim types further constrain but never broaden seller policy.', min_length=1, diff --git a/src/adcp/types/domains/core/audience_source.py b/src/adcp/types/domains/core/audience_source.py index 7d709856c..1ff0c0ce3 100644 --- a/src/adcp/types/domains/core/audience_source.py +++ b/src/adcp/types/domains/core/audience_source.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/audience_source.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from . import brand_ref @@ -30,7 +30,7 @@ class AudienceSource1(AdCPBaseModel): ), ] access_expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 time after which the buyer will revoke the grant. Declarative — tells the seller when the pipe closes so re-read behavior is predictable. Expiry bounds the access window, not retention: revocation does not claw back matched membership, and retention remains governed by the buyer-seller data processing agreement.' ), diff --git a/src/adcp/types/domains/core/authorized_agent_base.py b/src/adcp/types/domains/core/authorized_agent_base.py index 91a2594bd..e3d480690 100644 --- a/src/adcp/types/domains/core/authorized_agent_base.py +++ b/src/adcp/types/domains/core/authorized_agent_base.py @@ -1,20 +1,20 @@ # generated by datamodel-codegen: # filename: core/authorized_agent_base.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import Field from . import agent_encryption_key, agent_signing_key class AuthorizedAgentBaseFields(AdCPBaseModel): url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="The authorized agent's protocol endpoint URL: the exact agent URL buyers are given and send AdCP requests to, including the path (e.g., `https://agent.example.com/mcp`). A bare origin matches only an agent served at the root. An agent reachable at more than one agent URL (for example, separate MCP and A2A endpoints) needs one entry per URL. Callers comparing this URL against a registry (sales-agent list, signal-provider registry, TMP provider lookup, etc.) MUST canonicalize both sides per the AdCP URL canonicalization rules, not byte-equality — two URLs that differ only in case, default port, or percent-encoding of unreserved characters are the same agent. See docs/reference/url-canonicalization." ), @@ -44,7 +44,7 @@ class AuthorizedAgentBaseFields(AdCPBaseModel): NoExplicitNull, ] = None last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Optional ISO 8601 timestamp indicating when this `authorized_agents[]` entry last changed. Independent of the file-level `last_updated`. Lets validators perform a partial walk by skipping entries whose `last_updated` is older than their indexed value. Advisory — consumers MAY ignore and re-index the full file.' ), diff --git a/src/adcp/types/domains/core/brand_ref.py b/src/adcp/types/domains/core/brand_ref.py index 7ef0dfc49..b23f6a3a6 100644 --- a/src/adcp/types/domains/core/brand_ref.py +++ b/src/adcp/types/domains/core/brand_ref.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/brand_ref.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireEmail from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, ConfigDict, EmailStr, Field +from pydantic import ConfigDict, Field from . import brand_id as brand_id_1 from .assets import image_asset @@ -23,8 +23,8 @@ class DataSubjectContestation(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - url: Annotated[AnyUrl | None, NoExplicitNull] = None - email: Annotated[EmailStr | None, NoExplicitNull] = None + url: Annotated[WireAnyUrl | None, NoExplicitNull] = None + email: Annotated[WireEmail | None, NoExplicitNull] = None languages: Annotated[list[str] | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/brand_response_authorization_result.py b/src/adcp/types/domains/core/brand_response_authorization_result.py index 21271220e..1ba98cb57 100644 --- a/src/adcp/types/domains/core/brand_response_authorization_result.py +++ b/src/adcp/types/domains/core/brand_response_authorization_result.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/brand_response_authorization_result.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class Trust(StrEnum): @@ -44,7 +44,7 @@ class BrandResponseAuthorizationResult1(AdCPBaseModel): ] = None kid: Annotated[str, Field(description='JWS kid evaluated by the cross-check.', min_length=1)] jwks_uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='JWKS URI selected only from the matched brand.json agent entry, or its same-origin default.' ), @@ -68,7 +68,7 @@ class BrandResponseAuthorizationResult2(AdCPBaseModel): NoExplicitNull, ] = None jwks_uri: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='JWKS URI selected only from the matched brand.json agent entry, or its same-origin default.' ), diff --git a/src/adcp/types/domains/core/business_entity.py b/src/adcp/types/domains/core/business_entity.py index c1cbf241d..195f58bef 100644 --- a/src/adcp/types/domains/core/business_entity.py +++ b/src/adcp/types/domains/core/business_entity.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/business_entity.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import ConfigDict, EmailStr, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireEmail +from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -47,7 +47,7 @@ class Contact(AdCPBaseModel): name: Annotated[ str | None, Field(description='Full name of the contact', max_length=200), NoExplicitNull ] = None - email: Annotated[EmailStr | None, Field(max_length=254), NoExplicitNull] = None + email: Annotated[WireEmail | None, Field(max_length=254), NoExplicitNull] = None phone: Annotated[str | None, Field(max_length=30), NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/canonical_audience_evidence.py b/src/adcp/types/domains/core/canonical_audience_evidence.py index a31e1f918..b84762798 100644 --- a/src/adcp/types/domains/core/canonical_audience_evidence.py +++ b/src/adcp/types/domains/core/canonical_audience_evidence.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/canonical_audience_evidence.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type from . import audience_characteristic, brand_key, date_range @@ -32,7 +32,7 @@ class Baseline(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - system: AnyUrl + system: WireAnyUrl population_id: Annotated[str, Field(min_length=1)] version: Annotated[str, Field(min_length=1)] description: Annotated[str | None, NoExplicitNull] = None @@ -72,8 +72,8 @@ class CanonicalAudienceEvidence(AdCPBaseModel): measurement_window: date_range.DateRange sample_size: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None confidence: Annotated[StrictFloat | None, Field(ge=0.0, le=1.0), NoExplicitNull] = None - last_updated: AwareDatetime - methodology_url: Annotated[AnyUrl | None, NoExplicitNull] = None + last_updated: WireDatetime + methodology_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None attestation_digests: Annotated[ list[AttestationDigest] | None, Field( diff --git a/src/adcp/types/domains/core/canonical_delivery_forecast.py b/src/adcp/types/domains/core/canonical_delivery_forecast.py index 03a2c45c9..6821f7b70 100644 --- a/src/adcp/types/domains/core/canonical_delivery_forecast.py +++ b/src/adcp/types/domains/core/canonical_delivery_forecast.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_delivery_forecast.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import demographic_system as demographic_system_1 from ..enums import forecast_method @@ -35,6 +35,6 @@ class CanonicalDeliveryForecast(AdCPBaseModel): str | None, Field(max_length=64, pattern='^[a-z0-9_]+$'), NoExplicitNull ] = None reach_unit: Annotated[reach_unit_1.ReachUnit | None, NoExplicitNull] = None - generated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None - valid_until: Annotated[AwareDatetime | None, NoExplicitNull] = None + generated_at: Annotated[WireDatetime | None, NoExplicitNull] = None + valid_until: Annotated[WireDatetime | None, NoExplicitNull] = None ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/canonical_format_option.py b/src/adcp/types/domains/core/canonical_format_option.py index 676f2b26b..e38833661 100644 --- a/src/adcp/types/domains/core/canonical_format_option.py +++ b/src/adcp/types/domains/core/canonical_format_option.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/canonical_format_option.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ..enums import channels from . import creative_locale_policy, macro_resolution_capability, platform_extension_ref @@ -71,7 +71,7 @@ class CanonicalFormatOption(AdCPBaseModel): NoExplicitNull, ] = None display_name: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None - sample_render_url: Annotated[AnyUrl | None, NoExplicitNull] = None + sample_render_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None applies_to_channels: Annotated[ list[channels.MediaChannel] | None, Field(min_length=1), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/core/canonical_product.py b/src/adcp/types/domains/core/canonical_product.py index b1797ccc7..412f27773 100644 --- a/src/adcp/types/domains/core/canonical_product.py +++ b/src/adcp/types/domains/core/canonical_product.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/canonical_product.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool from ..enums import audio_distribution_type, catalog_type from ..enums import channels as channels_1 @@ -315,7 +315,7 @@ class CanonicalProduct(AdCPBaseModel): NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Expiration of a request-specific configured offer. Canonical products have no is_custom flag; expires_at is what marks an offer as request-specific. After it, the buyer rediscovers.' ), diff --git a/src/adcp/types/domains/core/canonical_proposal.py b/src/adcp/types/domains/core/canonical_proposal.py index 198266239..a3dc145e6 100644 --- a/src/adcp/types/domains/core/canonical_proposal.py +++ b/src/adcp/types/domains/core/canonical_proposal.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/canonical_proposal.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..enums import proposal_status as proposal_status_1 from ..media_buy import commercial_terms as commercial_terms_1 @@ -65,9 +65,9 @@ class CanonicalProposal(AdCPBaseModel): description='draft is indicative and unreserved; committed has firm terms with inventory reserved until expires_at; accepted is the historical snapshot attached to a MediaBuy.' ), ] - accepted_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + accepted_at: Annotated[WireDatetime | None, NoExplicitNull] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='For a draft, the indicative-terms freshness deadline. For a committed proposal, the inventory-hold deadline.' ), diff --git a/src/adcp/types/domains/core/canonical_reporting_commitment.py b/src/adcp/types/domains/core/canonical_reporting_commitment.py index 04e0cb474..8b1ec7de4 100644 --- a/src/adcp/types/domains/core/canonical_reporting_commitment.py +++ b/src/adcp/types/domains/core/canonical_reporting_commitment.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/canonical_reporting_commitment.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import available_metric from . import brand_key, canonical_metric_qualifier, vendor_metric_id @@ -22,7 +22,7 @@ class CanonicalReportingCommitment1(AdCPBaseModel): qualifier: Annotated[ canonical_metric_qualifier.CanonicalMetricQualifier | None, NoExplicitNull ] = None - effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + effective_at: Annotated[WireDatetime | None, NoExplicitNull] = None class CanonicalReportingCommitment2(AdCPBaseModel): @@ -35,7 +35,7 @@ class CanonicalReportingCommitment2(AdCPBaseModel): qualifier: Annotated[ canonical_metric_qualifier.CanonicalMetricQualifier | None, NoExplicitNull ] = None - effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + effective_at: Annotated[WireDatetime | None, NoExplicitNull] = None CanonicalReportingCommitment = Annotated[ diff --git a/src/adcp/types/domains/core/capabilities_changed_webhook.py b/src/adcp/types/domains/core/capabilities_changed_webhook.py index fc16c9071..7fe5fd74e 100644 --- a/src/adcp/types/domains/core/capabilities_changed_webhook.py +++ b/src/adcp/types/domains/core/capabilities_changed_webhook.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/capabilities_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -58,7 +58,7 @@ class CapabilitiesChangedWebhook(AdCPBaseModel): ), ] = 'capabilities.changed' fired_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller initiated this fire. Distinct from `changed_at`, which is when the seller recorded the material capability change.' ), @@ -73,13 +73,13 @@ class CapabilitiesChangedWebhook(AdCPBaseModel): ), ] agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical seller agent URL whose capabilities changed. Receivers that subscribe to multiple agents use this to select the cache entry to invalidate.' ), ] changed_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller recorded the material capability change. SHOULD match or precede `adcp.capability_changes.last_modified` on the next `get_adcp_capabilities` response.' ), diff --git a/src/adcp/types/domains/core/catalog.py b/src/adcp/types/domains/core/catalog.py index 9b6d401d7..cca311cf3 100644 --- a/src/adcp/types/domains/core/catalog.py +++ b/src/adcp/types/domains/core/catalog.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/catalog.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, ConfigDict, Field +from pydantic import ConfigDict, Field from ..enums import catalog_type from ..enums import content_id_type as content_id_type_1 @@ -48,7 +48,7 @@ class Catalog(AdCPBaseModel): ), ] url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL to an external catalog feed. The platform fetches and resolves items from this URL. For offering-type catalogs, the feed contains an array of Offering objects. For other types, the feed format is determined by feed_format. When omitted with type 'product', the platform uses its synced copy of the brand's product catalog." ), diff --git a/src/adcp/types/domains/core/catalog_item_availability_state.py b/src/adcp/types/domains/core/catalog_item_availability_state.py index 52e3d05b7..f6a5e5665 100644 --- a/src/adcp/types/domains/core/catalog_item_availability_state.py +++ b/src/adcp/types/domains/core/catalog_item_availability_state.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/catalog_item_availability_state.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from . import catalog_item_availability_error from . import ext as ext_1 @@ -60,14 +60,14 @@ class CatalogItemAvailabilityState(AdCPBaseModel): NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Current automatic expiry, present only while availability is suppressed with an expiry.' ), NoExplicitNull, ] = None updated_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Seller timestamp of the state represented by overlay_revision.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/catalog_item_availability_update.py b/src/adcp/types/domains/core/catalog_item_availability_update.py index afa7f41f1..8149feb07 100644 --- a/src/adcp/types/domains/core/catalog_item_availability_update.py +++ b/src/adcp/types/domains/core/catalog_item_availability_update.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/catalog_item_availability_update.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -88,7 +88,7 @@ class CatalogItemAvailabilityUpdate(AdCPBaseModel): NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Optional expiry for a suppress overlay. At this instant the seller automatically removes the buyer-authored suppression as if it received restore. Valid only with action suppress; omit for an indefinite suppression. Sellers MUST reject a timestamp that is not in the future when the request is processed. On a repeated suppress, this value is complete replacement state: a changed timestamp replaces the prior expiry, and omission clears a prior expiry to make suppression indefinite. Either change returns status applied, not unchanged.' ), diff --git a/src/adcp/types/domains/core/catalog_item_availability_update_result.py b/src/adcp/types/domains/core/catalog_item_availability_update_result.py index 38a5821dc..34fc98422 100644 --- a/src/adcp/types/domains/core/catalog_item_availability_update_result.py +++ b/src/adcp/types/domains/core/catalog_item_availability_update_result.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/catalog_item_availability_update_result.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from . import catalog_item_availability_error from . import ext as ext_1 @@ -67,14 +67,14 @@ class CatalogItemAvailabilityUpdateResult(AdCPBaseModel): NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Persisted expiry after this update, present only for a suppressed state with an expiry.' ), NoExplicitNull, ] = None applied_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Seller timestamp when the buyer-availability state took effect. Required for applied. Optional for unchanged when the seller knows the timestamp of the already-persisted state.' ), diff --git a/src/adcp/types/domains/core/collection_list_ref.py b/src/adcp/types/domains/core/collection_list_ref.py index 876e2220c..88a50c5dc 100644 --- a/src/adcp/types/domains/core/collection_list_ref.py +++ b/src/adcp/types/domains/core/collection_list_ref.py @@ -1,20 +1,20 @@ # generated by datamodel-codegen: # filename: core/collection_list_ref.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class CollectionListReference(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - agent_url: Annotated[AnyUrl, Field(description='URL of the agent managing the collection list')] + agent_url: Annotated[WireAnyUrl, Field(description='URL of the agent managing the collection list')] list_id: Annotated[ str, Field(description='Identifier for the collection list within the agent', min_length=1) ] diff --git a/src/adcp/types/domains/core/committed_metric.py b/src/adcp/types/domains/core/committed_metric.py index dfea203f0..82fdbc51e 100644 --- a/src/adcp/types/domains/core/committed_metric.py +++ b/src/adcp/types/domains/core/committed_metric.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/committed_metric.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import attribution_methodology as attribution_methodology_1 from ..enums import available_metric @@ -110,7 +110,7 @@ class CommittedMetric1(AdCPBaseModel): NoExplicitNull, ] = None committed_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when this metric became part of the contract. Day-1 commitments use `create_media_buy.confirmed_at`; mid-flight additions use the time the amendment was accepted.' ), @@ -154,7 +154,7 @@ class CommittedMetric2(AdCPBaseModel): NoExplicitNull, ] = None committed_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when this vendor metric became part of the contract.' ), diff --git a/src/adcp/types/domains/core/creative_filters.py b/src/adcp/types/domains/core/creative_filters.py index 47fe442bf..4836f0339 100644 --- a/src/adcp/types/domains/core/creative_filters.py +++ b/src/adcp/types/domains/core/creative_filters.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_filters.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..enums import asset_content_type, creative_status, indicator_type from . import account_ref @@ -53,22 +53,22 @@ class CreativeFilters(AdCPBaseModel): NoExplicitNull, ] = None created_after: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter creatives created after this date (ISO 8601)'), NoExplicitNull, ] = None created_before: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter creatives created before this date (ISO 8601)'), NoExplicitNull, ] = None updated_after: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter creatives last updated after this date (ISO 8601)'), NoExplicitNull, ] = None updated_before: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter creatives last updated before this date (ISO 8601)'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/creative_item.py b/src/adcp/types/domains/core/creative_item.py index 8d7210e69..7d86caeb5 100644 --- a/src/adcp/types/domains/core/creative_item.py +++ b/src/adcp/types/domains/core/creative_item.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_item.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, WireAnyUrl +from pydantic import ConfigDict, Field class CreativeItem1(AdCPBaseModel): @@ -27,7 +27,7 @@ class CreativeItem1(AdCPBaseModel): asset_id: Annotated[ str, Field(description='Unique identifier for the asset within the creative') ] - content_uri: Annotated[AnyUrl, Field(description='URL for media assets (images, videos, etc.)')] + content_uri: Annotated[WireAnyUrl, Field(description='URL for media assets (images, videos, etc.)')] class CreativeItem2(AdCPBaseModel): diff --git a/src/adcp/types/domains/core/creative_operation_format_declaration.py b/src/adcp/types/domains/core/creative_operation_format_declaration.py index 7b685a320..3ffa43c7e 100644 --- a/src/adcp/types/domains/core/creative_operation_format_declaration.py +++ b/src/adcp/types/domains/core/creative_operation_format_declaration.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/creative_operation_format_declaration.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import Field, StrictBool from ..enums import channels from . import creative_locale_policy, format_id, macro_resolution_capability, platform_extension_ref @@ -46,7 +46,7 @@ class CreativeOperationFormatDeclaration(AdCPBaseModel): ] = None technical_requirements_complete: Annotated[StrictBool | None, NoExplicitNull] = None display_name: Annotated[str | None, NoExplicitNull] = None - sample_render_url: Annotated[AnyUrl | None, NoExplicitNull] = None + sample_render_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None applies_to_channels: Annotated[list[channels.MediaChannel] | None, NoExplicitNull] = None seller_preference: Annotated[SellerPreference | None, NoExplicitNull] = None locale_policy: Annotated[creative_locale_policy.CreativeLocalePolicy | None, NoExplicitNull] = ( diff --git a/src/adcp/types/domains/core/creative_policy.py b/src/adcp/types/domains/core/creative_policy.py index 70c2593ac..398fa03a6 100644 --- a/src/adcp/types/domains/core/creative_policy.py +++ b/src/adcp/types/domains/core/creative_policy.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/creative_policy.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ..enums import co_branding_requirement, landing_page_requirement @@ -51,7 +51,7 @@ class AcceptedVerifier(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the governance agent. MUST use the `https://` scheme. The seller calls this URL via `get_creative_features` to verify a buyer's claim; the seller has already vetted the endpoint and accepts responsibility for outbound calls to it." ), diff --git a/src/adcp/types/domains/core/datetime_range.py b/src/adcp/types/domains/core/datetime_range.py index cca442b54..85f86db8b 100644 --- a/src/adcp/types/domains/core/datetime_range.py +++ b/src/adcp/types/domains/core/datetime_range.py @@ -1,18 +1,18 @@ # generated by datamodel-codegen: # filename: core/datetime_range.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, WireDatetime +from pydantic import ConfigDict, Field class DatetimeRange(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - start: Annotated[AwareDatetime, Field(description='Start timestamp (inclusive), ISO 8601')] - end: Annotated[AwareDatetime, Field(description='End timestamp (inclusive), ISO 8601')] + start: Annotated[WireDatetime, Field(description='Start timestamp (inclusive), ISO 8601')] + end: Annotated[WireDatetime, Field(description='End timestamp (inclusive), ISO 8601')] diff --git a/src/adcp/types/domains/core/delivery_forecast.py b/src/adcp/types/domains/core/delivery_forecast.py index a5f6eb90c..e20b09b84 100644 --- a/src/adcp/types/domains/core/delivery_forecast.py +++ b/src/adcp/types/domains/core/delivery_forecast.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/delivery_forecast.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import demographic_system as demographic_system_1 from ..enums import forecast_method @@ -90,10 +90,10 @@ class DeliveryForecast(AdCPBaseModel): NoExplicitNull, ] = None generated_at: Annotated[ - AwareDatetime | None, Field(description='When this forecast was computed'), NoExplicitNull + WireDatetime | None, Field(description='When this forecast was computed'), NoExplicitNull ] = None valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this forecast expires. After this time, the forecast should be refreshed. Forecast expiry does not affect proposal executability.' ), diff --git a/src/adcp/types/domains/core/deployment.py b/src/adcp/types/domains/core/deployment.py index 602f26c6d..1a901e0ed 100644 --- a/src/adcp/types/domains/core/deployment.py +++ b/src/adcp/types/domains/core/deployment.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/deployment.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from . import activation_key as activation_key_1 @@ -43,7 +43,7 @@ class Deployment1(AdCPBaseModel): NoExplicitNull, ] = None deployed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Timestamp when activation completed (if is_live=true)'), NoExplicitNull, ] = None @@ -57,7 +57,7 @@ class Deployment2(AdCPBaseModel): Literal['agent'], Field(description='Discriminator indicating this is an agent URL-based deployment'), ] = 'agent' - agent_url: Annotated[AnyUrl, Field(description='URL identifying the deployment agent')] + agent_url: Annotated[WireAnyUrl, Field(description='URL identifying the deployment agent')] account: Annotated[ str | None, Field(description='Account identifier if applicable'), NoExplicitNull ] = None @@ -80,7 +80,7 @@ class Deployment2(AdCPBaseModel): NoExplicitNull, ] = None deployed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Timestamp when activation completed (if is_live=true)'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/destination.py b/src/adcp/types/domains/core/destination.py index cfe638cf1..9bbb31681 100644 --- a/src/adcp/types/domains/core/destination.py +++ b/src/adcp/types/domains/core/destination.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/destination.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class Destination1(AdCPBaseModel): @@ -36,7 +36,7 @@ class Destination2(AdCPBaseModel): Field(description='Discriminator indicating this is an agent URL-based deployment'), ] = 'agent' agent_url: Annotated[ - AnyUrl, Field(description='URL identifying the deployment agent (for sales agents, etc.)') + WireAnyUrl, Field(description='URL identifying the deployment agent (for sales agents, etc.)') ] account: Annotated[ str | None, Field(description='Optional account identifier on the agent'), NoExplicitNull diff --git a/src/adcp/types/domains/core/destination_item.py b/src/adcp/types/domains/core/destination_item.py index 02919603a..65cb78780 100644 --- a/src/adcp/types/domains/core/destination_item.py +++ b/src/adcp/types/domains/core/destination_item.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/destination_item.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 from . import offering_asset_group @@ -76,10 +76,10 @@ class DestinationItem(AdCPBaseModel): NoExplicitNull, ] = None image_url: Annotated[ - AnyUrl | None, Field(description='Destination hero image URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Destination hero image URL.'), NoExplicitNull ] = None url: Annotated[ - AnyUrl | None, Field(description='Destination landing page or booking URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Destination landing page or booking URL.'), NoExplicitNull ] = None rating: Annotated[ StrictFloat | None, diff --git a/src/adcp/types/domains/core/downstream_connection_requirement.py b/src/adcp/types/domains/core/downstream_connection_requirement.py index c3b37a119..1f3e561ae 100644 --- a/src/adcp/types/domains/core/downstream_connection_requirement.py +++ b/src/adcp/types/domains/core/downstream_connection_requirement.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/downstream_connection_requirement.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field class ConnectionType(StrEnum): @@ -67,7 +67,7 @@ class ResourceRef(AdCPBaseModel): NoExplicitNull, ] = None profile_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Public URL for the owning identity, when available.'), NoExplicitNull, ] = None @@ -79,7 +79,7 @@ class ResourceRef(AdCPBaseModel): NoExplicitNull, ] = None post_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Public URL for the referenced post, when available.'), NoExplicitNull, ] = None @@ -134,7 +134,7 @@ class DownstreamConnectionRequirement(AdCPBaseModel): NoExplicitNull, ] = None authorization_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Seller-hosted or provider-hosted URL where a human can complete or restore this downstream connection.' ), @@ -148,7 +148,7 @@ class DownstreamConnectionRequirement(AdCPBaseModel): NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Expiration time for the downstream grant, when known.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/education_item.py b/src/adcp/types/domains/core/education_item.py index dcabeba08..d06e26424 100644 --- a/src/adcp/types/domains/core/education_item.py +++ b/src/adcp/types/domains/core/education_item.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/education_item.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from . import ext as ext_1 from . import offering_asset_group @@ -97,10 +97,10 @@ class EducationItem(AdCPBaseModel): NoExplicitNull, ] = None image_url: Annotated[ - AnyUrl | None, Field(description='Program or institution image URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Program or institution image URL.'), NoExplicitNull ] = None url: Annotated[ - AnyUrl | None, Field(description='Program landing page or enrollment URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Program landing page or enrollment URL.'), NoExplicitNull ] = None tags: Annotated[ list[str] | None, diff --git a/src/adcp/types/domains/core/evaluator_spec.py b/src/adcp/types/domains/core/evaluator_spec.py index e99bc95af..0fe49ec6e 100644 --- a/src/adcp/types/domains/core/evaluator_spec.py +++ b/src/adcp/types/domains/core/evaluator_spec.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/evaluator_spec.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from ..content_standards import artifact from . import ext as ext_1 @@ -44,7 +44,7 @@ class FeatureAgent(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the get_creative_features-capable agent the producing agent calls to obtain creative-feature values for the gate. MUST use https:// and MUST match an entry in the seller's `creative_policy.accepted_verifiers[].agent_url`; off-list → `EVALUATOR_AGENT_NOT_ACCEPTED`." ), @@ -161,7 +161,7 @@ class EvaluatorSpec3(AdCPBaseModel): ] = None ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of an external get_creative_features-capable judge agent the seller calls to score the produced leaves. MUST match an entry in the seller's `creative_policy.accepted_verifiers[].agent_url` (off-list → `EVALUATOR_AGENT_NOT_ACCEPTED`); an on-list agent that is unreachable or rejects the producing agent's transport authentication degrades to seller-default ranking (advisory errors[] note), not a failure. Authentication and trust material for this call belongs on the transport or in account provisioning, not in the evaluator payload." ), diff --git a/src/adcp/types/domains/core/event.py b/src/adcp/types/domains/core/event.py index d0350947d..c0f816661 100644 --- a/src/adcp/types/domains/core/event.py +++ b/src/adcp/types/domains/core/event.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/event.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from ..enums import action_source as action_source_1 from ..enums import event_type as event_type_1 @@ -30,7 +30,7 @@ class Event(AdCPBaseModel): ] event_type: Annotated[event_type_1.EventType, Field(description='Standard event type')] event_time: Annotated[ - AwareDatetime, Field(description='ISO 8601 timestamp when the event occurred') + WireDatetime, Field(description='ISO 8601 timestamp when the event occurred') ] user_match: Annotated[ user_match_1.UserMatch | None, @@ -55,7 +55,7 @@ class Event(AdCPBaseModel): NoExplicitNull, ] = None event_source_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL where the event occurred (required when action_source is 'website')" ), diff --git a/src/adcp/types/domains/core/event_source_health.py b/src/adcp/types/domains/core/event_source_health.py index d8e91c2c8..8209b89cd 100644 --- a/src/adcp/types/domains/core/event_source_health.py +++ b/src/adcp/types/domains/core/event_source_health.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/event_source_health.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..enums import assessment_status from . import diagnostic_issue @@ -63,14 +63,14 @@ class EventSourceHealth(AdCPBaseModel): NoExplicitNull, ] = None last_event_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp of the most recent event received from this source. Absent when no events have been received.' ), NoExplicitNull, ] = None evaluated_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp of when this health assessment was computed. When health is derived from reporting data, this may lag real-time. Buyer agents can use this to decide whether to trust stale assessments or re-request.' ), diff --git a/src/adcp/types/domains/core/flight_item.py b/src/adcp/types/domains/core/flight_item.py index 4f065dd4c..fd7134733 100644 --- a/src/adcp/types/domains/core/flight_item.py +++ b/src/adcp/types/domains/core/flight_item.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/flight_item.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from . import ext as ext_1 from . import offering_asset_group @@ -54,20 +54,20 @@ class FlightItem(AdCPBaseModel): str | None, Field(description='Route description or promotional text.'), NoExplicitNull ] = None departure_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Departure date and time (ISO 8601).'), NoExplicitNull, ] = None arrival_time: Annotated[ - AwareDatetime | None, Field(description='Arrival date and time (ISO 8601).'), NoExplicitNull + WireDatetime | None, Field(description='Arrival date and time (ISO 8601).'), NoExplicitNull ] = None image_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Promotional image URL (typically a destination photo).'), NoExplicitNull, ] = None url: Annotated[ - AnyUrl | None, Field(description='Booking page URL for this route.'), NoExplicitNull + WireAnyUrl | None, Field(description='Booking page URL for this route.'), NoExplicitNull ] = None tags: Annotated[ list[str] | None, diff --git a/src/adcp/types/domains/core/forecast_dimension_time.py b/src/adcp/types/domains/core/forecast_dimension_time.py index ef4f3838b..dd1e52f9d 100644 --- a/src/adcp/types/domains/core/forecast_dimension_time.py +++ b/src/adcp/types/domains/core/forecast_dimension_time.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/forecast_dimension_time.json -# timestamp: 2026-08-21T04:35:18+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, WireDatetime +from pydantic import ConfigDict, Field class TimeForecastDimension(AdCPBaseModel): @@ -16,11 +16,11 @@ class TimeForecastDimension(AdCPBaseModel): ) kind: Annotated[Literal['time'], Field(description='Dimension family discriminator.')] = 'time' start_time: Annotated[ - AwareDatetime, + WireDatetime, Field(description='Inclusive window start (RFC 3339 date-time with timezone offset).'), ] end_time: Annotated[ - AwareDatetime, + WireDatetime, Field( description='Exclusive window end (RFC 3339 date-time with timezone offset). MUST be after start_time.' ), diff --git a/src/adcp/types/domains/core/format.py b/src/adcp/types/domains/core/format.py index 56fa0cba1..ee098b1b3 100644 --- a/src/adcp/types/domains/core/format.py +++ b/src/adcp/types/domains/core/format.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/format.json -# timestamp: 2026-10-22T12:24:23+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, RootModel, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, RootModel, StrictBool, StrictFloat from ..enums import ( available_metric, @@ -780,7 +780,7 @@ class CanonicalParameters1(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -893,7 +893,7 @@ class CanonicalParameters2(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1006,7 +1006,7 @@ class CanonicalParameters3(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1119,7 +1119,7 @@ class CanonicalParameters4(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1232,7 +1232,7 @@ class CanonicalParameters5(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1345,7 +1345,7 @@ class CanonicalParameters6(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1458,7 +1458,7 @@ class CanonicalParameters7(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1571,7 +1571,7 @@ class CanonicalParameters8(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1684,7 +1684,7 @@ class CanonicalParameters9(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1797,7 +1797,7 @@ class CanonicalParameters10(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1910,7 +1910,7 @@ class CanonicalParameters11(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -2023,7 +2023,7 @@ class CanonicalParameters12(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -2136,7 +2136,7 @@ class CanonicalParameters13(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -2249,7 +2249,7 @@ class CanonicalParameters14(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -2362,7 +2362,7 @@ class CanonicalParameters15(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -2475,7 +2475,7 @@ class CanonicalParameters16(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -2780,7 +2780,7 @@ class Format(AdCPBaseModel): NoExplicitNull, ] = None example_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional URL to showcase page with examples and interactive demos of this format' ), diff --git a/src/adcp/types/domains/core/generation_credential.py b/src/adcp/types/domains/core/generation_credential.py index ec992dabd..8a0829077 100644 --- a/src/adcp/types/domains/core/generation_credential.py +++ b/src/adcp/types/domains/core/generation_credential.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/generation_credential.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from ..enums import right_use from . import ext as ext_1 @@ -34,14 +34,14 @@ class GenerationCredential(AdCPBaseModel): Field(description='Which rights uses this credential covers', min_length=1), ] expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this credential expires. Key lifetime is determined by the provider.' ), NoExplicitNull, ] = None endpoint: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="Provider API endpoint to use with this credential, if different from the provider's default" ), diff --git a/src/adcp/types/domains/core/geo_place_catalog_capability.py b/src/adcp/types/domains/core/geo_place_catalog_capability.py index 65077d279..a2e67185a 100644 --- a/src/adcp/types/domains/core/geo_place_catalog_capability.py +++ b/src/adcp/types/domains/core/geo_place_catalog_capability.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/geo_place_catalog_capability.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, ConfigDict, Field +from pydantic import ConfigDict, Field from . import geo_place_resolver @@ -23,7 +23,7 @@ class GeographicPlaceCatalogCapability(AdCPBaseModel): extra='forbid', ) source: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional catalog dataset or derivative-source identifier. This records provenance/coverage and does not change the identifier namespace in system.' ), diff --git a/src/adcp/types/domains/core/geo_place_catalog_entry.py b/src/adcp/types/domains/core/geo_place_catalog_entry.py index 0976189b1..9f8a67d6a 100644 --- a/src/adcp/types/domains/core/geo_place_catalog_entry.py +++ b/src/adcp/types/domains/core/geo_place_catalog_entry.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/geo_place_catalog_entry.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from . import ext as ext_1 from . import geo_place_type @@ -81,7 +81,7 @@ class GeographicPlaceCatalogEntry(AdCPBaseModel): NoExplicitNull, ] = None valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Known time after which the identifier must no longer be accepted for new targeting.' ), diff --git a/src/adcp/types/domains/core/geo_place_resolver.py b/src/adcp/types/domains/core/geo_place_resolver.py index 743e54b41..d2368e7c2 100644 --- a/src/adcp/types/domains/core/geo_place_resolver.py +++ b/src/adcp/types/domains/core/geo_place_resolver.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/geo_place_resolver.json -# timestamp: 2026-08-17T23:02:13+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, WireAnyUrl +from pydantic import ConfigDict, Field class Auth(StrEnum): @@ -21,7 +21,7 @@ class GeographicPlaceResolver(AdCPBaseModel): extra='forbid', ) url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='HTTPS endpoint accepting the standard geo-place resolution query parameters.' ), diff --git a/src/adcp/types/domains/core/geo_place_system.py b/src/adcp/types/domains/core/geo_place_system.py index 5fba727fb..cdba4ac00 100644 --- a/src/adcp/types/domains/core/geo_place_system.py +++ b/src/adcp/types/domains/core/geo_place_system.py @@ -1,13 +1,14 @@ # generated by datamodel-codegen: # filename: core/geo_place_system.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from pydantic import AnyUrl, Field, RootModel +from adcp.types.base import WireAnyUrl +from pydantic import Field, RootModel class GeographicPlaceIdentifierSystem1(StrEnum): @@ -16,9 +17,9 @@ class GeographicPlaceIdentifierSystem1(StrEnum): microsoft_ads = 'microsoft_ads' -class GeographicPlaceIdentifierSystem2(RootModel[AnyUrl]): +class GeographicPlaceIdentifierSystem2(RootModel[WireAnyUrl]): root: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Collision-safe identifier namespace for geographic places. Registered tokens have protocol-defined semantics. Unregistered systems MUST use an absolute HTTPS URI controlled by the catalog owner; consumers compare URI systems as exact opaque strings.', examples=[ diff --git a/src/adcp/types/domains/core/geo_place_type.py b/src/adcp/types/domains/core/geo_place_type.py index 3bc1f9929..550fc18a6 100644 --- a/src/adcp/types/domains/core/geo_place_type.py +++ b/src/adcp/types/domains/core/geo_place_type.py @@ -1,13 +1,14 @@ # generated by datamodel-codegen: # filename: core/geo_place_type.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from pydantic import AnyUrl, Field, RootModel +from adcp.types.base import WireAnyUrl +from pydantic import Field, RootModel class GeographicPlaceType1(StrEnum): @@ -29,9 +30,9 @@ class GeographicPlaceType1(StrEnum): ward = 'ward' -class GeographicPlaceType2(RootModel[AnyUrl]): +class GeographicPlaceType2(RootModel[WireAnyUrl]): root: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical place classification. Registered tokens have protocol-defined meanings. Catalog-specific classifications without a registered mapping MUST use an absolute HTTPS URI controlled by the vocabulary owner; consumers compare URI types as exact opaque strings.', examples=[ diff --git a/src/adcp/types/domains/core/hotel_item.py b/src/adcp/types/domains/core/hotel_item.py index 431733ca9..3d5de9999 100644 --- a/src/adcp/types/domains/core/hotel_item.py +++ b/src/adcp/types/domains/core/hotel_item.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/hotel_item.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from datetime import date from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 from . import offering_asset_group @@ -83,10 +83,10 @@ class HotelItem(AdCPBaseModel): NoExplicitNull, ] = None image_url: Annotated[ - AnyUrl | None, Field(description='Primary property image URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Primary property image URL.'), NoExplicitNull ] = None url: Annotated[ - AnyUrl | None, Field(description='Property landing page or booking URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Property landing page or booking URL.'), NoExplicitNull ] = None phone: Annotated[ str | None, Field(description='Property phone number in E.164 format.'), NoExplicitNull diff --git a/src/adcp/types/domains/core/impairment.py b/src/adcp/types/domains/core/impairment.py index fd4ca21be..1e3897a5a 100644 --- a/src/adcp/types/domains/core/impairment.py +++ b/src/adcp/types/domains/core/impairment.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/impairment.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import impairment_offline_state, impairment_reason_code @@ -90,7 +90,7 @@ class Impairment(AdCPBaseModel): NoExplicitNull, ] = None observed_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller observed the resource transition to its offline state.' ), diff --git a/src/adcp/types/domains/core/indicator.py b/src/adcp/types/domains/core/indicator.py index e292654f2..48ee7c7e2 100644 --- a/src/adcp/types/domains/core/indicator.py +++ b/src/adcp/types/domains/core/indicator.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/indicator.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import indicator_type from . import ext as ext_1 @@ -20,7 +20,7 @@ class Indicator(AdCPBaseModel): ) type: indicator_type.IndicatorType detected_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), diff --git a/src/adcp/types/domains/core/indicator_bearing.py b/src/adcp/types/domains/core/indicator_bearing.py index 0ac4b8057..0ac59f931 100644 --- a/src/adcp/types/domains/core/indicator_bearing.py +++ b/src/adcp/types/domains/core/indicator_bearing.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/indicator_bearing.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import indicator_type from . import indicator, indicator_scope @@ -33,7 +33,7 @@ class IndicatorBearingResourceState(AdCPBaseModel): NoExplicitNull, ] = None indicators_as_of: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller completed this evaluation. Required whenever indicators is present, including an empty array. State changes require a strictly newer timestamp; equal-timestamp conflicts are invalid and buyers retain stored state.' ), diff --git a/src/adcp/types/domains/core/indicators_changed_webhook.py b/src/adcp/types/domains/core/indicators_changed_webhook.py index c66092ae9..946ec8c5d 100644 --- a/src/adcp/types/domains/core/indicators_changed_webhook.py +++ b/src/adcp/types/domains/core/indicators_changed_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/indicators_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import indicator_type from . import ext as ext_1 @@ -52,7 +52,7 @@ class IndicatorsChangedWebhook(AdCPBaseModel): ), ] notification_type: Literal['indicators.changed'] = 'indicators.changed' - fired_at: AwareDatetime + fired_at: WireDatetime subscriber_id: Annotated[ str, Field(max_length=64, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$') ] @@ -77,6 +77,6 @@ class IndicatorsChangedWebhook(AdCPBaseModel): ), ] observed_at: Annotated[ - AwareDatetime, Field(description='When the seller observed the semantic snapshot change.') + WireDatetime, Field(description='When the seller observed the semantic snapshot change.') ] ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/insertion_order.py b/src/adcp/types/domains/core/insertion_order.py index 452405186..f1cc8dd41 100644 --- a/src/adcp/types/domains/core/insertion_order.py +++ b/src/adcp/types/domains/core/insertion_order.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/insertion_order.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat class TotalBudget(AdCPBaseModel): @@ -44,10 +44,10 @@ class Terms(AdCPBaseModel): TotalBudget | None, Field(description='Total committed budget'), NoExplicitNull ] = None flight_start: Annotated[ - AwareDatetime | None, Field(description='Campaign start date'), NoExplicitNull + WireDatetime | None, Field(description='Campaign start date'), NoExplicitNull ] = None flight_end: Annotated[ - AwareDatetime | None, Field(description='Campaign end date'), NoExplicitNull + WireDatetime | None, Field(description='Campaign end date'), NoExplicitNull ] = None payment_terms: Annotated[ PaymentTerms | None, Field(description='Payment terms'), NoExplicitNull @@ -73,14 +73,14 @@ class InsertionOrder(AdCPBaseModel): NoExplicitNull, ] = None terms_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to a human-readable document containing the full insertion order terms' ), NoExplicitNull, ] = None signing_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to an electronic signing service (e.g., DocuSign) for human signature workflows. When present, a human must sign before the buyer agent can proceed with create_media_buy.' ), diff --git a/src/adcp/types/domains/core/installment.py b/src/adcp/types/domains/core/installment.py index 86ac0dfec..6deedc314 100644 --- a/src/adcp/types/domains/core/installment.py +++ b/src/adcp/types/domains/core/installment.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/installment.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..enums import derivative_type, installment_status from . import ad_inventory_config @@ -65,7 +65,7 @@ class Installment(AdCPBaseModel): NoExplicitNull, ] = None scheduled_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the installment airs or publishes (ISO 8601)'), NoExplicitNull, ] = None @@ -85,7 +85,7 @@ class Installment(AdCPBaseModel): NoExplicitNull, ] = None valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this installment data expires and should be re-queried. Agents should re-query before committing budget to products with tentative installments.' ), diff --git a/src/adcp/types/domains/core/installment_deadlines.py b/src/adcp/types/domains/core/installment_deadlines.py index 6d9b4dbed..d4cc7e8f4 100644 --- a/src/adcp/types/domains/core/installment_deadlines.py +++ b/src/adcp/types/domains/core/installment_deadlines.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/installment_deadlines.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from . import material_deadline @@ -17,14 +17,14 @@ class InstallmentDeadlines(AdCPBaseModel): extra='allow', ) booking_deadline: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Last date/time to book a placement in this installment (ISO 8601). After this point, the seller will not accept new bookings.' ), NoExplicitNull, ] = None cancellation_deadline: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="Last date/time to cancel without penalty (ISO 8601). Cancellations after this point may incur fees per the seller's terms." ), diff --git a/src/adcp/types/domains/core/installment_delivery_metrics.py b/src/adcp/types/domains/core/installment_delivery_metrics.py index 26ae391b1..bd7200fcd 100644 --- a/src/adcp/types/domains/core/installment_delivery_metrics.py +++ b/src/adcp/types/domains/core/installment_delivery_metrics.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/installment_delivery_metrics.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import NoExplicitNull -from pydantic import AwareDatetime, Field +from adcp.types.base import NoExplicitNull, WireDatetime +from pydantic import Field from . import installment_ref as installment_ref_1 from .delivery_metrics import DeliveryMetrics @@ -23,7 +23,7 @@ class InstallmentDeliveryMetrics(DeliveryMetrics): NoExplicitNull, ] = None scheduled_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="Convenience echo of the installment's scheduled publication or air time." ), diff --git a/src/adcp/types/domains/core/installment_property_delivery_metrics.py b/src/adcp/types/domains/core/installment_property_delivery_metrics.py index 5819c2ff6..63c2ca0d4 100644 --- a/src/adcp/types/domains/core/installment_property_delivery_metrics.py +++ b/src/adcp/types/domains/core/installment_property_delivery_metrics.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/installment_property_delivery_metrics.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import NoExplicitNull -from pydantic import AwareDatetime, Field +from adcp.types.base import NoExplicitNull, WireDatetime +from pydantic import Field from . import identifier as identifier_1 from . import installment_ref as installment_ref_1 @@ -23,7 +23,7 @@ class InstallmentPropertyDeliveryMetrics(DeliveryMetrics): NoExplicitNull, ] = None scheduled_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="Convenience echo of the installment's scheduled publication or air time." ), diff --git a/src/adcp/types/domains/core/inventory_list_application.py b/src/adcp/types/domains/core/inventory_list_application.py index 0224d0156..91b35970b 100644 --- a/src/adcp/types/domains/core/inventory_list_application.py +++ b/src/adcp/types/domains/core/inventory_list_application.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/inventory_list_application.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field class Effect(StrEnum): @@ -46,7 +46,7 @@ class InventoryListApplication1(AdCPBaseModel): ), ] agent_url: Annotated[ - AnyUrl, Field(description='Agent URL from the effective property-list targeting reference.') + WireAnyUrl, Field(description='Agent URL from the effective property-list targeting reference.') ] list_id: Annotated[ str, @@ -56,13 +56,13 @@ class InventoryListApplication1(AdCPBaseModel): ), ] resolved_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='Timestamp identifying the complete resolved-list snapshot the seller evaluated. If the list response supplied resolved_at, the seller MUST copy it; otherwise the seller records when it completed assembling the snapshot, including all fetched pages.' ), ] evaluated_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description="When the seller intersected that snapshot with this product's then-current inventory." ), @@ -108,7 +108,7 @@ class InventoryListApplication2(AdCPBaseModel): ), ] agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field(description='Agent URL from the effective collection-list targeting reference.'), ] list_id: Annotated[ @@ -119,13 +119,13 @@ class InventoryListApplication2(AdCPBaseModel): ), ] resolved_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='Timestamp identifying the complete resolved-list snapshot the seller evaluated. If the list response supplied resolved_at, the seller MUST copy it; otherwise the seller records when it completed assembling the snapshot, including all fetched pages.' ), ] evaluated_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description="When the seller intersected that snapshot with this product's then-current inventory." ), diff --git a/src/adcp/types/domains/core/job_item.py b/src/adcp/types/domains/core/job_item.py index 6833d3895..bd4d75b0c 100644 --- a/src/adcp/types/domains/core/job_item.py +++ b/src/adcp/types/domains/core/job_item.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/job_item.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 from . import offering_asset_group @@ -90,7 +90,7 @@ class JobItem(AdCPBaseModel): date | None, Field(description='Application deadline (ISO 8601 date).'), NoExplicitNull ] = None apply_url: Annotated[ - AnyUrl | None, Field(description='Direct application URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Direct application URL.'), NoExplicitNull ] = None job_functions: Annotated[ list[str] | None, diff --git a/src/adcp/types/domains/core/limited_series.py b/src/adcp/types/domains/core/limited_series.py index d8b12715e..8238bf595 100644 --- a/src/adcp/types/domains/core/limited_series.py +++ b/src/adcp/types/domains/core/limited_series.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/limited_series.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field class LimitedSeries(AdCPBaseModel): @@ -18,8 +18,8 @@ class LimitedSeries(AdCPBaseModel): SchemaInt, Field(description='Planned number of installments in the series', ge=1) ] starts: Annotated[ - AwareDatetime | None, Field(description='When the series begins (ISO 8601)'), NoExplicitNull + WireDatetime | None, Field(description='When the series begins (ISO 8601)'), NoExplicitNull ] = None ends: Annotated[ - AwareDatetime | None, Field(description='When the series ends (ISO 8601)'), NoExplicitNull + WireDatetime | None, Field(description='When the series ends (ISO 8601)'), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/core/macro_bearing_url.py b/src/adcp/types/domains/core/macro_bearing_url.py index edadb821a..3d37abbd3 100644 --- a/src/adcp/types/domains/core/macro_bearing_url.py +++ b/src/adcp/types/domains/core/macro_bearing_url.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/macro_bearing_url.json -# timestamp: 2026-10-04T00:53:27+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,12 +8,13 @@ from typing import Annotated -from pydantic import AnyUrl, Field, RootModel +from adcp.types.base import WireAnyUrl +from pydantic import Field, RootModel -class MacroBearingUrl3(RootModel[AnyUrl]): +class MacroBearingUrl3(RootModel[WireAnyUrl]): root: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Backward-compatible URL value that preserves the existing `uri-template` branch (including deep links, protocol-relative references, and RFC 6570 host variables) while additionally accepting absolute HTTP(S) URLs with byte-preserved macro delimiters not legal in a strict URI, including `%%...%%`, `[...]`, and `${...}`. Macro-bearing HTTP(S) URLs require a valid lexical authority and valid percent triplets; an AdCP verifier replaces each recognized token with an unreserved sentinel and validates the resulting absolute URI. Token semantics are established only by `macro_declarations`.', title='Macro-bearing URL', diff --git a/src/adcp/types/domains/core/macro_declaration.py b/src/adcp/types/domains/core/macro_declaration.py index ba983f075..862b7616f 100644 --- a/src/adcp/types/domains/core/macro_declaration.py +++ b/src/adcp/types/domains/core/macro_declaration.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/macro_declaration.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from .assets import asset_union @@ -63,7 +63,7 @@ class MacroDeclaration(AdCPBaseModel): ] dialect: asset_union.MacroDialect dialect_namespace: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Authority-controlled registry or mapping URI. Required for IAB VAST, IAB DAAST, and vendor dialects.' ), diff --git a/src/adcp/types/domains/core/macro_resolution_capability.py b/src/adcp/types/domains/core/macro_resolution_capability.py index fbc5d7db6..da357fa85 100644 --- a/src/adcp/types/domains/core/macro_resolution_capability.py +++ b/src/adcp/types/domains/core/macro_resolution_capability.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/macro_resolution_capability.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..enums import macro_dialect, macro_resolver, macro_value_context, universal_macro from . import macro_encoding, macro_translation_target @@ -29,7 +29,7 @@ class MacroProcessingCapability(AdCPBaseModel): extra='forbid', ) dialect: macro_dialect.MacroDialectFamily - dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_namespace: Annotated[WireAnyUrl | None, NoExplicitNull] = None dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: MappingStatus diff --git a/src/adcp/types/domains/core/macro_resolution_result.py b/src/adcp/types/domains/core/macro_resolution_result.py index fd601c31a..dfe6ca0e0 100644 --- a/src/adcp/types/domains/core/macro_resolution_result.py +++ b/src/adcp/types/domains/core/macro_resolution_result.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/macro_resolution_result.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ..enums import ( macro_dialect, @@ -45,7 +45,7 @@ class MacroResolutionResult(AdCPBaseModel): ] token: Annotated[str, Field(min_length=1)] dialect: macro_dialect.MacroDialectFamily - dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_namespace: Annotated[WireAnyUrl | None, NoExplicitNull] = None dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: macro_mapping_status.MacroMappingStatus diff --git a/src/adcp/types/domains/core/macro_translation_target.py b/src/adcp/types/domains/core/macro_translation_target.py index 88893b2f4..57d2109e1 100644 --- a/src/adcp/types/domains/core/macro_translation_target.py +++ b/src/adcp/types/domains/core/macro_translation_target.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/macro_translation_target.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..enums import macro_dialect, macro_mapping_status, macro_resolver, universal_macro from . import macro_encoding @@ -19,7 +19,7 @@ class MacroTranslationTarget(AdCPBaseModel): ) token: Annotated[str, Field(min_length=1)] dialect: macro_dialect.MacroDialectFamily - dialect_namespace: Annotated[AnyUrl | None, NoExplicitNull] = None + dialect_namespace: Annotated[WireAnyUrl | None, NoExplicitNull] = None dialect_revision: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None dialect_semantic: Annotated[str, Field(min_length=1)] mapping_status: macro_mapping_status.MacroMappingStatus diff --git a/src/adcp/types/domains/core/material_deadline.py b/src/adcp/types/domains/core/material_deadline.py index d5eca4985..823a38e18 100644 --- a/src/adcp/types/domains/core/material_deadline.py +++ b/src/adcp/types/domains/core/material_deadline.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/material_deadline.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field class MaterialDeadline(AdCPBaseModel): @@ -22,7 +22,7 @@ class MaterialDeadline(AdCPBaseModel): ), ] due_at: Annotated[ - AwareDatetime, Field(description='When materials for this stage are due (ISO 8601)') + WireDatetime, Field(description='When materials for this stage are due (ISO 8601)') ] label: Annotated[ str | None, diff --git a/src/adcp/types/domains/core/mcp_webhook_payload.py b/src/adcp/types/domains/core/mcp_webhook_payload.py index d05d7489f..b7620a3c8 100644 --- a/src/adcp/types/domains/core/mcp_webhook_payload.py +++ b/src/adcp/types/domains/core/mcp_webhook_payload.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/mcp_webhook_payload.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import adcp_protocol, task_status from ..enums import task_type as task_type_1 @@ -69,7 +69,7 @@ class McpWebhookPayload(AdCPBaseModel): ), ] timestamp: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when this logical webhook delivery was first generated. Every retry under the same idempotency_key MUST repeat this exact body value, along with every other payload member; only transport/signature metadata such as a fresh RFC 9421 nonce or created parameter may change between attempts.' ), diff --git a/src/adcp/types/domains/core/media_buy.py b/src/adcp/types/domains/core/media_buy.py index 340a92505..eb490842d 100644 --- a/src/adcp/types/domains/core/media_buy.py +++ b/src/adcp/types/domains/core/media_buy.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/media_buy.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..enums import canceled_by as canceled_by_1 from ..enums import media_buy_health, media_buy_status @@ -26,7 +26,7 @@ class Cancellation(AdCPBaseModel): extra='forbid', ) canceled_at: Annotated[ - AwareDatetime, Field(description='ISO 8601 timestamp when this media buy was canceled.') + WireDatetime, Field(description='ISO 8601 timestamp when this media buy was canceled.') ] canceled_by: Annotated[ canceled_by_1.CanceledBy, @@ -101,7 +101,7 @@ class MediaBuy(AdCPBaseModel): NoExplicitNull, ] = None confirmed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when the seller committed to this media buy. May be null until seller commitment occurs in deferred/manual approval flows. Once populated, remains stable through later pause, resume, activation, completion, cancellation, and reporting transitions.' ), @@ -183,7 +183,7 @@ class MediaBuy(AdCPBaseModel): NoExplicitNull, ] = None creative_deadline: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='ISO 8601 timestamp for creative upload deadline'), NoExplicitNull, ] = None @@ -195,9 +195,9 @@ class MediaBuy(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime | None, Field(description='Creation timestamp'), NoExplicitNull + WireDatetime | None, Field(description='Creation timestamp'), NoExplicitNull ] = None updated_at: Annotated[ - AwareDatetime | None, Field(description='Last update timestamp'), NoExplicitNull + WireDatetime | None, Field(description='Last update timestamp'), NoExplicitNull ] = None ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/notification_config.py b/src/adcp/types/domains/core/notification_config.py index 7553e7aed..5220a2c51 100644 --- a/src/adcp/types/domains/core/notification_config.py +++ b/src/adcp/types/domains/core/notification_config.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/notification_config.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ..enums import auth_scheme from . import ext as ext_1 @@ -69,7 +69,7 @@ class NotificationConfig(AdCPBaseModel): ), ] url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Webhook endpoint URL. Same wire contract as `push-notification-config.url` — `format: "uri"`, no destination-port allowlist enforced by the protocol, SSRF protection via the IP-range check defined in docs/building/by-layer/L1/security.mdx#webhook-url-validation-ssrf. Sellers MUST validate URL syntax, HTTPS usage, hostname normalization, and reserved-range rejection when writing any config, including `active: false` configs. Sellers MUST complete an activation challenge or equivalent proof-of-control before treating a new or changed active subscriber as active.' ), diff --git a/src/adcp/types/domains/core/offering.py b/src/adcp/types/domains/core/offering.py index ac476ff9b..b6218f8c4 100644 --- a/src/adcp/types/domains/core/offering.py +++ b/src/adcp/types/domains/core/offering.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/offering.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from ..enums import metro_system from . import ext as ext_1 @@ -97,28 +97,28 @@ class Offering(AdCPBaseModel): str | None, Field(description='Short promotional tagline for the offering'), NoExplicitNull ] = None valid_from: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the offering becomes available. If not specified, offering is immediately available.' ), NoExplicitNull, ] = None valid_to: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the offering expires. If not specified, offering has no expiration.' ), NoExplicitNull, ] = None checkout_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL for checkout/purchase flow when the brand doesn't support agentic checkout." ), NoExplicitNull, ] = None landing_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="Landing page URL for this offering. For catalog-driven creatives, this is the per-item click-through destination that platforms map to the ad's link-out URL. Every offering in a catalog should have a landing_url unless the format provides its own destination logic." ), diff --git a/src/adcp/types/domains/core/opportunity_context.py b/src/adcp/types/domains/core/opportunity_context.py index cf88a322d..d79dba163 100644 --- a/src/adcp/types/domains/core/opportunity_context.py +++ b/src/adcp/types/domains/core/opportunity_context.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/opportunity_context.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from . import date_range @@ -70,7 +70,7 @@ class OpportunityContext(AdCPBaseModel): NoExplicitNull, ] = None response_deadline: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Deadline by which the buyer needs a seller response.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/overlay.py b/src/adcp/types/domains/core/overlay.py index 2b326b754..de8866098 100644 --- a/src/adcp/types/domains/core/overlay.py +++ b/src/adcp/types/domains/core/overlay.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/overlay.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat class Visual(AdCPBaseModel): @@ -16,21 +16,21 @@ class Visual(AdCPBaseModel): extra='forbid', ) url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to a theme-neutral overlay graphic (SVG or PNG). Use when a single file works for all backgrounds, e.g. an SVG using CSS custom properties or currentColor.' ), NoExplicitNull, ] = None light: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to the overlay graphic for use on light/bright backgrounds (SVG or PNG)' ), NoExplicitNull, ] = None dark: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to the overlay graphic for use on dark backgrounds (SVG or PNG)'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/package.py b/src/adcp/types/domains/core/package.py index 03dfa499c..f578bf9d1 100644 --- a/src/adcp/types/domains/core/package.py +++ b/src/adcp/types/domains/core/package.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/package.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import canceled_by as canceled_by_1 from ..enums import pacing as pacing_1 @@ -32,7 +32,7 @@ class Cancellation(AdCPBaseModel): extra='forbid', ) canceled_at: Annotated[ - AwareDatetime, Field(description='ISO 8601 timestamp when this package was canceled.') + WireDatetime, Field(description='ISO 8601 timestamp when this package was canceled.') ] canceled_by: Annotated[ canceled_by_1.CanceledBy, @@ -44,7 +44,7 @@ class Cancellation(AdCPBaseModel): NoExplicitNull, ] = None acknowledged_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when the seller acknowledged the cancellation. Confirms inventory has been released and billing stopped. Absent until the seller processes the cancellation.' ), @@ -284,14 +284,14 @@ class Package(AdCPBaseModel): NoExplicitNull, ] = None start_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="Flight start date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's start_time. Sellers SHOULD always include the resolved value in responses, even when inherited." ), NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="Flight end date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's end_time. Sellers SHOULD always include the resolved value in responses, even when inherited." ), @@ -325,7 +325,7 @@ class Package(AdCPBaseModel): NoExplicitNull, ] = None creative_deadline: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="ISO 8601 timestamp for creative upload or change deadline for this package. After this deadline, creative changes are rejected. When absent, the media buy's creative_deadline applies." ), diff --git a/src/adcp/types/domains/core/package_format_snapshot.py b/src/adcp/types/domains/core/package_format_snapshot.py index 3bda32ec6..966e4c778 100644 --- a/src/adcp/types/domains/core/package_format_snapshot.py +++ b/src/adcp/types/domains/core/package_format_snapshot.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/package_format_snapshot.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ..enums import channels, daast_version, vast_version from ..formats.canonical import ( @@ -144,7 +144,7 @@ class PackageFormatSnapshot1(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -257,7 +257,7 @@ class PackageFormatSnapshot2(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -370,7 +370,7 @@ class PackageFormatSnapshot3(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -483,7 +483,7 @@ class PackageFormatSnapshot4(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -596,7 +596,7 @@ class PackageFormatSnapshot5(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -709,7 +709,7 @@ class PackageFormatSnapshot6(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -822,7 +822,7 @@ class PackageFormatSnapshot7(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -935,7 +935,7 @@ class PackageFormatSnapshot8(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1048,7 +1048,7 @@ class PackageFormatSnapshot9(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1161,7 +1161,7 @@ class PackageFormatSnapshot10(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1274,7 +1274,7 @@ class PackageFormatSnapshot11(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1387,7 +1387,7 @@ class PackageFormatSnapshot12(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1500,7 +1500,7 @@ class PackageFormatSnapshot13(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1613,7 +1613,7 @@ class PackageFormatSnapshot14(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1726,7 +1726,7 @@ class PackageFormatSnapshot15(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1839,7 +1839,7 @@ class PackageFormatSnapshot16(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), diff --git a/src/adcp/types/domains/core/performance_feedback.py b/src/adcp/types/domains/core/performance_feedback.py index de74472de..72f749441 100644 --- a/src/adcp/types/domains/core/performance_feedback.py +++ b/src/adcp/types/domains/core/performance_feedback.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/performance_feedback.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..enums import attribution_methodology as attribution_methodology_1 from ..enums import available_metric @@ -25,10 +25,10 @@ class MeasurementPeriod(AdCPBaseModel): extra='allow', ) start: Annotated[ - AwareDatetime, Field(description='ISO 8601 start timestamp for measurement period') + WireDatetime, Field(description='ISO 8601 start timestamp for measurement period') ] end: Annotated[ - AwareDatetime, Field(description='ISO 8601 end timestamp for measurement period') + WireDatetime, Field(description='ISO 8601 end timestamp for measurement period') ] @@ -159,10 +159,10 @@ class PerformanceFeedback(AdCPBaseModel): ] = None status: Annotated[Status, Field(description='Processing status of the performance feedback')] submitted_at: Annotated[ - AwareDatetime, Field(description='ISO 8601 timestamp when feedback was submitted') + WireDatetime, Field(description='ISO 8601 timestamp when feedback was submitted') ] applied_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when feedback was applied to optimization algorithms' ), diff --git a/src/adcp/types/domains/core/performance_feedback_assertion.py b/src/adcp/types/domains/core/performance_feedback_assertion.py index 54f6dcf84..6d58c540b 100644 --- a/src/adcp/types/domains/core/performance_feedback_assertion.py +++ b/src/adcp/types/domains/core/performance_feedback_assertion.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/performance_feedback_assertion.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import feedback_source as feedback_source_1 from ..enums import metric_type as metric_type_1 @@ -153,14 +153,14 @@ class PerformanceFeedbackAssertion(AdCPBaseModel): NoExplicitNull, ] = None evidence_ref: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Provider-hosted evidence or result reference for parties authorized to inspect the full study.' ), NoExplicitNull, ] = None as_of: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the producer computed this assertion.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/placement_evidence.py b/src/adcp/types/domains/core/placement_evidence.py index 9897f25f4..6010e9270 100644 --- a/src/adcp/types/domains/core/placement_evidence.py +++ b/src/adcp/types/domains/core/placement_evidence.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/placement_evidence.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat class PlacementEvidence(AdCPBaseModel): @@ -15,13 +15,13 @@ class PlacementEvidence(AdCPBaseModel): extra='allow', ) url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='The evidence artifact — photograph, scanned tearsheet, or evidence package' ), ] captured_at: Annotated[ - AwareDatetime | None, Field(description='When the artifact was captured'), NoExplicitNull + WireDatetime | None, Field(description='When the artifact was captured'), NoExplicitNull ] = None latitude: Annotated[ StrictFloat | None, diff --git a/src/adcp/types/domains/core/placement_presentation.py b/src/adcp/types/domains/core/placement_presentation.py index 2a19c3262..87291298d 100644 --- a/src/adcp/types/domains/core/placement_presentation.py +++ b/src/adcp/types/domains/core/placement_presentation.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/placement_presentation.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, ConfigDict, Field, field_validator +from pydantic import ConfigDict, Field, field_validator class Canvas(AdCPBaseModel): @@ -64,7 +64,7 @@ class ImageRef(AdCPBaseModel): extra='forbid', ) uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Untrusted image asset. Fetch with public-address-only SSRF protection, no redirects, DNS pinning, and body/time limits; do not attach ambient credentials.' ), @@ -79,7 +79,7 @@ class ImageRef(AdCPBaseModel): @field_validator('uri') @classmethod - def _require_https_uri(cls, value: AnyUrl) -> AnyUrl: + def _require_https_uri(cls, value: WireAnyUrl) -> WireAnyUrl: if value.scheme != 'https': raise ValueError('uri must use https') return value diff --git a/src/adcp/types/domains/core/planned_delivery.py b/src/adcp/types/domains/core/planned_delivery.py index 3ef41da84..7879385e7 100644 --- a/src/adcp/types/domains/core/planned_delivery.py +++ b/src/adcp/types/domains/core/planned_delivery.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/planned_delivery.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..enums import channels as channels_1 from ..enums import pacing as pacing_1 @@ -71,12 +71,12 @@ class PlannedDelivery(AdCPBaseModel): NoExplicitNull, ] = None start_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Actual flight start the seller will use.'), NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Actual flight end the seller will use.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/platform_extension_ref.py b/src/adcp/types/domains/core/platform_extension_ref.py index de8012c52..ebc2cbf24 100644 --- a/src/adcp/types/domains/core/platform_extension_ref.py +++ b/src/adcp/types/domains/core/platform_extension_ref.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/platform_extension_ref.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, WireAnyUrl +from pydantic import ConfigDict, Field class PlatformExtensionReference(AdCPBaseModel): @@ -15,7 +15,7 @@ class PlatformExtensionReference(AdCPBaseModel): extra='allow', ) uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="HTTPS URL identifying the extension. `https://` is mandatory — `http://`, `file://`, `data:`, and other schemes are rejected at the schema layer (defense-in-depth on top of the fetch-contract normative rules). The URI base is the owning agent's URL; the path identifies the extension within that agent. Example: 'https://creative.adcontextprotocol.org/translated/meta/extensions/meta_pixel'. The full fetch contract — SSRF allowlist, response-size cap, $ref sandbox, schema-compile bounds — is documented on `product-format-declaration.json#format_schema` and applies to ALL fetches of this reference shape regardless of whether the field is named `format_schema` (load-bearing for validation) or `platform_extensions` (informational); the *transport* rules are identical, only the *consumption* semantics differ." ), diff --git a/src/adcp/types/domains/core/presentation_ref.py b/src/adcp/types/domains/core/presentation_ref.py index af4450597..64dc5f13c 100644 --- a/src/adcp/types/domains/core/presentation_ref.py +++ b/src/adcp/types/domains/core/presentation_ref.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/presentation_ref.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field, field_validator +from adcp.types.base import AdCPBaseModel, WireAnyUrl +from pydantic import ConfigDict, Field, field_validator class PlacementPresentationReference(AdCPBaseModel): @@ -15,7 +15,7 @@ class PlacementPresentationReference(AdCPBaseModel): extra='forbid', ) uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Publisher-controlled HTTPS URL for the presentation metadata. Consumers MUST apply the same SSRF, redirect, response-size, timeout, and DNS-rebinding protections used for format_schema fetches.' ), @@ -40,7 +40,7 @@ class PlacementPresentationReference(AdCPBaseModel): @field_validator('uri') @classmethod - def _require_https_uri(cls, value: AnyUrl) -> AnyUrl: + def _require_https_uri(cls, value: WireAnyUrl) -> WireAnyUrl: if value.scheme != 'https': raise ValueError('uri must use https') return value diff --git a/src/adcp/types/domains/core/preview_provider.py b/src/adcp/types/domains/core/preview_provider.py index e4317c416..a0e5c540b 100644 --- a/src/adcp/types/domains/core/preview_provider.py +++ b/src/adcp/types/domains/core/preview_provider.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/preview_provider.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, field_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool, field_validator class Route(AdCPBaseModel): @@ -42,7 +42,7 @@ class PublisherDesignatedPreviewProvider(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='HTTPS URL of the delegated creative-agent endpoint. Buyers call get_adcp_capabilities and preview_creative on this endpoint. They MUST allow only public IPs, pin DNS resolution through connection, refuse redirects, cap time and response size, and attach provider credentials only after exact normalized-origin binding.' ), @@ -57,7 +57,7 @@ class PublisherDesignatedPreviewProvider(AdCPBaseModel): @field_validator('agent_url') @classmethod - def _require_https_agent_url(cls, value: AnyUrl) -> AnyUrl: + def _require_https_agent_url(cls, value: WireAnyUrl) -> WireAnyUrl: if value.scheme != 'https': raise ValueError('agent_url must use https') return value diff --git a/src/adcp/types/domains/core/principal_changed_webhook.py b/src/adcp/types/domains/core/principal_changed_webhook.py index 18933a007..1108cfd20 100644 --- a/src/adcp/types/domains/core/principal_changed_webhook.py +++ b/src/adcp/types/domains/core/principal_changed_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/principal_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from . import ext as ext_1 @@ -51,7 +51,7 @@ class PrincipalChangedWebhook(AdCPBaseModel): ), ] = 'principal.changed' fired_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller initiated this fire. Distinct from `changed_at`, which is when the seller recorded the state transition.' ), @@ -66,13 +66,13 @@ class PrincipalChangedWebhook(AdCPBaseModel): ), ] agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical seller agent URL whose principal state changed. Receivers connected to multiple agents use this to select which principal record to re-read.' ), ] changed_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller recorded the principal-state transition.' ), diff --git a/src/adcp/types/domains/core/principal_state.py b/src/adcp/types/domains/core/principal_state.py index 01364216a..9d6afef59 100644 --- a/src/adcp/types/domains/core/principal_state.py +++ b/src/adcp/types/domains/core/principal_state.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/principal_state.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from . import ( agent_notification_config_state, @@ -37,7 +37,7 @@ class RetiredDestination(AdCPBaseModel): min_length=1, ), ] - revoked_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + revoked_at: Annotated[WireDatetime | None, NoExplicitNull] = None class PrincipalState(AdCPBaseModel): diff --git a/src/adcp/types/domains/core/product.py b/src/adcp/types/domains/core/product.py index b7bfb1bf2..21bcaac87 100644 --- a/src/adcp/types/domains/core/product.py +++ b/src/adcp/types/domains/core/product.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/product.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarFloat, ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, model_validator +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..enums import action_source, audio_distribution_type, catalog_type from ..enums import channels as channels_1 @@ -215,12 +215,12 @@ class MaterialSubmission(AdCPBaseModel): extra='allow', ) url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='HTTPS URL for uploading or submitting physical creative materials'), NoExplicitNull, ] = None email: Annotated[ - EmailStr | None, + WireEmail | None, Field(description='Email address for creative material submission'), NoExplicitNull, ] = None @@ -347,7 +347,7 @@ class Provider(AdCPBaseModel): extra='allow', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Provider's agent URL from the registry. Canonical identifier for this TMP provider." ), @@ -892,7 +892,7 @@ def _coerce_publisher_property_models(cls, data: Any) -> Any: NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Expiration timestamp. Required for request-specific configured products whose targeting resolution, price, forecast, inventory, or terms are time-bound. After this time, a seller that still recognizes the issued configured ID within the authenticated account and referenced discovery/refinement lineage rejects create_media_buy with PRODUCT_EXPIRED and the buyer re-runs get_products. Once the seller no longer retains an expiry tombstone, or whenever the ID belongs to another account or lineage, PRODUCT_NOT_FOUND applies instead; sellers are not required to retain tombstones indefinitely and MUST NOT disclose cross-tenant existence through error choice.' ), diff --git a/src/adcp/types/domains/core/product_allocation.py b/src/adcp/types/domains/core/product_allocation.py index 1fe2e9dfc..460a97d49 100644 --- a/src/adcp/types/domains/core/product_allocation.py +++ b/src/adcp/types/domains/core/product_allocation.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/product_allocation.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..enums import pacing as pacing_1 from . import daypart_target, delivery_forecast @@ -80,14 +80,14 @@ class ProductAllocation(AdCPBaseModel): NoExplicitNull, ] = None start_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Recommended flight start date/time for this allocation in ISO 8601 format. Allows publishers to propose per-flight scheduling within a proposal. When omitted, the allocation applies to the full campaign date range.' ), NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Recommended flight end date/time for this allocation in ISO 8601 format. Allows publishers to propose per-flight scheduling within a proposal. When omitted, the allocation applies to the full campaign date range.' ), diff --git a/src/adcp/types/domains/core/product_audience_evidence_requirements.py b/src/adcp/types/domains/core/product_audience_evidence_requirements.py index 4687cb3e1..21757d6df 100644 --- a/src/adcp/types/domains/core/product_audience_evidence_requirements.py +++ b/src/adcp/types/domains/core/product_audience_evidence_requirements.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/product_audience_evidence_requirements.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import audience_evidence_methodology, audience_resolution_method, audience_subject_type from . import brand_key @@ -63,7 +63,7 @@ class AcceptedAttestationIssuers2(AdCPBaseModel): extra='forbid', ) type: Literal['agent'] = 'agent' - agent_url: AnyUrl + agent_url: WireAnyUrl ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @@ -72,7 +72,7 @@ class AcceptedAttestationIssuers3(AdCPBaseModel): extra='forbid', ) type: Literal['origin'] = 'origin' - origin: AnyUrl + origin: WireAnyUrl ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @@ -123,6 +123,6 @@ class ProductAudienceEvidenceRequirements(AdCPBaseModel): list[AcceptedAttestationIssuers] | None, Field(min_length=1), NoExplicitNull ] = None accepted_attestation_claim_types: Annotated[ - list[AnyUrl] | None, Field(min_length=1), NoExplicitNull + list[WireAnyUrl] | None, Field(min_length=1), NoExplicitNull ] = None ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/product_filters.py b/src/adcp/types/domains/core/product_filters.py index b96781d40..34c9b8ce9 100644 --- a/src/adcp/types/domains/core/product_filters.py +++ b/src/adcp/types/domains/core/product_filters.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/product_filters.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,9 +8,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import audio_distribution_type, available_metric from ..enums import channels as channels_1 @@ -88,7 +88,7 @@ class Provider(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - agent_url: Annotated[AnyUrl, Field(description="Provider's agent URL from the registry.")] + agent_url: Annotated[WireAnyUrl, Field(description="Provider's agent URL from the registry.")] context_match: Annotated[ StrictBool | None, Field(description='When true, require this provider to support context match.'), @@ -112,7 +112,7 @@ class BuyerAgent(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - agent_url: AnyUrl + agent_url: WireAnyUrl class AudienceActivationMethods1(AdCPBaseModel): @@ -638,7 +638,7 @@ class ProductFilters(AdCPBaseModel): NoExplicitNull, ] = None required_axe_integrations: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field( deprecated=True, description='Deprecated: Use trusted_match filter instead. Filter to products executable through specific agentic ad exchanges. URLs are canonical identifiers.', diff --git a/src/adcp/types/domains/core/product_format_declaration.py b/src/adcp/types/domains/core/product_format_declaration.py index 47f37d788..010a1bc98 100644 --- a/src/adcp/types/domains/core/product_format_declaration.py +++ b/src/adcp/types/domains/core/product_format_declaration.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/product_format_declaration.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import Field, StrictBool from ..enums import channels from ..formats.canonical import ( @@ -84,7 +84,7 @@ class ProductFormatDeclaration1(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -197,7 +197,7 @@ class ProductFormatDeclaration2(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -310,7 +310,7 @@ class ProductFormatDeclaration3(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -423,7 +423,7 @@ class ProductFormatDeclaration4(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -536,7 +536,7 @@ class ProductFormatDeclaration5(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -649,7 +649,7 @@ class ProductFormatDeclaration6(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -762,7 +762,7 @@ class ProductFormatDeclaration7(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -875,7 +875,7 @@ class ProductFormatDeclaration8(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -988,7 +988,7 @@ class ProductFormatDeclaration9(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1101,7 +1101,7 @@ class ProductFormatDeclaration10(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1214,7 +1214,7 @@ class ProductFormatDeclaration11(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1327,7 +1327,7 @@ class ProductFormatDeclaration12(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1440,7 +1440,7 @@ class ProductFormatDeclaration13(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1553,7 +1553,7 @@ class ProductFormatDeclaration14(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1666,7 +1666,7 @@ class ProductFormatDeclaration15(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1779,7 +1779,7 @@ class ProductFormatDeclaration16(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), diff --git a/src/adcp/types/domains/core/product_offer_filters.py b/src/adcp/types/domains/core/product_offer_filters.py index 0227ced72..c132bd699 100644 --- a/src/adcp/types/domains/core/product_offer_filters.py +++ b/src/adcp/types/domains/core/product_offer_filters.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/product_offer_filters.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,9 +8,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import audio_distribution_type, available_metric from ..enums import channels as channels_1 @@ -50,11 +50,11 @@ class AvailabilityHorizon(AdCPBaseModel): extra='forbid', ) start_time: Annotated[ - AwareDatetime, + WireDatetime, Field(description='Inclusive horizon start (RFC 3339 date-time with timezone offset).'), ] end_time: Annotated[ - AwareDatetime, + WireDatetime, Field( description='Exclusive horizon end (RFC 3339 date-time with timezone offset). MUST be after start_time.' ), @@ -113,7 +113,7 @@ class Provider(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - agent_url: AnyUrl + agent_url: WireAnyUrl context_match: Annotated[StrictBool | None, NoExplicitNull] = None identity_match: Annotated[StrictBool | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/property_list_ref.py b/src/adcp/types/domains/core/property_list_ref.py index 9c2de6935..f7a5e693a 100644 --- a/src/adcp/types/domains/core/property_list_ref.py +++ b/src/adcp/types/domains/core/property_list_ref.py @@ -1,20 +1,20 @@ # generated by datamodel-codegen: # filename: core/property_list_ref.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class PropertyListReference(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - agent_url: Annotated[AnyUrl, Field(description='URL of the agent managing the property list')] + agent_url: Annotated[WireAnyUrl, Field(description='URL of the agent managing the property list')] list_id: Annotated[ str, Field(description='Identifier for the property list within the agent', min_length=1) ] diff --git a/src/adcp/types/domains/core/proposal.py b/src/adcp/types/domains/core/proposal.py index c88896b85..9bd0d0771 100644 --- a/src/adcp/types/domains/core/proposal.py +++ b/src/adcp/types/domains/core/proposal.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/proposal.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..enums import pacing as pacing_1 from ..enums import proposal_status as proposal_status_1 @@ -97,7 +97,7 @@ class Proposal(AdCPBaseModel): NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this proposal expires and can no longer be executed. For draft proposals, indicates when indicative pricing becomes stale. For committed proposals, indicates when the inventory hold lapses — the buyer must call create_media_buy before this time.' ), diff --git a/src/adcp/types/domains/core/protocol_envelope.py b/src/adcp/types/domains/core/protocol_envelope.py index 35498ba36..5369da89c 100644 --- a/src/adcp/types/domains/core/protocol_envelope.py +++ b/src/adcp/types/domains/core/protocol_envelope.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/protocol_envelope.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..enums import task_status from . import context as context_1 @@ -54,7 +54,7 @@ class ProtocolEnvelope(AdCPBaseModel): NoExplicitNull, ] = None timestamp: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when the response was generated. Useful for debugging, logging, cache validation, and tracking async operation progress.' ), diff --git a/src/adcp/types/domains/core/provenance.py b/src/adcp/types/domains/core/provenance.py index b22fee04a..7fdc2d531 100644 --- a/src/adcp/types/domains/core/provenance.py +++ b/src/adcp/types/domains/core/provenance.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/provenance.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import c2pa_watermark_action from ..enums import digital_source_type as digital_source_type_1 @@ -62,7 +62,7 @@ class Role(StrEnum): class DeclaredBy(AdCPBaseModel): agent_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL of the agent or service that declared this provenance'), NoExplicitNull, ] = None @@ -71,7 +71,7 @@ class DeclaredBy(AdCPBaseModel): class C2pa(AdCPBaseModel): manifest_url: Annotated[ - AnyUrl, Field(description='URL to the C2PA manifest store for this content') + WireAnyUrl, Field(description='URL to the C2PA manifest store for this content') ] @@ -80,7 +80,7 @@ class VerifyAgent(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the governance agent the buyer represents was used to embed/verify this layer. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." ), @@ -120,7 +120,7 @@ class EmbeddedProvenanceItem(AdCPBaseModel): NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the provenance data was embedded (ISO 8601)'), NoExplicitNull, ] = None @@ -131,7 +131,7 @@ class VerifyAgent10(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the governance agent the buyer represents was used to apply/detect this watermark. MUST use the `https://` scheme and MUST appear in the seller's `creative_policy.accepted_verifiers[].agent_url` list (canonicalized per /docs/reference/url-canonicalization: lowercase scheme and host, strip default port, normalize path dot-segments). Sellers MUST NOT call this URL until the canonicalized match is confirmed." ), @@ -232,7 +232,7 @@ class VerificationItem(AdCPBaseModel): ), ] verified_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the verification was performed (ISO 8601)'), NoExplicitNull, ] = None @@ -245,7 +245,7 @@ class VerificationItem(AdCPBaseModel): NoExplicitNull, ] = None details_url: Annotated[ - AnyUrl | None, Field(description='URL to the full verification report'), NoExplicitNull + WireAnyUrl | None, Field(description='URL to the full verification report'), NoExplicitNull ] = None @@ -273,7 +273,7 @@ class Watermark(AdCPBaseModel): NoExplicitNull, ] = None embedded_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the watermark was applied (ISO 8601)'), NoExplicitNull, ] = None @@ -316,14 +316,14 @@ class Provenance(AdCPBaseModel): NoExplicitNull, ] = None declared_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this provenance claim was made (ISO 8601). Distinct from created_time, which records when the content itself was produced. A provenance claim may be attached well after content creation, for example when retroactively declaring AI involvement for regulatory compliance.' ), NoExplicitNull, ] = None created_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this content was created or generated (ISO 8601)'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/push_notification_config.py b/src/adcp/types/domains/core/push_notification_config.py index 11d7fbde8..0d9447ebb 100644 --- a/src/adcp/types/domains/core/push_notification_config.py +++ b/src/adcp/types/domains/core/push_notification_config.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/push_notification_config.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..enums import auth_scheme @@ -35,7 +35,7 @@ class Authentication(AdCPBaseModel): class PushNotificationConfig(AdCPBaseModel): url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Webhook endpoint URL for task status notifications. The wire contract is unconstrained beyond `format: "uri"` — in particular, publishers SHOULD NOT enforce a destination-port allowlist by default, since buyers legitimately host receivers on non-standard TLS ports (`:9443`, `:4443`, path-routed multi-tenant gateways). The SSRF guard the protocol relies on is the IP-range check + DNS-rebinding-resistant connect pin defined in [Webhook URL validation (SSRF)](/docs/building/by-layer/L1/security#webhook-url-validation-ssrf), not port filtering. Operators who want a hardened destination-port allowlist as defense-in-depth (e.g., locked-down enterprise egress) opt in explicitly — see [Destination port: permissive by default](/docs/building/by-layer/L1/security#destination-port-permissive-by-default).' ), diff --git a/src/adcp/types/domains/core/real_estate_item.py b/src/adcp/types/domains/core/real_estate_item.py index 45b5ed6e3..ea876e254 100644 --- a/src/adcp/types/domains/core/real_estate_item.py +++ b/src/adcp/types/domains/core/real_estate_item.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/real_estate_item.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 from . import offering_asset_group @@ -116,9 +116,9 @@ class RealEstateItem(AdCPBaseModel): NoExplicitNull, ] = None image_url: Annotated[ - AnyUrl | None, Field(description='Primary property image URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Primary property image URL.'), NoExplicitNull ] = None - url: Annotated[AnyUrl | None, Field(description='Listing page URL.'), NoExplicitNull] = None + url: Annotated[WireAnyUrl | None, Field(description='Listing page URL.'), NoExplicitNull] = None neighborhood: Annotated[ str | None, Field(description='Neighborhood or area name.'), NoExplicitNull ] = None diff --git a/src/adcp/types/domains/core/reference_asset.py b/src/adcp/types/domains/core/reference_asset.py index 9c952e0c4..6dd0e308a 100644 --- a/src/adcp/types/domains/core/reference_asset.py +++ b/src/adcp/types/domains/core/reference_asset.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reference_asset.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class Role(StrEnum): @@ -26,7 +26,7 @@ class ReferenceAsset(AdCPBaseModel): extra='allow', ) url: Annotated[ - AnyUrl, Field(description='URL to the reference asset (image, video, or document)') + WireAnyUrl, Field(description='URL to the reference asset (image, video, or document)') ] role: Annotated[ Role, diff --git a/src/adcp/types/domains/core/reference_renderer.py b/src/adcp/types/domains/core/reference_renderer.py index da0ab1e98..81e5fae48 100644 --- a/src/adcp/types/domains/core/reference_renderer.py +++ b/src/adcp/types/domains/core/reference_renderer.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reference_renderer.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, field_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, field_validator class Provenance(AdCPBaseModel): @@ -15,7 +15,7 @@ class Provenance(AdCPBaseModel): extra='forbid', ) source_repository: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Allowlisted source repository that the npm provenance attestation MUST identify.' ), @@ -30,7 +30,7 @@ class Provenance(AdCPBaseModel): @field_validator('source_repository') @classmethod - def _require_github_source_repository(cls, value: AnyUrl) -> AnyUrl: + def _require_github_source_repository(cls, value: WireAnyUrl) -> WireAnyUrl: if value.scheme != 'https' or value.host != 'github.com' or value.port != 443: raise ValueError('source_repository must use https://github.com/') if value.username is not None or value.password is not None: diff --git a/src/adcp/types/domains/core/registry_event.py b/src/adcp/types/domains/core/registry_event.py index 5943fa871..050c93487 100644 --- a/src/adcp/types/domains/core/registry_event.py +++ b/src/adcp/types/domains/core/registry_event.py @@ -1,16 +1,15 @@ # generated by datamodel-codegen: # filename: core/registry_event.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from uuid import UUID -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireUuid from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, RootModel, StrictBool +from pydantic import ConfigDict, Field, RootModel, StrictBool from ..enums import adcp_protocol, collection_kind, distribution_identifier_type from . import agent_signing_key @@ -56,10 +55,10 @@ class Payload(AdCPBaseModel): extra='allow', ) alias_rid: Annotated[ - UUID, Field(description='Retired property_rid that now aliases to canonical_rid.') + WireUuid, Field(description='Retired property_rid that now aliases to canonical_rid.') ] canonical_rid: Annotated[ - UUID, Field(description='Canonical property_rid that consumers should retain.') + WireUuid, Field(description='Canonical property_rid that consumers should retain.') ] evidence: Annotated[ str | None, @@ -72,7 +71,7 @@ class Payload(AdCPBaseModel): class RegistryEvent3(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -103,7 +102,7 @@ class RegistryEvent3(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -114,14 +113,14 @@ class Payload1(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - property_rid: UUID - last_resolved_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + property_rid: WireUuid + last_resolved_at: Annotated[WireDatetime | None, NoExplicitNull] = None reason: Annotated[str | None, NoExplicitNull] = None class RegistryEvent4(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -152,7 +151,7 @@ class RegistryEvent4(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -164,10 +163,10 @@ class Payload3(AdCPBaseModel): extra='allow', ) alias_rid: Annotated[ - UUID, Field(description='Retired collection_rid that now aliases to canonical_rid.') + WireUuid, Field(description='Retired collection_rid that now aliases to canonical_rid.') ] canonical_rid: Annotated[ - UUID, Field(description='Canonical collection_rid that consumers should retain.') + WireUuid, Field(description='Canonical collection_rid that consumers should retain.') ] evidence: Annotated[ str | None, @@ -180,7 +179,7 @@ class Payload3(AdCPBaseModel): class RegistryEvent8(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -211,7 +210,7 @@ class RegistryEvent8(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -331,7 +330,7 @@ class AgentProfilePayload(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - agent_url: Annotated[AnyUrl | None, NoExplicitNull] = None + agent_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None name: Annotated[str | None, NoExplicitNull] = None type: Annotated[Type | None, NoExplicitNull] = None channels: Annotated[StringArray | None, NoExplicitNull] = None @@ -353,7 +352,7 @@ class AgentProfilePayload(AdCPBaseModel): property_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None publisher_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None has_tmp: Annotated[StrictBool | None, NoExplicitNull] = None - updated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + updated_at: Annotated[WireDatetime | None, NoExplicitNull] = None class Country(Market): @@ -383,7 +382,7 @@ class StoryboardStatus(StrEnum): class RegistryEvent10(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -414,7 +413,7 @@ class RegistryEvent10(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -425,7 +424,7 @@ class Payload5(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - agent_url: AnyUrl + agent_url: WireAnyUrl removed_from_publishers: Annotated[list[Domain] | None, Field(min_length=1), NoExplicitNull] = ( None ) @@ -433,7 +432,7 @@ class Payload5(AdCPBaseModel): class RegistryEvent11(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -464,7 +463,7 @@ class RegistryEvent11(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -478,7 +477,7 @@ class Payload6(AgentProfilePayload): class RegistryEvent12(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -509,7 +508,7 @@ class RegistryEvent12(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -530,7 +529,7 @@ class Payload7(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - agent_url: AnyUrl + agent_url: WireAnyUrl previous_status: ComplianceStatus current_status: ComplianceStatus headline: str | None = None @@ -544,7 +543,7 @@ class Payload7(AdCPBaseModel): class RegistryEvent13(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -575,7 +574,7 @@ class RegistryEvent13(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -586,7 +585,7 @@ class Payload8(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - agent_url: AnyUrl + agent_url: WireAnyUrl role: BadgeRole verified_specialisms: Annotated[list[str], Field(min_length=1)] adcp_version: Annotated[str | None, NoExplicitNull] = None @@ -601,7 +600,7 @@ class Payload8(AdCPBaseModel): class RegistryEvent14(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -632,7 +631,7 @@ class RegistryEvent14(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -643,7 +642,7 @@ class Payload9(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - agent_url: AnyUrl + agent_url: WireAnyUrl role: BadgeRole reason: str adcp_version: Annotated[str | None, NoExplicitNull] = None @@ -658,7 +657,7 @@ class Payload9(AdCPBaseModel): class RegistryEvent15(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -689,7 +688,7 @@ class RegistryEvent15(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -711,8 +710,8 @@ class Payload10(AdCPBaseModel): ] = None properties_added: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None properties_removed: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None - agents_added: Annotated[list[AnyUrl] | None, NoExplicitNull] = None - agents_removed: Annotated[list[AnyUrl] | None, NoExplicitNull] = None + agents_added: Annotated[list[WireAnyUrl] | None, NoExplicitNull] = None + agents_removed: Annotated[list[WireAnyUrl] | None, NoExplicitNull] = None agent_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None property_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None collection_count: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None @@ -736,7 +735,7 @@ class Payload10(AdCPBaseModel): class RegistryEvent16(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -767,7 +766,7 @@ class RegistryEvent16(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -779,13 +778,13 @@ class Payload12(AdCPBaseModel): extra='allow', ) id: Annotated[ - UUID | None, + WireUuid | None, Field( description='Registry authorization row id when the event is backed by a materialized effective authorization row.' ), NoExplicitNull, ] = None - agent_url: AnyUrl + agent_url: WireAnyUrl agent_url_canonical: Annotated[ str | None, Field(description='Registry-canonicalized form of agent_url for equality checks.'), @@ -815,7 +814,7 @@ class Payload12(AdCPBaseModel): NoExplicitNull, ] = None property_rid: Annotated[ - UUID | None, + WireUuid | None, Field( description='Catalog property_rid for materialized per-property authorization rows. Null for publisher-wide rows.' ), @@ -847,21 +846,21 @@ class Payload12(AdCPBaseModel): ), NoExplicitNull, ] = None - effective_from: Annotated[AwareDatetime | None, NoExplicitNull] = None - effective_until: Annotated[AwareDatetime | None, NoExplicitNull] = None + effective_from: Annotated[WireDatetime | None, NoExplicitNull] = None + effective_until: Annotated[WireDatetime | None, NoExplicitNull] = None evidence: Annotated[Evidence | None, NoExplicitNull] = None disputed: Annotated[StrictBool | None, NoExplicitNull] = None created_by: str | None = None - expires_at: AwareDatetime | None = None - created_at: AwareDatetime | None = None - updated_at: AwareDatetime | None = None + expires_at: WireDatetime | None = None + created_at: WireDatetime | None = None + updated_at: WireDatetime | None = None override_applied: Annotated[StrictBool | None, NoExplicitNull] = None override_reason: str | None = None class RegistryEvent18(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -892,7 +891,7 @@ class RegistryEvent18(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -905,7 +904,7 @@ class Payload13(Payload12): class RegistryEvent19(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -936,7 +935,7 @@ class RegistryEvent19(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -949,7 +948,7 @@ class Payload14(Payload12): class RegistryEvent20(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -980,7 +979,7 @@ class RegistryEvent20(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -995,7 +994,7 @@ class PropertyPayload(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - property_rid: Annotated[UUID | None, NoExplicitNull] = None + property_rid: Annotated[WireUuid | None, NoExplicitNull] = None classification: Annotated[Classification | None, NoExplicitNull] = None source: Annotated[PropertySource | None, NoExplicitNull] = None identifiers: Annotated[ @@ -1020,7 +1019,7 @@ class CompliancePayload(Payload7): class RegistryEvent1(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -1051,7 +1050,7 @@ class RegistryEvent1(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -1060,7 +1059,7 @@ class RegistryEvent1(AdCPBaseModel): class RegistryEvent2(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -1091,7 +1090,7 @@ class RegistryEvent2(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -1099,13 +1098,13 @@ class RegistryEvent2(AdCPBaseModel): class Payload2(PropertyPayload): - reactivated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + reactivated_at: Annotated[WireDatetime | None, NoExplicitNull] = None property_rid: Any class RegistryEvent5(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -1136,7 +1135,7 @@ class RegistryEvent5(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -1146,14 +1145,14 @@ class RegistryEvent5(AdCPBaseModel): class Payload11(PublisherAdagentsPayload): properties_added: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None properties_removed: Annotated[SchemaInt | None, Field(ge=0), NoExplicitNull] = None - agents_added: Annotated[list[AnyUrl] | None, NoExplicitNull] = None - agents_removed: Annotated[list[AnyUrl] | None, NoExplicitNull] = None + agents_added: Annotated[list[WireAnyUrl] | None, NoExplicitNull] = None + agents_removed: Annotated[list[WireAnyUrl] | None, NoExplicitNull] = None changed_fields: Annotated[ChangedFields | None, NoExplicitNull] = None class RegistryEvent17(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -1184,7 +1183,7 @@ class RegistryEvent17(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -1195,7 +1194,7 @@ class CollectionPayload(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - collection_rid: Annotated[UUID | None, NoExplicitNull] = None + collection_rid: Annotated[WireUuid | None, NoExplicitNull] = None publisher_domain: Annotated[Domain | None, NoExplicitNull] = None collection_id: Annotated[ str | None, @@ -1220,7 +1219,7 @@ class CollectionPayload(AdCPBaseModel): class RegistryEvent6(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -1251,7 +1250,7 @@ class RegistryEvent6(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -1260,7 +1259,7 @@ class RegistryEvent6(AdCPBaseModel): class RegistryEvent7(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -1291,7 +1290,7 @@ class RegistryEvent7(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), @@ -1306,7 +1305,7 @@ class Payload4(CollectionPayload): class RegistryEvent9(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier and feed cursor. UUID v7 is REQUIRED so consumers can apply events in event_id order without relying on producer clocks.' ), @@ -1337,7 +1336,7 @@ class RegistryEvent9(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the registry emitted the event. Advisory only; consumers MUST order and cursor by event_id.' ), diff --git a/src/adcp/types/domains/core/registry_feed_response.py b/src/adcp/types/domains/core/registry_feed_response.py index 7ec6e80bf..b4d629a28 100644 --- a/src/adcp/types/domains/core/registry_feed_response.py +++ b/src/adcp/types/domains/core/registry_feed_response.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: core/registry_feed_response.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from uuid import UUID -from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, SchemaInt, WireDatetime, WireUuid +from pydantic import ConfigDict, Field, StrictBool from . import registry_event @@ -18,10 +17,10 @@ class Freshness(AdCPBaseModel): extra='forbid', ) generated_at: Annotated[ - AwareDatetime, Field(description='Server timestamp when this feed page was generated.') + WireDatetime, Field(description='Server timestamp when this feed page was generated.') ] latest_event_created_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Newest event creation timestamp currently visible in the feed for the requested type filter. Null when no matching event exists inside retention.' ), @@ -45,7 +44,7 @@ class RegistryFeedResponse(AdCPBaseModel): ) events: list[registry_event.RegistryEvent] cursor: Annotated[ - UUID | None, + WireUuid | None, Field( description='Pass this value as cursor on the next request to continue polling. Null only when the feed has no events and no prior cursor.' ), diff --git a/src/adcp/types/domains/core/reporting_adjustment.py b/src/adcp/types/domains/core/reporting_adjustment.py index 071cac2d2..7125e83a4 100644 --- a/src/adcp/types/domains/core/reporting_adjustment.py +++ b/src/adcp/types/domains/core/reporting_adjustment.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_adjustment.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from . import reporting_control_total @@ -26,8 +26,8 @@ class AccountingPeriod(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime class ReportingAdjustment(AdCPBaseModel): @@ -80,5 +80,5 @@ class ReportingAdjustment(AdCPBaseModel): ), NoExplicitNull, ] = None - correction_observed_at: AwareDatetime - created_at: AwareDatetime + correction_observed_at: WireDatetime + created_at: WireDatetime diff --git a/src/adcp/types/domains/core/reporting_adjustment_receipt.py b/src/adcp/types/domains/core/reporting_adjustment_receipt.py index 371f3a110..e3a7f0f5c 100644 --- a/src/adcp/types/domains/core/reporting_adjustment_receipt.py +++ b/src/adcp/types/domains/core/reporting_adjustment_receipt.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/reporting_adjustment_receipt.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field class Status(StrEnum): @@ -56,5 +56,5 @@ class ReportingAdjustmentReceipt(AdCPBaseModel): rejection_codes: Annotated[ list[ReportingAdjustmentRejectionCode] | None, Field(min_length=1), NoExplicitNull ] = None - observed_at: AwareDatetime - received_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + observed_at: WireDatetime + received_at: Annotated[WireDatetime | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reporting_canonical_content_digest.py b/src/adcp/types/domains/core/reporting_canonical_content_digest.py index 84d8f7272..d8e639b08 100644 --- a/src/adcp/types/domains/core/reporting_canonical_content_digest.py +++ b/src/adcp/types/domains/core/reporting_canonical_content_digest.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/reporting_canonical_content_digest.json -# timestamp: 2026-10-03T15:15:43+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, WireAnyUrl +from pydantic import ConfigDict, Field class ReportingCanonicalContentDigest(AdCPBaseModel): @@ -19,7 +19,7 @@ class ReportingCanonicalContentDigest(AdCPBaseModel): value: Annotated[str, Field(pattern='^[A-Fa-f0-9]{64}$')] canonicalization_id: Annotated[str, Field(max_length=128, min_length=1)] canonicalization_uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Location of the exact immutable canonicalization contract. Consumers verify canonicalization_sha256 before applying it.' ), diff --git a/src/adcp/types/domains/core/reporting_consumer_status.py b/src/adcp/types/domains/core/reporting_consumer_status.py index fde8eed4e..293c525ae 100644 --- a/src/adcp/types/domains/core/reporting_consumer_status.py +++ b/src/adcp/types/domains/core/reporting_consumer_status.py @@ -1,22 +1,22 @@ # generated by datamodel-codegen: # filename: core/reporting_consumer_status.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field class Period(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime source_timezone: Annotated[str, Field(min_length=1)] @@ -122,7 +122,7 @@ class ReportingConsumerStatus(AdCPBaseModel): ), ] status_as_of: Annotated[ - AwareDatetime, + WireDatetime, Field( description='When the consumer established this status. For received, this is when the named revision first became consumable to this consumer; sellers use it as buyer-attributed arrival evidence rather than silently substituting publication time.' ), @@ -161,14 +161,14 @@ class ReportingConsumerStatus(AdCPBaseModel): NoExplicitNull, ] = None seller_ledger_as_of: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ledger_as_of echoed from seller_ledger_snapshot_id. Present if and only if seller_ledger_snapshot_id is present.' ), NoExplicitNull, ] = None recorded_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the seller durably recorded this immutable statement.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/reporting_coverage.py b/src/adcp/types/domains/core/reporting_coverage.py index 9491e1b06..658eb5469 100644 --- a/src/adcp/types/domains/core/reporting_coverage.py +++ b/src/adcp/types/domains/core/reporting_coverage.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/reporting_coverage.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field class Status(StrEnum): @@ -55,7 +55,7 @@ class ReportingCoverage(AdCPBaseModel): extra='forbid', ) status: Status - evaluated_at: AwareDatetime + evaluated_at: WireDatetime media_buy_ids: Annotated[ list[ReportingMediaBuyId], Field( diff --git a/src/adcp/types/domains/core/reporting_delivery_capabilities.py b/src/adcp/types/domains/core/reporting_delivery_capabilities.py index 11a95495f..c2c3da151 100644 --- a/src/adcp/types/domains/core/reporting_delivery_capabilities.py +++ b/src/adcp/types/domains/core/reporting_delivery_capabilities.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_capabilities.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, EmailStr, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireEmail +from pydantic import ConfigDict, Field, StrictBool from . import reporting_delivery_offering, reporting_reliability_statistics @@ -21,14 +21,14 @@ class OperationsContact(AdCPBaseModel): regex_engine="python-re", ) url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='HTTPS page a human uses to open or track a reporting issue, such as a support portal or status page. Same hardened origin shape as the offering document URIs: never an IP literal, userinfo URL, loopback host, AdCP task endpoint, webhook target, or credentialed link.' ), NoExplicitNull, ] = None email: Annotated[ - EmailStr | None, + WireEmail | None, Field( description='Monitored operations mailbox for reporting escalations. A role address, not an individual.', max_length=254, diff --git a/src/adcp/types/domains/core/reporting_delivery_config.py b/src/adcp/types/domains/core/reporting_delivery_config.py index 04a60c6b7..82692fdee 100644 --- a/src/adcp/types/domains/core/reporting_delivery_config.py +++ b/src/adcp/types/domains/core/reporting_delivery_config.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_config.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -11,8 +11,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..enums import reporting_finality from . import ( @@ -160,7 +160,7 @@ class ReportingDeliveryConfiguration(AdCPBaseModel): None ) revocation_effective_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Optional requested cutoff for deactivation. No new publication may begin after the applied cutoff; historical access is limited to the contracted recovery window.' ), diff --git a/src/adcp/types/domains/core/reporting_delivery_config_state.py b/src/adcp/types/domains/core/reporting_delivery_config_state.py index 4209ddd31..f1f6ad96d 100644 --- a/src/adcp/types/domains/core/reporting_delivery_config_state.py +++ b/src/adcp/types/domains/core/reporting_delivery_config_state.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_config_state.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from . import reporting_coverage, reporting_delivery_config, reporting_status_issue @@ -33,8 +33,8 @@ class Setup(AdCPBaseModel): min_length=1, ), ] - url: Annotated[AnyUrl | None, NoExplicitNull] = None - expires_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + url: Annotated[WireAnyUrl | None, NoExplicitNull] = None + expires_at: Annotated[WireDatetime | None, NoExplicitNull] = None class ReportingDeliveryConfigLifecycleState(StrEnum): @@ -60,18 +60,18 @@ class ReportingDeliveryConfigurationState(AdCPBaseModel): ), NoExplicitNull, ] = None - validated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None - activated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None - deactivated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + validated_at: Annotated[WireDatetime | None, NoExplicitNull] = None + activated_at: Annotated[WireDatetime | None, NoExplicitNull] = None + deactivated_at: Annotated[WireDatetime | None, NoExplicitNull] = None publication_stopped_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Applied schedule boundary at or after deactivation. No obligation whose period starts at or after this cutoff is created; earlier obligations remain owed through their SLA and recovery lifecycle.' ), NoExplicitNull, ] = None seller_managed_access_ends_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='End of historical access to a producer-hosted share/resource for a still-authorized principal after voluntary deactivation. Inapplicable to data already written into a buyer-owned destination.' ), diff --git a/src/adcp/types/domains/core/reporting_delivery_offering.py b/src/adcp/types/domains/core/reporting_delivery_offering.py index 8123b8d84..6b04cd66d 100644 --- a/src/adcp/types/domains/core/reporting_delivery_offering.py +++ b/src/adcp/types/domains/core/reporting_delivery_offering.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_offering.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..enums import reporting_finality from . import ( @@ -29,7 +29,7 @@ class ReportingProfile(AdCPBaseModel): id: Annotated[str, Field(max_length=128, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,128}$')] version: Annotated[str, Field(max_length=64, min_length=1)] schema_uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Authenticated seller/provider or AdCP-registry HTTPS origin only; never an IP literal, userinfo URL, redirect target, or mutable validation authority.' ), @@ -78,7 +78,7 @@ class ReportingProfile(AdCPBaseModel): 'application/vnd.adcp.reporting-canonicalization+json' ) canonicalization_uri: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Retrievable exact canonicalization contract on the authenticated seller/provider or AdCP-registry origin. SDKs apply the same bounded, redirect-free SSRF controls as schema_uri and verify canonicalization_sha256 before use.' ), @@ -186,7 +186,7 @@ class ReportingDeliveryOffering(AdCPBaseModel): ), ] report_definition_uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Retrievable immutable reporting-report-definition.json document on the authenticated seller/provider or AdCP-registry origin.' ), diff --git a/src/adcp/types/domains/core/reporting_delivery_ready_webhook.py b/src/adcp/types/domains/core/reporting_delivery_ready_webhook.py index 5aa2ee923..88e71f3fe 100644 --- a/src/adcp/types/domains/core/reporting_delivery_ready_webhook.py +++ b/src/adcp/types/domains/core/reporting_delivery_ready_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_delivery_ready_webhook.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from ..enums import reporting_finality from . import reporting_delivery_offering @@ -42,7 +42,7 @@ class ReportingDeliveryReadyWebhook(AdCPBaseModel): ), ] notification_type: Literal['reporting.delivery_ready'] = 'reporting.delivery_ready' - fired_at: AwareDatetime + fired_at: WireDatetime subscriber_id: Annotated[ str, Field(max_length=64, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$') ] @@ -59,5 +59,5 @@ class ReportingDeliveryReadyWebhook(AdCPBaseModel): ] readiness: Readiness finality: reporting_finality.ReportingFinality - data_through: AwareDatetime | None + data_through: WireDatetime | None feed_purpose: reporting_delivery_offering.ReportingFeedPurpose diff --git a/src/adcp/types/domains/core/reporting_file_manifest.py b/src/adcp/types/domains/core/reporting_file_manifest.py index 6bc62f075..72d6d3c72 100644 --- a/src/adcp/types/domains/core/reporting_file_manifest.py +++ b/src/adcp/types/domains/core/reporting_file_manifest.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_file_manifest.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from . import reporting_control_total, reporting_file_compression, reporting_file_entry @@ -17,8 +17,8 @@ class Period(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime source_timezone: Annotated[str, Field(min_length=1)] @@ -52,4 +52,4 @@ class ReportingFileManifest(AdCPBaseModel): total_size_bytes: Annotated[SchemaInt, Field(ge=0)] row_count: Annotated[SchemaInt, Field(ge=0)] control_totals: list[reporting_control_total.ReportingControlTotal] - created_at: AwareDatetime + created_at: WireDatetime diff --git a/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py b/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py index 55fb958a4..d3c43209b 100644 --- a/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py +++ b/src/adcp/types/domains/core/reporting_ledger_changed_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_ledger_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import reporting_finality from . import ext as ext_1 @@ -42,7 +42,7 @@ class ReportingLedgerChangedWebhook(AdCPBaseModel): ), ] notification_type: Literal['reporting.ledger_changed'] = 'reporting.ledger_changed' - fired_at: AwareDatetime + fired_at: WireDatetime subscriber_id: Annotated[ str, Field(max_length=64, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$') ] diff --git a/src/adcp/types/domains/core/reporting_materialization.py b/src/adcp/types/domains/core/reporting_materialization.py index 85762a0c6..41b2e238d 100644 --- a/src/adcp/types/domains/core/reporting_materialization.py +++ b/src/adcp/types/domains/core/reporting_materialization.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_materialization.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from . import reporting_delivery_offering, reporting_resource, reporting_verification @@ -78,11 +78,11 @@ class ReportingMaterialization(AdCPBaseModel): ), ] ready_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When consumer-path or destination verification completed.'), NoExplicitNull, ] = None - failed_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + failed_at: Annotated[WireDatetime | None, NoExplicitNull] = None failure_code: Annotated[ str | None, Field( @@ -97,4 +97,4 @@ class ReportingMaterialization(AdCPBaseModel): verification: Annotated[reporting_verification.ReportingVerification | None, NoExplicitNull] = ( None ) - created_at: AwareDatetime + created_at: WireDatetime diff --git a/src/adcp/types/domains/core/reporting_obligation.py b/src/adcp/types/domains/core/reporting_obligation.py index 22993ed06..804b62a17 100644 --- a/src/adcp/types/domains/core/reporting_obligation.py +++ b/src/adcp/types/domains/core/reporting_obligation.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_obligation.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from ..enums import reporting_finality, reporting_health from . import ( @@ -24,8 +24,8 @@ class Period(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime source_timezone: Annotated[str, Field(min_length=1)] @@ -67,7 +67,7 @@ class ReportingObligation(AdCPBaseModel): ), ] scope_resolved_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='Instant at which the configured scope was resolved and frozen for this obligation. For all_media_buys, include every caller-authorized account media buy whose effective flight overlaps the half-open period and was known by this cutoff. Later-created or backdated buys do not rewrite this obligation.' ), @@ -79,7 +79,7 @@ class ReportingObligation(AdCPBaseModel): ), ] period: Period - expected_at: AwareDatetime + expected_at: WireDatetime schedule: Annotated[ reporting_schedule.ReportingSchedule, Field(description='Resolved immutable schedule generation that created this obligation.'), @@ -199,7 +199,7 @@ class ReportingObligation(AdCPBaseModel): ] = None issues: list[reporting_status_issue.ReportingStatusIssue] resource_retained_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Minimum time through which at least one verified materialization for a completed obligation remains readable. Managed-delivery only; Core revisions are retained per status_retention_days and readable through the existing API transports.' ), diff --git a/src/adcp/types/domains/core/reporting_receipt.py b/src/adcp/types/domains/core/reporting_receipt.py index 17703e45c..ac2342e61 100644 --- a/src/adcp/types/domains/core/reporting_receipt.py +++ b/src/adcp/types/domains/core/reporting_receipt.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/reporting_receipt.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from . import ( reporting_canonical_content_digest, @@ -80,5 +80,5 @@ class ReportingReceipt(AdCPBaseModel): rejection_codes: Annotated[list[RejectionCode] | None, Field(min_length=1), NoExplicitNull] = ( None ) - observed_at: AwareDatetime - received_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + observed_at: WireDatetime + received_at: Annotated[WireDatetime | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reporting_reliability_statistics.py b/src/adcp/types/domains/core/reporting_reliability_statistics.py index 8faa6a66a..6e0795d74 100644 --- a/src/adcp/types/domains/core/reporting_reliability_statistics.py +++ b/src/adcp/types/domains/core/reporting_reliability_statistics.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_reliability_statistics.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field class AdjustmentMagnitudeItem(AdCPBaseModel): @@ -33,8 +33,8 @@ class Evidence(AdCPBaseModel): regex_engine="python-re", ) basis: Basis - generated_at: AwareDatetime - attestation_uri: Annotated[AnyUrl | None, NoExplicitNull] = None + generated_at: WireDatetime + attestation_uri: Annotated[WireAnyUrl | None, NoExplicitNull] = None attestation_sha256: Annotated[ str | None, Field(pattern='^[A-Fa-f0-9]{64}$'), NoExplicitNull ] = None @@ -52,8 +52,8 @@ class ReportingReliabilityMeasurementPeriod(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime class ReportingReliabilityStatistics(AdCPBaseModel): diff --git a/src/adcp/types/domains/core/reporting_resource.py b/src/adcp/types/domains/core/reporting_resource.py index 0393eaa3c..02548a36a 100644 --- a/src/adcp/types/domains/core/reporting_resource.py +++ b/src/adcp/types/domains/core/reporting_resource.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/reporting_resource.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from . import reporting_native_version_ref @@ -75,7 +75,7 @@ class ReportingResource(AdCPBaseModel): Field(description='How this descriptor selects the exact immutable materialization.'), ] expires_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='Mandatory finite lower-bound endpoint through which this exact resource remains resolvable; it cannot be earlier than the advertised retention contract.' ), diff --git a/src/adcp/types/domains/core/reporting_revision.py b/src/adcp/types/domains/core/reporting_revision.py index 3114f0d3a..04abbc93f 100644 --- a/src/adcp/types/domains/core/reporting_revision.py +++ b/src/adcp/types/domains/core/reporting_revision.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_revision.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from ..enums import reporting_finality from . import reporting_canonical_content_digest, reporting_control_total, reporting_coverage @@ -18,8 +18,8 @@ class Period(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime source_timezone: Annotated[str, Field(min_length=1)] @@ -65,12 +65,12 @@ class ReportingRevision(AdCPBaseModel): pattern='^[A-Za-z0-9_.:-]{1,255}$', ), ] - report_definition_uri: AnyUrl + report_definition_uri: WireAnyUrl report_definition_sha256: Annotated[str, Field(pattern='^[A-Fa-f0-9]{64}$')] reporting_profile: Annotated[str, Field(max_length=128, min_length=1)] schema_version: Annotated[str, Field(max_length=64, min_length=1)] schema_uri: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Machine-readable schema on the authenticated seller/provider or AdCP-registry origin.' ), @@ -127,18 +127,18 @@ class ReportingRevision(AdCPBaseModel): NoExplicitNull, ] = None finalized_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the producer applied the declared finality basis to this official revision.' ), NoExplicitNull, ] = None observed_at: Annotated[ - AwareDatetime, + WireDatetime, Field(description='When the seller obtained or committed this source observation.'), ] data_through: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Latest event time conservatively included, or null when precision is unknown.' ), @@ -170,4 +170,4 @@ class ReportingRevision(AdCPBaseModel): canonical_content_digest: Annotated[ reporting_canonical_content_digest.ReportingCanonicalContentDigest | None, NoExplicitNull ] = None - created_at: AwareDatetime + created_at: WireDatetime diff --git a/src/adcp/types/domains/core/reporting_schedule.py b/src/adcp/types/domains/core/reporting_schedule.py index 28a05ae60..8524b4b26 100644 --- a/src/adcp/types/domains/core/reporting_schedule.py +++ b/src/adcp/types/domains/core/reporting_schedule.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_schedule.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field class ReportingScheduleAlignment(StrEnum): @@ -32,7 +32,7 @@ class ReportingSchedule(AdCPBaseModel): ] alignment: ReportingScheduleAlignment period_anchor: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Required for billing_cycle alignment. This immutable instant anchors the recurring half-open billing periods so producer and consumer derive the same month, quarter, or other contractual cycle.' ), diff --git a/src/adcp/types/domains/core/reporting_schedule_offering.py b/src/adcp/types/domains/core/reporting_schedule_offering.py index b52b0aced..a07a0cd48 100644 --- a/src/adcp/types/domains/core/reporting_schedule_offering.py +++ b/src/adcp/types/domains/core/reporting_schedule_offering.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_schedule_offering.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from . import reporting_schedule @@ -49,7 +49,7 @@ class ReportingScheduleOffering(AdCPBaseModel): ), NoExplicitNull, ] = None - period_anchor: Annotated[AwareDatetime | None, NoExplicitNull] = None + period_anchor: Annotated[WireDatetime | None, NoExplicitNull] = None period_timezone: Annotated[str | None, Field(max_length=255, min_length=1), NoExplicitNull] = ( None ) diff --git a/src/adcp/types/domains/core/reporting_status_changed_webhook.py b/src/adcp/types/domains/core/reporting_status_changed_webhook.py index cc98bb9d0..01407d9a8 100644 --- a/src/adcp/types/domains/core/reporting_status_changed_webhook.py +++ b/src/adcp/types/domains/core/reporting_status_changed_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_status_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from ..enums import reporting_health from . import ext as ext_1 @@ -43,7 +43,7 @@ class ReportingStatusChangedWebhook(AdCPBaseModel): ), ] notification_type: Literal['reporting.status_changed'] = 'reporting.status_changed' - fired_at: AwareDatetime + fired_at: WireDatetime subscriber_id: Annotated[ str, Field( diff --git a/src/adcp/types/domains/core/reporting_status_issue.py b/src/adcp/types/domains/core/reporting_status_issue.py index 873a23820..5024607b3 100644 --- a/src/adcp/types/domains/core/reporting_status_issue.py +++ b/src/adcp/types/domains/core/reporting_status_issue.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_status_issue.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from . import reporting_coverage, reporting_delivery_offering @@ -76,7 +76,7 @@ class ReportingStatusIssue(AdCPBaseModel): code: Code severity: ReportingStatusSeverity opened_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller first observed this logical condition, carried unchanged across every re-emission until the issue is retired. It anchors the escalation clock advertised as consumer_mismatch_escalation_seconds and lets a consumer age an issue without keeping its own first-seen table. Required when code is CONSUMER_STATUS_MISMATCH.' ), @@ -149,6 +149,6 @@ class ReportingStatusIssue(AdCPBaseModel): package_ids: Annotated[ list[reporting_coverage.ReportingPackageId] | None, Field(min_length=1), NoExplicitNull ] = None - period_start: Annotated[AwareDatetime | None, NoExplicitNull] = None - period_end: Annotated[AwareDatetime | None, NoExplicitNull] = None - expected_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + period_start: Annotated[WireDatetime | None, NoExplicitNull] = None + period_end: Annotated[WireDatetime | None, NoExplicitNull] = None + expected_at: Annotated[WireDatetime | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/core/reporting_verification.py b/src/adcp/types/domains/core/reporting_verification.py index 2aad46853..1aba7425d 100644 --- a/src/adcp/types/domains/core/reporting_verification.py +++ b/src/adcp/types/domains/core/reporting_verification.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_verification.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from . import ( reporting_canonical_content_digest, @@ -61,7 +61,7 @@ class ReportingVerification(AdCPBaseModel): extra='forbid', ) verified_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='When the producer completed verification through the claimed consumer/destination path.' ), diff --git a/src/adcp/types/domains/core/reporting_webhook.py b/src/adcp/types/domains/core/reporting_webhook.py index 58d23feac..32ffe7163 100644 --- a/src/adcp/types/domains/core/reporting_webhook.py +++ b/src/adcp/types/domains/core/reporting_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/reporting_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..enums import auth_scheme, available_metric @@ -44,7 +44,7 @@ class ReportingWebhook(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - url: Annotated[AnyUrl, Field(description='Webhook endpoint URL for reporting notifications')] + url: Annotated[WireAnyUrl, Field(description='Webhook endpoint URL for reporting notifications')] token: Annotated[ str | None, Field( diff --git a/src/adcp/types/domains/core/response_payload_jws_envelope.py b/src/adcp/types/domains/core/response_payload_jws_envelope.py index 17e1f4f20..ca86594fd 100644 --- a/src/adcp/types/domains/core/response_payload_jws_envelope.py +++ b/src/adcp/types/domains/core/response_payload_jws_envelope.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/response_payload_jws_envelope.json -# timestamp: 2026-10-03T16:00:16+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field class Task(StrEnum): @@ -33,7 +33,7 @@ class ResponsePayload(AdCPBaseModel): ), ] agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical URL of the responding brand agent entry whose response-signing key verifies this envelope.' ), diff --git a/src/adcp/types/domains/core/rights_attestation_evaluation.py b/src/adcp/types/domains/core/rights_attestation_evaluation.py index 74c7452d4..0ed4445ff 100644 --- a/src/adcp/types/domains/core/rights_attestation_evaluation.py +++ b/src/adcp/types/domains/core/rights_attestation_evaluation.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/rights_attestation_evaluation.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from . import brand_ref from . import ext as ext_1 @@ -22,7 +22,7 @@ class Subject(AdCPBaseModel): type: Literal['agent'] = 'agent' resource_type: Literal['https://adcontextprotocol.org/claims/subjects/rights-grant'] = 'https://adcontextprotocol.org/claims/subjects/rights-grant' agent_url: Annotated[ - AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') + WireAnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @@ -39,7 +39,7 @@ class Subject19(AdCPBaseModel): ), ] = 'https://adcontextprotocol.org/claims/subjects/rights-grant' namespace: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Absolute URI identifying the namespace in which id is unique. This may be an AdCP agent endpoint, a catalog origin, or a domain-specific namespace URI.' ), @@ -121,7 +121,7 @@ class Issuer5(AdCPBaseModel): ] = 'agent' brand: brand_ref.BrandReference agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical HTTPS endpoint of the issuing agent. Evaluators compare it using AdCP URL canonicalization rules.' ), @@ -141,7 +141,7 @@ class Issuer6(AdCPBaseModel): ] = 'origin' brand: brand_ref.BrandReference origin: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical HTTPS origin with no path, query, fragment, or userinfo. This identifies the issuer; it does not authorize a fetch.' ), diff --git a/src/adcp/types/domains/core/rights_constraint.py b/src/adcp/types/domains/core/rights_constraint.py index 6f335c198..ddc8bc266 100644 --- a/src/adcp/types/domains/core/rights_constraint.py +++ b/src/adcp/types/domains/core/rights_constraint.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/rights_constraint.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool from ..enums import right_type as right_type_1 from ..enums import right_use @@ -19,7 +19,7 @@ class RightsAgent(AdCPBaseModel): - url: Annotated[AnyUrl, Field(description='MCP endpoint URL of the rights agent')] + url: Annotated[WireAnyUrl, Field(description='MCP endpoint URL of the rights agent')] id: Annotated[str, Field(description='Agent identifier')] @@ -64,7 +64,7 @@ class Subject(AdCPBaseModel): type: Literal['agent'] = 'agent' resource_type: Literal['https://adcontextprotocol.org/claims/subjects/rights-grant'] = 'https://adcontextprotocol.org/claims/subjects/rights-grant' agent_url: Annotated[ - AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') + WireAnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @@ -81,7 +81,7 @@ class Subject29(AdCPBaseModel): ), ] = 'https://adcontextprotocol.org/claims/subjects/rights-grant' namespace: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Absolute URI identifying the namespace in which id is unique. This may be an AdCP agent endpoint, a catalog origin, or a domain-specific namespace URI.' ), @@ -127,7 +127,7 @@ class Issuer8(AdCPBaseModel): ] = 'agent' brand: brand_ref.BrandReference agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical HTTPS endpoint of the issuing agent. Evaluators compare it using AdCP URL canonicalization rules.' ), @@ -147,7 +147,7 @@ class Issuer9(AdCPBaseModel): ] = 'origin' brand: brand_ref.BrandReference origin: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Canonical HTTPS origin with no path, query, fragment, or userinfo. This identifies the issuer; it does not authorize a fetch.' ), @@ -228,12 +228,12 @@ class RightsConstraint(AdCPBaseModel): NoExplicitNull, ] = None valid_from: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Start of the rights validity period'), NoExplicitNull, ] = None valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='End of the rights validity period. Creative should not be served after this time.' ), @@ -309,7 +309,7 @@ class RightsConstraint(AdCPBaseModel): NoExplicitNull, ] = None verification_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( deprecated=True, description='DEPRECATED legacy informational locator. It is not proof of grant status, a revocation source, a credential locator, or permission to make a network request. Receivers MUST NOT fetch it during rights evaluation, and HTTP status codes have no AdCP authorization meaning. Rights authorization uses attestation_refs and a verifier-of-record evaluation under evaluator-owned policy.', diff --git a/src/adcp/types/domains/core/seller_agent_ref.py b/src/adcp/types/domains/core/seller_agent_ref.py index c1b0e0e1a..f8813d241 100644 --- a/src/adcp/types/domains/core/seller_agent_ref.py +++ b/src/adcp/types/domains/core/seller_agent_ref.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/seller_agent_ref.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class SellerAgentReference(AdCPBaseModel): @@ -15,7 +15,7 @@ class SellerAgentReference(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="The seller agent's API endpoint URL as declared in the property publisher's adagents.json `authorized_agents[].url`. MUST use the `https://` scheme. Receivers compare this URL against the `authorized_agents` list using the AdCP URL canonicalization rules — not byte-equality — and reject mismatches with `seller_not_authorized`. See docs/reference/url-canonicalization." ), diff --git a/src/adcp/types/domains/core/signal_coverage_forecast.py b/src/adcp/types/domains/core/signal_coverage_forecast.py index 5a1b4ea92..0bccccb74 100644 --- a/src/adcp/types/domains/core/signal_coverage_forecast.py +++ b/src/adcp/types/domains/core/signal_coverage_forecast.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/signal_coverage_forecast.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Dict, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat from ..enums import forecast_method from . import date_range as date_range_1 @@ -180,12 +180,12 @@ class SignalCoverageForecast(AdCPBaseModel): ), ] generated_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this coverage forecast was computed.'), NoExplicitNull, ] = None valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this coverage forecast expires.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/signal_definition.py b/src/adcp/types/domains/core/signal_definition.py index 68ccb87eb..78921a286 100644 --- a/src/adcp/types/domains/core/signal_definition.py +++ b/src/adcp/types/domains/core/signal_definition.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/signal_definition.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import audience_resolution_method, audience_subject_type from ..enums import consent_basis as consent_basis_1 @@ -88,7 +88,7 @@ class Taxonomy(AdCPBaseModel): extra='forbid', ) ref: Annotated[ - AnyUrl, Field(description='URI identifying the taxonomy or taxonomy documentation.') + WireAnyUrl, Field(description='URI identifying the taxonomy or taxonomy documentation.') ] version: Annotated[ str | None, @@ -287,12 +287,12 @@ class Channel(AdCPBaseModel): list[Right], Field(description='Rights supported by this channel.', min_length=1) ] url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='HTTPS URL for submitting the rights request.'), NoExplicitNull, ] = None email: Annotated[ - EmailStr | None, + WireEmail | None, Field(description='Email address for submitting the rights request.'), NoExplicitNull, ] = None @@ -340,7 +340,7 @@ class DataSubjectRights(AdCPBaseModel): NoExplicitNull, ] = None ccpa_opt_out_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description="US-specific 'Do Not Sell or Share' opt-out URL where required."), NoExplicitNull, ] = None @@ -459,7 +459,7 @@ class SignalDefinition(AdCPBaseModel): NoExplicitNull, ] = None criteria_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional URL to a longer-form methodology or criteria document. This is a disclosure pointer; buyers should not branch programmatically on the linked content.' ), @@ -584,7 +584,7 @@ class SignalDefinition(AdCPBaseModel): NoExplicitNull, ] = None last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this definition record was last updated. This indicates freshness of the definition record, not an attestation that the underlying data or model was refreshed at that time.' ), diff --git a/src/adcp/types/domains/core/signal_definition_enrichment.py b/src/adcp/types/domains/core/signal_definition_enrichment.py index 62b7af392..1cdfa1d30 100644 --- a/src/adcp/types/domains/core/signal_definition_enrichment.py +++ b/src/adcp/types/domains/core/signal_definition_enrichment.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/signal_definition_enrichment.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool from ..enums import consent_basis as consent_basis_1 from ..enums import restricted_attribute @@ -46,7 +46,7 @@ class Taxonomy(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - ref: AnyUrl + ref: WireAnyUrl version: Annotated[str | None, NoExplicitNull] = None segtax: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None etag: Annotated[str | None, NoExplicitNull] = None @@ -189,8 +189,8 @@ class Channel(AdCPBaseModel): extra='forbid', ) rights: Annotated[list[Right], Field(min_length=1)] - url: Annotated[AnyUrl | None, NoExplicitNull] = None - email: Annotated[EmailStr | None, NoExplicitNull] = None + url: Annotated[WireAnyUrl | None, NoExplicitNull] = None + email: Annotated[WireEmail | None, NoExplicitNull] = None languages: Annotated[list[str] | None, NoExplicitNull] = None countries: Annotated[list[Country] | None, NoExplicitNull] = None @@ -209,7 +209,7 @@ class DataSubjectRights(AdCPBaseModel): ] = None channels: Annotated[list[Channel], Field(min_length=1)] response_sla_days: Annotated[SchemaInt | None, Field(ge=1, le=90), NoExplicitNull] = None - ccpa_opt_out_url: Annotated[AnyUrl | None, NoExplicitNull] = None + ccpa_opt_out_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None class Modeling(AdCPBaseModel): @@ -254,7 +254,7 @@ class SignalDefinitionEnrichment(AdCPBaseModel): NoExplicitNull, ] = None segmentation_criteria: Annotated[str | None, Field(max_length=500), NoExplicitNull] = None - criteria_url: Annotated[AnyUrl | None, NoExplicitNull] = None + criteria_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None data_sources: Annotated[list[DataSource] | None, Field(min_length=1), NoExplicitNull] = None methodology: Annotated[Methodology | None, NoExplicitNull] = None audience_expansion: Annotated[StrictBool | None, NoExplicitNull] = None @@ -287,7 +287,7 @@ class SignalDefinitionEnrichment(AdCPBaseModel): NoExplicitNull, ] = None last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this definition record was last updated. This indicates freshness of the definition record, not an attestation that the underlying data or model was refreshed at that time.' ), diff --git a/src/adcp/types/domains/core/signal_id.py b/src/adcp/types/domains/core/signal_id.py index 85041b060..27ddfad8a 100644 --- a/src/adcp/types/domains/core/signal_id.py +++ b/src/adcp/types/domains/core/signal_id.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_id.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, WireAnyUrl +from pydantic import ConfigDict, Field class SignalId8(AdCPBaseModel): @@ -47,7 +47,7 @@ class SignalId9(AdCPBaseModel): ), ] = 'agent' agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="URL of the signal source that provides this signal (e.g., 'https://signals.example/.well-known/adcp/signals')" ), diff --git a/src/adcp/types/domains/core/signal_listing.py b/src/adcp/types/domains/core/signal_listing.py index 6e65b568a..2d4cbe200 100644 --- a/src/adcp/types/domains/core/signal_listing.py +++ b/src/adcp/types/domains/core/signal_listing.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_listing.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..enums import restricted_attribute, signal_value_type from . import demographic_predicate as demographic_predicate_1 @@ -60,14 +60,14 @@ class SignalListing(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional link to published methodology, media-kit, or data documentation. For data_provider and signal_source refs, this SHOULD match or supplement the referenced definition.' ), NoExplicitNull, ] = None last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this listing record was last updated. This indicates freshness of the listing record, not an attestation that the underlying data or model was refreshed at that time.' ), diff --git a/src/adcp/types/domains/core/signal_modeling_disclosure.py b/src/adcp/types/domains/core/signal_modeling_disclosure.py index ea50f557b..896a737e2 100644 --- a/src/adcp/types/domains/core/signal_modeling_disclosure.py +++ b/src/adcp/types/domains/core/signal_modeling_disclosure.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/signal_modeling_disclosure.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool class Audience(StrEnum): @@ -44,7 +44,7 @@ class Jurisdiction(AdCPBaseModel): NoExplicitNull, ] = None disclosure_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="Optional URL to the provider's canonical disclosure or methodology page for this jurisdiction." ), diff --git a/src/adcp/types/domains/core/signal_ref.py b/src/adcp/types/domains/core/signal_ref.py index 888ba70ba..f3dab705f 100644 --- a/src/adcp/types/domains/core/signal_ref.py +++ b/src/adcp/types/domains/core/signal_ref.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/signal_ref.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, WireAnyUrl +from pydantic import ConfigDict, Field class SignalRef1(AdCPBaseModel): @@ -66,7 +66,7 @@ class SignalRef3(AdCPBaseModel): ), ] = 'signal_source' signal_source_url: Annotated[ - AnyUrl, Field(description='URL of the signal source that issues this source-native signal.') + WireAnyUrl, Field(description='URL of the signal source that issues this source-native signal.') ] signal_id: Annotated[ str, diff --git a/src/adcp/types/domains/core/special.py b/src/adcp/types/domains/core/special.py index 2ec0ed2b6..0f30f7323 100644 --- a/src/adcp/types/domains/core/special.py +++ b/src/adcp/types/domains/core/special.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/special.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..enums import special_category @@ -25,10 +25,10 @@ class Special(AdCPBaseModel): NoExplicitNull, ] = None starts: Annotated[ - AwareDatetime | None, Field(description='When the event starts (ISO 8601)'), NoExplicitNull + WireDatetime | None, Field(description='When the event starts (ISO 8601)'), NoExplicitNull ] = None ends: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the event ends (ISO 8601). Omit for single-day events.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/start_timing.py b/src/adcp/types/domains/core/start_timing.py index 3992e2387..57414695b 100644 --- a/src/adcp/types/domains/core/start_timing.py +++ b/src/adcp/types/domains/core/start_timing.py @@ -1,16 +1,17 @@ # generated by datamodel-codegen: # filename: core/start_timing.json -# timestamp: 2026-10-02T23:18:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from pydantic import AwareDatetime, Field +from adcp.types.base import WireDatetime +from pydantic import Field StartTiming = Annotated[ - Literal['asap'] | AwareDatetime, + Literal['asap'] | WireDatetime, Field( description="Campaign start timing: 'asap' or ISO 8601 date-time", title='Start Timing' ), diff --git a/src/adcp/types/domains/core/store_item.py b/src/adcp/types/domains/core/store_item.py index 2b1999cbe..3b6b6cabf 100644 --- a/src/adcp/types/domains/core/store_item.py +++ b/src/adcp/types/domains/core/store_item.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/store_item.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from . import catchment from . import ext as ext_1 @@ -87,7 +87,7 @@ class StoreItem(AdCPBaseModel): NoExplicitNull, ] = None url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Store-specific page URL (e.g., store locator detail page).'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/talent.py b/src/adcp/types/domains/core/talent.py index 41bffc402..f7a728322 100644 --- a/src/adcp/types/domains/core/talent.py +++ b/src/adcp/types/domains/core/talent.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: core/talent.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..enums import talent_role @@ -22,7 +22,7 @@ class Talent(AdCPBaseModel): ] name: Annotated[str, Field(description="Person's name as credited on the collection")] brand_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL to this person's brand.json entry. Enables buyer agents to evaluate the talent's brand identity and associations." ), diff --git a/src/adcp/types/domains/core/tasks_get_response.py b/src/adcp/types/domains/core/tasks_get_response.py index 6a0bfee66..c9bb0a70f 100644 --- a/src/adcp/types/domains/core/tasks_get_response.py +++ b/src/adcp/types/domains/core/tasks_get_response.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/tasks_get_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..enums import adcp_protocol, task_status from ..enums import task_type as task_type_1 @@ -77,7 +77,7 @@ class HistoryItem(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - timestamp: Annotated[AwareDatetime, Field(description='When this exchange occurred (ISO 8601)')] + timestamp: Annotated[WireDatetime, Field(description='When this exchange occurred (ISO 8601)')] type: Annotated[ Type, Field(description='Whether this was a request from client or response from server') ] @@ -95,13 +95,13 @@ class TasksGetResponse(AdcpVersionEnvelope, ProtocolEnvelope): ] status: Annotated[task_status.TaskStatus, Field(description='Current task status')] created_at: Annotated[ - AwareDatetime, Field(description='When the task was initially created (ISO 8601)') + WireDatetime, Field(description='When the task was initially created (ISO 8601)') ] updated_at: Annotated[ - AwareDatetime, Field(description='When the task was last updated (ISO 8601)') + WireDatetime, Field(description='When the task was last updated (ISO 8601)') ] completed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the task completed (ISO 8601, only for completed/failed/canceled tasks)' ), diff --git a/src/adcp/types/domains/core/tasks_list_request.py b/src/adcp/types/domains/core/tasks_list_request.py index 2b2dc2036..295ea24b7 100644 --- a/src/adcp/types/domains/core/tasks_list_request.py +++ b/src/adcp/types/domains/core/tasks_list_request.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/tasks_list_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..enums import adcp_protocol, sort_direction, task_status from ..enums import task_type as task_type_1 @@ -52,22 +52,22 @@ class Filters(AdCPBaseModel): NoExplicitNull, ] = None created_after: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter tasks created after this date (ISO 8601)'), NoExplicitNull, ] = None created_before: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter tasks created before this date (ISO 8601)'), NoExplicitNull, ] = None updated_after: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter tasks last updated after this date (ISO 8601)'), NoExplicitNull, ] = None updated_before: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter tasks last updated before this date (ISO 8601)'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/core/tasks_list_response.py b/src/adcp/types/domains/core/tasks_list_response.py index 9bc088955..38bd45b2f 100644 --- a/src/adcp/types/domains/core/tasks_list_response.py +++ b/src/adcp/types/domains/core/tasks_list_response.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/tasks_list_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..enums import task_status from ..enums import task_type as task_type_1 @@ -98,13 +98,13 @@ class Task(AdCPBaseModel): domain: Annotated[Domain, Field(description='AdCP domain this task belongs to')] status: Annotated[task_status.TaskStatus, Field(description='Current task status')] created_at: Annotated[ - AwareDatetime, Field(description='When the task was initially created (ISO 8601)') + WireDatetime, Field(description='When the task was initially created (ISO 8601)') ] updated_at: Annotated[ - AwareDatetime, Field(description='When the task was last updated (ISO 8601)') + WireDatetime, Field(description='When the task was last updated (ISO 8601)') ] completed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the task completed (ISO 8601, only for completed/failed/canceled tasks)' ), diff --git a/src/adcp/types/domains/core/transformer.py b/src/adcp/types/domains/core/transformer.py index c694305ec..9758c9f7d 100644 --- a/src/adcp/types/domains/core/transformer.py +++ b/src/adcp/types/domains/core/transformer.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: core/transformer.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, model_validator +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..enums import channels from ..formats.canonical import ( @@ -41,7 +41,7 @@ class BrandAgent(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - url: Annotated[AnyUrl, Field(description='MCP endpoint URL of the brand agent.')] + url: Annotated[WireAnyUrl, Field(description='MCP endpoint URL of the brand agent.')] id: Annotated[str, Field(description='Agent identifier.')] @@ -163,7 +163,7 @@ class InputFormat1(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -276,7 +276,7 @@ class InputFormat2(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -389,7 +389,7 @@ class InputFormat3(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -502,7 +502,7 @@ class InputFormat4(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -615,7 +615,7 @@ class InputFormat5(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -728,7 +728,7 @@ class InputFormat6(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -841,7 +841,7 @@ class InputFormat7(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -954,7 +954,7 @@ class InputFormat8(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1067,7 +1067,7 @@ class InputFormat9(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1180,7 +1180,7 @@ class InputFormat10(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1293,7 +1293,7 @@ class InputFormat11(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1406,7 +1406,7 @@ class InputFormat12(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1519,7 +1519,7 @@ class InputFormat13(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1632,7 +1632,7 @@ class InputFormat14(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1745,7 +1745,7 @@ class InputFormat15(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), @@ -1858,7 +1858,7 @@ class InputFormat16(AdCPBaseModel): NoExplicitNull, ] = None sample_render_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional public HTTPS page where a human can inspect a sample render of this declaration using assets chosen by the party publishing the enclosing declaration. Consumers MUST identify that source correctly: publisher or community mirror for `adagents.json` `formats[]`, seller for product or inline-placement declarations, and creative agent for `creative.supported_formats`. Informational only: this is not a renderer endpoint, buyer-asset preview, validation result, creative approval, proof of publisher acceptance, or guarantee of live delivery. Declaring parties SHOULD keep the URL stable while the declaration is active.' ), diff --git a/src/adcp/types/domains/core/vehicle_item.py b/src/adcp/types/domains/core/vehicle_item.py index 546ddbf55..6930237c5 100644 --- a/src/adcp/types/domains/core/vehicle_item.py +++ b/src/adcp/types/domains/core/vehicle_item.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/vehicle_item.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from . import ext as ext_1 from . import offering_asset_group @@ -119,10 +119,10 @@ class VehicleItem(AdCPBaseModel): Location | None, Field(description='Dealer or vehicle location.'), NoExplicitNull ] = None image_url: Annotated[ - AnyUrl | None, Field(description='Primary vehicle image URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Primary vehicle image URL.'), NoExplicitNull ] = None url: Annotated[ - AnyUrl | None, Field(description='Vehicle listing page URL.'), NoExplicitNull + WireAnyUrl | None, Field(description='Vehicle listing page URL.'), NoExplicitNull ] = None tags: Annotated[ list[str] | None, diff --git a/src/adcp/types/domains/core/verification_token_claims.py b/src/adcp/types/domains/core/verification_token_claims.py index 71a1ceb59..ec36bf51e 100644 --- a/src/adcp/types/domains/core/verification_token_claims.py +++ b/src/adcp/types/domains/core/verification_token_claims.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/verification_token_claims.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field class Role(StrEnum): @@ -35,12 +35,12 @@ class AgenticadvertisingOrgVerificationTokenClaims(AdCPBaseModel): extra='allow', ) iss: Literal['https://aao.org'] = 'https://aao.org' - sub: AnyUrl + sub: WireAnyUrl aud: Literal['aao-verification'] = 'aao-verification' jti: Annotated[str, Field(min_length=1)] iat: Annotated[SchemaInt, Field(ge=0)] exp: Annotated[SchemaInt, Field(ge=0)] - agent_url: AnyUrl + agent_url: WireAnyUrl role: Role verified_specialisms: Annotated[list[str], Field(min_length=1)] verification_modes: Annotated[list[VerificationTokenMode], Field(min_length=1)] @@ -52,7 +52,7 @@ class AgenticadvertisingOrgVerificationTokenClaims(AdCPBaseModel): NoExplicitNull, ] = None first_failing_spec_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Start of the current Strict Spec failure episode. Omitted when no Strict Spec failure clock is active. The registry remains authoritative for real-time status.' ), diff --git a/src/adcp/types/domains/core/webhook_activity_record.py b/src/adcp/types/domains/core/webhook_activity_record.py index 499a48330..95856cbe9 100644 --- a/src/adcp/types/domains/core/webhook_activity_record.py +++ b/src/adcp/types/domains/core/webhook_activity_record.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/webhook_activity_record.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from ..enums import notification_type as notification_type_1 from . import ext as ext_1 @@ -50,13 +50,13 @@ class WebhookActivityRecord(AdCPBaseModel): NoExplicitNull, ] = None fired_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller initiated the HTTP request for this attempt.' ), ] completed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="ISO 8601 timestamp when the seller observed the response (or terminal timeout / connection error — for `timeout` and `connection_error` outcomes, `completed_at` is set to the moment the seller declared the attempt terminal). Explicitly `null` when the attempt is still in flight or queued for retry (status `pending`); MUST be set as `null` rather than omitted so callers can distinguish 'still in flight' from 'field missing'." ), @@ -89,7 +89,7 @@ class WebhookActivityRecord(AdCPBaseModel): ), ] url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Target URL for this fire. Query string and fragment MUST be stripped before surfacing — buyers commonly stash bearer tokens in the query string and sellers MUST NOT echo those back through this debug surface. Sellers SHOULD additionally redact path segments matching obvious secret patterns (e.g., a path segment that is high-entropy random material or matches a UUID / token format). Buyers matching this against their own configured URL should compare by origin + path; query strings will not match and that mismatch is expected.' ), diff --git a/src/adcp/types/domains/core/webhook_challenge.py b/src/adcp/types/domains/core/webhook_challenge.py index 41f1a47e7..f2851097b 100644 --- a/src/adcp/types/domains/core/webhook_challenge.py +++ b/src/adcp/types/domains/core/webhook_challenge.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: core/webhook_challenge.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..enums import notification_type @@ -72,7 +72,7 @@ class WebhookChallenge(AdCPBaseModel): ), ] seller_agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Exact seller agent URL whose RFC 9421 webhook profile key signs this challenge and that will send subsequent webhooks.' ), diff --git a/src/adcp/types/domains/core/wholesale_feed_event.py b/src/adcp/types/domains/core/wholesale_feed_event.py index 16e2a5c1c..0a2703adb 100644 --- a/src/adcp/types/domains/core/wholesale_feed_event.py +++ b/src/adcp/types/domains/core/wholesale_feed_event.py @@ -1,16 +1,15 @@ # generated by datamodel-codegen: # filename: core/wholesale_feed_event.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from collections.abc import Sequence from typing import Annotated, Literal -from uuid import UUID -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime, WireUuid +from pydantic import ConfigDict, Field, StrictFloat from ..enums import signal_catalog_type, signal_value_type from . import canonical_pricing_option @@ -122,14 +121,14 @@ class Payload(AdCPBaseModel): NoExplicitNull, ] = None effective_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the price change takes effect. A value in the future is a pre-announcement: consumers MAY warm caches but MUST NOT bind decisions until the effective time has passed. See specs/wholesale-feed-webhooks.md §`effective_at` and pre-announce.' ), NoExplicitNull, ] = None retracts_event_id: Annotated[ - UUID | None, + WireUuid | None, Field( description="Optional. When this event retracts a prior pre-announced *.priced (per the §`effective_at` and pre-announce rules), set this to the event_id of the announcement being retracted. Lets consumers detect retraction deterministically by event_id lookup rather than maintaining per-entity 'latest-event' state. When present, the retraction MUST carry effective_at <= the retracted event's effective_at." ), @@ -140,7 +139,7 @@ class Payload(AdCPBaseModel): class WholesaleFeedEvent3(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals.' ), @@ -162,7 +161,7 @@ class WholesaleFeedEvent3(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the agent emitted the event. Advisory — consumers MUST order by event_id (UUID v7), not by created_at, to avoid clock-skew artifacts.' ), @@ -186,7 +185,7 @@ class Payload20(AdCPBaseModel): class WholesaleFeedEvent4(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals.' ), @@ -208,7 +207,7 @@ class WholesaleFeedEvent4(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the agent emitted the event. Advisory — consumers MUST order by event_id (UUID v7), not by created_at, to avoid clock-skew artifacts.' ), @@ -233,14 +232,14 @@ class Payload23(AdCPBaseModel): ] previous_pricing_option_ids: Annotated[list[str] | None, NoExplicitNull] = None effective_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the price change takes effect. A value in the future is a pre-announcement: consumers MAY warm caches but MUST NOT bind decisions until the effective time has passed. See specs/wholesale-feed-webhooks.md §`effective_at` and pre-announce.' ), NoExplicitNull, ] = None retracts_event_id: Annotated[ - UUID | None, + WireUuid | None, Field( description='Optional. When this event retracts a prior pre-announced *.priced, set this to the event_id of the announcement being retracted. See product.priced for the full retraction contract.' ), @@ -251,7 +250,7 @@ class Payload23(AdCPBaseModel): class WholesaleFeedEvent7(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals.' ), @@ -273,7 +272,7 @@ class WholesaleFeedEvent7(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the agent emitted the event. Advisory — consumers MUST order by event_id (UUID v7), not by created_at, to avoid clock-skew artifacts.' ), @@ -298,7 +297,7 @@ class Payload24(AdCPBaseModel): class WholesaleFeedEvent8(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals.' ), @@ -320,7 +319,7 @@ class WholesaleFeedEvent8(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the agent emitted the event. Advisory — consumers MUST order by event_id (UUID v7), not by created_at, to avoid clock-skew artifacts.' ), @@ -364,7 +363,7 @@ class Payload25(AdCPBaseModel): class WholesaleFeedEvent9(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals.' ), @@ -386,7 +385,7 @@ class WholesaleFeedEvent9(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the agent emitted the event. Advisory — consumers MUST order by event_id (UUID v7), not by created_at, to avoid clock-skew artifacts.' ), @@ -469,7 +468,7 @@ class Payload21(AdCPBaseModel): class WholesaleFeedEvent5(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals.' ), @@ -491,7 +490,7 @@ class WholesaleFeedEvent5(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the agent emitted the event. Advisory — consumers MUST order by event_id (UUID v7), not by created_at, to avoid clock-skew artifacts.' ), @@ -528,7 +527,7 @@ class Payload22(AdCPBaseModel): class WholesaleFeedEvent6(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals.' ), @@ -550,7 +549,7 @@ class WholesaleFeedEvent6(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the agent emitted the event. Advisory — consumers MUST order by event_id (UUID v7), not by created_at, to avoid clock-skew artifacts.' ), @@ -592,7 +591,7 @@ class Payload17(AdCPBaseModel): class WholesaleFeedEvent1(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals.' ), @@ -614,7 +613,7 @@ class WholesaleFeedEvent1(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the agent emitted the event. Advisory — consumers MUST order by event_id (UUID v7), not by created_at, to avoid clock-skew artifacts.' ), @@ -654,7 +653,7 @@ class Payload18(AdCPBaseModel): class WholesaleFeedEvent2(AdCPBaseModel): event_id: Annotated[ - UUID, + WireUuid, Field( description='Stable logical event identifier for this wholesale feed change. MUST equal the enclosing webhook notification_id. UUID v7 is RECOMMENDED so receivers can detect obvious out-of-order delivery; missed or distrusted pushes are repaired through list_products / get_signals.' ), @@ -676,7 +675,7 @@ class WholesaleFeedEvent2(AdCPBaseModel): ), ] created_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the agent emitted the event. Advisory — consumers MUST order by event_id (UUID v7), not by created_at, to avoid clock-skew artifacts.' ), diff --git a/src/adcp/types/domains/core/wholesale_feed_webhook.py b/src/adcp/types/domains/core/wholesale_feed_webhook.py index eb768c8c7..d1796abba 100644 --- a/src/adcp/types/domains/core/wholesale_feed_webhook.py +++ b/src/adcp/types/domains/core/wholesale_feed_webhook.py @@ -1,15 +1,14 @@ # generated by datamodel-codegen: # filename: core/wholesale_feed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from uuid import UUID -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime, WireUuid +from pydantic import ConfigDict, Field from . import ext as ext_1 from . import wholesale_feed_event @@ -51,7 +50,7 @@ class WholesaleFeedWebhook(AdCPBaseModel): ), ] notification_id: Annotated[ - UUID, + WireUuid, Field( description='Stable identifier for this logical wholesale feed event. MUST equal event.event_id. Re-emissions of the same logical event reuse this value under a new idempotency_key.' ), @@ -63,7 +62,7 @@ class WholesaleFeedWebhook(AdCPBaseModel): ), ] fired_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller initiated this webhook fire. Distinct from event.created_at, which is when the seller observed or recorded the feed change.' ), diff --git a/src/adcp/types/domains/creative/audit_observation.py b/src/adcp/types/domains/creative/audit_observation.py index b67ba72bb..77b7e691c 100644 --- a/src/adcp/types/domains/creative/audit_observation.py +++ b/src/adcp/types/domains/creative/audit_observation.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: creative/audit_observation.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import ext as ext_1 @@ -37,7 +37,7 @@ class Details(AdCPBaseModel): extra='forbid', ) agent_url: Annotated[ - AnyUrl, Field(description='Governance agent URL that produced the observation.') + WireAnyUrl, Field(description='Governance agent URL that produced the observation.') ] feature_id: Annotated[ str | None, @@ -60,7 +60,7 @@ class Details(AdCPBaseModel): NoExplicitNull, ] = None substituted_for: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Buyer-nominated verifier URL when the seller or orchestrator used a different on-list governance agent.' ), diff --git a/src/adcp/types/domains/creative/creative_assignment_changed_webhook.py b/src/adcp/types/domains/creative/creative_assignment_changed_webhook.py index 947fce518..70d9df215 100644 --- a/src/adcp/types/domains/creative/creative_assignment_changed_webhook.py +++ b/src/adcp/types/domains/creative/creative_assignment_changed_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: creative/creative_assignment_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import ext as ext_1 @@ -36,7 +36,7 @@ class CreativeAssignmentChangedWebhook(AdCPBaseModel): ), ] notification_type: Literal['creative.assignment_changed'] = 'creative.assignment_changed' - fired_at: AwareDatetime + fired_at: WireDatetime subscriber_id: Annotated[ str, Field(max_length=64, min_length=1, pattern='^[A-Za-z0-9_.:-]{1,64}$') ] @@ -45,5 +45,5 @@ class CreativeAssignmentChangedWebhook(AdCPBaseModel): package_id: str creative_id: str change_kind: ChangeKind - observed_at: AwareDatetime + observed_at: WireDatetime ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/creative/creative_feature_result.py b/src/adcp/types/domains/creative/creative_feature_result.py index f4f63795e..548a55d73 100644 --- a/src/adcp/types/domains/creative/creative_feature_result.py +++ b/src/adcp/types/domains/creative/creative_feature_result.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/creative_feature_result.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import ext as ext_1 @@ -41,10 +41,10 @@ class CreativeFeatureResult(AdCPBaseModel): NoExplicitNull, ] = None measured_at: Annotated[ - AwareDatetime | None, Field(description='When this feature was evaluated'), NoExplicitNull + WireDatetime | None, Field(description='When this feature was evaluated'), NoExplicitNull ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this evaluation expires and should be refreshed'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/creative/creative_purged_webhook.py b/src/adcp/types/domains/creative/creative_purged_webhook.py index 979e10e3b..a2771c424 100644 --- a/src/adcp/types/domains/creative/creative_purged_webhook.py +++ b/src/adcp/types/domains/creative/creative_purged_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: creative/creative_purged_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import ext as ext_1 from ..enums import creative_event_reason_code @@ -50,7 +50,7 @@ class CreativePurgedWebhook(AdCPBaseModel): Literal['creative.purged'], Field(description='Fixed notification type discriminator.') ] = 'creative.purged' fired_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller initiated this fire. Sellers MUST fire `creative.purged` immediately after purge — coalescence is not permitted on this notification type.' ), @@ -81,7 +81,7 @@ class CreativePurgedWebhook(AdCPBaseModel): ), ] purged_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the seller observed the destruction. Distinct from `fired_at`.' ), diff --git a/src/adcp/types/domains/creative/creative_status_changed_webhook.py b/src/adcp/types/domains/creative/creative_status_changed_webhook.py index 1ba5c03a9..693579543 100644 --- a/src/adcp/types/domains/creative/creative_status_changed_webhook.py +++ b/src/adcp/types/domains/creative/creative_status_changed_webhook.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: creative/creative_status_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import creative_revision_id from ..core import ext as ext_1 @@ -35,7 +35,7 @@ class Transition(AdCPBaseModel): ] to: Annotated[creative_status.CreativeStatus, Field(description='New status.')] observed_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description="ISO 8601 timestamp when the seller observed the transition. Distinct from `fired_at` — `fired_at` reflects when the webhook was emitted, which may lag `observed_at` by up to the seller's coalescence window. **Ordering with `media-buy.impairment`:** when a creative transition also causes a media-buy impairment (e.g., `approved → suspended` or `approved → rejected` while assignments exist), the `creative.status_changed` and `impairment` fires are not ordered — buyers MUST NOT assume one arrives before the other. Reconcile via the snapshot (`list_creatives` and `get_media_buys`) when the two fires reference the same `creative_id`." ), @@ -76,7 +76,7 @@ class CreativeStatusChangedWebhook(AdCPBaseModel): ), ] = 'creative.status_changed' fired_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description="ISO 8601 timestamp when the seller initiated this fire. Distinct from when the transition was observed (`transition.observed_at`) — fires MAY be coalesced or delayed up to the seller's declared coalescence window for `creative.status_changed`." ), diff --git a/src/adcp/types/domains/creative/get_creative_delivery_response.py b/src/adcp/types/domains/creative/get_creative_delivery_response.py index 25127fa9f..78fb59c5a 100644 --- a/src/adcp/types/domains/creative/get_creative_delivery_response.py +++ b/src/adcp/types/domains/creative/get_creative_delivery_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/get_creative_delivery_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from collections.abc import Sequence from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 from ..core import creative_variant, delivery_metrics, error @@ -25,8 +25,8 @@ class ReportingPeriod(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - start: Annotated[AwareDatetime, Field(description='ISO 8601 start timestamp')] - end: Annotated[AwareDatetime, Field(description='ISO 8601 end timestamp')] + start: Annotated[WireDatetime, Field(description='ISO 8601 start timestamp')] + end: Annotated[WireDatetime, Field(description='ISO 8601 end timestamp')] timezone: Annotated[ str | None, Field( diff --git a/src/adcp/types/domains/creative/get_creative_features_terminal_success.py b/src/adcp/types/domains/creative/get_creative_features_terminal_success.py index f742bd712..c8afb4904 100644 --- a/src/adcp/types/domains/creative/get_creative_features_terminal_success.py +++ b/src/adcp/types/domains/creative/get_creative_features_terminal_success.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/get_creative_features_terminal_success.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from ..core import context as context_1 from ..core import creative_consumption @@ -32,7 +32,7 @@ class GetCreativeFeaturesSuccess(AdCPBaseModel): Field(description='Feature values for the evaluated creative'), ] detail_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL to the vendor's full assessment report. The vendor controls what information is disclosed and access control." ), diff --git a/src/adcp/types/domains/creative/list_creative_formats_response.py b/src/adcp/types/domains/creative/list_creative_formats_response.py index 1da0e9eb8..3c7f54a49 100644 --- a/src/adcp/types/domains/creative/list_creative_formats_response.py +++ b/src/adcp/types/domains/creative/list_creative_formats_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_creative_formats_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import error @@ -22,7 +22,7 @@ class CreativeAgent(AdCPBaseModel): agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Base URL for the creative agent (e.g., 'https://reference.example.com', 'https://dco.example.com')." ), diff --git a/src/adcp/types/domains/creative/list_creatives_response.py b/src/adcp/types/domains/creative/list_creatives_response.py index c3e438ffd..22abb8b0c 100644 --- a/src/adcp/types/domains/creative/list_creatives_response.py +++ b/src/adcp/types/domains/creative/list_creatives_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/list_creatives_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -10,8 +10,8 @@ from collections.abc import Sequence from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, RootModel, StrictBool, StringConstraints, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, RootModel, StrictBool, StringConstraints, model_validator from ..core import account as account_1 from ..core import context as context_1 @@ -84,7 +84,7 @@ class Indicator(AdCPBaseModel): ), ] detected_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), @@ -112,7 +112,7 @@ class Snapshot(AdCPBaseModel): extra='allow', ) as_of: Annotated[ - AwareDatetime, Field(description='When this snapshot was captured by the platform') + WireDatetime, Field(description='When this snapshot was captured by the platform') ] staleness_seconds: Annotated[ SchemaInt, @@ -129,7 +129,7 @@ class Snapshot(AdCPBaseModel): ), ] last_served: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Last time this creative served an impression. Absent when the creative has never served.' ), @@ -146,7 +146,7 @@ class Purge(AdCPBaseModel): Field(description='Always `soft` on tombstones — hard purges do not surface on this read.'), ] = 'soft' at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 timestamp when the creative was destroyed. Matches the `purged_at` field on the corresponding `creative.purged` webhook fire.' ), @@ -237,7 +237,7 @@ class AssignedPackage(IndicatorBearingResourceState): ), NoExplicitNull, ] = None - assigned_date: Annotated[AwareDatetime, Field(description='When this assignment was created')] + assigned_date: Annotated[WireDatetime, Field(description='When this assignment was created')] approval_status: Annotated[ creative_approval_status.CreativeApprovalStatus | None, Field( @@ -261,7 +261,7 @@ class AssignedPackage(IndicatorBearingResourceState): NoExplicitNull, ] = None indicators_as_of: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller last completed the evaluation represented by indicators for this relationship. Required whenever indicators is present, including an empty array.' ), @@ -371,8 +371,8 @@ class Creative(AdCPBaseModel): status: Annotated[ creative_status.CreativeStatus, Field(description='Current approval status of the creative') ] - created_date: Annotated[AwareDatetime, Field(description='When the creative was created')] - updated_date: Annotated[AwareDatetime, Field(description='When the creative was last modified')] + created_date: Annotated[WireDatetime, Field(description='When the creative was created')] + updated_date: Annotated[WireDatetime, Field(description='When the creative was last modified')] assets: Annotated[ dict[Annotated[str, StringConstraints(pattern=r'^[a-z0-9_]+$')], asset_union.AssetVariant | Assets] | None, Field( diff --git a/src/adcp/types/domains/creative/preview_creative_response.py b/src/adcp/types/domains/creative/preview_creative_response.py index d5734aea2..c595f84d7 100644 --- a/src/adcp/types/domains/creative/preview_creative_response.py +++ b/src/adcp/types/domains/creative/preview_creative_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Literal, TypeAlias -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StringConstraints +from adcp.types.base import WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StringConstraints from ..core.version_envelope import AdcpVersionEnvelope from . import preview_render as preview_render_1 @@ -52,8 +53,8 @@ class Preview2(AdcpVersionEnvelope): class Response(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') previews: Annotated[list[Preview2], Field(min_length=1)] - interactive_url: AnyUrl | None = None - expires_at: AwareDatetime | None = None + interactive_url: WireAnyUrl | None = None + expires_at: WireDatetime | None = None class Result(AdcpVersionEnvelope): @@ -76,8 +77,8 @@ class PreviewCreativeResponse1(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelo response_type: Literal['single'] = 'single' previews: Annotated[list[Preview], Field(min_length=1)] quality_used: creative_quality_1.CreativeQuality | None = None - interactive_url: AnyUrl | None = None - expires_at: AwareDatetime | None = None + interactive_url: WireAnyUrl | None = None + expires_at: WireDatetime | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None @@ -97,7 +98,7 @@ class PreviewCreativeResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelo creative_id: str | None = None previews: Annotated[list[Preview3], Field(min_length=1)] manifest: creative_manifest_1.CreativeManifest | None = None - expires_at: AwareDatetime | None = None + expires_at: WireDatetime | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None diff --git a/src/adcp/types/domains/creative/preview_render.py b/src/adcp/types/domains/creative/preview_render.py index a3284dfe4..5de96e7e6 100644 --- a/src/adcp/types/domains/creative/preview_render.py +++ b/src/adcp/types/domains/creative/preview_render.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: creative/preview_render.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import preview_renderer_metadata @@ -52,7 +52,7 @@ class PreviewRender1(AdCPBaseModel): Literal['url'], Field(description='Discriminator indicating preview_url is provided') ] = 'url' preview_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Untrusted URL to an HTML page that renders this piece. Consumers MUST load it only in a cross-origin iframe with an empty sandbox token set and a caller-enforced restrictive CSP. Provider embedding metadata is advisory and MUST NOT loosen that policy.' ), @@ -133,7 +133,7 @@ class PreviewRender3(AdCPBaseModel): ), ] = 'both' preview_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Untrusted URL to an HTML page that renders this piece. Consumers MUST load it only in a cross-origin iframe with an empty sandbox token set and a caller-enforced restrictive CSP. Provider embedding metadata is advisory and MUST NOT loosen that policy.' ), diff --git a/src/adcp/types/domains/creative/sync_creatives_response.py b/src/adcp/types/domains/creative/sync_creatives_response.py index 20cdbd188..eb162676a 100644 --- a/src/adcp/types/domains/creative/sync_creatives_response.py +++ b/src/adcp/types/domains/creative/sync_creatives_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Literal, TypeAlias -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StringConstraints +from adcp.types.base import WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StringConstraints from ..core.version_envelope import AdcpVersionEnvelope from ..core import account as account_1 @@ -37,8 +38,8 @@ class Creative(AdcpVersionEnvelope): errors: list[error_1.Error] | None = None warnings: list[str] | None = None macro_resolution_results: list[macro_resolution_result_1.MacroResolutionResult] | None = None - preview_url: AnyUrl | None = None - expires_at: AwareDatetime | None = None + preview_url: WireAnyUrl | None = None + expires_at: WireDatetime | None = None assigned_to: list[str] | None = None assignment_errors: dict[Annotated[str, StringConstraints(pattern='^[a-zA-Z0-9_-]+$')], str] | None = None diff --git a/src/adcp/types/domains/creative/validate_input_request.py b/src/adcp/types/domains/creative/validate_input_request.py index 5373a681c..10e6d10e5 100644 --- a/src/adcp/types/domains/creative/validate_input_request.py +++ b/src/adcp/types/domains/creative/validate_input_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: creative/validate_input_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import account_ref, brand_ref, creative_manifest @@ -37,7 +37,7 @@ class Targets2(AdCPBaseModel): class Targets3(AdCPBaseModel): kind: Literal['third_party_format'] = 'third_party_format' id: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='URI-form format identifier referencing a third-party format definition (e.g., `https://creativevendor.example/formats/image_300x250@sha256:...`). Validators fetch the definition (digest-pinned, cached) and validate the manifest against it.' ), diff --git a/src/adcp/types/domains/error_details/account_setup_required.py b/src/adcp/types/domains/error_details/account_setup_required.py index a32396035..9cbd905bf 100644 --- a/src/adcp/types/domains/error_details/account_setup_required.py +++ b/src/adcp/types/domains/error_details/account_setup_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/account_setup_required.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class AccountSetupRequiredDetails(AdCPBaseModel): @@ -15,7 +15,7 @@ class AccountSetupRequiredDetails(AdCPBaseModel): extra='allow', ) setup_url: Annotated[ - AnyUrl | None, Field(description='URL where account setup can be completed'), NoExplicitNull + WireAnyUrl | None, Field(description='URL where account setup can be completed'), NoExplicitNull ] = None setup_steps: Annotated[ list[str] | None, diff --git a/src/adcp/types/domains/error_details/authorization_required.py b/src/adcp/types/domains/error_details/authorization_required.py index da78aeb09..0801c9651 100644 --- a/src/adcp/types/domains/error_details/authorization_required.py +++ b/src/adcp/types/domains/error_details/authorization_required.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/authorization_required.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import downstream_connection_requirement @@ -31,7 +31,7 @@ class AuthorizationRequiredDetails(AdCPBaseModel): NoExplicitNull, ] = None authorization_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='General recovery URL when there is a single obvious authorization step or when the seller has its own connection-management page.' ), diff --git a/src/adcp/types/domains/error_details/creative_rejected.py b/src/adcp/types/domains/error_details/creative_rejected.py index fe18694f7..a431792c6 100644 --- a/src/adcp/types/domains/error_details/creative_rejected.py +++ b/src/adcp/types/domains/error_details/creative_rejected.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/creative_rejected.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class CreativeRejectedDetails(AdCPBaseModel): @@ -18,7 +18,7 @@ class CreativeRejectedDetails(AdCPBaseModel): str | None, Field(description='Identifier for the violated policy'), NoExplicitNull ] = None policy_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL where the full policy can be reviewed'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/governance_agent_not_accepted.py b/src/adcp/types/domains/error_details/governance_agent_not_accepted.py index f9e072682..6e72e2b12 100644 --- a/src/adcp/types/domains/error_details/governance_agent_not_accepted.py +++ b/src/adcp/types/domains/error_details/governance_agent_not_accepted.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/governance_agent_not_accepted.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..governance import accepted_governance_agents as accepted_governance_agents_1 @@ -18,7 +18,7 @@ class GovernanceAgentNotAcceptedDetails1(AdCPBaseModel): ) disclosure: Literal['disclosed'] = 'disclosed' attempted_agent_origin: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Parsed HTTPS origin only. The seller MUST strip userinfo, path, query, and fragment and MUST NOT echo the raw candidate URL.' ), diff --git a/src/adcp/types/domains/error_details/policy_violation.py b/src/adcp/types/domains/error_details/policy_violation.py index a512732bc..b815700ae 100644 --- a/src/adcp/types/domains/error_details/policy_violation.py +++ b/src/adcp/types/domains/error_details/policy_violation.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: error_details/policy_violation.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class Origin(StrEnum): @@ -44,7 +44,7 @@ class PolicyViolationDetails(AdCPBaseModel): NoExplicitNull, ] = None policy_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL where the full policy can be reviewed'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/error_details/stale_response.py b/src/adcp/types/domains/error_details/stale_response.py index b9fcb53f9..fbaa26d02 100644 --- a/src/adcp/types/domains/error_details/stale_response.py +++ b/src/adcp/types/domains/error_details/stale_response.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: error_details/stale_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field class Upstream(AdCPBaseModel): @@ -15,7 +15,7 @@ class Upstream(AdCPBaseModel): extra='allow', ) url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL of the unreachable upstream, when the upstream is HTTP-addressable. Sellers MUST omit when the upstream is an internal (non-URL) dependency.' ), diff --git a/src/adcp/types/domains/error_details/vendor_error_codes.py b/src/adcp/types/domains/error_details/vendor_error_codes.py index f6440abee..bacd3181c 100644 --- a/src/adcp/types/domains/error_details/vendor_error_codes.py +++ b/src/adcp/types/domains/error_details/vendor_error_codes.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: error_details/vendor_error_codes.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import Field class Recovery(StrEnum): @@ -25,7 +25,7 @@ class Codes(AdCPBaseModel): class Vendors(AdCPBaseModel): name: Annotated[str, Field(description='Full vendor name')] url: Annotated[ - AnyUrl | None, Field(description='Vendor website or documentation URL'), NoExplicitNull + WireAnyUrl | None, Field(description='Vendor website or documentation URL'), NoExplicitNull ] = None codes: Annotated[ dict[str, Codes], diff --git a/src/adcp/types/domains/extensions/extension_meta.py b/src/adcp/types/domains/extensions/extension_meta.py index 766691f9e..7d6903c2f 100644 --- a/src/adcp/types/domains/extensions/extension_meta.py +++ b/src/adcp/types/domains/extensions/extension_meta.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: extensions/extension_meta.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import Field class AdcpExtensionFileSchema(AdCPBaseModel): @@ -32,7 +32,7 @@ class AdcpExtensionFileSchema(AdCPBaseModel): NoExplicitNull, ] = None docs_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to documentation for implementors of this extension'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/governance/accepted_governance_agents.py b/src/adcp/types/domains/governance/accepted_governance_agents.py index 07468ee00..adac694cc 100644 --- a/src/adcp/types/domains/governance/accepted_governance_agents.py +++ b/src/adcp/types/domains/governance/accepted_governance_agents.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: governance/accepted_governance_agents.json -# timestamp: 2026-10-04T00:59:59+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Literal -from adcp.types.base import AdCPBaseModel, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field, RootModel +from adcp.types.base import AdCPBaseModel, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field, RootModel class AnyOf1(AdCPBaseModel): @@ -17,7 +17,7 @@ class AnyOf1(AdCPBaseModel): ) kind: Literal['agent_url'] = 'agent_url' agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Exact canonical agent endpoint without userinfo, query, or fragment. Redirect targets, DNS aliases, and URLs asserted by the candidate do not satisfy this matcher.' ), @@ -35,7 +35,7 @@ class AnyOf2(AdCPBaseModel): ) kind: Literal['verification'] = 'verification' registry: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Seller-configured trusted verification registry without userinfo, query, or fragment. The candidate cannot supply or override this URL; fetches use the registry-resolution security contract.' ), diff --git a/src/adcp/types/domains/governance/check_governance_request.py b/src/adcp/types/domains/governance/check_governance_request.py index a1a4e98cb..e68cc3f0b 100644 --- a/src/adcp/types/domains/governance/check_governance_request.py +++ b/src/adcp/types/domains/governance/check_governance_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/check_governance_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictFloat, StringConstraints +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat, StringConstraints from ..core import brand_ref, business_entity, canonical_proposal from ..core import context as context_1 @@ -42,8 +42,8 @@ class ReportingPeriod(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime class Pacing(StrEnum): @@ -120,7 +120,7 @@ class DeliveryMetrics(AdCPBaseModel): NoExplicitNull, ] = None issued_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the seller issued the canonical statement.'), NoExplicitNull, ] = None @@ -219,7 +219,7 @@ class DeliveryMetrics2(AdCPBaseModel): NoExplicitNull, ] = None issued_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the seller issued the canonical statement.'), NoExplicitNull, ] = None @@ -291,7 +291,7 @@ class Subject(AdCPBaseModel): type: Literal['agent'] = 'agent' resource_type: Literal['https://adcontextprotocol.org/claims/subjects/signal'] = 'https://adcontextprotocol.org/claims/subjects/signal' agent_url: Annotated[ - AnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') + WireAnyUrl, Field(description='Canonical HTTPS endpoint of the agent the claim concerns.') ] ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None @@ -308,7 +308,7 @@ class Subject6(AdCPBaseModel): ), ] = 'https://adcontextprotocol.org/claims/subjects/signal' namespace: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Absolute URI identifying the namespace in which id is unique. This may be an AdCP agent endpoint, a catalog origin, or a domain-specific namespace URI.' ), @@ -386,7 +386,7 @@ class CheckGovernanceRequest2(AdcpVersionEnvelope): NoExplicitNull, ] = None caller: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Claimed URL of the agent making the request. The transport credential MUST resolve to an agent URL; the governance agent requires an exact match and uses only that resolved URL for authorization, audit, and signed context issuance. On intent checks the authenticated buyer must be the plan owner or hold an active delegation, while approved_sellers is evaluated against the target service that becomes the token audience. On execution checks the authenticated caller MUST equal that preserved audience. An unresolved body assertion never grants plan access or authorization.' ), @@ -399,7 +399,7 @@ class CheckGovernanceRequest2(AdcpVersionEnvelope): NoExplicitNull, ] = purchase_type_1.PurchaseType.media_buy target_agent: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Exact agent URL of the downstream service that will receive the governed task. Required on intent checks and copied byte-for-byte into the signed governance_context aud claim. This routing and authorization field is not part of payload: payload remains exactly the downstream task arguments. A consultation re-check MUST use the same target_agent.' ), @@ -543,7 +543,7 @@ class CheckGovernanceRequest3(CheckGovernanceRequest1, CheckGovernanceRequest2): NoExplicitNull, ] = None caller: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Claimed URL of the agent making the request. The transport credential MUST resolve to an agent URL; the governance agent requires an exact match and uses only that resolved URL for authorization, audit, and signed context issuance. On intent checks the authenticated buyer must be the plan owner or hold an active delegation, while approved_sellers is evaluated against the target service that becomes the token audience. On execution checks the authenticated caller MUST equal that preserved audience. An unresolved body assertion never grants plan access or authorization.' ), @@ -556,7 +556,7 @@ class CheckGovernanceRequest3(CheckGovernanceRequest1, CheckGovernanceRequest2): NoExplicitNull, ] = purchase_type_1.PurchaseType.media_buy target_agent: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Exact agent URL of the downstream service that will receive the governed task. Required on intent checks and copied byte-for-byte into the signed governance_context aud claim. This routing and authorization field is not part of payload: payload remains exactly the downstream task arguments. A consultation re-check MUST use the same target_agent.' ), diff --git a/src/adcp/types/domains/governance/check_governance_response.py b/src/adcp/types/domains/governance/check_governance_response.py index 81cc41cd2..02fdeb409 100644 --- a/src/adcp/types/domains/governance/check_governance_response.py +++ b/src/adcp/types/domains/governance/check_governance_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/check_governance_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..core import context as context_1 from ..core import ext as ext_1 @@ -47,8 +47,8 @@ class ReportingPeriod(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime class CanonicalPayload(AdCPBaseModel): @@ -66,7 +66,7 @@ class DeliveryStatement(AdCPBaseModel): statement_id: str statement_digest: Annotated[str, Field(pattern='^sha256:[a-f0-9]{64}$')] sequence: Annotated[SchemaInt, Field(ge=1)] - issued_at: AwareDatetime + issued_at: WireDatetime seller_reference: str reporting_period: ReportingPeriod cumulative_spend: Annotated[StrictFloat, Field(ge=0.0)] @@ -239,14 +239,14 @@ def _status_to_verdict(cls, data: Any) -> Any: NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="When this approval expires. In the cross-role shape, present only when verdict is 'approved'. Deprecated legacy conditions responses may also carry it for 3.x compatibility. The caller must act before this time or re-call check_governance. A lapsed approval is no approval." ), NoExplicitNull, ] = None next_check: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller should next call check_governance with delivery metrics. Present when the governance agent expects ongoing delivery reporting.' ), diff --git a/src/adcp/types/domains/governance/get_plan_audit_logs_response.py b/src/adcp/types/domains/governance/get_plan_audit_logs_response.py index ed50527f8..f340ae8b6 100644 --- a/src/adcp/types/domains/governance/get_plan_audit_logs_response.py +++ b/src/adcp/types/domains/governance/get_plan_audit_logs_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/get_plan_audit_logs_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import attestation_evaluation, attestation_reference from ..core import context as context_1 @@ -147,7 +147,7 @@ class Escalation(AdCPBaseModel): NoExplicitNull, ] = None resolved_at: Annotated[ - AwareDatetime | None, Field(description='ISO 8601 resolution timestamp.'), NoExplicitNull + WireDatetime | None, Field(description='ISO 8601 resolution timestamp.'), NoExplicitNull ] = None @@ -342,8 +342,8 @@ class ReportingPeriod(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime class CanonicalPayload(AdCPBaseModel): @@ -361,7 +361,7 @@ class DeliveryStatement(AdCPBaseModel): statement_id: str statement_digest: Annotated[str, Field(pattern='^sha256:[a-f0-9]{64}$')] sequence: Annotated[SchemaInt, Field(ge=1)] - issued_at: AwareDatetime + issued_at: WireDatetime seller_reference: str cumulative_spend: Annotated[StrictFloat, Field(ge=0.0)] currency: Annotated[str, Field(pattern='^[A-Z]{3}$')] @@ -414,7 +414,7 @@ class Delivery(AdCPBaseModel): ), NoExplicitNull, ] = None - observed_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + observed_at: Annotated[WireDatetime | None, NoExplicitNull] = None seller_statement_id: Annotated[ str | None, Field( @@ -484,7 +484,7 @@ class Evidence(AdCPBaseModel): evidence_id: str evidence_type: EvidenceType digest: Annotated[str, Field(pattern='^sha256:[a-f0-9]{64}$')] - issued_at: AwareDatetime + issued_at: WireDatetime class DeliveryReportingPeriod(ReportingPeriod): @@ -583,7 +583,7 @@ class Entry(AdCPBaseModel): ) id: Annotated[str, Field(description='Entry identifier.')] type: Annotated[Type, Field(description='Entry type.')] - timestamp: Annotated[AwareDatetime, Field(description='ISO 8601 timestamp.')] + timestamp: Annotated[WireDatetime, Field(description='ISO 8601 timestamp.')] plan_id: Annotated[ str | None, Field( @@ -592,7 +592,7 @@ class Entry(AdCPBaseModel): NoExplicitNull, ] = None caller: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL of the agent that made the request. Resolved from the credentials used on the governance callback.' ), @@ -701,7 +701,7 @@ class Entry(AdCPBaseModel): str | None, Field(description='Seller-supplied adjustment reason.'), NoExplicitNull ] = None effective_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description="When the seller's adjustment became effective."), NoExplicitNull, ] = None @@ -785,8 +785,8 @@ class Entry(AdCPBaseModel): adjustment_state: Annotated[AdjustmentState | None, NoExplicitNull] = None verified_amount: Annotated[StrictFloat | None, Field(ge=0.0), NoExplicitNull] = None evidence: Annotated[Evidence | None, NoExplicitNull] = None - reviewed_by: Annotated[AnyUrl | None, NoExplicitNull] = None - reviewed_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + reviewed_by: Annotated[WireAnyUrl | None, NoExplicitNull] = None + reviewed_at: Annotated[WireDatetime | None, NoExplicitNull] = None review_reason: Annotated[str | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/governance/policy_entry.py b/src/adcp/types/domains/governance/policy_entry.py index 5ef9b3597..f08102075 100644 --- a/src/adcp/types/domains/governance/policy_entry.py +++ b/src/adcp/types/domains/governance/policy_entry.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/policy_entry.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ..core import ext as ext_1 from ..enums import channels as channels_1 @@ -169,7 +169,7 @@ class PolicyEntry(AdCPBaseModel): NoExplicitNull, ] = None source_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Link to the source regulation, standard, or legislation.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/governance/report_plan_adjustment_request.py b/src/adcp/types/domains/governance/report_plan_adjustment_request.py index d77016249..e7167849d 100644 --- a/src/adcp/types/domains/governance/report_plan_adjustment_request.py +++ b/src/adcp/types/domains/governance/report_plan_adjustment_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/report_plan_adjustment_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..core import context as context_1 from ..core import ext as ext_1 @@ -75,7 +75,7 @@ class Evidence(AdCPBaseModel): ] evidence_type: EvidenceType digest: Annotated[str, Field(pattern='^sha256:[a-f0-9]{64}$')] - issued_at: AwareDatetime + issued_at: WireDatetime class ReportPlanAdjustmentRequest(AdcpRequest, AdcpVersionEnvelope): @@ -149,7 +149,7 @@ class ReportPlanAdjustmentRequest(AdcpRequest, AdcpVersionEnvelope): NoExplicitNull, ] = None effective_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description="When the seller's adjustment became effective."), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/governance/report_plan_outcome_request.py b/src/adcp/types/domains/governance/report_plan_outcome_request.py index 053d3bf36..85cee5374 100644 --- a/src/adcp/types/domains/governance/report_plan_outcome_request.py +++ b/src/adcp/types/domains/governance/report_plan_outcome_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/report_plan_outcome_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import context as context_1 from ..core import ext as ext_1 @@ -37,8 +37,8 @@ class ReportingPeriod(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime class Delivery(AdCPBaseModel): @@ -59,7 +59,7 @@ class Delivery(AdCPBaseModel): description='Whether the buyer is forwarding the seller statement it received or reporting an independently measured observation. A forwarded seller statement is not independent evidence.' ), ] - observed_at: AwareDatetime + observed_at: WireDatetime seller_statement_id: Annotated[ str | None, Field( @@ -126,7 +126,7 @@ class SellerResponse(AdCPBaseModel): NoExplicitNull, ] = None creative_deadline: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='ISO 8601 deadline for creative submission.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/governance/sync_plans_request.py b/src/adcp/types/domains/governance/sync_plans_request.py index bbc9df3e5..a25cb3607 100644 --- a/src/adcp/types/domains/governance/sync_plans_request.py +++ b/src/adcp/types/domains/governance/sync_plans_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: governance/sync_plans_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import brand_ref from ..core import context as context_1 @@ -161,8 +161,8 @@ class Flight(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: Annotated[AwareDatetime, Field(description='Flight start (ISO 8601).')] - end: Annotated[AwareDatetime, Field(description='Flight end (ISO 8601).')] + start: Annotated[WireDatetime, Field(description='Flight start (ISO 8601).')] + end: Annotated[WireDatetime, Field(description='Flight end (ISO 8601).')] class BudgetLimit(AdCPBaseModel): @@ -177,7 +177,7 @@ class Delegation(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - agent_url: Annotated[AnyUrl, Field(description='URL of the delegated agent.')] + agent_url: Annotated[WireAnyUrl, Field(description='URL of the delegated agent.')] authority: Annotated[ delegation_authority.DelegationAuthority, Field(description='Authority level granted to this agent.'), @@ -197,7 +197,7 @@ class Delegation(AdCPBaseModel): NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this delegation expires. After expiration, the governance agent denies actions from this agent.' ), @@ -347,7 +347,7 @@ class Plan(AdCPBaseModel): NoExplicitNull, ] = None approved_sellers: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field(description='List of approved seller agent URLs. null means any seller.'), ] = None delegations: Annotated[ diff --git a/src/adcp/types/domains/manifest_schema.py b/src/adcp/types/domains/manifest_schema.py index e2148cfd2..b1bf5dd03 100644 --- a/src/adcp/types/domains/manifest_schema.py +++ b/src/adcp/types/domains/manifest_schema.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: manifest.schema.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from pydantic import ConfigDict, Field, StrictBool class Protocol(StrEnum): @@ -294,7 +294,7 @@ class AdcpManifest(AdCPBaseModel): ), ] generated_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO-8601 timestamp the manifest was generated. SDKs MAY use this for cache invalidation.' ), diff --git a/src/adcp/types/domains/media_buy/accept_proposal_request.py b/src/adcp/types/domains/media_buy/accept_proposal_request.py index 2096c59c6..ee2e1a16c 100644 --- a/src/adcp/types/domains/media_buy/accept_proposal_request.py +++ b/src/adcp/types/domains/media_buy/accept_proposal_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/accept_proposal_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..core import canonical_account_ref from ..core import context as context_1 @@ -34,7 +34,7 @@ class IoAcceptance(AdCPBaseModel): extra='forbid', ) io_id: Annotated[str, Field(min_length=1)] - accepted_at: AwareDatetime + accepted_at: WireDatetime signatory: Annotated[str, Field(max_length=250, min_length=1)] signature_id: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/accept_proposal_response.py b/src/adcp/types/domains/media_buy/accept_proposal_response.py index bdee58017..a69ab8e45 100644 --- a/src/adcp/types/domains/media_buy/accept_proposal_response.py +++ b/src/adcp/types/domains/media_buy/accept_proposal_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/accept_proposal_response.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import canonical_media_buy_action from ..core import context as context_1 @@ -123,7 +123,7 @@ class AcceptProposalResponse1(AdCPBaseModel): ] = None revision: Annotated[SchemaInt, Field(ge=1)] media_buy_status: Annotated[media_buy_status_1.MediaBuyStatus | None, NoExplicitNull] = None - confirmed_at: AwareDatetime | None = None + confirmed_at: WireDatetime | None = None accepted_proposal: AcceptedProposal purchase_bindings: Annotated[ list[PurchaseBinding], diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py b/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py index 44f2aa74a..e503d3212 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_catalog.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_catalog.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, model_validator from ..core import ext as ext_1 from . import acceptance_policy_profile, acceptance_policy_profile_ref @@ -21,7 +21,7 @@ class AcceptancePolicyCatalog(AdCPBaseModel): extra='forbid', ) catalog_version: Annotated[str, Field(min_length=1)] - generated_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + generated_at: Annotated[WireDatetime | None, NoExplicitNull] = None profiles: Annotated[ list[acceptance_policy_profile.AcceptancePolicyProfile] | None, Field(min_length=1), diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_requirement.py b/src/adcp/types/domains/media_buy/acceptance_policy_requirement.py index 7715e6fad..1b3204f40 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_requirement.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_requirement.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_requirement.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from ..core import ext as ext_1 from ..enums import restricted_attribute @@ -162,8 +162,8 @@ class AcceptancePolicyRequirement15(AdCPBaseModel): extra='forbid', ) kind: Literal['time_restriction'] = 'time_restriction' - starts_at: Annotated[AwareDatetime | None, NoExplicitNull] = None - ends_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + starts_at: Annotated[WireDatetime | None, NoExplicitNull] = None + ends_at: Annotated[WireDatetime | None, NoExplicitNull] = None description: Annotated[str | None, Field(min_length=1), NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/acceptance_policy_rule.py b/src/adcp/types/domains/media_buy/acceptance_policy_rule.py index 7832dfdd8..9c77491aa 100644 --- a/src/adcp/types/domains/media_buy/acceptance_policy_rule.py +++ b/src/adcp/types/domains/media_buy/acceptance_policy_rule.py @@ -1,15 +1,15 @@ # generated by datamodel-codegen: # filename: media_buy/acceptance_policy_rule.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from ..core import ext as ext_1 from . import acceptance_policy_requirement @@ -123,6 +123,6 @@ class AcceptancePolicyRule(AdCPBaseModel): ), NoExplicitNull, ] = None - effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None - expires_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + effective_at: Annotated[WireDatetime | None, NoExplicitNull] = None + expires_at: Annotated[WireDatetime | None, NoExplicitNull] = None ext: Annotated[ext_1.ExtensionObject | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/media_buy/build_creative_response.py b/src/adcp/types/domains/media_buy/build_creative_response.py index e7f676575..2e21be8fe 100644 --- a/src/adcp/types/domains/media_buy/build_creative_response.py +++ b/src/adcp/types/domains/media_buy/build_creative_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Any, Literal, TypeAlias -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StringConstraints +from adcp.types.base import WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StringConstraints from ..core.version_envelope import AdcpVersionEnvelope from ..core import context as context_1 @@ -42,8 +43,8 @@ class Preview2(AdcpVersionEnvelope): class Preview(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') previews: Annotated[list[Preview2], Field(min_length=1)] - interactive_url: AnyUrl | None = None - expires_at: AwareDatetime + interactive_url: WireAnyUrl | None = None + expires_at: WireDatetime class Input2(AdcpVersionEnvelope): @@ -65,8 +66,8 @@ class Preview4(AdcpVersionEnvelope): class Preview3(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') previews: Annotated[list[Preview4], Field(min_length=1)] - interactive_url: AnyUrl | None = None - expires_at: AwareDatetime + interactive_url: WireAnyUrl | None = None + expires_at: WireDatetime class CatalogItemRef(AdcpVersionEnvelope): @@ -140,7 +141,7 @@ class BuildCreativeResponse1(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope build_variant_id: str | None = None recipe_hash: str | None = None sandbox: bool | None = None - expires_at: AwareDatetime | None = None + expires_at: WireDatetime | None = None preview: Preview | None = None preview_error: error_1.Error | None = None pricing_option_id: str | None = None @@ -162,7 +163,7 @@ class BuildCreativeResponse3(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope model_config = ConfigDict(extra='allow') creative_manifests: Annotated[list[creative_manifest_1.CreativeManifest], Field(min_length=1)] sandbox: bool | None = None - expires_at: AwareDatetime | None = None + expires_at: WireDatetime | None = None preview: Preview3 | None = None preview_error: error_1.Error | None = None pricing_option_id: str | None = None @@ -187,7 +188,7 @@ class BuildCreativeResponse4(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope budget_status: Literal['complete', 'capped'] | None = None errors: list[error_1.Error] | None = None sandbox: bool | None = None - expires_at: AwareDatetime | None = None + expires_at: WireDatetime | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None @@ -196,7 +197,7 @@ class BuildCreativeResponse5(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope model_config = ConfigDict(extra='allow') mode: Literal['estimate'] = 'estimate' estimate: Estimate - expires_at: AwareDatetime | None = None + expires_at: WireDatetime | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None diff --git a/src/adcp/types/domains/media_buy/buy_products_request.py b/src/adcp/types/domains/media_buy/buy_products_request.py index 148df0d5d..a518799c3 100644 --- a/src/adcp/types/domains/media_buy/buy_products_request.py +++ b/src/adcp/types/domains/media_buy/buy_products_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/buy_products_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import ( bidding_policy, @@ -154,7 +154,7 @@ class BuyProductsRequest(AdcpRequest, AdcpVersionEnvelope): canonical_budget_allocation.CanonicalBudgetAllocation | None, NoExplicitNull ] = None start_time: start_timing.StartTiming - end_time: AwareDatetime + end_time: WireDatetime pacing: Annotated[ pacing_1.Pacing | None, Field( diff --git a/src/adcp/types/domains/media_buy/buy_products_response.py b/src/adcp/types/domains/media_buy/buy_products_response.py index 2db9dd14b..a9c88c420 100644 --- a/src/adcp/types/domains/media_buy/buy_products_response.py +++ b/src/adcp/types/domains/media_buy/buy_products_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/buy_products_response.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import canonical_media_buy_action from ..core import context as context_1 @@ -123,7 +123,7 @@ class BuyProductsResponse1(AdCPBaseModel): ] = None revision: Annotated[SchemaInt, Field(ge=1)] media_buy_status: Annotated[media_buy_status_1.MediaBuyStatus | None, NoExplicitNull] = None - confirmed_at: AwareDatetime | None = None + confirmed_at: WireDatetime | None = None accepted_proposal: AcceptedProposal purchase_bindings: Annotated[ list[PurchaseBinding], diff --git a/src/adcp/types/domains/media_buy/change_term_constraints.py b/src/adcp/types/domains/media_buy/change_term_constraints.py index d4f93a7d5..3fc2d120d 100644 --- a/src/adcp/types/domains/media_buy/change_term_constraints.py +++ b/src/adcp/types/domains/media_buy/change_term_constraints.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/change_term_constraints.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..core import duration @@ -103,12 +103,12 @@ class MediaBuyChangeTermConstraints2(AdCPBaseModel): NoExplicitNull, ] = None earliest_result: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Earliest resulting start or end timestamp accepted for this action.'), NoExplicitNull, ] = None latest_result: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Latest resulting start or end timestamp accepted for this action.'), NoExplicitNull, ] = None @@ -140,8 +140,8 @@ class MediaBuyChangeTermConstraints4(AdCPBaseModel): ), NoExplicitNull, ] = None - earliest_effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None - latest_effective_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + earliest_effective_at: Annotated[WireDatetime | None, NoExplicitNull] = None + latest_effective_at: Annotated[WireDatetime | None, NoExplicitNull] = None @model_validator(mode='after') diff --git a/src/adcp/types/domains/media_buy/commercial_terms.py b/src/adcp/types/domains/media_buy/commercial_terms.py index e77ed00f9..9201e56a5 100644 --- a/src/adcp/types/domains/media_buy/commercial_terms.py +++ b/src/adcp/types/domains/media_buy/commercial_terms.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/commercial_terms.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat, model_validator from ..core import ( bidding_policy, @@ -39,7 +39,7 @@ class CancellationTerms(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - effective_at: AwareDatetime + effective_at: WireDatetime fee: Annotated[Fee | None, NoExplicitNull] = None reason: Annotated[str | None, Field(max_length=500, min_length=1), NoExplicitNull] = None @@ -86,7 +86,7 @@ class CommercialTerms(AdCPBaseModel): ), ] start_time: start_timing.StartTiming - end_time: AwareDatetime + end_time: WireDatetime total_budget: Annotated[TotalBudget | None, NoExplicitNull] = None daily_budget_cap: Annotated[ StrictFloat | None, diff --git a/src/adcp/types/domains/media_buy/control_media_buy_response.py b/src/adcp/types/domains/media_buy/control_media_buy_response.py index 4571fdcc1..9ea2d455a 100644 --- a/src/adcp/types/domains/media_buy/control_media_buy_response.py +++ b/src/adcp/types/domains/media_buy/control_media_buy_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/control_media_buy_response.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,9 +9,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from ..core import canonical_media_buy_action from ..core import context as context_1 @@ -74,7 +74,7 @@ class ControlMediaBuyResponse1(AdcpResponse, AdCPBaseModel): media_buy_id: Annotated[str, Field(min_length=1)] revision: Annotated[SchemaInt, Field(ge=1)] media_buy_status: Annotated[media_buy_status_1.MediaBuyStatus | None, NoExplicitNull] = None - implementation_date: AwareDatetime | None = None + implementation_date: WireDatetime | None = None affected_package_ids: Annotated[list[AffectedPackageId] | None, NoExplicitNull] = None available_actions: Annotated[ list[canonical_media_buy_action.CanonicalMediaBuyAction] | None, NoExplicitNull diff --git a/src/adcp/types/domains/media_buy/create_media_buy_request.py b/src/adcp/types/domains/media_buy/create_media_buy_request.py index 2d168f6c8..2b169447a 100644 --- a/src/adcp/types/domains/media_buy/create_media_buy_request.py +++ b/src/adcp/types/domains/media_buy/create_media_buy_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/create_media_buy_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -10,8 +10,8 @@ from collections.abc import Sequence from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import account_ref, bidding_policy, brand_ref from ..core import budget_allocation as budget_allocation_1 @@ -54,7 +54,7 @@ class IoAcceptance(AdCPBaseModel): str, Field(description="The io_id from the proposal's insertion_order being accepted") ] accepted_at: Annotated[ - AwareDatetime, Field(description='ISO 8601 timestamp when the IO was accepted') + WireDatetime, Field(description='ISO 8601 timestamp when the IO was accepted') ] signatory: Annotated[ str, @@ -108,7 +108,7 @@ class ArtifactWebhook(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - url: Annotated[AnyUrl, Field(description='Webhook endpoint URL for artifact delivery')] + url: Annotated[WireAnyUrl, Field(description='Webhook endpoint URL for artifact delivery')] token: Annotated[ str | None, Field( @@ -303,7 +303,7 @@ class CreateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): ] = None start_time: start_timing.StartTiming end_time: Annotated[ - AwareDatetime, Field(description='Campaign end date/time in ISO 8601 format') + WireDatetime, Field(description='Campaign end date/time in ISO 8601 format') ] pacing: Annotated[ pacing_1.Pacing | None, diff --git a/src/adcp/types/domains/media_buy/create_media_buy_response.py b/src/adcp/types/domains/media_buy/create_media_buy_response.py index d9cd4d726..b81452666 100644 --- a/src/adcp/types/domains/media_buy/create_media_buy_response.py +++ b/src/adcp/types/domains/media_buy/create_media_buy_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Any, Literal, TypeAlias -from pydantic import AwareDatetime, ConfigDict, Field, StringConstraints, model_validator +from adcp.types.base import WireDatetime +from pydantic import ConfigDict, Field, StringConstraints, model_validator from ..core.version_envelope import AdcpVersionEnvelope from ..core import account as account_1 @@ -38,8 +39,8 @@ class CreateMediaBuyResponse1(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelop account: account_1.Account | None = None invoice_recipient: business_entity_1.BusinessEntity | None = None media_buy_status: media_buy_status_1.MediaBuyStatus | None = None - confirmed_at: AwareDatetime | None - creative_deadline: AwareDatetime | None = None + confirmed_at: WireDatetime | None + creative_deadline: WireDatetime | None = None revision: Annotated[int, Field(ge=1)] currency: Annotated[str, StringConstraints(pattern='^[A-Z]{3}$')] | None = None total_budget: Annotated[float, Field(ge=0)] | None = None diff --git a/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py b/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py index ecb618592..26f7e5452 100644 --- a/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py +++ b/src/adcp/types/domains/media_buy/get_media_buy_delivery_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buy_delivery_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -10,8 +10,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import attribution_window as attribution_window_1 from ..core import ( @@ -69,13 +69,13 @@ class ReportingPeriod(AdCPBaseModel): extra='allow', ) start: Annotated[ - AwareDatetime, + WireDatetime, Field( description="Inclusive RFC 3339 start instant. For a date-bounded request, the start of start_date in the reporting timezone, written with that zone's UTC offset or as the equivalent UTC instant (e.g., 2026-04-15T00:00:00-04:00 or 2026-04-15T04:00:00Z for America/New_York; 2026-04-15T00:00:00Z for UTC)." ), ] end: Annotated[ - AwareDatetime, + WireDatetime, Field( description='Exclusive RFC 3339 end instant. For a date-bounded request, the start of end_date in the reporting timezone (e.g., 2026-04-16T00:00:00-04:00 for a report covering 2026-04-15 in America/New_York).' ), @@ -461,7 +461,7 @@ class BySpotItem(DeliveryMetrics): NoExplicitNull, ] = None aired_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='RFC 3339 timestamp when the spot actually aired. Its presence proves an airing; it is not inferred from impressions.' ), @@ -506,13 +506,13 @@ class ByPackageItem1(DeliveryMetrics): class Window(AdCPBaseModel): window_start: Annotated[ - AwareDatetime, + WireDatetime, Field( description='ISO 8601 start of the window slice (inclusive). Daily, weekly, monthly, and quarterly slices start on reporting-timezone calendar boundaries (see reporting_period.timezone), which are UTC midnights only when that timezone is UTC. Weekly slices start Monday 00:00 in the reporting timezone (ISO 8601 weeks). Quarterly slices start on 1 January, 1 April, 1 July and 1 October.' ), ] window_end: Annotated[ - AwareDatetime, + WireDatetime, Field( description="ISO 8601 end of the window slice (exclusive). The next row's window_start equals this value when slices are contiguous." ), @@ -597,7 +597,7 @@ class ByPackageItem(DeliveryMetrics): NoExplicitNull, ] = None finalized_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="ISO 8601 timestamp at which this package's data became final. Present only when `is_final: true`. Anchors reconciliation and (when later defined) dispute-window clocks against the buy's `measurement_terms.billing_measurement.measurement_window`." ), @@ -1200,7 +1200,7 @@ class MediaBuyDelivery(AdCPBaseModel): ), ] expected_availability: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When delayed data is expected to be available (only present when status is reporting_delayed)' ), @@ -1221,7 +1221,7 @@ class MediaBuyDelivery(AdCPBaseModel): NoExplicitNull, ] = None finalized_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="ISO 8601 timestamp at which this row became final. Present only when `is_final: true`. Anchors the buyer's reconciliation and (when later defined) dispute-window clocks against the buy's `measurement_terms.billing_measurement`. Computed as the latest `finalized_at` across the row's packages for the reconciliation window." ), @@ -1293,7 +1293,7 @@ class GetMediaBuyDeliveryResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv NoExplicitNull, ] = None next_expected_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="ISO 8601 timestamp for next expected notification (only present in webhook deliveries when notification_type is not 'final')" ), diff --git a/src/adcp/types/domains/media_buy/get_media_buys_response.py b/src/adcp/types/domains/media_buy/get_media_buys_response.py index f11e593c3..a11535b2a 100644 --- a/src/adcp/types/domains/media_buy/get_media_buys_response.py +++ b/src/adcp/types/domains/media_buy/get_media_buys_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buys_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -10,8 +10,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import account as account_1 from ..core import bidding_policy @@ -68,7 +68,7 @@ class Cancellation(AdCPBaseModel): extra='forbid', ) canceled_at: Annotated[ - AwareDatetime, Field(description='ISO 8601 timestamp when this media buy was canceled.') + WireDatetime, Field(description='ISO 8601 timestamp when this media buy was canceled.') ] canceled_by: Annotated[ canceled_by_1.CanceledBy, Field(description='Which party initiated the cancellation.') @@ -87,7 +87,7 @@ class HistoryItem(AdCPBaseModel): revision: Annotated[ SchemaInt, Field(description='Revision number after this change was applied.', ge=1) ] - timestamp: Annotated[AwareDatetime, Field(description='When this change occurred.')] + timestamp: Annotated[WireDatetime, Field(description='When this change occurred.')] actor: Annotated[ str | None, Field( @@ -137,7 +137,7 @@ class Cancellation1(AdCPBaseModel): extra='forbid', ) canceled_at: Annotated[ - AwareDatetime, Field(description='ISO 8601 timestamp when this package was canceled.') + WireDatetime, Field(description='ISO 8601 timestamp when this package was canceled.') ] canceled_by: Annotated[ canceled_by_1.CanceledBy, @@ -167,7 +167,7 @@ class Snapshot(AdCPBaseModel): extra='allow', ) as_of: Annotated[ - AwareDatetime, + WireDatetime, Field(description='ISO 8601 timestamp when this snapshot was captured by the platform'), ] staleness_seconds: Annotated[ @@ -234,7 +234,7 @@ class Indicator(AdCPBaseModel): ), ] detected_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), @@ -269,7 +269,7 @@ class Indicator1(AdCPBaseModel): ), ] detected_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), @@ -304,7 +304,7 @@ class Indicator2(AdCPBaseModel): ), ] detected_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller first detected the current uninterrupted occurrence of this indicator. Keep this value stable while the condition remains present. If the condition clears and is later detected again, use the new detection time. Optional because some upstream platforms expose current assessments without an original detection timestamp.' ), @@ -364,7 +364,7 @@ class CreativeApproval(IndicatorBearingResourceState): NoExplicitNull, ] = None indicators_as_of: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the seller last completed the evaluation represented by indicators for this relationship. Required whenever indicators is present, including an empty array.' ), @@ -535,14 +535,14 @@ class Package(IndicatorBearingResourceState): NoExplicitNull, ] = None start_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 flight start time for this package. Use to determine whether the package is within its scheduled flight before interpreting delivery status.' ), NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='ISO 8601 flight end time for this package'), NoExplicitNull, ] = None @@ -564,7 +564,7 @@ class Package(IndicatorBearingResourceState): NoExplicitNull, ] = None creative_deadline: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="ISO 8601 timestamp for creative upload or change deadline for this package. After this deadline, creative changes are rejected. When absent, the media buy's creative_deadline applies." ), @@ -700,7 +700,7 @@ class MediaBuy(IndicatorBearingResourceState): ] = None status: media_buy_status.MediaBuyStatus status_as_of: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp indicating when the seller last refreshed the returned media-buy-level `status` from its source of truth. Use this to interpret cached or rolled-up list statuses, especially for curator/storefront aggregators where one buyer-facing buy maps to multiple upstream legs. For rolled-up statuses, this timestamp MUST NOT be later than the oldest upstream status observation that could affect the returned roll-up, so it never overstates freshness. Omit or return null to make no freshness assertion; buyers MUST NOT infer that an omitted or null value means the status is live. This is distinct from `updated_at`, which records when the media buy was last modified.' ), @@ -781,26 +781,26 @@ class MediaBuy(IndicatorBearingResourceState): NoExplicitNull, ] = None start_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 flight start time for this media buy (earliest package start_time). Avoids requiring buyers to compute min(packages[].start_time).' ), NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 flight end time for this media buy (latest package end_time). Avoids requiring buyers to compute max(packages[].end_time).' ), NoExplicitNull, ] = None creative_deadline: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='ISO 8601 timestamp for creative upload deadline'), NoExplicitNull, ] = None confirmed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when the seller committed to this media buy. May be null until seller commitment occurs in deferred/manual approval flows. Once populated, remains stable through later pause, resume, activation, completion, cancellation, and reporting transitions.' ), @@ -818,10 +818,10 @@ class MediaBuy(IndicatorBearingResourceState): ), ] created_at: Annotated[ - AwareDatetime | None, Field(description='Creation timestamp'), NoExplicitNull + WireDatetime | None, Field(description='Creation timestamp'), NoExplicitNull ] = None updated_at: Annotated[ - AwareDatetime | None, Field(description='Last update timestamp'), NoExplicitNull + WireDatetime | None, Field(description='Last update timestamp'), NoExplicitNull ] = None context: Annotated[ context_1.ContextObject | None, diff --git a/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py b/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py index 1699e6a90..90a6ac3aa 100644 --- a/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py +++ b/src/adcp/types/domains/media_buy/get_products_async_response_submitted.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/get_products_async_response_submitted.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import error @@ -39,7 +39,7 @@ class GetProductsSubmitted(AdCPBaseModel): NoExplicitNull, ] = None estimated_completion: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Estimated completion time for the search'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/media_buy/get_reporting_status_request.py b/src/adcp/types/domains/media_buy/get_reporting_status_request.py index 140437f82..0e8cac33a 100644 --- a/src/adcp/types/domains/media_buy/get_reporting_status_request.py +++ b/src/adcp/types/domains/media_buy/get_reporting_status_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_reporting_status_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,9 +9,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from ..core import canonical_account_ref from ..core import context as context_1 @@ -30,8 +30,8 @@ class Period(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime class ReportingStatusView(StrEnum): diff --git a/src/adcp/types/domains/media_buy/get_reporting_status_response.py b/src/adcp/types/domains/media_buy/get_reporting_status_response.py index 6a7922c9a..db7e5bb10 100644 --- a/src/adcp/types/domains/media_buy/get_reporting_status_response.py +++ b/src/adcp/types/domains/media_buy/get_reporting_status_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/get_reporting_status_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, model_validator from ..core import context as context_1 from ..core import error @@ -75,8 +75,8 @@ class Scope(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - period_start: AwareDatetime - period_end: AwareDatetime + period_start: WireDatetime + period_end: WireDatetime scope_closed: Annotated[ StrictBool, Field(description='True only when no new obligation can enter this evaluated scope.'), @@ -99,7 +99,7 @@ class Scope(AdCPBaseModel): feed_purposes: list[reporting_delivery_offering.ReportingFeedPurpose] finality: list[reporting_finality.ReportingFinality] ledger_retained_from: Annotated[ - AwareDatetime, + WireDatetime, Field( description='Earliest period boundary for which anti-entropy metadata is retained for every selected configuration generation.' ), @@ -127,7 +127,7 @@ class GetReportingStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve NoExplicitNull, ] = None ledger_as_of: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Exclusive observation boundary for ledger_snapshot_id. Revisions committed later appear only in a later reconciliation.' ), @@ -163,13 +163,13 @@ class GetReportingStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnve NoExplicitNull, ] = None data_through: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Conservative latest included event time across satisfied obligations in scope, or null when unavailable/unknown.' ), ] = None next_expected_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Next obligation due time for an open scope. In a complete summary (view: summary, health: complete), the nearest future period start, strictly after ledger_as_of, across all active committed configuration generations in scope.delivery_config_generations. Sellers MUST populate it when such a scheduled period exists outside the closed evaluated scope, and omit it when none exists. This complete-scope projection applies only to summary responses. It is derived from the configuration schedule and does not represent an open obligation in the evaluated scope; its presence does not indicate that the scope is still open. Projecting it MUST NOT create, expose, lease, count, or alter an obligation whose period has not closed, or change obligation_counts, scope, or coverage. Obligation expected_at remains period.end plus schedule.delivery_sla; get_media_buy_delivery.next_expected_at remains the next webhook notification time.' ), diff --git a/src/adcp/types/domains/media_buy/legacy_purchase_continuation_input.py b/src/adcp/types/domains/media_buy/legacy_purchase_continuation_input.py index 922139c42..9f6784e24 100644 --- a/src/adcp/types/domains/media_buy/legacy_purchase_continuation_input.py +++ b/src/adcp/types/domains/media_buy/legacy_purchase_continuation_input.py @@ -1,14 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/legacy_purchase_continuation_input.json -# timestamp: 2026-10-04T00:53:27+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from uuid import UUID -from adcp.types.base import AdCPBaseModel +from adcp.types.base import AdCPBaseModel, WireUuid from adcp.types._scalar import ScalarStr from pydantic import ConfigDict, Field, field_validator @@ -31,7 +30,7 @@ class CompatibilityPurchaseCoordinatorInput(AdCPBaseModel): extra='forbid', ) idempotency_key: Annotated[ - UUID, + WireUuid, Field( description='Replay identity for this logical coordinator operation. Exact retries resume the durable operation record instead of redeeming the continuation again.' ), diff --git a/src/adcp/types/domains/media_buy/list_creative_formats_response.py b/src/adcp/types/domains/media_buy/list_creative_formats_response.py index 945755279..15417b218 100644 --- a/src/adcp/types/domains/media_buy/list_creative_formats_response.py +++ b/src/adcp/types/domains/media_buy/list_creative_formats_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/list_creative_formats_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 from ..core import error @@ -29,7 +29,7 @@ class Source(StrEnum): class CreativeAgent(AdCPBaseModel): agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="Base URL for the creative agent (e.g., 'https://reference.example.com', 'https://dco.example.com')." ), diff --git a/src/adcp/types/domains/media_buy/media_buy_commitment_response.py b/src/adcp/types/domains/media_buy/media_buy_commitment_response.py index 869389c64..ee6d14b81 100644 --- a/src/adcp/types/domains/media_buy/media_buy_commitment_response.py +++ b/src/adcp/types/domains/media_buy/media_buy_commitment_response.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: media_buy/media_buy_commitment_response.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from ..core import canonical_media_buy_action from ..core import context as context_1 @@ -109,7 +109,7 @@ class MediaBuyCommitmentResponse1(AdCPBaseModel): ] = None revision: Annotated[SchemaInt, Field(ge=1)] media_buy_status: Annotated[media_buy_status_1.MediaBuyStatus | None, NoExplicitNull] = None - confirmed_at: AwareDatetime | None = None + confirmed_at: WireDatetime | None = None accepted_proposal: AcceptedProposal purchase_bindings: Annotated[ list[PurchaseBinding], diff --git a/src/adcp/types/domains/media_buy/media_buy_delivery_webhook_result.py b/src/adcp/types/domains/media_buy/media_buy_delivery_webhook_result.py index aad82084f..932a920dc 100644 --- a/src/adcp/types/domains/media_buy/media_buy_delivery_webhook_result.py +++ b/src/adcp/types/domains/media_buy/media_buy_delivery_webhook_result.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: media_buy/media_buy_delivery_webhook_result.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import attribution_window as attribution_window_1 from ..core import context as context_1 @@ -32,8 +32,8 @@ class ReportingPeriod(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - start: AwareDatetime - end: AwareDatetime + start: WireDatetime + end: WireDatetime timezone: Annotated[ str | None, Field( @@ -91,7 +91,7 @@ class ByPackageItem(DeliveryMetrics): ] = None paused: Annotated[StrictBool | None, NoExplicitNull] = None is_final: Annotated[StrictBool | None, NoExplicitNull] = None - finalized_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + finalized_at: Annotated[WireDatetime | None, NoExplicitNull] = None measurement_window: Annotated[str | None, Field(max_length=50), NoExplicitNull] = None supersedes_window: Annotated[str | None, Field(max_length=50), NoExplicitNull] = None metric_values: Annotated[ @@ -124,7 +124,7 @@ class MediaBuyDelivery(AdCPBaseModel): ), ] expected_availability: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When delayed data is expected to be available. Present when status is reporting_delayed.' ), @@ -143,7 +143,7 @@ class MediaBuyDelivery(AdCPBaseModel): NoExplicitNull, ] = None finalized_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Timestamp when this row became final. Present only when is_final is true.' ), @@ -195,7 +195,7 @@ class MediaBuyDeliveryWebhookResult(AdCPBaseModel): NoExplicitNull, ] = None next_expected_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp for the next expected notification. Omitted on final notifications.' ), diff --git a/src/adcp/types/domains/media_buy/package_request.py b/src/adcp/types/domains/media_buy/package_request.py index 366f00c3d..a879e2ef1 100644 --- a/src/adcp/types/domains/media_buy/package_request.py +++ b/src/adcp/types/domains/media_buy/package_request.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: media_buy/package_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from collections.abc import Sequence from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat, model_validator +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat, model_validator from ..core import audience_evidence_pin from ..core import audience_evidence_requirements as audience_evidence_requirements_1 @@ -208,14 +208,14 @@ class PackageRequest(AdcpVersionEnvelope): NoExplicitNull, ] = None start_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="Flight start date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's start_time. Must fall within the media buy's date range." ), NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="Flight end date/time for this package in ISO 8601 format. When omitted, the package inherits the media buy's end_time. Must fall within the media buy's date range." ), diff --git a/src/adcp/types/domains/media_buy/package_update.py b/src/adcp/types/domains/media_buy/package_update.py index f3ec451d4..6e9f4908d 100644 --- a/src/adcp/types/domains/media_buy/package_update.py +++ b/src/adcp/types/domains/media_buy/package_update.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: media_buy/package_update.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import bidding_policy, catalog from ..core import context as context_1 @@ -112,14 +112,14 @@ class PackageUpdate(AdCPBaseModel): ), ] = None start_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="Updated flight start date/time for this package in ISO 8601 format. Must fall within the media buy's date range." ), NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description="Updated flight end date/time for this package in ISO 8601 format. Must fall within the media buy's date range." ), diff --git a/src/adcp/types/domains/media_buy/product_purchase.py b/src/adcp/types/domains/media_buy/product_purchase.py index 49f84e9fe..f0481ef89 100644 --- a/src/adcp/types/domains/media_buy/product_purchase.py +++ b/src/adcp/types/domains/media_buy/product_purchase.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: media_buy/product_purchase.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat from ..core import ( audience_evidence_pin, @@ -86,14 +86,14 @@ class ProductPurchase(AdCPBaseModel): StrictFloat | None, Field(ge=0.0, title='Product Purchase Impressions'), NoExplicitNull ] = None start_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Resolved package flight start. On direct-purchase input, omission inherits the MediaBuy start; accepted proposal snapshots carry the resolved timestamp.' ), NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Resolved package flight end. On direct-purchase input, omission inherits the MediaBuy end; accepted proposal snapshots carry the resolved timestamp.' ), diff --git a/src/adcp/types/domains/media_buy/product_purchase_input.py b/src/adcp/types/domains/media_buy/product_purchase_input.py index 76c19ffbc..68ca489db 100644 --- a/src/adcp/types/domains/media_buy/product_purchase_input.py +++ b/src/adcp/types/domains/media_buy/product_purchase_input.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: media_buy/product_purchase_input.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat from ..core import ( audience_evidence_pin, @@ -86,14 +86,14 @@ class ProductPurchaseInput(AdCPBaseModel): StrictFloat | None, Field(ge=0.0, title='Product Purchase Impressions'), NoExplicitNull ] = None start_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Resolved package flight start. On direct-purchase input, omission inherits the MediaBuy start; accepted proposal snapshots carry the resolved timestamp.' ), NoExplicitNull, ] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Resolved package flight end. On direct-purchase input, omission inherits the MediaBuy end; accepted proposal snapshots carry the resolved timestamp.' ), diff --git a/src/adcp/types/domains/media_buy/proposal_refinement.py b/src/adcp/types/domains/media_buy/proposal_refinement.py index daa42b41c..99df08868 100644 --- a/src/adcp/types/domains/media_buy/proposal_refinement.py +++ b/src/adcp/types/domains/media_buy/proposal_refinement.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: media_buy/proposal_refinement.json -# timestamp: 2026-10-21T12:09:21+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field, StrictFloat from ..core import product_change_map from . import product_discovery_criteria, proposal_budget_constraint @@ -43,8 +43,8 @@ class Flight(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - start_no_later_than: Annotated[AwareDatetime | None, NoExplicitNull] = None - end_no_earlier_than: Annotated[AwareDatetime | None, NoExplicitNull] = None + start_no_later_than: Annotated[WireDatetime | None, NoExplicitNull] = None + end_no_earlier_than: Annotated[WireDatetime | None, NoExplicitNull] = None class Constraints(AdCPBaseModel): diff --git a/src/adcp/types/domains/media_buy/provide_performance_feedback_response.py b/src/adcp/types/domains/media_buy/provide_performance_feedback_response.py index 14ebe83cd..ff0ce73cc 100644 --- a/src/adcp/types/domains/media_buy/provide_performance_feedback_response.py +++ b/src/adcp/types/domains/media_buy/provide_performance_feedback_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Literal, TypeAlias -from pydantic import AwareDatetime, ConfigDict, Field, StringConstraints +from adcp.types.base import WireDatetime +from pydantic import ConfigDict, Field, StringConstraints from ..core.version_envelope import AdcpVersionEnvelope from ..core import context as context_1 @@ -23,8 +24,8 @@ class ProvidePerformanceFeedbackResponse1(AdcpResponse, AdcpVersionEnvelope, Pro feedback_id: Annotated[str, StringConstraints(min_length=1)] | None = None application_status: Literal['accepted', 'applied', 'not_applied'] | None = None status_reason: Annotated[str, StringConstraints(max_length=500)] | None = None - received_at: AwareDatetime | None = None - applied_at: AwareDatetime | None = None + received_at: WireDatetime | None = None + applied_at: WireDatetime | None = None sandbox: bool | None = None context: context_1.ContextObject | None = None ext: ext_1.ExtensionObject | None = None diff --git a/src/adcp/types/domains/media_buy/refine_proposals_response.py b/src/adcp/types/domains/media_buy/refine_proposals_response.py index bb2621ddc..135548ecb 100644 --- a/src/adcp/types/domains/media_buy/refine_proposals_response.py +++ b/src/adcp/types/domains/media_buy/refine_proposals_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/refine_proposals_response.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,9 +9,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field, StrictFloat +from pydantic import ConfigDict, Field, StrictFloat from ..core import canonical_delivery_forecast, canonical_product, canonical_proposal from ..core import context as context_1 @@ -105,9 +105,9 @@ class Proposal(AdCPBaseModel): title='Proposal Status', ), ] = 'draft' - accepted_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + accepted_at: Annotated[WireDatetime | None, NoExplicitNull] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='For a draft, the indicative-terms freshness deadline. For a committed proposal, the inventory-hold deadline.' ), @@ -178,9 +178,9 @@ class Proposal3(AdCPBaseModel): title='Proposal Status', ), ] = 'committed' - accepted_at: Annotated[AwareDatetime | None, NoExplicitNull] = None + accepted_at: Annotated[WireDatetime | None, NoExplicitNull] = None expires_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='For a draft, the indicative-terms freshness deadline. For a committed proposal, the inventory-hold deadline.' ), diff --git a/src/adcp/types/domains/media_buy/request_proposals_response.py b/src/adcp/types/domains/media_buy/request_proposals_response.py index 5f7b864b6..d70869cae 100644 --- a/src/adcp/types/domains/media_buy/request_proposals_response.py +++ b/src/adcp/types/domains/media_buy/request_proposals_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/request_proposals_response.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,9 +9,9 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime from adcp.types._scalar import ScalarStr -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import ConfigDict, Field from ..core import canonical_product from ..core import context as context_1 @@ -119,7 +119,7 @@ class PurchaseContinuation1(AdCPBaseModel): ), ] continuation_expires_at: Annotated[ - AwareDatetime, + WireDatetime, Field(description='Absolute expiry of the single-use compatibility continuation.'), ] source_adcp_version: Annotated[ @@ -195,7 +195,7 @@ class Proposal(CanonicalProposal): ), ] = 'draft' expires_at: Annotated[ - AwareDatetime, + WireDatetime, Field( description='For a draft, the indicative-terms freshness deadline. For a committed proposal, the inventory-hold deadline.' ), diff --git a/src/adcp/types/domains/media_buy/sync_audiences_response.py b/src/adcp/types/domains/media_buy/sync_audiences_response.py index 02bc2dd2c..27dd8f243 100644 --- a/src/adcp/types/domains/media_buy/sync_audiences_response.py +++ b/src/adcp/types/domains/media_buy/sync_audiences_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Literal, TypeAlias -from pydantic import AwareDatetime, ConfigDict, Field, StringConstraints +from adcp.types.base import WireDatetime +from pydantic import ConfigDict, Field, StringConstraints from ..core.version_envelope import AdcpVersionEnvelope from ..core import brand_ref as brand_ref_1 @@ -51,7 +52,7 @@ class Audience(AdcpVersionEnvelope): matched_count: Annotated[int, Field(ge=0)] | None = None effective_match_rate: Annotated[float, Field(ge=0, le=1)] | None = None match_breakdown: Annotated[list[MatchBreakdown], Field(min_length=1)] | None = None - last_synced_at: AwareDatetime | None = None + last_synced_at: WireDatetime | None = None source: Source | None = None minimum_size: Annotated[int, Field(ge=1)] | None = None errors: list[error_1.Error] | None = None diff --git a/src/adcp/types/domains/media_buy/sync_catalogs_response.py b/src/adcp/types/domains/media_buy/sync_catalogs_response.py index f6767bf71..d4b52a790 100644 --- a/src/adcp/types/domains/media_buy/sync_catalogs_response.py +++ b/src/adcp/types/domains/media_buy/sync_catalogs_response.py @@ -8,7 +8,8 @@ from typing import Annotated, Any, Literal, TypeAlias -from pydantic import AwareDatetime, ConfigDict, Field, StringConstraints +from adcp.types.base import WireDatetime +from pydantic import ConfigDict, Field, StringConstraints from ..core.version_envelope import AdcpVersionEnvelope from ..core import catalog_item_availability_state as catalog_item_availability_state_1 @@ -40,8 +41,8 @@ class Catalog(AdcpVersionEnvelope): items_pending: Annotated[int, Field(ge=0)] | None = None items_rejected: Annotated[int, Field(ge=0)] | None = None item_issues: list[ItemIssue] | None = None - last_synced_at: AwareDatetime | None = None - next_fetch_at: AwareDatetime | None = None + last_synced_at: WireDatetime | None = None + next_fetch_at: WireDatetime | None = None changes: list[str] | None = None errors: list[error_1.Error] | None = None warnings: list[str] | None = None diff --git a/src/adcp/types/domains/media_buy/update_media_buy_request.py b/src/adcp/types/domains/media_buy/update_media_buy_request.py index cfb718333..a583135d9 100644 --- a/src/adcp/types/domains/media_buy/update_media_buy_request.py +++ b/src/adcp/types/domains/media_buy/update_media_buy_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: media_buy/update_media_buy_request.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from collections.abc import Sequence from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import account_ref, bidding_policy from ..core import budget_allocation as budget_allocation_1 @@ -95,7 +95,7 @@ class UpdateMediaBuyRequest(AdcpRequest, AdcpVersionEnvelope): ] = None start_time: Annotated[start_timing.StartTiming | None, NoExplicitNull] = None end_time: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='New end date/time in ISO 8601 format'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/media_buy/update_media_buy_response.py b/src/adcp/types/domains/media_buy/update_media_buy_response.py index abc2f3484..872a35d66 100644 --- a/src/adcp/types/domains/media_buy/update_media_buy_response.py +++ b/src/adcp/types/domains/media_buy/update_media_buy_response.py @@ -10,7 +10,8 @@ from typing import Annotated, Any, Literal, TypeAlias -from pydantic import AwareDatetime, ConfigDict, Field, StringConstraints, model_validator +from adcp.types.base import WireDatetime +from pydantic import ConfigDict, Field, StringConstraints, model_validator from ..core.version_envelope import AdcpVersionEnvelope from ..core import business_entity as business_entity_1 @@ -44,7 +45,7 @@ class UpdateMediaBuyResponse1(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelop budget_allocation: Any | None = None pacing: pacing_1.Pacing | None = None bidding: Any | None = None - implementation_date: AwareDatetime | None = None + implementation_date: WireDatetime | None = None invoice_recipient: business_entity_1.BusinessEntity | None = None affected_packages: Sequence[package_1.Package] | None = None valid_actions: list[media_buy_valid_action_1.MediaBuyValidAction] | None = None diff --git a/src/adcp/types/domains/property/authorization_result.py b/src/adcp/types/domains/property/authorization_result.py index 6f17f6337..e8e7699aa 100644 --- a/src/adcp/types/domains/property/authorization_result.py +++ b/src/adcp/types/domains/property/authorization_result.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: property/authorization_result.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field class Status(StrEnum): @@ -41,7 +41,7 @@ class AuthorizationResult(AdCPBaseModel): NoExplicitNull, ] = None sales_agent_url: Annotated[ - AnyUrl | None, Field(description='The sales agent URL that was validated'), NoExplicitNull + WireAnyUrl | None, Field(description='The sales agent URL that was validated'), NoExplicitNull ] = None violation: Annotated[ Violation | None, diff --git a/src/adcp/types/domains/property/delivery_record.py b/src/adcp/types/domains/property/delivery_record.py index 05ba8887e..baf8899c0 100644 --- a/src/adcp/types/domains/property/delivery_record.py +++ b/src/adcp/types/domains/property/delivery_record.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/delivery_record.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import ext as ext_1 from ..core import identifier as identifier_1 @@ -32,7 +32,7 @@ class DeliveryRecord(AdCPBaseModel): NoExplicitNull, ] = None sales_agent_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL of the sales agent that sold this inventory. If provided, authorization is validated against the property's adagents.json." ), diff --git a/src/adcp/types/domains/property/get_property_list_response.py b/src/adcp/types/domains/property/get_property_list_response.py index be376c68d..ea78d2bd5 100644 --- a/src/adcp/types/domains/property/get_property_list_response.py +++ b/src/adcp/types/domains/property/get_property_list_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/get_property_list_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import ext as ext_1 @@ -39,10 +39,10 @@ class GetPropertyListResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelop ] = None pagination: Annotated[pagination_response.PaginationResponse | None, NoExplicitNull] = None resolved_at: Annotated[ - AwareDatetime | None, Field(description='When the list was resolved'), NoExplicitNull + WireDatetime | None, Field(description='When the list was resolved'), NoExplicitNull ] = None cache_valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='Cache expiration timestamp. Re-fetch the list after this time to get updated identifiers.' ), diff --git a/src/adcp/types/domains/property/property_feature_definition.py b/src/adcp/types/domains/property/property_feature_definition.py index d97ab887e..ad0b17000 100644 --- a/src/adcp/types/domains/property/property_feature_definition.py +++ b/src/adcp/types/domains/property/property_feature_definition.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: property/property_feature_definition.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field, StrictFloat from ..core import ext as ext_1 @@ -81,7 +81,7 @@ class PropertyFeatureDefinition(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='URL to documentation explaining how this feature is calculated/measured' ), diff --git a/src/adcp/types/domains/property/property_feature_result.py b/src/adcp/types/domains/property/property_feature_result.py index e17b9b6d2..fcab98b9f 100644 --- a/src/adcp/types/domains/property/property_feature_result.py +++ b/src/adcp/types/domains/property/property_feature_result.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: property/property_feature_result.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import ext as ext_1 from ..core import property_id @@ -40,7 +40,7 @@ class PropertyFeatureResult(AdCPBaseModel): ), ] last_evaluated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When features were last evaluated for this property'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/property/property_feature_value.py b/src/adcp/types/domains/property/property_feature_value.py index 9c48e4841..00dda78f2 100644 --- a/src/adcp/types/domains/property/property_feature_value.py +++ b/src/adcp/types/domains/property/property_feature_value.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/property_feature_value.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import ext as ext_1 @@ -35,12 +35,12 @@ class PropertyFeatureValue(AdCPBaseModel): NoExplicitNull, ] = None measured_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this specific value was measured'), NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this certification/value expires (for time-limited certifications)' ), diff --git a/src/adcp/types/domains/property/property_list.py b/src/adcp/types/domains/property/property_list.py index 8be313aa0..7ef37da83 100644 --- a/src/adcp/types/domains/property/property_list.py +++ b/src/adcp/types/domains/property/property_list.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/property_list.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field from ..core import account_ref, brand_ref, vendor_pricing_option from . import base_property_source, property_list_filters @@ -49,7 +49,7 @@ class PropertyList(AdCPBaseModel): NoExplicitNull, ] = None webhook_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to receive notifications when the resolved list changes'), NoExplicitNull, ] = None @@ -62,10 +62,10 @@ class PropertyList(AdCPBaseModel): NoExplicitNull, ] = 24 created_at: Annotated[ - AwareDatetime | None, Field(description='When the list was created'), NoExplicitNull + WireDatetime | None, Field(description='When the list was created'), NoExplicitNull ] = None updated_at: Annotated[ - AwareDatetime | None, Field(description='When the list was last modified'), NoExplicitNull + WireDatetime | None, Field(description='When the list was last modified'), NoExplicitNull ] = None property_count: Annotated[ SchemaInt | None, diff --git a/src/adcp/types/domains/property/property_list_changed_webhook.py b/src/adcp/types/domains/property/property_list_changed_webhook.py index 4392f3bfd..4bf1b1834 100644 --- a/src/adcp/types/domains/property/property_list_changed_webhook.py +++ b/src/adcp/types/domains/property/property_list_changed_webhook.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/property_list_changed_webhook.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireDatetime +from pydantic import ConfigDict, Field from ..core import ext as ext_1 @@ -54,9 +54,9 @@ class PropertyListChangedWebhook(AdCPBaseModel): Field(description='Summary of changes to the resolved list'), NoExplicitNull, ] = None - resolved_at: Annotated[AwareDatetime, Field(description='When the list was re-resolved')] + resolved_at: Annotated[WireDatetime, Field(description='When the list was re-resolved')] cache_valid_until: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When the consumer should refresh from the governance agent'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/property/update_property_list_request.py b/src/adcp/types/domains/property/update_property_list_request.py index 76d4fad1b..6f81fe252 100644 --- a/src/adcp/types/domains/property/update_property_list_request.py +++ b/src/adcp/types/domains/property/update_property_list_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: property/update_property_list_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import NoExplicitNull -from pydantic import AnyUrl, ConfigDict, Field +from adcp.types.base import NoExplicitNull, WireAnyUrl +from pydantic import ConfigDict, Field from ..core import account_ref, brand_ref from ..core import context as context_1 @@ -52,7 +52,7 @@ class UpdatePropertyListRequest(AdcpRequest, AdcpVersionEnvelope): NoExplicitNull, ] = None webhook_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Update the webhook URL for list change notifications (set to empty string to remove)' ), diff --git a/src/adcp/types/domains/property/validate_property_delivery_response.py b/src/adcp/types/domains/property/validate_property_delivery_response.py index 20f912b60..50f42df24 100644 --- a/src/adcp/types/domains/property/validate_property_delivery_response.py +++ b/src/adcp/types/domains/property/validate_property_delivery_response.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: property/validate_property_delivery_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import context as context_1 from ..core import ext as ext_1 @@ -73,7 +73,7 @@ class Aggregate(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL explaining how this aggregate was calculated'), NoExplicitNull, ] = None @@ -147,10 +147,10 @@ class ValidatePropertyDeliveryResponse(AdcpVersionEnvelope, ProtocolEnvelope): ), ] validated_at: Annotated[ - AwareDatetime, Field(description='Timestamp when validation was performed') + WireDatetime, Field(description='Timestamp when validation was performed') ] list_resolved_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Timestamp of the property list resolution used for validation'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py b/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py index ef884fd58..b170102f9 100644 --- a/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py +++ b/src/adcp/types/domains/protocol/get_adcp_capabilities_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_adcp_capabilities_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -10,11 +10,9 @@ from adcp.types._str_enum import StrEnum from typing import Any, Annotated, Dict, Literal -from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull +from adcp.types.base import AdCPBaseModel, GeoPlaceSystemKey, NoExplicitNull, WireAnyUrl, WireDatetime from adcp.types._scalar import ScalarInt, ScalarStr from pydantic import ( - AnyUrl, - AwareDatetime, ConfigDict, Field, RootModel, @@ -216,7 +214,7 @@ class CapabilityChanges(AdCPBaseModel): NoExplicitNull, ] = None last_modified: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp when the seller last changed the advertised capability document. This is the scoped replacement for legacy top-level `last_updated` when buyers need cache validation semantics.' ), @@ -491,7 +489,7 @@ class Account(AdCPBaseModel): NoExplicitNull, ] = False authorization_endpoint: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='OAuth authorization endpoint for obtaining operator-level credentials. Present when the seller supports OAuth for operator authentication. The agent directs the operator to this URL to authenticate and obtain a bearer token. If absent and require_operator_auth is true, operators obtain credentials out-of-band (e.g., seller portal, API key).' ), @@ -573,7 +571,7 @@ class AcceptancePolicyDiscovery(AdCPBaseModel): extra='forbid', ) catalog_url: Annotated[ - AnyUrl, + WireAnyUrl, Field(description='HTTPS document that validates against acceptance-policy-catalog.json.'), ] catalog_digest: Annotated[ @@ -1039,7 +1037,7 @@ class SignalActivation(AdCPBaseModel): ), ] claim_types: Annotated[ - list[AnyUrl], + list[WireAnyUrl], Field( description='Signal-quality claim identifiers accepted for this governed action. Values are open absolute URIs and MUST be a subset of adcp.attestations.accepted_claim_types.', min_length=1, @@ -1094,7 +1092,7 @@ class PropertyFeature(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to documentation explaining how this feature is calculated or measured. Helps buyers understand and compare methodologies across vendors.' ), @@ -1131,7 +1129,7 @@ class CreativeFeature(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='URL to documentation explaining how this feature is calculated or measured.' ), @@ -1182,7 +1180,7 @@ class Transport1(AdCPBaseModel): extra='allow', ) type: Annotated[Type6, Field(description='Protocol transport type')] - url: Annotated[AnyUrl, Field(description='Agent endpoint URL for this transport')] + url: Annotated[WireAnyUrl, Field(description='Agent endpoint URL for this transport')] class Endpoint(AdCPBaseModel): @@ -1488,24 +1486,24 @@ class KeyOrigins(AdCPBaseModel): extra='forbid', ) governance_signing: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Origin (scheme + host) serving the governance-signing JWKS.'), NoExplicitNull, ] = None request_signing: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='Origin (scheme + host) serving the request-signing JWKS.'), NoExplicitNull, ] = None webhook_signing: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Origin (scheme + host) serving the JWKS used for webhook delivery. Webhooks are signed with `adcp_use: "request-signing"` keys; the deprecated `adcp_use: "webhook-signing"` value remains accepted during the backward-compatibility window.' ), NoExplicitNull, ] = None tmp_signing: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Origin (scheme + host) serving the TMP-signing JWKS, when this operator participates in TMP.' ), @@ -1536,7 +1534,7 @@ class Identity(AdCPBaseModel): extra='allow', ) brand_json_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="HTTPS URL of the operator's brand.json (typically `https://{operator-domain}/.well-known/brand.json`). Trust-root pointer for this agent's signing keys. See [security.mdx §Discovering an agent's signing keys via `brand_json_url`](https://adcontextprotocol.org/docs/building/by-layer/L1/security#discovering-an-agents-signing-keys-via-brand_json_url) for the verifier algorithm and `x-adcp-validation` for structured constraints. Distinct from `sponsored_intelligence.brand_url`, which is a rendering pointer for SI agent visuals — verifiers MUST use this field for key discovery and MUST NOT fall back to `sponsored_intelligence.brand_url` as a trust-root pointer." ), @@ -1609,7 +1607,7 @@ class Accreditation(AdCPBaseModel): NoExplicitNull, ] = None evidence_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="Optional URL pointing at the accrediting body's public listing for this certification (the buyer's path to verify the claim independently)." ), @@ -2130,7 +2128,7 @@ class SponsoredIntelligence(AdCPBaseModel): Field(description='Modalities, components, and commerce capabilities'), ] brand_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='URL to brand.json with colors, fonts, logos, tone'), NoExplicitNull, ] = None @@ -2201,7 +2199,7 @@ class Metric(AdCPBaseModel): ), ] standard_reference: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional URI pointing at the published standard this metric IMPLEMENTS (e.g., IAB Attention Measurement Guidelines, MRC Viewable Impression Measurement, GARM emissions framework). Distinct from `accreditations[]` — `standard_reference` is what the metric is built against; `accreditations[]` is third-party certification that the implementation actually conforms. Buyer agents normalizing across vendors SHOULD apply the AdCP URL canonicalization rules before comparing — vendors implementing the same standard MAY use different URL forms for the same canonical document.' ), @@ -2230,7 +2228,7 @@ class Metric(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="URL to the vendor's full methodology documentation for this metric. Buyers SHOULD link or fetch this when human review of the methodology is in scope (compliance, RFP review, accreditation audit). Field name mirrors `governance.property_features[].methodology_url`." ), @@ -2314,7 +2312,7 @@ class Execution(AdCPBaseModel): NoExplicitNull, ] = None axe_integrations: Annotated[ - list[AnyUrl] | None, + list[WireAnyUrl] | None, Field( deprecated=True, description='Deprecated. Legacy AXE integrations. Use trusted_match for new integrations.', @@ -2973,7 +2971,7 @@ class GetAdcpCapabilitiesResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnv NoExplicitNull, ] = None last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='ISO 8601 timestamp of when capabilities were last updated. Buyers can use this for cache invalidation.' ), diff --git a/src/adcp/types/domains/protocol/get_task_status_response.py b/src/adcp/types/domains/protocol/get_task_status_response.py index 941006d3e..bc8ed4ad2 100644 --- a/src/adcp/types/domains/protocol/get_task_status_response.py +++ b/src/adcp/types/domains/protocol/get_task_status_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/get_task_status_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool, StrictFloat +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import context as context_1 from ..core import ext as ext_1 @@ -79,7 +79,7 @@ class HistoryItem(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - timestamp: Annotated[AwareDatetime, Field(description='When this exchange occurred (ISO 8601)')] + timestamp: Annotated[WireDatetime, Field(description='When this exchange occurred (ISO 8601)')] type: Annotated[ Type, Field(description='Whether this was a request from client or response from server') ] @@ -97,13 +97,13 @@ class GetTaskStatusResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) ] status: Annotated[task_status.TaskStatus, Field(description='Current task status')] created_at: Annotated[ - AwareDatetime, Field(description='When the task was initially created (ISO 8601)') + WireDatetime, Field(description='When the task was initially created (ISO 8601)') ] updated_at: Annotated[ - AwareDatetime, Field(description='When the task was last updated (ISO 8601)') + WireDatetime, Field(description='When the task was last updated (ISO 8601)') ] completed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the task completed (ISO 8601, only for completed/failed/canceled tasks)' ), diff --git a/src/adcp/types/domains/protocol/list_tasks_request.py b/src/adcp/types/domains/protocol/list_tasks_request.py index 7e028e47e..3c024126a 100644 --- a/src/adcp/types/domains/protocol/list_tasks_request.py +++ b/src/adcp/types/domains/protocol/list_tasks_request.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/list_tasks_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..core import account_ref from ..core import context as context_1 @@ -62,22 +62,22 @@ class Filters(AdCPBaseModel): NoExplicitNull, ] = None created_after: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter tasks created after this date (ISO 8601)'), NoExplicitNull, ] = None created_before: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter tasks created before this date (ISO 8601)'), NoExplicitNull, ] = None updated_after: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter tasks last updated after this date (ISO 8601)'), NoExplicitNull, ] = None updated_before: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Filter tasks last updated before this date (ISO 8601)'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/protocol/list_tasks_response.py b/src/adcp/types/domains/protocol/list_tasks_response.py index 483a0bd97..b97580342 100644 --- a/src/adcp/types/domains/protocol/list_tasks_response.py +++ b/src/adcp/types/domains/protocol/list_tasks_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: protocol/list_tasks_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 from ..core import ext as ext_1 @@ -100,13 +100,13 @@ class Task(AdCPBaseModel): domain: Annotated[Domain, Field(description='AdCP domain this task belongs to')] status: Annotated[task_status.TaskStatus, Field(description='Current task status')] created_at: Annotated[ - AwareDatetime, Field(description='When the task was initially created (ISO 8601)') + WireDatetime, Field(description='When the task was initially created (ISO 8601)') ] updated_at: Annotated[ - AwareDatetime, Field(description='When the task was last updated (ISO 8601)') + WireDatetime, Field(description='When the task was last updated (ISO 8601)') ] completed_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When the task completed (ISO 8601, only for completed/failed/canceled tasks)' ), diff --git a/src/adcp/types/domains/signals/get_signals_async_response_submitted.py b/src/adcp/types/domains/signals/get_signals_async_response_submitted.py index 255f65889..838ea874a 100644 --- a/src/adcp/types/domains/signals/get_signals_async_response_submitted.py +++ b/src/adcp/types/domains/signals/get_signals_async_response_submitted.py @@ -1,13 +1,13 @@ # generated by datamodel-codegen: # filename: signals/get_signals_async_response_submitted.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import context as context_1 from ..core import error @@ -39,7 +39,7 @@ class GetSignalsSubmitted(AdCPBaseModel): NoExplicitNull, ] = None estimated_completion: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Estimated completion time for the signal discovery task.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/signals/get_signals_response.py b/src/adcp/types/domains/signals/get_signals_response.py index 0186bd4e7..f28cc7887 100644 --- a/src/adcp/types/domains/signals/get_signals_response.py +++ b/src/adcp/types/domains/signals/get_signals_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: signals/get_signals_response.json -# timestamp: 2026-10-21T11:25:44+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -10,9 +10,9 @@ from collections.abc import Sequence from typing import Annotated, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime, WireEmail from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool, StrictFloat +from pydantic import ConfigDict, Field, StrictBool, StrictFloat from ..core import context as context_1 from ..core import demographic_predicate as demographic_predicate_1 @@ -66,7 +66,7 @@ class Taxonomy(AdCPBaseModel): model_config = ConfigDict( extra='forbid', ) - ref: AnyUrl + ref: WireAnyUrl version: Annotated[str | None, NoExplicitNull] = None segtax: Annotated[SchemaInt | None, Field(ge=1), NoExplicitNull] = None etag: Annotated[str | None, NoExplicitNull] = None @@ -209,8 +209,8 @@ class Channel(AdCPBaseModel): extra='forbid', ) rights: Annotated[list[Right], Field(min_length=1)] - url: Annotated[AnyUrl | None, NoExplicitNull] = None - email: Annotated[EmailStr | None, NoExplicitNull] = None + url: Annotated[WireAnyUrl | None, NoExplicitNull] = None + email: Annotated[WireEmail | None, NoExplicitNull] = None languages: Annotated[list[str] | None, NoExplicitNull] = None countries: Annotated[list[Country] | None, NoExplicitNull] = None @@ -229,7 +229,7 @@ class DataSubjectRights(AdCPBaseModel): ] = None channels: Annotated[list[Channel], Field(min_length=1)] response_sla_days: Annotated[SchemaInt | None, Field(ge=1, le=90), NoExplicitNull] = None - ccpa_opt_out_url: Annotated[AnyUrl | None, NoExplicitNull] = None + ccpa_opt_out_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None class Scope(StrEnum): @@ -375,14 +375,14 @@ class Signal(AdCPBaseModel): NoExplicitNull, ] = None methodology_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description='Optional link to published methodology, media-kit, or data documentation. For data_provider and signal_source refs, this SHOULD match or supplement the referenced definition.' ), NoExplicitNull, ] = None last_updated: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this definition record was last updated. This indicates freshness of the definition record, not an attestation that the underlying data or model was refreshed at that time.' ), @@ -413,7 +413,7 @@ class Signal(AdCPBaseModel): NoExplicitNull, ] = None segmentation_criteria: Annotated[str | None, Field(max_length=500), NoExplicitNull] = None - criteria_url: Annotated[AnyUrl | None, NoExplicitNull] = None + criteria_url: Annotated[WireAnyUrl | None, NoExplicitNull] = None data_sources: Annotated[list[DataSource] | None, Field(min_length=1), NoExplicitNull] = None methodology: Annotated[Methodology | None, NoExplicitNull] = None audience_expansion: Annotated[StrictBool | None, NoExplicitNull] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_get_offering_response.py b/src/adcp/types/domains/sponsored_intelligence/si_get_offering_response.py index e09183a07..34d38180e 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_get_offering_response.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_get_offering_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_get_offering_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -8,8 +8,8 @@ from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 from ..core import error @@ -35,7 +35,7 @@ class Offering(AdCPBaseModel): str | None, Field(description='Short promotional tagline'), NoExplicitNull ] = None expires_at: Annotated[ - AwareDatetime | None, Field(description='When this offering expires'), NoExplicitNull + WireDatetime | None, Field(description='When this offering expires'), NoExplicitNull ] = None availability_status: Annotated[ offering_availability_status.OfferingAvailabilityStatus | None, @@ -50,9 +50,9 @@ class Offering(AdCPBaseModel): NoExplicitNull, ] = None image_url: Annotated[ - AnyUrl | None, Field(description='Hero image for the offering'), NoExplicitNull + WireAnyUrl | None, Field(description='Hero image for the offering'), NoExplicitNull ] = None - landing_url: Annotated[AnyUrl | None, Field(description='Landing page URL'), NoExplicitNull] = ( + landing_url: Annotated[WireAnyUrl | None, Field(description='Landing page URL'), NoExplicitNull] = ( None ) @@ -69,7 +69,7 @@ class MatchingProduct(AdCPBaseModel): original_price: Annotated[ str | None, Field(description='Original price if on sale'), NoExplicitNull ] = None - image_url: Annotated[AnyUrl | None, Field(description='Product image'), NoExplicitNull] = None + image_url: Annotated[WireAnyUrl | None, Field(description='Product image'), NoExplicitNull] = None availability_summary: Annotated[ str | None, Field( @@ -84,7 +84,7 @@ class MatchingProduct(AdCPBaseModel): ), NoExplicitNull, ] = None - url: Annotated[AnyUrl | None, Field(description='Product detail page URL'), NoExplicitNull] = ( + url: Annotated[WireAnyUrl | None, Field(description='Product detail page URL'), NoExplicitNull] = ( None ) @@ -112,7 +112,7 @@ class SiGetOfferingResponse(AdcpResponse, AdcpVersionEnvelope, ProtocolEnvelope) NoExplicitNull, ] = None checked_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this offering information was retrieved'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_identity.py b/src/adcp/types/domains/sponsored_intelligence/si_identity.py index a9ca5f49d..043436762 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_identity.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_identity.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_identity.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, EmailStr, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime, WireEmail +from pydantic import ConfigDict, Field, StrictBool class ConsentScopeEnum(StrEnum): @@ -24,7 +24,7 @@ class PrivacyPolicyAcknowledged(AdCPBaseModel): extra='allow', ) brand_policy_url: Annotated[ - AnyUrl | None, Field(description="URL to brand's privacy policy"), NoExplicitNull + WireAnyUrl | None, Field(description="URL to brand's privacy policy"), NoExplicitNull ] = None brand_policy_version: Annotated[ str | None, Field(description='Version of policy acknowledged'), NoExplicitNull @@ -46,7 +46,7 @@ class User(AdCPBaseModel): model_config = ConfigDict( extra='allow', ) - email: Annotated[EmailStr | None, Field(description="User's email address"), NoExplicitNull] = ( + email: Annotated[WireEmail | None, Field(description="User's email address"), NoExplicitNull] = ( None ) name: Annotated[str | None, Field(description="User's display name"), NoExplicitNull] = None @@ -69,7 +69,7 @@ class SiIdentity(AdCPBaseModel): StrictBool, Field(description='Whether user consented to share identity') ] consent_timestamp: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When consent was granted (ISO 8601)'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context.py b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context.py index 724a90e8e..944b3cfe7 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_sponsored_context.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..core import brand_ref from ..core import ext as ext_1 @@ -98,7 +98,7 @@ class DeclaredBy(AdCPBaseModel): extra='allow', ) agent_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field(description='HTTPS URL of the declaring agent or service.'), NoExplicitNull, ] = None @@ -158,7 +158,7 @@ class SiSponsoredContext(AdCPBaseModel): ), ] declared_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='When this sponsored-context declaration was made.'), NoExplicitNull, ] = None diff --git a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py index 10b6bc467..2d702eaa8 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_sponsored_context_receipt.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_sponsored_context_receipt.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AwareDatetime, ConfigDict, Field +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireDatetime +from pydantic import ConfigDict, Field from ..core import ext as ext_1 from . import si_context_use, si_sponsored_context @@ -64,7 +64,7 @@ class HostReceipt(AdCPBaseModel): NoExplicitNull, ] = None received_at: Annotated[ - AwareDatetime, Field(description='When the host received the sponsored context.') + WireDatetime, Field(description='When the host received the sponsored context.') ] host_surface: Annotated[ str | None, diff --git a/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_response.py b/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_response.py index e05c15c20..e2137b026 100644 --- a/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_response.py +++ b/src/adcp/types/domains/sponsored_intelligence/si_terminate_session_response.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: sponsored_intelligence/si_terminate_session_response.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations @@ -9,8 +9,8 @@ from adcp.types._str_enum import StrEnum from typing import Annotated, Any -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..core import context as context_1 from ..core import error @@ -25,7 +25,7 @@ class AcpHandoff(AdCPBaseModel): extra='allow', ) checkout_url: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( description="Brand's ACP checkout endpoint. Hosts MUST validate this is HTTPS before opening." ), @@ -46,7 +46,7 @@ class AcpHandoff(AdCPBaseModel): NoExplicitNull, ] = None expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field( description='When this handoff data expires. Hosts should initiate checkout before this time.' ), diff --git a/src/adcp/types/domains/trusted_match/context_match_request.py b/src/adcp/types/domains/trusted_match/context_match_request.py index adadb8079..abcea5797 100644 --- a/src/adcp/types/domains/trusted_match/context_match_request.py +++ b/src/adcp/types/domains/trusted_match/context_match_request.py @@ -1,16 +1,15 @@ # generated by datamodel-codegen: # filename: trusted_match/context_match_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from uuid import UUID -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireUuid from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, ConfigDict, Field +from pydantic import ConfigDict, Field from ..content_standards import artifact as artifact_1 from ..core import property_id as property_id_1 @@ -192,7 +191,7 @@ class ContextMatchRequest(AdcpVersionEnvelope): extra='forbid', ) field_schema: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( alias='$schema', description='Optional schema URI for validation. Ignored at runtime.' ), @@ -216,7 +215,7 @@ class ContextMatchRequest(AdcpVersionEnvelope): ), ] property_rid: Annotated[ - UUID, + WireUuid, Field( description='Property catalog UUID (UUID v7). Globally unique, stable identifier assigned by the property catalog. The primary key for TMP matching and property list targeting.' ), @@ -238,7 +237,7 @@ class ContextMatchRequest(AdcpVersionEnvelope): ), ] seller_agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="API endpoint URL of the seller agent issuing this request. The provider uses this to resolve the active package set it has synced for this seller; when `package_ids` is omitted, evaluation occurs against that full set. If `seller_agent_url` does not match any seller the provider has synced packages for, the provider MUST return an empty offer set — it MUST NOT fall back to another seller's active set. The value identifies the asking seller, is identical for every user on a given placement, and carries no user identity, so it neither varies the request per user nor weakens the context/identity decorrelation boundary. Compared using the AdCP URL canonicalization rules, not byte-equality — see docs/reference/url-canonicalization. Consistent with `seller_agent_url` on the identity match request, `seller_agent.agent_url` on `AvailablePackage`, and `agent_url` in `adagents.json`." ), diff --git a/src/adcp/types/domains/trusted_match/identity_match_request.py b/src/adcp/types/domains/trusted_match/identity_match_request.py index ed7540657..f094c8294 100644 --- a/src/adcp/types/domains/trusted_match/identity_match_request.py +++ b/src/adcp/types/domains/trusted_match/identity_match_request.py @@ -1,14 +1,14 @@ # generated by datamodel-codegen: # filename: trusted_match/identity_match_request.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Any, Literal -from adcp.types.base import AdCPBaseModel, NoExplicitNull -from pydantic import AnyUrl, AwareDatetime, ConfigDict, Field, StrictBool +from adcp.types.base import AdCPBaseModel, NoExplicitNull, WireAnyUrl, WireDatetime +from pydantic import ConfigDict, Field, StrictBool from ..core import brand_ref from ..core.version_envelope import AdcpVersionEnvelope @@ -128,7 +128,7 @@ class Attestation(AdCPBaseModel): ), ] expires_at: Annotated[ - AwareDatetime | None, + WireDatetime | None, Field(description='Attestation validity horizon. Receivers MUST reject when past.'), NoExplicitNull, ] = None @@ -164,7 +164,7 @@ class IdentityMatchRequest(AdcpVersionEnvelope): extra='forbid', ) field_schema: Annotated[ - AnyUrl | None, + WireAnyUrl | None, Field( alias='$schema', description='Optional schema URI for validation. Ignored at runtime.' ), @@ -188,7 +188,7 @@ class IdentityMatchRequest(AdcpVersionEnvelope): ), ] seller_agent_url: Annotated[ - AnyUrl, + WireAnyUrl, Field( description="API endpoint URL of the seller agent issuing this request. The buyer's identity-match service uses this to resolve the active package set it has registered for this seller; when `package_ids` is omitted, evaluation occurs against that full set. If `seller_agent_url` does not match any seller for which the buyer has registered active packages, the buyer MUST return an empty `eligible_package_ids` set — it MUST NOT fall back to evaluating against another seller's active set. Compared using the AdCP URL canonicalization rules, not byte-equality — see docs/reference/url-canonicalization. Consistent with `seller_agent.agent_url` on `AvailablePackage` and `agent_url` in `adagents.json`." ), diff --git a/src/adcp/types/domains/trusted_match/provider_registration.py b/src/adcp/types/domains/trusted_match/provider_registration.py index bc1fad6be..6cbb09f08 100644 --- a/src/adcp/types/domains/trusted_match/provider_registration.py +++ b/src/adcp/types/domains/trusted_match/provider_registration.py @@ -1,16 +1,15 @@ # generated by datamodel-codegen: # filename: trusted_match/provider_registration.json -# timestamp: 2026-10-21T11:05:54+00:00 +# timestamp: 2026-10-25T19:32:54+00:00 from __future__ import annotations from adcp.types._str_enum import StrEnum from typing import Annotated, Literal -from uuid import UUID -from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt +from adcp.types.base import AdCPBaseModel, NoExplicitNull, SchemaInt, WireAnyUrl, WireUuid from adcp.types._scalar import ScalarStr -from pydantic import AnyUrl, ConfigDict, Field, StrictBool, field_validator, model_validator +from pydantic import ConfigDict, Field, StrictBool, field_validator, model_validator from ..enums import uid_type @@ -53,7 +52,7 @@ class TmpProviderRegistration1(AdCPBaseModel): ), ] endpoint: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Base URL the router calls. The router appends /context for Context Match and /identity for Identity Match. MUST be HTTPS in production, validated against the canonical reserved IPv4 and IPv6 ranges, with the TCP connection pinned to the validated IP (DNS re-resolution alone is insufficient against rebinding). Publishers comparing two provider registrations for the same `endpoint` MUST canonicalize both per the AdCP URL canonicalization rules; two registrations differing only in case, default port, or path-slash collapsing are the same provider. See docs/trusted-match/specification#provider-registration-security, docs/building/by-layer/L1/security#webhook-url-validation-ssrf, and docs/reference/url-canonicalization.' ), @@ -83,7 +82,7 @@ class TmpProviderRegistration1(AdCPBaseModel): NoExplicitNull, ] = None properties: Annotated[ - list[UUID] | None, + list[WireUuid] | None, Field( description='Property RIDs (UUID v7) this provider serves. When present, the router only sends requests from these properties to this provider. When absent, the provider serves all properties.', min_length=1, @@ -127,7 +126,7 @@ class TmpProviderRegistration1(AdCPBaseModel): @field_validator('endpoint') @classmethod - def _require_https_endpoint(cls, value: AnyUrl) -> AnyUrl: + def _require_https_endpoint(cls, value: WireAnyUrl) -> WireAnyUrl: if value.scheme != 'https': raise ValueError('endpoint must use https') return value @@ -156,7 +155,7 @@ class TmpProviderRegistration2(AdCPBaseModel): ), ] endpoint: Annotated[ - AnyUrl, + WireAnyUrl, Field( description='Base URL the router calls. The router appends /context for Context Match and /identity for Identity Match. MUST be HTTPS in production, validated against the canonical reserved IPv4 and IPv6 ranges, with the TCP connection pinned to the validated IP (DNS re-resolution alone is insufficient against rebinding). Publishers comparing two provider registrations for the same `endpoint` MUST canonicalize both per the AdCP URL canonicalization rules; two registrations differing only in case, default port, or path-slash collapsing are the same provider. See docs/trusted-match/specification#provider-registration-security, docs/building/by-layer/L1/security#webhook-url-validation-ssrf, and docs/reference/url-canonicalization.' ), @@ -187,7 +186,7 @@ class TmpProviderRegistration2(AdCPBaseModel): NoExplicitNull, ] = None properties: Annotated[ - list[UUID] | None, + list[WireUuid] | None, Field( description='Property RIDs (UUID v7) this provider serves. When present, the router only sends requests from these properties to this provider. When absent, the provider serves all properties.', min_length=1, @@ -231,7 +230,7 @@ class TmpProviderRegistration2(AdCPBaseModel): @field_validator('endpoint') @classmethod - def _require_https_endpoint(cls, value: AnyUrl) -> AnyUrl: + def _require_https_endpoint(cls, value: WireAnyUrl) -> WireAnyUrl: if value.scheme != 'https': raise ValueError('endpoint must use https') return value diff --git a/src/adcp/validation/schema_loader.py b/src/adcp/validation/schema_loader.py index 9e956c807..1ba5c3a1e 100644 --- a/src/adcp/validation/schema_loader.py +++ b/src/adcp/validation/schema_loader.py @@ -32,15 +32,15 @@ import warnings from copy import deepcopy from dataclasses import dataclass -from datetime import date from functools import lru_cache from importlib.resources import as_file, files from pathlib import Path from typing import Any, Literal, cast -from urllib.parse import quote, unquote, urldefrag, urljoin, urlparse, urlsplit +from urllib.parse import unquote, urldefrag, urljoin, urlparse, urlsplit from pydantic import AnyUrl, TypeAdapter, ValidationError +from adcp import _formats from adcp._deferred_adapters import deferred_adapter from adcp.validation.version import resolve_bundle_key @@ -67,13 +67,10 @@ def _uri_adapter() -> TypeAdapter[AnyUrl]: Direction = Literal["request", "sync", "submitted", "working", "input-required"] -_RFC3339_DATE_TIME = re.compile( - r"^\d{4}-\d{2}-\d{2}[Tt]" - r"(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d" - r"(?:\.\d+)?" - r"(?:[Zz]|[+-](?:[01]\d|2[0-3]):[0-5]\d)$", - re.ASCII, -) +# The strict ``date-time`` grammar lives in ``adcp._formats`` so the wire- +# preserving types (``adcp.types._wire``) apply the same check before keeping +# a string's spelling; the names below are what this module always exported. +_RFC3339_DATE_TIME = _formats.RFC3339_DATE_TIME def _is_rfc3339_date_time(instance: Any) -> bool: @@ -87,17 +84,7 @@ def _is_rfc3339_date_time(instance: Any) -> bool: """ if not isinstance(instance, str): return True - if _RFC3339_DATE_TIME.fullmatch(instance) is None: - return False - try: - # The grammar already checks time and offset ranges. Validate only - # the calendar here: Python 3.10's datetime parser accepts only three - # or six fractional digits, whereas RFC 3339 permits any positive - # number. Validation must neither coerce nor truncate the wire value. - date.fromisoformat(instance[:10]) - except ValueError: - return False - return True + return _formats.is_rfc3339_date_time(instance) # Hostname grammar from RFC 1123 section 2.1: dot-separated labels of @@ -115,30 +102,16 @@ def _is_rfc3339_date_time(instance: Any) -> bool: def _is_uri(instance: Any) -> bool: """Validate URI syntax while retaining AnyUrl's internationalized URLs. - Check the original syntax before applying the ``pydantic.AnyUrl`` parser - used by generated models. AnyUrl alone accepts malformed percent escapes - and normalizes whitespace/backslashes; that would bypass the explicit - ad-server-token branch in macro-bearing-url. Retaining its check also - prevents values passing schema validation but failing model validation. + Check the original syntax (``adcp._formats.is_uri_syntax``) before applying + the ``pydantic.AnyUrl`` parser used by generated models. AnyUrl alone + accepts malformed percent escapes and normalizes whitespace/backslashes; + that would bypass the explicit ad-server-token branch in + macro-bearing-url. Retaining its check also prevents values passing schema + validation but failing model validation. """ if not isinstance(instance, str): return True - from rfc3986_validator import validate_rfc3986 - - if any(char.isspace() or 0x7F <= ord(char) <= 0x9F for char in instance): - return False - try: - # Escape only non-ASCII characters for the RFC 3986 syntax check. - # Leave original ASCII untouched so normalization cannot hide malformed - # percent escapes or backslashes. AnyUrl still checks IDN validity. - syntax = ( - instance - if instance.isascii() - else "".join(char if char.isascii() else quote(char, safe="") for char in instance) - ) - except UnicodeEncodeError: - return False - if not validate_rfc3986(syntax): + if not _formats.is_uri_syntax(instance): return False try: _uri_adapter().validate_python(instance) diff --git a/tests/conformance/_round_trip.py b/tests/conformance/_round_trip.py new file mode 100644 index 000000000..28d156a2e --- /dev/null +++ b/tests/conformance/_round_trip.py @@ -0,0 +1,815 @@ +"""Support for the wire round-trip suite. + +A schema-valid wire document that passes through a generated model — +``Model.model_validate_json(doc).model_dump(mode="json")`` with the SDK's +default dump settings — must come back as the same JSON value. AdCP binds +bytes to that value: ``content_digest`` is SHA-256 over the RFC 8785 (JCS) +form of an object's declared fields, a ``context`` is "echoed unchanged", +and a buyer verifies a seller's digest over what it received. A model that +rewrites a field on the way through breaks every one of those contracts +without failing anything (#1610). + +This module builds the corpus and names what changed: + +* :func:`documents` collects schema-valid documents — every top-level + ``examples`` entry in the pinned bundle, a synthesized request for every + tool, and the checked-in differential fixtures — and :func:`variants` + derives from each of them the forms a normalizing type rewrites (a bare + URL origin, a ``+00:00`` offset with ``.000`` fractional seconds, an + upper-case UUID, ...), keeping only the ones the bundled validator accepts; +* :func:`round_trip` runs one document through its model and returns every + leaf-level :class:`Difference`, each with a :data:`CLASSES` name; +* :data:`DIGEST_COVERED` and :data:`ECHO_COVERED` list the schemas whose + bytes the protocol binds, where no difference of any class is acceptable. + +Comparison is under JCS, so key order and number formatting (``1`` vs +``1.0``) are already normalized away; what remains is a real rewrite. +""" + +from __future__ import annotations + +import copy +import importlib +import json +import re +from collections.abc import Iterator +from dataclasses import dataclass, field +from functools import cache +from pathlib import Path +from types import UnionType +from typing import Annotated, Any, Union, get_origin + +import rfc8785 +from pydantic import TypeAdapter, ValidationError + +import adcp.types._eager # noqa: F401 (see below) +from adcp.validation.schema_loader import ( + _ensure_state, + _make_ref_resolver, + get_named_validator, +) +from tests.conformance import _schema_parity as parity + +# The models graded here are the ones adopters get: ``adcp.types._eager`` runs +# the import-time patchers (``_ergonomic``, ``_forward_compat``) that add +# validators and coercions to the generated classes. Importing a domain module +# directly before that has happened grades an unpatched class, and the verdict +# then depends on test order (a mutual-exclusivity validator on +# ``PublisherPropertySelector`` refused a document in CI's full session that an +# isolated run accepted). + +REPO_ROOT = Path(__file__).resolve().parents[2] + +# --------------------------------------------------------------------------- +# Schemas whose bytes the protocol binds. +# --------------------------------------------------------------------------- + +#: Schemas that carry a digest or signature computed over their own declared +#: fields (or over a declared sub-object), read off the bundle's descriptions +#: at pin 3.2.4. A model for any of these must dump exactly what it parsed: +#: ``digest(model.model_dump(mode="json")) == model.``. +DIGEST_COVERED: frozenset[str] = frozenset( + { + # content_digest = SHA-256(JCS(entry minus content_digest, attestation_refs)) + "core/audience-evidence.json", + # The compact snapshot carries the same content_digest; a pin and a + # selection repeat it and a seller MUST match all four identity fields. + "core/canonical-audience-evidence.json", + "core/audience-evidence-pin.json", + "core/audience-evidence-selection.json", + "core/canonical-audience-evidence-selection.json", + # content_digest = SHA-256(JCS(rights_constraint minus content_digest, + # attestation_refs, verification_url)) + "core/rights-constraint.json", + "core/rights-attestation-evaluation.json", + # revision_content_digest = SHA-256(JCS(set minus $schema, creative_id, + # revision_id, revision_content_digest, name)) + "core/creative-representation-set.json", + # selected_output_digest is computed over the derived CreativeManifest + # minus a fixed exclusion list; the manifest is the digested object. + "core/representation-selection.json", + "core/creative-manifest.json", + # content_digest = SHA-256(JCS(profile minus content_digest)) + "media-buy/acceptance-policy-profile.json", + # terms_digest = SHA-256(JCS(commercial_terms)) + "core/canonical-proposal.json", + "media-buy/commercial-terms.json", + # tracker_execution_contract_digest = SHA-256(JCS(tracker_execution_contract)); + # product_snapshot_digest over the snapshot's product-binding preimage. + "core/package-format-snapshot.json", + # reference_digest = SHA-256(JCS(complete AttestationReference)) + "core/attestation-reference.json", + "core/attestation-evaluation.json", + # statement_digest = SHA-256(JCS({seller_reference, delivery_metrics})) + "governance/check-governance-request.json", + # runtime_attestation_binding_digest = SHA-256(JCS({evaluations, findings})) + "governance/check-governance-response.json", + # plan_hash = SHA-256(JCS(plan_payload)); the governance_context JWS + # carries runtime_attestation_binding_digest. + "governance/get-plan-audit-logs-response.json", + } +) + +#: Objects the protocol says are carried through unchanged: opaque buyer +#: data every response echoes, and the extension object. A model that holds +#: one must return the bytes it was given. +ECHO_COVERED: frozenset[str] = frozenset( + { + "core/context.json", + "core/ext.json", + } +) + +# --------------------------------------------------------------------------- +# Naming a difference. +# --------------------------------------------------------------------------- + +#: Every kind of rewrite the sweep distinguishes, with what produces it. +CLASSES: dict[str, str] = { + "url_normalized": ( + "a format: uri field is parsed as pydantic AnyUrl, which re-serializes " + "the URL it parsed (trailing slash on a bare origin, lower-cased host " + "and scheme, dropped default port, percent-encoding)" + ), + "datetime_reformatted": ( + "a format: date-time field is parsed as AwareDatetime and re-serialized " + "in pydantic's form (Z for +00:00, fractional seconds dropped when zero " + "or truncated to microseconds)" + ), + "date_reformatted": "a format: date field is re-serialized in a different form", + "uuid_normalized": "a format: uuid field is parsed as UUID and re-serialized lower-case", + "email_normalized": ( + "a format: email field is parsed as EmailStr, which lower-cases the domain" + ), + "alias_emitted": ( + "the dump writes the Python field name where the wire has a name that is " + "not an identifier ($schema, from, pass): model_dump() without by_alias" + ), + "default_emitted": ( + "the model emits a field the document omitted: a schema default the " + "generator wrote into the field, or a value the model computes" + ), + "null_dropped": ("an explicit null the schema admits is dropped by the dump's exclude_none"), + "field_dropped": ( + "a property the document carried is absent after the round trip: an " + "unknown field dropped by extra='ignore', or a union arm that does not " + "carry it" + ), + "value_rewritten": "a scalar value changed in a way no other class explains", + "shape_changed": "a value changed JSON type (object, array, scalar)", +} + + +@dataclass(frozen=True) +class Difference: + """One leaf the round trip changed.""" + + schema: str + document: str + pointer: str + kind: str + before: Any + after: Any + + def describe(self) -> str: + where = f"{self.schema} [{self.document}] {self.pointer or '/'}" + return f"{where}: {self.kind} {self.before!r} -> {self.after!r}" + + +@dataclass(frozen=True) +class Refusal: + """A schema-valid document the model does not accept.""" + + schema: str + document: str + error_types: frozenset[str] + message: str + + +@dataclass +class Document: + """A schema-valid wire document and the schema it is valid under.""" + + schema: str + name: str + payload: Any + #: The tool name, when the document is a task request. + tool: str | None = None + origin: str = "example" + extra: dict[str, Any] = field(default_factory=dict) + + +_URL = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*://\S+$") +_DATE_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$") +_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") +_UUID = re.compile(r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") +_EMAIL = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def _canonical(value: Any) -> bytes: + return rfc8785.dumps(value) + + +def classify_change(before: Any, after: Any) -> str: + """Name the class of a changed leaf.""" + if isinstance(before, str) and isinstance(after, str): + if _URL.match(before): + return "url_normalized" + if _DATE_TIME.match(before): + return "datetime_reformatted" + if _DATE.match(before): + return "date_reformatted" + if _UUID.match(before): + return "uuid_normalized" + if _EMAIL.match(before): + return "email_normalized" + return "value_rewritten" + if isinstance(before, (dict, list)) or isinstance(after, (dict, list)): + return "shape_changed" + if type(before) is not type(after) and not ( + isinstance(before, (int, float)) and isinstance(after, (int, float)) + ): + return "shape_changed" + return "value_rewritten" + + +def differences(schema: str, name: str, payload: Any, dumped: Any) -> list[Difference]: + """Every leaf-level difference between ``payload`` and ``dumped`` under JCS. + + Walks both values together. A property present on one side only is + reported once, at the property, not once per descendant; a property whose + Python field name was emitted instead of its wire alias (``field_schema`` + for ``$schema``) is reported as one ``alias_emitted`` difference. + """ + if _canonical(payload) == _canonical(dumped): + return [] + found: list[Difference] = [] + _walk(schema, name, "", payload, dumped, found) + return found + + +def _walk( + schema: str, name: str, pointer: str, before: Any, after: Any, found: list[Difference] +) -> None: + if isinstance(before, dict) and isinstance(after, dict): + gone = {key for key in before if key not in after} + new = {key for key in after if key not in before} + for key in sorted(gone): + alias = _ALIASES.get(key) + if alias is not None and alias in new: + new.discard(alias) + found.append( + Difference(schema, name, f"{pointer}/{key}", "alias_emitted", key, alias) + ) + _walk(schema, name, f"{pointer}/{key}", before[key], after[alias], found) + continue + kind = "null_dropped" if before[key] is None else "field_dropped" + found.append(Difference(schema, name, f"{pointer}/{key}", kind, before[key], None)) + for key in sorted(new): + found.append( + Difference(schema, name, f"{pointer}/{key}", "default_emitted", None, after[key]) + ) + for key in before: + if key in after: + _walk(schema, name, f"{pointer}/{key}", before[key], after[key], found) + return + if isinstance(before, list) and isinstance(after, list): + if len(before) != len(after): + found.append(Difference(schema, name, pointer, "shape_changed", before, after)) + return + for index, (left, right) in enumerate(zip(before, after)): + _walk(schema, name, f"{pointer}/{index}", left, right, found) + return + if _canonical(before) != _canonical(after): + found.append( + Difference(schema, name, pointer, classify_change(before, after), before, after) + ) + + +#: Wire names the generator cannot use as Python identifiers, with the field +#: name it emits instead when ``model_dump`` runs without ``by_alias``. +_ALIASES: dict[str, str] = { + "$schema": "field_schema", + "pass": "pass_", + "from": "from_", + "media-buy": "media_buy", + "_truncation": "field_truncation", + "2xl": "field_2xl", +} + + +# --------------------------------------------------------------------------- +# Locating the model for a schema. +# --------------------------------------------------------------------------- + + +def _snake(text: str) -> str: + return re.sub(r"[^0-9a-zA-Z]+", "_", text).strip("_").lower() + + +def _camel(text: str) -> str: + return "".join(part[:1].upper() + part[1:] for part in re.split(r"[^0-9a-zA-Z]+", text) if part) + + +@cache +def schema_root() -> Path: + state = _ensure_state(None) + assert state is not None, "the pinned schema bundle is not on disk" + return state.root.root + + +def _request_model_for(tool: str) -> Any: + import adcp.types as types + + camel = "".join(part.capitalize() for part in tool.split("_")) + for name in (f"{camel}Request", camel): + model = getattr(types, name, None) + if model is not None and hasattr(model, "model_validate"): + return model + return None + + +@cache +def model_for(schema: str) -> Any: + """The generated class for a bundle schema path, or ``None``. + + The generator writes one module per schema file at + ``adcp.types.domains..`` and names the root class after + the schema's ``title`` (falling back to the file stem). + """ + rel = Path(schema) + if rel.parts[0] in ("bundled", "mcp") or len(rel.parts) == 1: + return None + module_name = ( + "adcp.types.domains." + ".".join(_snake(p) for p in rel.parts[:-1]) + "." + _snake(rel.stem) + ) + try: + module = importlib.import_module(module_name) + except ImportError: + return None + document = json.loads((schema_root() / rel).read_text()) + candidates = [_camel(document.get("title", "")), _camel(rel.stem)] + by_lower = {name.lower(): obj for name, obj in vars(module).items()} + for name in candidates: + found = getattr(module, name, None) + if found is None: + found = by_lower.get(name.lower()) + if found is None: + continue + if hasattr(found, "model_validate_json"): + return found + if get_origin(found) in (Annotated, Union, UnionType): + # A schema whose root is a oneOf/anyOf is exposed as a type alias + # over its arms (post_generate_fixes.unwrap_rootmodel_unions). + return TypeAdapter(found) + return None + + +def parse_json(grader: Any, text: str) -> Any: + if isinstance(grader, TypeAdapter): + return grader.validate_json(text) + return grader.model_validate_json(text) + + +def dump_json(grader: Any, parsed: Any) -> Any: + """The SDK's default dump: ``model_dump(mode="json")`` as AdCPBaseModel defines it.""" + if hasattr(parsed, "model_dump"): + # A union arm is an AdCP model: dump it the way the SDK does. + return parsed.model_dump(mode="json") + return grader.dump_python(parsed, mode="json", exclude_none=True) + + +def dump_as_received(grader: Any, parsed: Any) -> Any: + """``AdCPBaseModel.model_dump_as_received``: only what the document carried. + + ``exclude_unset`` leaves off every field the document did not set, which + is where a generated default would otherwise appear; ``exclude_none=False`` + keeps an explicit ``null`` the schema admits (a field the schema refuses + ``null`` on is left off by its own ``NoExplicitNull`` exclusion either + way); ``by_alias`` writes the wire name. A union alias root gets the same + settings through its adapter. + """ + if hasattr(parsed, "model_dump_as_received"): + return parsed.model_dump_as_received() + kwargs: dict[str, Any] = dict( + mode="json", exclude_unset=True, exclude_none=False, by_alias=True + ) + if hasattr(parsed, "model_dump"): + # A RootModel over a union: pydantic's own dump with the same settings. + return parsed.model_dump(**kwargs) + return grader.dump_python(parsed, **kwargs) + + +# --------------------------------------------------------------------------- +# The corpus. +# --------------------------------------------------------------------------- + + +def _bundle_schemas() -> Iterator[Path]: + root = schema_root() + for file in sorted(root.rglob("*.json")): + rel = file.relative_to(root) + if rel.parts[0] in ("bundled", "mcp") or len(rel.parts) == 1: + continue + yield rel + + +def is_valid(schema: str, payload: Any) -> bool: + validator = get_named_validator(schema) + return validator is not None and validator.is_valid(payload) + + +@cache +def synthesize_named(schema: str) -> Any: + """A schema-valid document for any bundle schema, or ``None``. + + :func:`_schema_parity.synthesize` instantiates a tool's request schema + with the request validator as the oracle; this does the same for a named + schema with :func:`get_named_validator`, then grows the document with + every optional property that keeps it valid. + """ + validator = get_named_validator(schema) + if validator is None: + return None + state = _ensure_state(None) + assert state is not None + file = state.root.root / schema + node = json.loads(file.read_text()) + resolver = _make_ref_resolver(state, file, node) + try: + candidates = parity.instances(node, resolver, 0, 64) + except parity.UnsynthesizableError: + return None + for candidate in candidates: + if validator.is_valid(candidate): + return _grow_named(validator, candidate, node, resolver) + return None + + +def _grow_named(validator: Any, payload: Any, schema: Any, resolver: Any) -> Any: + if not isinstance(payload, dict): + return payload + + def walk(node: Any, obj: Any, depth: int = 0) -> None: + if depth > 5 or not isinstance(obj, dict) or not isinstance(node, dict): + return + if "$ref" in node: + with resolver.resolving(node["$ref"]) as target: + walk(target, obj, depth) + return + for keyword in ("allOf", "oneOf", "anyOf"): + for member in node.get(keyword) or []: + walk(member, obj, depth) + properties = node.get("properties") or {} + for name, sub in properties.items(): + if name in obj: + continue + try: + value = parity.instances(sub, resolver, depth + 1, 1)[0] + except parity.UnsynthesizableError: + continue + obj[name] = value + if not validator.is_valid(payload): + del obj[name] + for name, sub in properties.items(): + if isinstance(obj.get(name), dict): + walk(sub, obj[name], depth + 1) + + walk(schema, payload) + return payload + + +def _merge(base: Any, over: Any) -> Any: + if isinstance(base, dict) and isinstance(over, dict): + merged = dict(base) + for key, value in over.items(): + merged[key] = _merge(merged[key], value) if key in merged else value + return merged + return over + + +def complete_example(schema: str, example: Any) -> Any: + """``example`` as the bundle wrote it if valid, else completed over a synthesized base. + + Most bundle examples are fragments: they illustrate a few properties and + omit the required ones. Laying the fragment over a synthesized valid + document keeps every byte the example chose and fills in only what the + schema demands; the validator decides whether the result counts. + """ + if is_valid(schema, example): + return example + base = synthesize_named(schema) + if base is None or not isinstance(base, dict) or not isinstance(example, dict): + return None + candidate = _merge(copy.deepcopy(base), example) + return candidate if is_valid(schema, candidate) else None + + +def bundle_examples() -> Iterator[Document]: + """Every top-level ``examples`` entry, as written or completed to validity.""" + root = schema_root() + for rel in _bundle_schemas(): + document = json.loads((root / rel).read_text()) + examples = document.get("examples") if isinstance(document, dict) else None + if not isinstance(examples, list): + continue + for index, example in enumerate(examples): + if not isinstance(example, (dict, list)): + continue + payload = complete_example(rel.as_posix(), example) + if payload is not None: + origin = "example" if payload is example else "completed-example" + yield Document(rel.as_posix(), f"examples[{index}]", payload, origin=origin) + + +def named_documents() -> Iterator[Document]: + """One synthesized document for every bundle schema that has a model.""" + for rel in _bundle_schemas(): + schema = rel.as_posix() + if model_for(schema) is None: + continue + payload = synthesize_named(schema) + if payload is not None: + yield Document(schema, "synthesized", copy.deepcopy(payload), origin="synthesized") + + +def tool_requests() -> Iterator[Document]: + """A synthesized schema-valid request for every tool the SDK models.""" + state = _ensure_state(None) + assert state is not None + for tool in parity.request_tools(): + file = state.file_index[(tool, "request")] + if file.is_relative_to(state.root.bundled): + continue + try: + payload = parity.synthesize(tool) + except parity.UnsynthesizableError: + continue + rel = file.relative_to(state.root.root).as_posix() + yield Document(rel, f"synthesized:{tool}", payload, tool=tool, origin="synthesized") + + +def fixture_requests() -> Iterator[Document]: + """The checked-in differential fixtures the bundled schema accepts.""" + state = _ensure_state(None) + assert state is not None + cases = json.loads( + (REPO_ROOT / "tests" / "fixtures" / "validation-parity-cases.json").read_text() + ) + for case in cases: + if not case.get("canonical_valid") or case.get("direction") != "request": + continue + file = state.file_index.get((case["tool"], "request")) + if file is None or file.is_relative_to(state.root.bundled): + continue + rel = file.relative_to(state.root.root).as_posix() + yield Document( + rel, f"fixture:{case['id']}", case["payload"], tool=case["tool"], origin="fixture" + ) + + +#: Hand-written documents for the bound schemas the instantiator cannot derive +#: from the schema alone (a ``oneOf`` on ``audience.value``, a ``required`` set +#: inside an ``allOf`` branch) and for the opaque echo objects. Each carries +#: a digest computed over its own JCS form where the schema declares one, a +#: bare-origin URL, a ``+00:00`` offset with ``.000`` fractional seconds, a +#: mixed-case host, and -- for the opaque objects -- nested ``null`` values: +#: the forms a normalizing type or an ``exclude_none`` dump rewrites. +FIXTURE_CORPUS = REPO_ROOT / "tests" / "fixtures" / "round_trip" / "corpus.json" + + +def fixture_documents() -> Iterator[Document]: + """The checked-in digest-covered corpus, each confirmed valid by the bundled validator.""" + corpus = json.loads(FIXTURE_CORPUS.read_text()) + for schema, payload in corpus.items(): + assert is_valid(schema, payload), f"{FIXTURE_CORPUS.name}: {schema} is not schema-valid" + yield Document(schema, "fixture:corpus", payload, origin="fixture") + + +def documents() -> list[Document]: + return [ + *bundle_examples(), + *named_documents(), + *tool_requests(), + *fixture_requests(), + *fixture_documents(), + ] + + +# --------------------------------------------------------------------------- +# Variants: the forms a normalizing type rewrites. +# --------------------------------------------------------------------------- + +#: Substitutions tried at every leaf whose value has the matching shape. Each +#: is schema-valid wherever the original was, which the validator confirms +#: before the variant is kept; each is a form a normalizing parser rewrites. +URL_VARIANTS = ( + "https://example.com", # bare origin: AnyUrl appends "/" + "https://Example.COM/a", # host case: AnyUrl lower-cases + "HTTPS://example.com/a", # scheme case: AnyUrl lower-cases + "https://example.com:443/a", # default port: AnyUrl drops it + "https://example.com/a%2Fb", # percent-encoding: kept or re-encoded + "https://example.com/a?b=1&c=2#frag", + "https://example.com/a/", +) +DATE_TIME_VARIANTS = ( + "2026-10-01T00:00:00.000+00:00", # +00:00 and .000: pydantic writes Z, drops .000 + "2026-10-01T00:00:00+00:00", + "2026-10-01T00:00:00-05:00", + "2026-10-01T00:00:00.5Z", + "2026-10-01T00:00:00.123456789Z", # nanoseconds: pydantic keeps microseconds + "2026-10-01t00:00:00z", # RFC 3339 permits lower-case T and Z +) +UUID_VARIANTS = ("00000000-0000-4000-8000-00000000ABCD",) +EMAIL_VARIANTS = ("Ops@Example.COM",) # EmailStr lower-cases the domain + + +def _shape_variants(value: Any) -> tuple[Any, ...]: + if not isinstance(value, str): + return () + if _URL.match(value): + return URL_VARIANTS + if _DATE_TIME.match(value): + return DATE_TIME_VARIANTS + if _UUID.match(value): + return UUID_VARIANTS + if _EMAIL.match(value): + return EMAIL_VARIANTS + return () + + +def variants(document: Document, *, limit_per_pointer: int | None = None) -> Iterator[Document]: + """Schema-valid variants of ``document`` exercising each risky form. + + One leaf is replaced at a time, so a difference names the leaf that caused + it. A leaf is only varied once per form, at the first pointer of each + shape, because every ``format: uri`` field in one document is typed the + same way and varying all of them multiplies runtime without adding + coverage — except that a form may be valid at one field and not another, + so each pointer is still tried until one accepts the form. + """ + accepted: set[tuple[str, Any]] = set() + for pointer, value in parity.leaves(document.payload): + for substitute in _shape_variants(value): + key = (_shape_key(value), substitute) + if key in accepted: + continue + candidate = copy.deepcopy(document.payload) + parity.replace_at(candidate, pointer, substitute) + if not is_valid(document.schema, candidate): + continue + accepted.add(key) + yield Document( + document.schema, + f"{document.name}{pointer}={substitute}", + candidate, + tool=document.tool, + origin="variant", + extra={"pointer": pointer, "form": substitute}, + ) + + +def _shape_key(value: str) -> str: + if _URL.match(value): + return "url" + if _DATE_TIME.match(value): + return "date-time" + if _UUID.match(value): + return "uuid" + return "email" + + +# --------------------------------------------------------------------------- +# The round trip. +# --------------------------------------------------------------------------- + + +def round_trip( + document: Document, *, as_received: bool = False +) -> tuple[list[Difference], Refusal | None]: + """Run ``document`` through its model; return what changed, or why it was refused. + + ``as_received`` dumps with :func:`dump_as_received` instead of the SDK's + default settings. + """ + model = grader_for(document) + if model is None: + return [], None + try: + parsed = parse_json(model, json.dumps(document.payload)) + except ValidationError as exc: + return [], Refusal( + document.schema, + document.name, + frozenset(error["type"] for error in exc.errors()), + "; ".join(f"{'/'.join(map(str, e['loc']))}: {e['msg']}" for e in exc.errors()[:3]), + ) + except (TypeError, ValueError) as exc: + # A model whose validator raises rather than refuses is a defect of + # its own; the sweep records it and moves on. + return [], Refusal( + document.schema, document.name, frozenset({type(exc).__name__}), str(exc)[:200] + ) + dumped = dump_json(model, parsed) if not as_received else dump_as_received(model, parsed) + return differences(document.schema, document.name, document.payload, dumped), None + + +def grader_for(document: Document) -> Any: + if document.tool is not None: + model = _request_model_for(document.tool) + if model is not None: + return model + return model_for(document.schema) + + +def has_model(document: Document) -> bool: + return grader_for(document) is not None + + +def _resolver_for(schema: str) -> Any: + state = _ensure_state(None) + assert state is not None + file = state.root.root / schema + return _make_ref_resolver(state, file, json.loads(file.read_text())) + + +if __name__ == "__main__": # pragma: no cover - exploratory report + import sys + from collections import Counter, defaultdict + + include_variants = "--variants" in sys.argv + per_class: Counter[str] = Counter() + distinct: dict[str, set[tuple[str, str]]] = defaultdict(set) + received_distinct: dict[str, set[tuple[str, str]]] = defaultdict(set) + received_class: Counter[str] = Counter() + per_schema: dict[str, Counter[str]] = defaultdict(Counter) + received_schema: dict[str, Counter[str]] = defaultdict(Counter) + samples: dict[str, list[Difference]] = defaultdict(list) + schema_samples: dict[tuple[str, str], Difference] = {} + refusals: list[Refusal] = [] + unmodelled: set[str] = set() + graded = 0 + origins: Counter[str] = Counter() + for base in documents(): + origins[base.origin] += 1 + docs = [base, *(variants(base) if include_variants else [])] + for doc in docs: + if not has_model(doc): + unmodelled.add(doc.schema) + continue + graded += 1 + diffs, refusal = round_trip(doc) + if refusal is not None: + refusals.append(refusal) + continue + for d in diffs: + per_class[d.kind] += 1 + distinct[d.kind].add((d.schema, re.sub(r"/\d+(?=/|$)", "/*", d.pointer))) + per_schema[d.schema][d.kind] += 1 + schema_samples.setdefault((d.schema, d.kind), d) + if len(samples[d.kind]) < 8: + samples[d.kind].append(d) + for d in round_trip(doc, as_received=True)[0]: + received_class[d.kind] += 1 + received_distinct[d.kind].add((d.schema, re.sub(r"/\d+(?=/|$)", "/*", d.pointer))) + received_schema[d.schema][d.kind] += 1 + schema_samples.setdefault((d.schema, "received:" + d.kind), d) + print( + f"graded documents: {graded}; unmodelled schemas: {len(unmodelled)}; " + f"refusals: {len(refusals)}; origins: {dict(origins)}" + ) + print("\n== per class ==") + for kind, count in per_class.most_common(): + fields = distinct[kind] + schemas = {schema for schema, _ in fields} + print(f" {kind:22} {count:6} hits; {len(fields):5} fields; {len(schemas):4} schemas") + print("\n== per class, dumping as received (exclude_unset, nulls kept, by_alias) ==") + for kind, count in received_class.most_common(): + fields = received_distinct[kind] + schemas = {schema for schema, _ in fields} + print(f" {kind:22} {count:6} hits; {len(fields):5} fields; {len(schemas):4} schemas") + print("\n== digest/echo-covered schemas with differences ==") + for schema in sorted(per_schema): + if schema in DIGEST_COVERED or schema in ECHO_COVERED: + print(f" {schema}: {dict(per_schema[schema])}") + print("\n== all schemas with differences ==") + for schema in sorted(per_schema): + print(f" {schema}: {dict(per_schema[schema])}") + print("\n== digest/echo-covered schemas with differences, as received ==") + for schema in sorted(received_schema): + if schema in DIGEST_COVERED or schema in ECHO_COVERED: + print(f" {schema}: {dict(received_schema[schema])}") + print("\n== one sample per (schema, class) ==") + for (schema, kind), d in sorted(schema_samples.items()): + print(f" {schema} [{kind}] {d.pointer}: {d.before!r} -> {d.after!r}"[:200]) + print("\n== samples ==") + for kind, items in samples.items(): + print(f" [{kind}]") + for d in items: + print(" " + d.describe()[:220]) + print("\n== refusals ==") + for r in refusals[:40]: + print(f" {r.schema} [{r.document}] {sorted(r.error_types)} {r.message[:160]}") + print("\n== unmodelled ==") + for s in sorted(unmodelled): + print(" " + s) diff --git a/tests/conformance/test_wire_round_trip.py b/tests/conformance/test_wire_round_trip.py new file mode 100644 index 000000000..da69824b2 --- /dev/null +++ b/tests/conformance/test_wire_round_trip.py @@ -0,0 +1,252 @@ +"""A schema-valid wire document survives parse → dump unchanged. + +AdCP binds bytes to several objects: ``content_digest`` is SHA-256 over the +RFC 8785 form of an entry's declared fields, a ``context`` is "echoed +unchanged", and a buyer verifies a seller's digest over what it received. A +generated model that rewrites a value on the way through breaks those +contracts without failing anything, which is how #1610 shipped: ``AnyUrl`` +appended a slash to a bare origin and ``AwareDatetime`` rewrote ``+00:00`` as +``Z``, and a parsed-then-dumped ``AudienceEvidence`` no longer matched its own +digest. #1384 (``agent_url``), #1449 (defaults added to a published format), +#1587 (nested nulls) and #1575 (context null keys) were earlier instances of +the same missing invariant, each fixed at one field or one object. + +This suite states the invariant and measures it. For every bundle schema with +a model it takes schema-valid documents — the bundle's ``examples``, a +synthesized document, the differential fixtures, and a checked-in corpus for +the digest-covered schemas — derives variants that exercise each form a +normalizing parser rewrites, runs each through +``Model.model_validate_json(doc).model_dump(mode="json")`` with the SDK's +default settings, and compares the result to the input under JCS. + +Two tiers. A schema whose bytes the protocol binds +(:data:`_round_trip.DIGEST_COVERED`, :data:`_round_trip.ECHO_COVERED`) must +round-trip with no difference of any class. Every other schema may show only +the classes named in :data:`ACCEPTED`, each with the reason it remains and the +issue that tracks it; the set only shrinks, and a class that stops occurring +must be deleted from it. A class outside ``ACCEPTED`` — a rewritten URL, +datetime, UUID or email, a changed value, a changed shape — fails anywhere. +""" + +from __future__ import annotations + +from collections import defaultdict +from functools import cache + +import pytest + +from tests.conformance import _round_trip as rt + +#: The classes of difference this SDK still produces under its default dump +#: settings, each with the reason it is here. A difference of any other class +#: is a regression. A class that stops occurring is deleted — this set only +#: shrinks. +ACCEPTED: dict[str, str] = { + # The generator writes a schema ``default`` into the field and the dump + # emits every field, so a document that omitted a defaulted property gains + # it. "Omitted" and "present with the default" read the same to a schema + # validator; they hash differently. #1613 proposes leaving an unset + # optional default off the dump; until then, dump as received + # (``exclude_unset=True``) to recover the bytes. + "default_emitted": "the generator materializes schema defaults on dump (#1613)", + # ``extra='ignore'`` drops a property the pinned model does not declare, + # and a union arm declares only its own properties while the schema's arm + # admits its siblings'. No digest-covered schema is affected at this pin; + # the hazard is a newer-spec field inside one. #1614. + "field_dropped": "unknown and sibling-arm properties are dropped (#1614)", + # ``model_dump()`` without ``by_alias`` writes ``field_schema`` for + # ``$schema`` and ``pass_`` for ``pass``; the SDK's own wire paths pass + # ``by_alias=True``. #1615. + "alias_emitted": "model_dump() writes the Python name of an aliased key (#1615)", + # ``exclude_none`` drops an explicit ``null`` on a property whose schema + # admits it; on the Python side ``None`` means "not supplied". #1615. + "null_dropped": "exclude_none drops an admitted explicit null (#1615)", +} + +#: Schemas whose bytes the protocol binds. Under ``model_dump_as_received`` +#: no difference of any class; under the default dump no rewrite, no dropped +#: field, no alias and no dropped null -- only, for the schemas listed in +#: :data:`BOUND_DEFAULTS`, a schema default the document omitted. +BOUND = sorted(rt.DIGEST_COVERED | rt.ECHO_COVERED) + +#: Bound schemas whose *default* dump still materializes a schema default the +#: document omitted (#1613): ``commercial_terms`` reaches ``targeting.json`` +#: (``age_restriction.verification_required``), so ``terms_digest`` recomputed +#: over ``model_dump(mode="json")`` mismatches whenever the seller left it +#: out. ``model_dump_as_received`` recovers the bytes, and that is the +#: verification path until #1613 lands. The set only shrinks; a bound schema +#: joining it is a regression. +BOUND_DEFAULTS = frozenset( + { + "core/canonical-proposal.json", + "media-buy/commercial-terms.json", + } +) + +#: Schemas with at least one schema-valid document the generated model refuses +#: (raises, or fails validation). Refusing is the schema/model parity suite's +#: domain, not this one's; the set is recorded so a refusal cannot silently +#: remove a schema from the round-trip grade, and so that it only shrinks. +#: ``mcp-webhook-payload`` and ``async-response-data`` raise ``TypeError`` +#: (#1616); the others refuse documents the bundled schema accepts +#: (``publisher-property-selector`` through the mutual-exclusivity validator +#: ``_ergonomic`` adds, which the schema does not express). +REFUSING = frozenset( + { + "brand/creative-approval-request.json", + "brand/verify-brand-claims-response.json", + "core/async-response-data.json", + "core/mcp-webhook-payload.json", + "core/publisher-property-selector.json", + "core/reporting-delivery-capabilities.json", + "media-buy/change-term.json", + } +) + + +@cache +def _corpus() -> dict[str, list[rt.Document]]: + """Every base document, by schema.""" + by_schema: dict[str, list[rt.Document]] = defaultdict(list) + for document in rt.documents(): + if rt.has_model(document): + by_schema[document.schema].append(document) + return dict(by_schema) + + +@cache +def _graded(schema: str) -> tuple[list[tuple[rt.Document, list[rt.Difference]]], list[rt.Refusal]]: + """Every document and variant for ``schema``, with what the round trip changed. + + A bound schema is graded on every document and every variant of each. For + the rest, variants are derived from the first document only: every + ``format: uri`` field in a schema is typed the same way, so varying one + document's leaves covers the type and varying ten multiplies runtime. + """ + documents = _corpus().get(schema, []) + graded: list[tuple[rt.Document, list[rt.Difference]]] = [] + refusals: list[rt.Refusal] = [] + for index, base in enumerate(documents): + expand = schema in rt.DIGEST_COVERED or schema in rt.ECHO_COVERED or index == 0 + for document in [base, *(rt.variants(base) if expand else [])]: + differences, refusal = rt.round_trip(document) + if refusal is not None: + refusals.append(refusal) + else: + graded.append((document, differences)) + return graded, refusals + + +SCHEMAS = sorted(_corpus()) + + +def test_every_bound_schema_has_a_document() -> None: + """A digest- or echo-covered schema with no document is ungraded, which is a coverage hole.""" + missing = [schema for schema in BOUND if not _corpus().get(schema)] + assert not missing, ( + f"no schema-valid document for {missing}; add one to " + f"{rt.FIXTURE_CORPUS.relative_to(rt.REPO_ROOT)}" + ) + + +@pytest.mark.parametrize("schema", BOUND) +def test_bound_object_round_trips_byte_for_byte_as_received(schema: str) -> None: + """``model_dump_as_received`` of a digest- or echo-covered object is the document.""" + graded, refusals = _graded(schema) + assert not refusals, "\n".join( + [f"{len(refusals)} schema-valid document(s) for {schema} refused by the model:"] + + [f" [{r.document}] {sorted(r.error_types)}: {r.message}" for r in refusals[:10]] + ) + assert graded, f"{schema} graded no documents" + changed = [d for document, _ in graded for d in rt.round_trip(document, as_received=True)[0]] + assert not changed, "\n".join( + [f"{len(changed)} difference(s) in a digest- or echo-covered object ({schema}):"] + + [f" {d.describe()}" for d in changed[:20]] + ) + + +@pytest.mark.parametrize("schema", BOUND) +def test_bound_object_is_never_rewritten_by_the_default_dump(schema: str) -> None: + """The default dump of a bound object rewrites nothing; at most it adds a declared default.""" + graded, _refusals = _graded(schema) + changed = [d for _, differences in graded for d in differences] + rewritten = [d for d in changed if d.kind != "default_emitted"] + assert not rewritten, "\n".join( + [f"{len(rewritten)} rewrite(s) in a digest- or echo-covered object ({schema}):"] + + [f" {d.describe()}" for d in rewritten[:20]] + ) + defaults = [d for d in changed if d.kind == "default_emitted"] + if schema in BOUND_DEFAULTS: + assert ( + defaults + ), f"{schema} no longer emits an omitted default; delete it from BOUND_DEFAULTS" + else: + assert not defaults, "\n".join( + [f"{schema} now emits a default the document omitted (#1613):"] + + [f" {d.describe()}" for d in defaults[:20]] + ) + + +@pytest.mark.parametrize("schema", SCHEMAS) +def test_schema_round_trips_or_declares_its_differences(schema: str) -> None: + """Every difference belongs to a class this SDK declares, with its reason.""" + graded, _refusals = _graded(schema) + undeclared = [d for _, differences in graded for d in differences if d.kind not in ACCEPTED] + assert not undeclared, "\n".join( + [ + f"{len(undeclared)} rewrite(s) of a class this SDK does not declare ({schema}):", + *(f" {d.describe()}" for d in undeclared[:20]), + "", + "A wire value the model did not change must dump as received. Either fix the", + "type (see adcp.types._wire) or, for a new class of difference, add it to", + "tests/conformance/test_wire_round_trip.py ACCEPTED with its reason.", + ] + ) + + +def test_every_accepted_class_still_occurs() -> None: + """An accepted class that no longer happens is deleted, not kept.""" + observed = { + d.kind for schema in SCHEMAS for _, differences in _graded(schema)[0] for d in differences + } + stale = sorted(set(ACCEPTED) - observed) + assert not stale, f"accepted classes that no longer occur: {stale}; delete them from ACCEPTED" + unknown = sorted(set(ACCEPTED) - set(rt.CLASSES)) + assert not unknown, f"ACCEPTED names classes the sweep does not produce: {unknown}" + + +def test_dump_as_received_leaves_only_unknown_fields() -> None: + """The explicit "as received" dump recovers everything but what the model never held. + + ``AdCPBaseModel.model_dump_as_received`` leaves off unset defaults, keeps + an admitted explicit ``null`` and writes wire names, so the only residue is + a property the model dropped on input (#1614). This is the path a verifier + uses to recompute a digest over a parsed object today. + """ + residue: dict[str, set[str]] = defaultdict(set) + for schema in SCHEMAS: + for document, _ in _graded(schema)[0]: + if document.origin == "variant": + continue + for d in rt.round_trip(document, as_received=True)[0]: + residue[d.kind].add(schema) + unexpected = { + kind: sorted(schemas) for kind, schemas in residue.items() if kind != "field_dropped" + } + assert not unexpected, f"dump-as-received residue beyond dropped fields: {unexpected}" + + +def test_refusing_schemas_are_declared() -> None: + """A schema the model refuses a valid document for is named; the set only shrinks.""" + refusing = {schema for schema in SCHEMAS if _graded(schema)[1]} + new = sorted(refusing - REFUSING) + assert not new, "\n".join( + [f"schema-valid documents refused by the model for {len(new)} undeclared schema(s):"] + + [ + f" {schema}: {sorted(_graded(schema)[1][0].error_types)} " + f"{_graded(schema)[1][0].message}" + for schema in new[:10] + ] + ) + fixed = sorted(REFUSING - refusing) + assert not fixed, f"schemas that no longer refuse: {fixed}; delete them from REFUSING" diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index e448656d0..6e9c3d0ff 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -1002,6 +1002,9 @@ "AssetVariant": "union[instance:pydantic.fields:FieldInfo,union[core.assets.asset_union:AudioAsset,core.assets.asset_union:BriefAsset,core.assets.asset_union:CardAsset,core.assets.asset_union:CatalogAsset,core.assets.asset_union:CssAsset,core.assets.asset_union:DaastAsset,core.assets.asset_union:DaastTrackerAsset,core.assets.asset_union:DisplayTagAsset,core.assets.asset_union:HtmlAsset,core.assets.asset_union:ImageAsset,core.assets.asset_union:JavascriptAsset,core.assets.asset_union:MarkdownAsset,core.assets.asset_union:PixelTrackerAsset,core.assets.asset_union:PublishedPostAsset,core.assets.asset_union:TextAsset,core.assets.asset_union:UrlAsset,core.assets.asset_union:VastAsset,core.assets.asset_union:VastTrackerAsset,core.assets.asset_union:VideoAsset,core.assets.asset_union:WebhookAsset,core.assets.asset_union:ZipAsset]]", "AssignedPackage": "creative.list_creatives_response", "Assignments": "creative.list_creatives_response", + "AudienceEvidence": "core.audience_evidence", + "AudienceEvidencePin": "core.audience_evidence_pin", + "AudienceEvidenceSelection": "core.audience_evidence_selection", "AudienceSource": "enums.audience_source", "AudienceTooSmallDetails": "error_details.audience_too_small", "AudioContent": "core.assets.audio_asset:AudioAsset", @@ -1045,6 +1048,8 @@ "CalibrateContentResponse1": "content_standards.calibrate_content_response", "CalibrateContentSuccessResponse": "content_standards.calibrate_content_response:CalibrateContentResponse1", "CanonicalAssetSource": "core.canonical_projection_ref:AssetSource", + "CanonicalAudienceEvidence": "core.canonical_audience_evidence", + "CanonicalAudienceEvidenceSelection": "core.canonical_audience_evidence_selection", "CanonicalCompositionModel": "formats.canonical._base:CompositionModel", "CanonicalFormatAgentPlacement": "formats.canonical.agent_placement:CanonicalFormatAgentPlacementAiSurfaceSponsoredPlacement", "CanonicalFormatBase": "formats.canonical._base", @@ -4363,7 +4368,7 @@ "SpotReportingCapability": "core.spot_reporting_capability", "StaleResponseDetails": "error_details.stale_response", "StandardEnum": "sponsored_intelligence.si_capabilities", - "StartTiming": "union[instance:pydantic.fields:FieldInfo,union[pydantic.types:AwareDatetime,union[value:'asap']]]", + "StartTiming": "union[instance:pydantic.fields:FieldInfo,union[adcp.types._wire:WireDatetime,union[value:'asap']]]", "StartingPosition": "account.list_account_changes_request", "State": "formats.canonical.seller_rendered_stateful_display", "Status": "aao.agent_publishers", @@ -6645,7 +6650,7 @@ "Special": "core.special", "Specification": "core.product", "SpotReportingCapability": "core.spot_reporting_capability", - "StartTiming": "union[instance:pydantic.fields:FieldInfo,union[pydantic.types:AwareDatetime,union[value:'asap']]]", + "StartTiming": "union[instance:pydantic.fields:FieldInfo,union[adcp.types._wire:WireDatetime,union[value:'asap']]]", "StoreItem": "core.store_item", "Storyboard": "core.registry_event", "StoryboardStatus": "core.registry_event", diff --git a/tests/fixtures/round_trip/corpus.json b/tests/fixtures/round_trip/corpus.json new file mode 100644 index 000000000..6e9164184 --- /dev/null +++ b/tests/fixtures/round_trip/corpus.json @@ -0,0 +1,265 @@ +{ + "core/audience-evidence.json": { + "evidence_id": "evidence_age", + "snapshot_id": "evidence_age_2026_q3", + "version": "2026-q3", + "audience": { + "dimension": "age", + "range": { + "min": 25, + "max": 54 + }, + "label": "Adults 25-54" + }, + "relationship": "composition", + "value": 0.6, + "unit": "fraction", + "baseline": { + "system": "https://measurement.example", + "population_id": "us_adults", + "version": "2026" + }, + "evidence_type": "measured", + "methodology": "projected", + "subject_type": "individual", + "provider": { + "domain": "measurement.example" + }, + "measurement_window": { + "start": "2026-07-01", + "end": "2026-09-30" + }, + "last_updated": "2026-10-01T00:00:00.000+00:00", + "methodology_url": "https://Measurement.Example/methodology", + "content_digest": "sha256:5c149933a2feffe1849fe7d16691acf7644881340e79dd2261c3e95deb98f1f7", + "attestation_refs": [ + { + "issuer": { + "type": "origin", + "origin": "https://credentials.example" + }, + "claim_type": "https://adcontextprotocol.org/claims/audience-evidence", + "subject": { + "type": "resource", + "resource_type": "https://adcontextprotocol.org/claims/subjects/audience-evidence", + "namespace": "https://measurement.example", + "id": "evidence_age_2026_q3", + "content_digest": "sha256:5c149933a2feffe1849fe7d16691acf7644881340e79dd2261c3e95deb98f1f7" + }, + "locator": { + "type": "credential_uri", + "credential_uri": "https://credentials.example/vc/1" + } + } + ] + }, + "core/canonical-audience-evidence.json": { + "evidence_id": "evidence_age", + "snapshot_id": "evidence_age_2026_q3", + "version": "2026-q3", + "audience": { + "dimension": "age", + "range": { + "min": 25, + "max": 54 + }, + "label": "Adults 25-54" + }, + "relationship": "composition", + "value": 0.6, + "unit": "fraction", + "baseline": { + "system": "https://measurement.example", + "population_id": "us_adults", + "version": "2026" + }, + "evidence_type": "measured", + "methodology": "projected", + "subject_type": "individual", + "provider": { + "domain": "measurement.example" + }, + "measurement_window": { + "start": "2026-07-01", + "end": "2026-09-30" + }, + "last_updated": "2026-10-01T00:00:00.000+00:00", + "methodology_url": "https://Measurement.Example/methodology", + "content_digest": "sha256:5c149933a2feffe1849fe7d16691acf7644881340e79dd2261c3e95deb98f1f7" + }, + "media-buy/commercial-terms.json": { + "brand": { + "domain": "acme.example" + }, + "purchases": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_1", + "pricing": { + "pricing_option_id": "cpm_1", + "pricing_model": "cpm", + "fixed_price": 5, + "currency": "USD" + }, + "start_time": "2026-11-01T00:00:00.000+00:00", + "end_time": "2026-11-30T23:59:59+00:00", + "targeting_overlay": { + "age_restriction": { + "min": 21 + } + } + } + ], + "start_time": "2026-11-01T00:00:00.000+00:00", + "end_time": "2026-11-30T23:59:59+00:00" + }, + "core/canonical-proposal.json": { + "proposal_id": "prop_1", + "proposal_kind": "new_media_buy", + "proposal_status": "committed", + "name": "Q4", + "expires_at": "2026-10-15T00:00:00.000+00:00", + "commercial_terms": { + "brand": { + "domain": "acme.example" + }, + "purchases": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_1", + "pricing": { + "pricing_option_id": "cpm_1", + "pricing_model": "cpm", + "fixed_price": 5, + "currency": "USD" + }, + "start_time": "2026-11-01T00:00:00.000+00:00", + "end_time": "2026-11-30T23:59:59+00:00", + "targeting_overlay": { + "age_restriction": { + "min": 21 + } + } + } + ], + "start_time": "2026-11-01T00:00:00.000+00:00", + "end_time": "2026-11-30T23:59:59+00:00" + }, + "terms_digest": "sha256:9ZThE6bffvQLxvNEvrwwyF70xPr6eFFQuAuaMP3XEcg" + }, + "core/creative-representation-set.json": { + "creative_id": "cr_1", + "revision_id": "rev_1", + "name": "Banner", + "representations": [ + { + "representation_id": "rep_1", + "source": { + "system": "https://studio.example", + "source_representation": "asset_7" + }, + "format_kind": "image", + "assets": { + "image_main": { + "asset_type": "image", + "url": "https://CDN.Example/a.png", + "width": 300, + "height": 250 + } + } + } + ], + "revision_content_digest": "sha256:efc9ee3b58cf06c6af9187dda76cbed4899e28cbaba39cffba97606b124d3d23" + }, + "core/rights-attestation-evaluation.json": { + "rights_id": "rights_1", + "content_digest": "sha256:5041bf1f713df204784353e82f6a4a535931cb64f1f4b4a5aeaffcb720918b22", + "reference": { + "issuer": { + "type": "brand", + "brand": { + "domain": "acme.example" + } + }, + "claim_type": "https://adcontextprotocol.org/claims/rights/grant", + "subject": { + "type": "resource", + "resource_type": "https://adcontextprotocol.org/claims/subjects/rights-grant", + "namespace": "https://rights.example", + "id": "rights_1", + "content_digest": "sha256:5041bf1f713df204784353e82f6a4a535931cb64f1f4b4a5aeaffcb720918b22" + }, + "locator": { + "type": "credential_uri", + "credential_uri": "https://credentials.example/vc/rights_1" + }, + "validity_hint": { + "not_before": "2026-01-01T00:00:00+00:00", + "expires_at": "2027-01-01T00:00:00.000Z" + } + }, + "evaluation": { + "reference_digest": "sha256:4a5ef326daa88b5757003264296c103f68393b1aa69150255dfe9cbcfee294d4", + "outcome": "verified", + "evaluated_at": "2026-10-01T00:00:00.000+00:00", + "evaluated_by": "https://Governance.Example", + "credential_digest": "sha256:5543b0e2cdc4859cafc5a7ff61612b4c15cb5f7a0236c915b8a8a579349335d3", + "proof_format": "https://www.w3.org/TR/vc-data-model-2.0/", + "revocation_checked_at": "2026-10-01T00:00:00+00:00", + "valid_until": "2026-12-31T00:00:00+00:00", + "action_binding": { + "action_type": "https://adcontextprotocol.org/actions/rights-grant-evaluation", + "action_id": "rights_1", + "action_digest": "sha256:5041bf1f713df204784353e82f6a4a535931cb64f1f4b4a5aeaffcb720918b22" + } + } + }, + "governance/check-governance-response.json": { + "check_id": "chk_1", + "verdict": "approved", + "explanation": "ok", + "plan_id": "plan_1", + "expires_at": "2026-10-02T00:00:00.000+00:00", + "runtime_attestation_evaluations": [ + { + "reference_digest": "sha256:4a5ef326daa88b5757003264296c103f68393b1aa69150255dfe9cbcfee294d4", + "outcome": "verified", + "evaluated_at": "2026-10-01T00:00:00.000+00:00", + "evaluated_by": "https://Governance.Example", + "credential_digest": "sha256:5543b0e2cdc4859cafc5a7ff61612b4c15cb5f7a0236c915b8a8a579349335d3", + "proof_format": "https://www.w3.org/TR/vc-data-model-2.0/", + "revocation_checked_at": "2026-10-01T00:00:00+00:00", + "valid_until": "2026-12-31T00:00:00+00:00", + "action_binding": { + "action_type": "https://adcontextprotocol.org/actions/governance-check", + "action_id": "chk_1" + } + } + ], + "runtime_attestation_binding_digest": "sha256:295927e83297514165a359593222b89693f8b9625a269898662a0a90f2dad84b" + }, + "core/context.json": { + "buyer_ref": "po-2026-10", + "trace": { + "id": "4b1c", + "parent": null, + "hops": [ + 1, + null, + { + "at": "2026-10-01T00:00:00.000+00:00" + } + ] + }, + "note": null, + "url": "https://Buyer.Example" + }, + "core/ext.json": { + "gam": { + "advertiser_id": "123", + "flags": null, + "endpoint": "https://gam.example" + }, + "roku": null + } +} diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index 206179cc1..ecaf66630 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -2211,7 +2211,8 @@ def test_generated_create_media_buy_success_matches_schema_nullability(): and isinstance(node.target, ast.Name) and node.target.id == "confirmed_at" ) - assert ast.unparse(confirmed_at.annotation) == "AwareDatetime | None" + # ``WireDatetime`` is the generated spelling of ``AwareDatetime`` (#1610). + assert ast.unparse(confirmed_at.annotation) == "WireDatetime | None" assert confirmed_at.value is None diff --git a/tests/test_composability_invariant.py b/tests/test_composability_invariant.py index 5a908935b..344a5333e 100644 --- a/tests/test_composability_invariant.py +++ b/tests/test_composability_invariant.py @@ -97,6 +97,8 @@ def test_capability_fields_have_no_unreviewed_public_model_collisions() -> None: # These are intentionally different concepts that happen to share a schema # name. They must not be replaced by the public class of the same name. semantic_name_collisions = { + # The capabilities flag object, not the core evidence entry (#1610). + ("CapabilitiesMediaBuy", "audience_evidence", "AudienceEvidence"), ("CapabilitiesMediaBuy", "content_standards", "ContentStandards"), ("CapabilitiesMediaBuy", "performance_feedback", "PerformanceFeedback"), ("Execution", "trusted_match", "TrustedMatch"), diff --git a/tests/test_governance_authorization.py b/tests/test_governance_authorization.py index b7b140dc4..4d072866d 100644 --- a/tests/test_governance_authorization.py +++ b/tests/test_governance_authorization.py @@ -354,7 +354,7 @@ def test_intent_and_execution_builders_keep_plan_on_buyer_side() -> None: payload={"media_buy_id": "mb-1", "budget": 12}, proposed_commitment=build_governance_commitment(2, "USD"), ) - assert str(intent.target_agent) == "https://seller.example/" + assert str(intent.target_agent) == "https://seller.example" # as given (#1610) assert intent.payload == {"media_buy_id": "mb-1", "budget": 12} assert intent.governance_context is None diff --git a/tests/test_post_generate_wire_types.py b/tests/test_post_generate_wire_types.py new file mode 100644 index 000000000..7d421676a --- /dev/null +++ b/tests/test_post_generate_wire_types.py @@ -0,0 +1,50 @@ +"""``preserve_wire_text_on_normalizing_fields`` renames references, not prose (#1610). + +The pass points every generated ``AwareDatetime`` / ``AnyUrl`` / ``UUID`` / +``EmailStr`` field at its wire-preserving type. A field description that +says "Use a fresh UUID v4" must keep its wording, and ``uuid.UUID`` as an +attribute is not a reference to the imported name. +""" + +from __future__ import annotations + +import textwrap + +from scripts import post_generate_fixes as fixes + +_SOURCE = textwrap.dedent(''' + from uuid import UUID + + from pydantic import AnyUrl, AwareDatetime, Field + + + class Model(AdCPBaseModel): + """A UUID identifies it; an AnyUrl locates it.""" + + key: Annotated[UUID, Field(description="Use a fresh UUID v4 for each request.")] + url: AnyUrl | None = None + when: AwareDatetime + other = uuid.UUID + ''') + + +def test_identifier_tokens_are_renamed_and_strings_are_not() -> None: + renamed = fixes._rename_code_names( + _SOURCE, {"UUID": "WireUuid", "AnyUrl": "WireAnyUrl", "AwareDatetime": "WireDatetime"} + ) + assert "key: Annotated[WireUuid, Field(" in renamed + assert "url: WireAnyUrl | None = None" in renamed + assert "when: WireDatetime" in renamed + assert "from uuid import WireUuid" in renamed # the import line is a reference too + assert 'description="Use a fresh UUID v4 for each request."' in renamed + assert '"""A UUID identifies it; an AnyUrl locates it."""' in renamed + assert "other = uuid.UUID" in renamed + + +def test_every_format_type_the_generator_renders_has_a_replacement() -> None: + assert dict(fixes._WIRE_PRESERVING_TYPES) == { + ("pydantic", "AwareDatetime"): "WireDatetime", + ("pydantic", "AnyUrl"): "WireAnyUrl", + ("pydantic", "EmailStr"): "WireEmail", + ("uuid", "UUID"): "WireUuid", + } diff --git a/tests/test_reporting_adjustment_evidence.py b/tests/test_reporting_adjustment_evidence.py index 7bcbc360d..0caea2337 100644 --- a/tests/test_reporting_adjustment_evidence.py +++ b/tests/test_reporting_adjustment_evidence.py @@ -98,9 +98,15 @@ def test_raw_values_and_presence_are_hashed_before_model_normalization(detail: s assert evidence.observed_adjustment_sha256 == raw["canonical_adjustment_sha256"] assert evidence.input_kind == "bytes" assert evidence.adjustment == typed - normalized = typed.model_dump(mode="json", exclude_none=True) - normalized.pop("canonical_adjustment_sha256") - assert rfc8785.dumps(normalized) != evidence.canonical_json + # A typed view parsed from a differently spelled document hashes the same + # raw bytes: the evidence is bound to what was received, not to the model. + respelled = dict(raw, created_at="2026-09-02T00:00:00+00:00") + other = ReportingAdjustment.model_validate(respelled) + assert other.model_dump(mode="json")["created_at"] == "2026-09-02T00:00:00+00:00" + respelled_evidence = capture_reporting_adjustment_evidence( + wire, typed_adjustment=other, scope=SCOPE + ) + assert respelled_evidence.canonical_json == evidence.canonical_json assert json.loads(evidence.canonical_json)["control_total_deltas"][0]["value"] == "-0.00" assert json.loads(evidence.canonical_json)["control_total_deltas"][0]["value_type"] == "decimal" diff --git a/tests/test_type_aliases.py b/tests/test_type_aliases.py index 7fe3f5a04..a6fcb6bdb 100644 --- a/tests/test_type_aliases.py +++ b/tests/test_type_aliases.py @@ -512,7 +512,7 @@ def test_deployment_aliases_can_instantiate(): type="agent", agent_url="https://agent.example.com", is_live=False ) assert agent_deployment.type == "agent" - assert str(agent_deployment.agent_url) == "https://agent.example.com/" + assert str(agent_deployment.agent_url) == "https://agent.example.com" # as given (#1610) assert agent_deployment.is_live is False @@ -552,7 +552,7 @@ def test_destination_aliases_can_instantiate(): # Create AgentDestination agent_dest = AgentDestination(type="agent", agent_url="https://agent.example.com") assert agent_dest.type == "agent" - assert str(agent_dest.agent_url) == "https://agent.example.com/" + assert str(agent_dest.agent_url) == "https://agent.example.com" # as given (#1610) def test_deployment_destination_aliases_in_exports(): diff --git a/tests/test_wire_types.py b/tests/test_wire_types.py new file mode 100644 index 000000000..af4b331aa --- /dev/null +++ b/tests/test_wire_types.py @@ -0,0 +1,429 @@ +"""The wire-preserving scalar types keep the bytes the wire delivered (#1610). + +``AudienceEvidence.content_digest`` is SHA-256 over the RFC 8785 form of the +entry's declared fields. The generated model used to parse ``baseline.system`` +into ``AnyUrl`` (which appends a slash to a bare origin) and ``last_updated`` +into ``AwareDatetime`` (which rewrites ``+00:00`` as ``Z`` and drops ``.000``), +so a parsed-then-dumped entry no longer matched its own digest. The types in +:mod:`adcp.types._wire` validate the same way, keep the attribute API, and in +JSON mode serialize the text they were parsed from. +""" + +from __future__ import annotations + +import copy +import hashlib +import json +import pickle +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +import pytest +import rfc8785 +from pydantic import AnyUrl, BaseModel, TypeAdapter, ValidationError + +from adcp.types import AudienceEvidence +from adcp.types._wire import WireAnyUrl, WireDatetime, WireEmail, WireUuid, rfc3339 +from adcp.types.base import AdCPBaseModel + + +class Plain(BaseModel): + when: WireDatetime + url: WireAnyUrl + uid: WireUuid + email: WireEmail + optional_url: WireAnyUrl | None = None + + +class Model(AdCPBaseModel): + """An AdCP model: ``model_dump`` passes ``serialize_as_any=True``.""" + + when: WireDatetime + url: WireAnyUrl + uid: WireUuid + email: WireEmail + + +WIRE = { + "when": "2026-10-01T00:00:00.000+00:00", + "url": "https://Measurement.Example", + "uid": "00000000-0000-4000-8000-00000000ABCD", + "email": "Ops@Example.COM", +} + + +@pytest.mark.parametrize("model", [Plain, Model]) +def test_parsed_text_is_dumped_byte_for_byte(model: type[BaseModel]) -> None: + parsed = model.model_validate_json(json.dumps(WIRE)) + assert parsed.model_dump(mode="json") == { + **WIRE, + **({"optional_url": None} if model is Plain else {}), + } + assert json.loads(parsed.model_dump_json())["when"] == WIRE["when"] + + +def test_python_mode_validation_keeps_the_text_too() -> None: + assert Model.model_validate(WIRE).model_dump(mode="json") == WIRE + + +@pytest.mark.parametrize( + "text", + [ + "2026-10-01T00:00:00+00:00", + "2026-10-01T00:00:00-05:00", + "2026-10-01T00:00:00.5Z", + "2026-10-01T00:00:00.123456789Z", + "2026-10-01t00:00:00z", + ], +) +def test_every_rfc3339_spelling_survives(text: str) -> None: + parsed = Model.model_validate({**WIRE, "when": text}) + assert parsed.model_dump(mode="json")["when"] == text + + +@pytest.mark.parametrize( + "text", + [ + "https://example.com", + "https://Example.COM/a", + "HTTPS://example.com/a", + "https://example.com:443/a", + "https://example.com/a%2Fb", + "https://example.com/a?b=1&c=2#frag", + ], +) +def test_every_url_spelling_survives(text: str) -> None: + parsed = Model.model_validate({**WIRE, "url": text}) + assert parsed.model_dump(mode="json")["url"] == text + assert str(parsed.url) == text + + +def test_attribute_api_is_the_parsed_type() -> None: + parsed = Model.model_validate(WIRE) + assert isinstance(parsed.when, datetime) + assert (parsed.when.year, parsed.when.tzinfo.utcoffset(None)) == (2026, timedelta(0)) + assert parsed.when == datetime(2026, 10, 1, tzinfo=timezone.utc) + assert isinstance(parsed.url, AnyUrl) + assert (parsed.url.scheme, parsed.url.host, parsed.url.path) == ( + "https", + "measurement.example", + "/", + ) + assert parsed.url.unicode_string() == "https://measurement.example/" + assert isinstance(parsed.uid, uuid.UUID) + assert parsed.uid.hex == "0000000000004000800000000000abcd" + assert parsed.uid == uuid.UUID("00000000-0000-4000-8000-00000000abcd") + assert isinstance(parsed.email, str) + + +def test_equality_and_hashing_match_the_pydantic_types() -> None: + parsed = Model.model_validate(WIRE) + plain = AnyUrl("https://measurement.example/") + assert parsed.url == plain + assert plain == parsed.url + assert len({parsed.url, plain}) == 1 + assert len({parsed.when, datetime(2026, 10, 1, tzinfo=timezone.utc)}) == 1 + assert len({parsed.uid, uuid.UUID(int=parsed.uid.int)}) == 1 + + +def test_copies_keep_the_wire_text() -> None: + parsed = Model.model_validate(WIRE) + dumped = parsed.model_dump(mode="json") + assert copy.deepcopy(parsed).model_dump(mode="json") == dumped + assert copy.copy(parsed).model_dump(mode="json") == dumped + assert parsed.model_copy(deep=True).model_dump(mode="json") == dumped + assert pickle.loads(pickle.dumps(parsed)).model_dump(mode="json") == dumped + assert Model.model_validate(parsed.model_dump()).model_dump(mode="json") == dumped + + +def test_a_value_built_in_python_serializes_in_the_pydantic_form() -> None: + built = Model( + when=datetime(2026, 10, 1, 12, 30, 0, 500000, tzinfo=timezone(timedelta(hours=-5))), + url=AnyUrl("https://x.example"), + uid=uuid.UUID("00000000-0000-4000-8000-00000000ABCD"), + email="a@b.example", + ) + assert built.model_dump(mode="json") == { + "when": "2026-10-01T12:30:00.500000-05:00", + "url": "https://x.example/", + "uid": "00000000-0000-4000-8000-00000000abcd", + "email": "a@b.example", + } + utc = Model(**{**built.model_dump(), "when": datetime(2026, 10, 1, tzinfo=timezone.utc)}) + assert utc.model_dump(mode="json")["when"] == "2026-10-01T00:00:00Z" + + +def test_a_derived_datetime_drops_the_wire_text() -> None: + parsed = Model.model_validate(WIRE) + assert parsed.when.wire == WIRE["when"] + assert type(parsed.when.replace(year=2027)) is WireDatetime + assert parsed.when.replace(year=2027).wire == "2027-10-01T00:00:00Z" + assert (parsed.when + timedelta(hours=1)).wire == "2026-10-01T01:00:00Z" + assert ( + rfc3339(parsed.when.astimezone(timezone(timedelta(hours=5)))) == "2026-10-01T05:00:00+05:00" + ) + + +@pytest.mark.parametrize( + "field, value", + [ + ("when", "2026-10-01T00:00:00"), + ("when", "not a date"), + ("url", "not a url"), + ("url", ""), + ("uid", "not a uuid"), + ("email", "not an email"), + ], +) +def test_validation_is_unchanged(field: str, value: Any) -> None: + with pytest.raises(ValidationError): + Model.model_validate({**WIRE, field: value}) + + +def test_json_schema_advertises_the_same_formats() -> None: + properties = Plain.model_json_schema()["properties"] + assert properties["when"] == {"format": "date-time", "title": "When", "type": "string"} + assert properties["url"] == {"format": "uri", "minLength": 1, "title": "Url", "type": "string"} + assert properties["uid"] == {"format": "uuid", "title": "Uid", "type": "string"} + assert properties["email"] == {"format": "email", "title": "Email", "type": "string"} + assert properties["optional_url"]["anyOf"] == [ + {"format": "uri", "minLength": 1, "type": "string"}, + {"type": "null"}, + ] + assert TypeAdapter(WireEmail).json_schema() == {"type": "string", "format": "email"} + + +def test_python_mode_dump_returns_the_typed_values() -> None: + dumped = Model.model_validate(WIRE).model_dump() + assert isinstance(dumped["when"], WireDatetime) + assert isinstance(dumped["url"], WireAnyUrl) + assert isinstance(dumped["uid"], WireUuid) + + +# --------------------------------------------------------------------------- +# The issue's reproduction. +# --------------------------------------------------------------------------- + +_DIGEST_EXCLUDED = {"content_digest", "attestation_refs"} + + +def _content_digest(entry: dict[str, Any]) -> str: + core = {k: v for k, v in entry.items() if k not in _DIGEST_EXCLUDED} + return "sha256:" + hashlib.sha256(rfc8785.dumps(core)).hexdigest() + + +def _entry() -> dict[str, Any]: + entry = { + "evidence_id": "evidence_age", + "snapshot_id": "evidence_age_2026_q3", + "version": "2026-q3", + "audience": {"dimension": "age", "range": {"min": 25, "max": 54}, "label": "Adults 25-54"}, + "relationship": "composition", + "value": 0.6, + "unit": "fraction", + "baseline": { + "system": "https://measurement.example", + "population_id": "us_adults", + "version": "2026", + }, + "evidence_type": "measured", + "methodology": "projected", + "subject_type": "individual", + "provider": {"domain": "measurement.example"}, + "measurement_window": {"start": "2026-07-01", "end": "2026-09-30"}, + "last_updated": "2026-10-01T00:00:00.000+00:00", + } + entry["content_digest"] = _content_digest(entry) + return entry + + +def test_audience_evidence_round_trips_its_own_digest() -> None: + entry = _entry() + assert _content_digest(entry) == entry["content_digest"] + dumped = AudienceEvidence.model_validate(entry).model_dump(mode="json") + assert dumped["baseline"]["system"] == "https://measurement.example" + assert dumped["last_updated"] == "2026-10-01T00:00:00.000+00:00" + assert _content_digest(dumped) == dumped["content_digest"] + assert AudienceEvidence.model_validate_json(json.dumps(entry)).model_dump(mode="json") == entry + + +def test_audience_evidence_attributes_are_still_typed() -> None: + parsed = AudienceEvidence.model_validate(_entry()) + assert parsed.last_updated.year == 2026 + assert parsed.last_updated.tzinfo is not None + assert parsed.baseline.system.host == "measurement.example" + + +def test_audience_evidence_is_exported_from_adcp_types() -> None: + import adcp.types + + assert adcp.types.AudienceEvidence is AudienceEvidence + assert AudienceEvidence.__module__ == "adcp.types.domains.core.audience_evidence" + + +def test_model_dump_as_received_leaves_off_what_the_document_omitted() -> None: + from adcp.types.domains.core.canonical_proposal import CanonicalProposal + + terms: dict[str, Any] = { + "brand": {"domain": "acme.example"}, + "purchases": [ + { + "product_id": "prod_1", + "pricing_option_id": "cpm_1", + "pricing": { + "pricing_option_id": "cpm_1", + "pricing_model": "cpm", + "fixed_price": 5, + "currency": "USD", + }, + "start_time": "2026-11-01T00:00:00.000+00:00", + "end_time": "2026-11-30T23:59:59+00:00", + "targeting_overlay": {"age_restriction": {"min": 21}}, + } + ], + "start_time": "2026-11-01T00:00:00.000+00:00", + "end_time": "2026-11-30T23:59:59+00:00", + } + digest = hashlib.sha256(rfc8785.dumps(terms)).digest() + import base64 + + proposal = { + "proposal_id": "prop_1", + "proposal_kind": "new_media_buy", + "proposal_status": "committed", + "name": "Q4", + "expires_at": "2026-10-15T00:00:00.000+00:00", + "commercial_terms": terms, + "terms_digest": "sha256:" + base64.urlsafe_b64encode(digest).rstrip(b"=").decode(), + } + parsed = CanonicalProposal.model_validate_json(json.dumps(proposal)) + # The default dump materializes ``verification_required: false`` (#1613)... + overlay = parsed.model_dump(mode="json")["commercial_terms"]["purchases"][0][ + "targeting_overlay" + ] + assert overlay["age_restriction"] == {"min": 21, "verification_required": False} + # ...and the as-received dump is the document, so terms_digest verifies. + received = parsed.model_dump_as_received() + assert received == proposal + assert rfc8785.dumps(received["commercial_terms"]) == rfc8785.dumps(terms) + + +def test_model_dump_as_received_keeps_an_admitted_null_and_the_wire_alias() -> None: + from adcp.types.domains.core.targeting_input import TargetingOverlayInput as TargetingInput + + document = {"signal_targeting_groups": None} + parsed = TargetingInput.model_validate_json(json.dumps(document)) + assert parsed.model_dump(mode="json") == {} + assert parsed.model_dump_as_received() == document + + +# --------------------------------------------------------------------------- +# Only strictly valid wire text is kept; lenient spellings normalize as before. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "text, expected", + [ + (str(datetime(2026, 10, 1, tzinfo=timezone.utc)), "2026-10-01T00:00:00Z"), # space, not T + ("2026-10-01T00:00:00+0000", "2026-10-01T00:00:00Z"), # offset without a colon + ("2026-10-01T00:00Z", "2026-10-01T00:00:00Z"), # no seconds + ("1790000000", "2026-09-21T14:13:20Z"), # epoch string + ], +) +def test_a_lenient_datetime_spelling_is_normalized(text: str, expected: str) -> None: + parsed = Model.model_validate({**WIRE, "when": text}) + assert parsed.model_dump(mode="json")["when"] == expected + assert parsed.when.wire == expected + + +@pytest.mark.parametrize( + "text, expected", + [ + (" https://x.example ", "https://x.example/"), # surrounding whitespace + ("https:x.example", "https://x.example/"), # no authority + ("https://x.example/a b", "https://x.example/a%20b"), # unescaped space + ("https://x.example/%zz", "https://x.example/%zz"), # malformed escape: parser keeps it + ("https://x.example\n", "https://x.example/"), # trailing newline + ("https://x.example/a\n", "https://x.example/a"), # trailing newline after a path + ], +) +def test_a_lenient_url_spelling_is_normalized(text: str, expected: str) -> None: + parsed = Model.model_validate({**WIRE, "url": text}) + assert parsed.model_dump(mode="json")["url"] == expected + assert str(parsed.url) == expected + + +def test_str_and_host_cannot_disagree() -> None: + """A backslash the WHATWG parser reads as a slash must not reach a client as written.""" + parsed = Model.model_validate({**WIRE, "url": "https://good.example\\@evil.example"}) + assert parsed.url.host == "good.example" + assert str(parsed.url) == "https://good.example/@evil.example" + assert parsed.model_dump(mode="json")["url"] == "https://good.example/@evil.example" + direct = WireAnyUrl("https://good.example\\@evil.example") + assert (direct.host, str(direct)) == ("good.example", "https://good.example/@evil.example") + + +@pytest.mark.parametrize( + "text", + [ + "https://user@x.example/", # userinfo: full RFC 3986 path, host agrees + "https://[::1]/a", # IP literal + "https://straße.de/x", # internationalized host, compared to unicode_host() + ], +) +def test_strict_but_unusual_url_text_is_kept_when_the_host_agrees(text: str) -> None: + parsed = Model.model_validate({**WIRE, "url": text}) + assert parsed.model_dump(mode="json")["url"] == text + + +@pytest.mark.parametrize( + "text", + [ + "{00000000-0000-4000-8000-00000000abcd}", + "urn:uuid:00000000-0000-4000-8000-00000000abcd", + "00000000000040008000000000000abc", + ], +) +def test_a_lenient_uuid_spelling_is_normalized(text: str) -> None: + parsed = Model.model_validate({**WIRE, "uid": text}) + assert parsed.model_dump(mode="json")["uid"] == str(uuid.UUID(text)) + + +@pytest.mark.parametrize( + "text, expected", + [ + ("Bob ", "bob@example.com"), + (" bob@example.com", "bob@example.com"), + ("Bob@Example.COM", "Bob@Example.COM"), + ], +) +def test_an_email_is_kept_only_as_the_trimmed_address(text: str, expected: str) -> None: + parsed = Model.model_validate({**WIRE, "email": text}) + assert parsed.model_dump(mode="json")["email"] == expected + + +def test_a_lenient_spelling_passed_to_a_constructor_is_normalized_too() -> None: + built = Model( + when="2026-10-01 00:00:00+00:00", + url=" https://x.example ", + uid=WIRE["uid"], + email="a@b.example", + ) + assert built.model_dump(mode="json")["when"] == "2026-10-01T00:00:00Z" + assert built.model_dump(mode="json")["url"] == "https://x.example/" + + +@pytest.mark.parametrize( + "text", + ["https://2130706433/", "https://0x7f.1/", "https://127.1/x", "https://0177.0.0.1/"], +) +def test_a_shorthand_ipv4_host_is_not_echoed(text: str) -> None: + """``.host`` reads 127.0.0.1, so ``str()`` must name that host too (an SSRF guard reads it).""" + from urllib.parse import urlsplit + + parsed = Model.model_validate({**WIRE, "url": text}) + assert parsed.url.host == "127.0.0.1" + assert urlsplit(str(parsed.url)).hostname == "127.0.0.1" + assert parsed.model_dump(mode="json")["url"] == str(parsed.url) From 5314faa97492960ec113efcfdd2ab233c536e2c5 Mon Sep 17 00:00:00 2001 From: "aao-ipr-bot[bot]" <279263549+aao-ipr-bot[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:08:56 -0400 Subject: [PATCH 53/54] chore(main): release 9.2.0 (#1627) Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com> --- .release-please-manifest.json | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ pyproject.toml | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index b97d97e1f..c1ce1f5a7 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,4 +1,4 @@ { - ".": "9.1.1", + ".": "9.2.0", "packages/adcp-reporting-storage": "0.1.0-beta.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index d179d4b2f..ed441bd63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -169,6 +169,24 @@ change public consumer defaults. See [format-kind migration](docs/canonical-format-kinds-migration.md). +## [9.2.0](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.1.1...v9.2.0) (2026-10-26) + + +### Features + +* **server:** let PrincipalResolverError carry a stable error code ([#1633](https://github.com/adcontextprotocol/adcp-client-python/issues/1633)) ([cf96d57](https://github.com/adcontextprotocol/adcp-client-python/commit/cf96d573ba2240fb5b666dc0f891378f253dfbe4)), closes [#1623](https://github.com/adcontextprotocol/adcp-client-python/issues/1623) + + +### Bug Fixes + +* **adagents:** strip surrounding whitespace in normalize_url ([#1629](https://github.com/adcontextprotocol/adcp-client-python/issues/1629)) ([4e2ae8f](https://github.com/adcontextprotocol/adcp-client-python/commit/4e2ae8fc153a662ec67bbb24ef478621b37570db)), closes [#1624](https://github.com/adcontextprotocol/adcp-client-python/issues/1624) +* **compat:** infer display_tag and every 3.2 asset type from the asset key ([#1628](https://github.com/adcontextprotocol/adcp-client-python/issues/1628)) ([88cf04a](https://github.com/adcontextprotocol/adcp-client-python/commit/88cf04a1cd684595b6ae58712cec42e01590c58f)), closes [#1624](https://github.com/adcontextprotocol/adcp-client-python/issues/1624) +* **compat:** never downgrade into a payload the older schema refuses ([#1630](https://github.com/adcontextprotocol/adcp-client-python/issues/1630)) ([b5234e2](https://github.com/adcontextprotocol/adcp-client-python/commit/b5234e221b539abb7c6797aa278e9c2874ea38fa)) +* **decisioning:** project ADCPTaskError from platform methods to its own wire error ([#1632](https://github.com/adcontextprotocol/adcp-client-python/issues/1632)) ([7b95b26](https://github.com/adcontextprotocol/adcp-client-python/commit/7b95b26f0d61b1b35edaae1d192f8f74adc40289)), closes [#1622](https://github.com/adcontextprotocol/adcp-client-python/issues/1622) +* **reporting:** classify pending status and owed history as transient, unresolved references as REFERENCE_NOT_FOUND ([#1631](https://github.com/adcontextprotocol/adcp-client-python/issues/1631)) ([a2ec81c](https://github.com/adcontextprotocol/adcp-client-python/commit/a2ec81c0679042c0a3cb0a0779d03df53311e174)), closes [#1618](https://github.com/adcontextprotocol/adcp-client-python/issues/1618) +* **testing:** stop build_all_models hanging after a generated_poc alias import ([#1626](https://github.com/adcontextprotocol/adcp-client-python/issues/1626)) ([6f61234](https://github.com/adcontextprotocol/adcp-client-python/commit/6f61234cc2717e5e9cff2a824b28d3eb480af6f8)), closes [#1619](https://github.com/adcontextprotocol/adcp-client-python/issues/1619) +* **types:** inherit the generated field on every hand-written redeclaration ([#1625](https://github.com/adcontextprotocol/adcp-client-python/issues/1625)) ([6e0ed68](https://github.com/adcontextprotocol/adcp-client-python/commit/6e0ed688e16c3be94fb117fef5157b4cda08a136)) + ## [9.1.1](https://github.com/adcontextprotocol/adcp-client-python/compare/v9.1.0...v9.1.1) (2026-10-25) diff --git a/pyproject.toml b/pyproject.toml index 03f5cc304..bf48be89b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "adcp" -version = "9.1.1" +version = "9.2.0" description = "Official Python client for the Ad Context Protocol (AdCP)" authors = [ {name = "AdCP Community", email = "maintainers@adcontextprotocol.org"} From 7d8b0439e3165ce5d0107a9ba97ae867751bf708 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 27 Oct 2026 00:12:50 +0000 Subject: [PATCH 54/54] fix(sync): inherit generated fields on the identity facades and skip the retired generated_poc alias test main's #1625 guard requires every hand-written redeclaration of a generated field to inherit it; next's experimental identity facades predate that rule. main's #1626 test imports adcp.types.generated_poc, which next removed in SDK 10 (#1536), so it skips when the alias is absent. Co-Authored-By: Claude Fable 5.1 --- src/adcp/types/experimental_identity.py | 15 ++++++++++++--- tests/test_freezegun_models.py | 2 ++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/adcp/types/experimental_identity.py b/src/adcp/types/experimental_identity.py index f7e9d981c..dd2407a2f 100644 --- a/src/adcp/types/experimental_identity.py +++ b/src/adcp/types/experimental_identity.py @@ -51,6 +51,7 @@ from adcp.types._identity_preview import ( TrustV1Trust as TrustRecord, ) +from adcp.types.base import inherit_field from adcp.types.domains.core.principal_changed_webhook import ( PrincipalChangedWebhook as _PrincipalChangedWebhook, ) @@ -95,12 +96,14 @@ class RequestSigning(_RequestSigning): class GetAdcpCapabilitiesResponse(_GetAdcpCapabilitiesResponse): # Pyright treats mutable Pydantic fields as invariant even for subclasses. + # Each redeclaration inherits the generated field (description, never-null + # marker) and only narrows its annotation (#1620). account: AccountCapabilities | None = ( # pyright: ignore[reportIncompatibleVariableOverride] - None + inherit_field(_GetAdcpCapabilitiesResponse, "account") ) request_signing: ( # pyright: ignore[reportIncompatibleVariableOverride] RequestSigning | None - ) = None + ) = inherit_field(_GetAdcpCapabilitiesResponse, "request_signing") class PrincipalUnconfiguredResult(_PrincipalUnconfiguredResult): @@ -124,9 +127,15 @@ class GetPrincipalResponse(_GetPrincipalResponse): ) +_DraftPrincipalChangedReason = ( + _PrincipalChangedReason | Literal["relationship_changed", "relationship_expiring"] +) +_draft_principal_changed_reason = inherit_field(_PrincipalChangedWebhook, "reason", required=True) + + class PrincipalChangedWebhook(_PrincipalChangedWebhook): # Draft webhook reasons intentionally expand the stable response enum. - reason: _PrincipalChangedReason | Literal["relationship_changed", "relationship_expiring"] # type: ignore[assignment] + reason: _DraftPrincipalChangedReason = _draft_principal_changed_reason # type: ignore[assignment] class GrantAbsentDetails(BaseModel): diff --git a/tests/test_freezegun_models.py b/tests/test_freezegun_models.py index 0fe01e197..b39358c1a 100644 --- a/tests/test_freezegun_models.py +++ b/tests/test_freezegun_models.py @@ -138,6 +138,8 @@ def test_build_all_models_returns_after_a_generated_poc_alias_import() -> None: # second sys.modules key, which used to make the lazy-export walk spin # forever. A subprocess with a timeout turns a regression into a failure # instead of a hung CI job. + if importlib.util.find_spec("adcp.types.generated_poc") is None: + pytest.skip("adcp.types.generated_poc was removed in SDK 10 (#1536)") program = r""" import sys import warnings