From 6e33ef0ec71fa088c529154da17459b1025fee84 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 19 May 2026 14:54:21 -0700 Subject: [PATCH 1/2] fix(types): resolve capability sub-models via field annotation, not numbered names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ``capabilities.py`` imported ``Features2 as SignalsFeatures`` and ``Idempotency3 as IdempotencyUnsupported`` from the bundled ``get_adcp_capabilities_response`` module. Those numbered class names are an internal codegen detail: ``datamodel-code-generator`` 0.56.1 assigns numbered suffixes (``Features1``, ``Features2``…) to inline schemas from a global counter whose traversal order shifts with both upstream schema layout and filesystem-iteration order (APFS-on-macOS vs ext4-on-Linux). The committed bundled file was generated when the counter produced ``Features2`` and ``Idempotency3``; today's regen (on the same pinned generator) produces ``Features1`` and ``Idempotency1``, breaking ``capabilities.py`` at import time and taking the whole ``adcp`` package down with it — which is what ``Validate schemas are up-to-date`` CI was reporting. Reach the classes via their parents' field annotation instead. ``Signals.features`` and ``Adcp.idempotency`` are stable wire-spec class names; their field annotations carry the union arms directly, independent of whatever number the codegen assigned this regen. Pin both lookups with explicit assertions so future schema changes that break the shape fail loudly at boot rather than silently mis-resolving. Also commits the cosmetic regen drift the same run produced: - ``SCHEMA_DELTAS.md``: previously reported ``extension_meta`` delta is already merged upstream; deltas list is now empty. - ``_ergonomic.py``: ``list[...]`` → ``Sequence[...]`` in three ``BeforeValidator`` annotations (regen normalization). - enum + media_buy bundled files: duplicate-import cleanup. Test plan: - pytest tests/ -x → 4731 passed, 34 skipped - mypy + ruff clean - Reproduced the CI failure in a Linux 3.11-slim Docker container against schemas 3.0.7; verified the fix resolves SignalsFeatures → Features1 and IdempotencyUnsupported → Idempotency1 cleanly after fresh regen. Co-Authored-By: Claude Opus 4.7 (1M context) --- SCHEMA_DELTAS.md | 5 +- src/adcp/types/_ergonomic.py | 6 +- src/adcp/types/_generated.py | 2 +- src/adcp/types/capabilities.py | 57 +++++++++++++------ .../enums/daast_tracking_event.py | 2 +- .../generated_poc/enums/daast_version.py | 2 +- .../enums/forecastable_metric.py | 2 +- .../get_media_buy_delivery_response.py | 3 +- .../media_buy/get_media_buys_response.py | 3 +- .../media_buy/update_media_buy_response.py | 3 +- 10 files changed, 51 insertions(+), 34 deletions(-) diff --git a/SCHEMA_DELTAS.md b/SCHEMA_DELTAS.md index d3ef8d1bf..0f0db1c28 100644 --- a/SCHEMA_DELTAS.md +++ b/SCHEMA_DELTAS.md @@ -1,6 +1,3 @@ # Generated-types delta -## Field changes - -- `extensions/extension_meta.py` - - `AdcpExtensionFileSchema`: `-field_id` +_No field-shape changes detected._ diff --git a/src/adcp/types/_ergonomic.py b/src/adcp/types/_ergonomic.py index 37f22b6ff..6fbbf22f6 100644 --- a/src/adcp/types/_ergonomic.py +++ b/src/adcp/types/_ergonomic.py @@ -256,7 +256,7 @@ def _apply_coercion() -> None: PackageRequest, "creatives", Annotated[ - list[CreativeAsset] | None, + Sequence[CreativeAsset] | None, BeforeValidator(coerce_subclass_list(CreativeAsset)), ], ) @@ -281,7 +281,7 @@ def _apply_coercion() -> None: CreateMediaBuyRequest, "packages", Annotated[ - list[PackageRequest] | None, + Sequence[PackageRequest] | None, BeforeValidator(coerce_subclass_list(PackageRequest)), ], ) @@ -383,7 +383,7 @@ def _apply_coercion() -> None: ListCreativesResponse, "creatives", Annotated[ - list[Creative], + Sequence[Creative], BeforeValidator(coerce_subclass_list(Creative)), ], ) diff --git a/src/adcp/types/_generated.py b/src/adcp/types/_generated.py index 1a649a252..5adaaea9f 100644 --- a/src/adcp/types/_generated.py +++ b/src/adcp/types/_generated.py @@ -10,7 +10,7 @@ DO NOT EDIT MANUALLY. Generated from: https://github.com/adcontextprotocol/adcp/tree/main/schemas -Generation date: 2026-05-08 18:05:19 UTC +Generation date: 2026-05-19 21:40:28 UTC """ # ruff: noqa: E501, I001 diff --git a/src/adcp/types/capabilities.py b/src/adcp/types/capabilities.py index 6927ddba8..1f0c1b4b8 100644 --- a/src/adcp/types/capabilities.py +++ b/src/adcp/types/capabilities.py @@ -32,6 +32,8 @@ from __future__ import annotations +from typing import get_args as _get_args + from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( A2ui, Adcp, @@ -88,6 +90,9 @@ from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( Account as CapabilitiesAccount, ) +from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( + Adcp as _Adcp, +) # ``Capabilities`` (line 580 of the generated module) is the SI-block's # inner ``capabilities`` field type — modalities / components / commerce @@ -109,31 +114,49 @@ from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( Creative as CapabilitiesCreative, ) - -# ``Features2`` is the codegen name for the ``Signals.features`` type -# (numbered because ``Features`` already names the media_buy features -# block at line 142 of the generated module). Surface under a stable -# adopter-facing name so signals declarations read cleanly. -from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( - Features2 as SignalsFeatures, -) - -# ``Idempotency`` ships as a ``oneOf`` on the wire (``IdempotencySupported`` -# vs ``IdempotencyUnsupported``) — the codegen names them ``Idempotency`` -# and ``Idempotency3`` (with the numbered variant covering the -# ``supported: false`` arm). Surface the union halves under stable -# semantic names so adopters can construct either side without remembering -# which numbered variant is which. from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( Idempotency as IdempotencySupported, ) from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( - Idempotency3 as IdempotencyUnsupported, + MediaBuy as CapabilitiesMediaBuy, ) from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import ( - MediaBuy as CapabilitiesMediaBuy, + Signals as _Signals, ) +# ``Signals.features`` and the unsupported arm of the ``Adcp.idempotency`` +# discriminated union are inline schemas the codegen materializes under +# numbered class names (``Features`` / ``Idempotency``). Those +# numbers are not stable across regens: ``datamodel-code-generator`` +# 0.56.1 assigns them from a global counter whose traversal order shifts +# with both upstream schema layout AND filesystem-iteration order +# (APFS-on-macOS vs ext4-on-Linux), so the same pinned generator produces +# ``Features1`` in one environment and ``Features2`` in another. Reach +# the classes via their parents' field annotation — both ``Signals`` and +# ``Adcp`` are stable wire-spec class names, and the field annotations +# carry the union arm types directly. +_signals_features_arms = [ + arm for arm in _get_args(_Signals.model_fields["features"].annotation) if arm is not type(None) +] +if len(_signals_features_arms) != 1: + raise RuntimeError( + "capabilities: Signals.features annotation lost its concrete type " + f"(got {_signals_features_arms!r})" + ) +SignalsFeatures: type = _signals_features_arms[0] + +_idempotency_arms = [ + arm + for arm in _get_args(_Adcp.model_fields["idempotency"].annotation) + if arm is not IdempotencySupported and arm is not type(None) +] +if len(_idempotency_arms) != 1: + raise RuntimeError( + "capabilities: expected exactly one non-supported Idempotency arm, " + f"got {_idempotency_arms!r}" + ) +IdempotencyUnsupported: type = _idempotency_arms[0] + __all__ = [ "A2ui", "Adcp", diff --git a/src/adcp/types/generated_poc/enums/daast_tracking_event.py b/src/adcp/types/generated_poc/enums/daast_tracking_event.py index 293bc3f73..60c67ea25 100644 --- a/src/adcp/types/generated_poc/enums/daast_tracking_event.py +++ b/src/adcp/types/generated_poc/enums/daast_tracking_event.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: enums/daast_tracking_event.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-05-19T21:40:22+00:00 from __future__ import annotations diff --git a/src/adcp/types/generated_poc/enums/daast_version.py b/src/adcp/types/generated_poc/enums/daast_version.py index 36b24df93..cf2ad375b 100644 --- a/src/adcp/types/generated_poc/enums/daast_version.py +++ b/src/adcp/types/generated_poc/enums/daast_version.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: enums/daast_version.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-05-19T21:40:22+00:00 from __future__ import annotations diff --git a/src/adcp/types/generated_poc/enums/forecastable_metric.py b/src/adcp/types/generated_poc/enums/forecastable_metric.py index bf70f8a50..41a8bec22 100644 --- a/src/adcp/types/generated_poc/enums/forecastable_metric.py +++ b/src/adcp/types/generated_poc/enums/forecastable_metric.py @@ -1,6 +1,6 @@ # generated by datamodel-codegen: # filename: enums/forecastable_metric.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-05-19T21:40:22+00:00 from __future__ import annotations diff --git a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py index c2ffd0652..64fb536d7 100644 --- a/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py +++ b/src/adcp/types/generated_poc/media_buy/get_media_buy_delivery_response.py @@ -1,13 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buy_delivery_response.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-05-19T21:40:22+00:00 from __future__ import annotations from collections.abc import Sequence from enum import Enum from typing import Annotated, Any -from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field diff --git a/src/adcp/types/generated_poc/media_buy/get_media_buys_response.py b/src/adcp/types/generated_poc/media_buy/get_media_buys_response.py index cd6f720d6..e7803fbfc 100644 --- a/src/adcp/types/generated_poc/media_buy/get_media_buys_response.py +++ b/src/adcp/types/generated_poc/media_buy/get_media_buys_response.py @@ -1,13 +1,12 @@ # generated by datamodel-codegen: # filename: media_buy/get_media_buys_response.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-05-19T21:40:22+00:00 from __future__ import annotations from collections.abc import Sequence from enum import Enum from typing import Annotated -from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field diff --git a/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py b/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py index eed31c44e..ee714a354 100644 --- a/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py +++ b/src/adcp/types/generated_poc/media_buy/update_media_buy_response.py @@ -1,12 +1,11 @@ # generated by datamodel-codegen: # filename: media_buy/update_media_buy_response.json -# timestamp: 2026-05-02T19:36:29+00:00 +# timestamp: 2026-05-19T21:40:22+00:00 from __future__ import annotations from collections.abc import Sequence from typing import Annotated -from collections.abc import Sequence from adcp.types.base import AdCPBaseModel from pydantic import AwareDatetime, ConfigDict, Field From 55690fbfba639213f36488847c1365a2912a625e Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 19 May 2026 15:10:38 -0700 Subject: [PATCH 2/2] fix(server): emit RFC 3339 ``Z``-suffixed timestamps, not ``+00:00`` offsets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AdCP storyboard runner (``@adcp/sdk`` on npm) validates response shapes with ``zod``. ``zod.string().datetime()`` rejects the ``+00:00`` offset by default — only the Zulu form (``...Z``) passes. Python's ``datetime.isoformat()`` produces ``+00:00`` for tz-aware UTC values, so every response builder that auto-stamped a timestamp (``confirmed_at``, ``canceled_at``, ``expires_at``, ``reporting_period.start/end``, ``created_date`` / ``updated_date`` on listed creatives) emitted output that failed schema validation in both the ``examples/seller_agent.py`` and ``sales-proposal-mode (proposal_finalize)`` storyboard jobs: Schema validation: /confirmed_at: Invalid ISO datetime Reproduced locally: $ node -e 'const {z}=require("zod"); z.string().datetime().parse("2026-05-19T21:56:22.349222+00:00")' ZodError: [{"code":"invalid_string","validation":"datetime",...}] $ node -e 'const {z}=require("zod"); z.string().datetime().parse("2026-05-19T21:56:22.349222Z")' # succeeds Add ``_rfc3339_now()`` in ``server/responses.py`` and route the three auto-stamp sites inside that file through it; inline the equivalent ``.isoformat().replace("+00:00", "Z")`` shim in the four other touch points (``server/helpers.py`` canceled_at, ``server/proposal.py`` expires_at, ``sales_proposal_mode_seller`` confirmed_at and expires_at). ajv-formats and python-rfc3339 both still accept the new form, and ``test_server_helpers.py:373`` was already tolerant of either suffix — no test changes needed. Test plan: - pytest tests/ -x → 4731 passed, 34 skipped, 1 xfailed - Verified ``_rfc3339_now()`` output ends in ``Z`` and parses cleanly in zod ``string().datetime()`` (against ``@adcp/sdk`` 's pinned zod). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/platform.py | 2 +- .../src/proposal_manager.py | 2 +- src/adcp/server/helpers.py | 2 +- src/adcp/server/proposal.py | 2 +- src/adcp/server/responses.py | 32 +++++++++++++------ 5 files changed, 27 insertions(+), 13 deletions(-) diff --git a/examples/sales_proposal_mode_seller/src/platform.py b/examples/sales_proposal_mode_seller/src/platform.py index c673c3f8b..61d95b01c 100644 --- a/examples/sales_proposal_mode_seller/src/platform.py +++ b/examples/sales_proposal_mode_seller/src/platform.py @@ -155,7 +155,7 @@ def create_media_buy( "media_buy_id": media_buy_id, "buyer_ref": getattr(req, "buyer_ref", None), "status": "active", - "confirmed_at": datetime.now(timezone.utc).isoformat(), + "confirmed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "proposal_id": str(proposal_id) if proposal_id else None, "packages": [ { diff --git a/examples/sales_proposal_mode_seller/src/proposal_manager.py b/examples/sales_proposal_mode_seller/src/proposal_manager.py index 190db2a9d..6e60f2240 100644 --- a/examples/sales_proposal_mode_seller/src/proposal_manager.py +++ b/examples/sales_proposal_mode_seller/src/proposal_manager.py @@ -269,7 +269,7 @@ async def finalize_proposal( if pid in firm_cpm: entry["firm_cpm"] = firm_cpm[pid] expires_at = datetime.now(timezone.utc) + timedelta(hours=24) - committed_payload["expires_at"] = expires_at.isoformat() + committed_payload["expires_at"] = expires_at.isoformat().replace("+00:00", "Z") return FinalizeProposalSuccess( proposal=committed_payload, expires_at=expires_at, diff --git a/src/adcp/server/helpers.py b/src/adcp/server/helpers.py index 206a23102..648c339dc 100644 --- a/src/adcp/server/helpers.py +++ b/src/adcp/server/helpers.py @@ -402,7 +402,7 @@ def cancel_media_buy_response( "media_buy_id": media_buy_id, "status": "canceled", "canceled_by": canceled_by, - "canceled_at": canceled_at or datetime.now(timezone.utc).isoformat(), + "canceled_at": canceled_at or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), "valid_actions": [], "sandbox": sandbox, } diff --git a/src/adcp/server/proposal.py b/src/adcp/server/proposal.py index 40cb7beaa..3ccda4ed5 100644 --- a/src/adcp/server/proposal.py +++ b/src/adcp/server/proposal.py @@ -301,7 +301,7 @@ def build(self) -> dict[str, Any]: if self._brief_alignment: proposal["brief_alignment"] = self._brief_alignment if self._expires_at: - proposal["expires_at"] = self._expires_at.isoformat() + proposal["expires_at"] = self._expires_at.isoformat().replace("+00:00", "Z") if self._budget_guidance: proposal["total_budget_guidance"] = self._budget_guidance if self._ext: diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index aa00ab7fd..bb2976d2e 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -28,6 +28,20 @@ async def get_products(): from adcp.server.helpers import valid_actions_for_status + +def _rfc3339_now() -> str: + """Current UTC time as an RFC 3339 timestamp with ``Z`` suffix. + + Python's :meth:`datetime.isoformat` emits ``+00:00`` for UTC, but + several strict schema validators in the AdCP ecosystem — notably + the ``zod.string().datetime()`` check that the AdCP storyboard + runner uses — reject the offset form by default. Normalizing to + the Zulu form (``...Z``) keeps response timestamps acceptable to + every common validator without losing precision. + """ + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + _logger = logging.getLogger("adcp.server") @@ -312,7 +326,7 @@ def media_buy_response( "media_buy_id": media_buy_id, "packages": _serialize(packages), "revision": revision if revision is not None else 1, - "confirmed_at": confirmed_at or datetime.now(timezone.utc).isoformat(), + "confirmed_at": confirmed_at or _rfc3339_now(), "sandbox": sandbox, } if buyer_ref is not None: @@ -407,7 +421,7 @@ def delivery_response( currency: ISO 4217 currency code. sandbox: Whether this is simulated data. """ - now = datetime.now(timezone.utc).isoformat() + now = _rfc3339_now() return { "reporting_period": reporting_period or {"start": now, "end": now}, "media_buy_deliveries": media_buy_deliveries, @@ -465,14 +479,14 @@ def list_creatives_response( Timestamp defaults: every Creative item in the spec requires ``created_date`` and ``updated_date`` (ISO 8601 UTC). For any dict item that omits either field, this helper fills it with the current - UTC timestamp (``datetime.now(timezone.utc).isoformat()``). Both - fields default to the same value when neither is provided, which - matches the intuitive meaning for a freshly-listed item. Explicit - caller-provided values are always preserved. Pydantic model items - are passed through ``_serialize`` unchanged — callers using typed - Creative models should set timestamps on the model. + UTC timestamp via :func:`_rfc3339_now` (Zulu suffix, RFC 3339). + Both fields default to the same value when neither is provided, + which matches the intuitive meaning for a freshly-listed item. + Explicit caller-provided values are always preserved. Pydantic + model items are passed through ``_serialize`` unchanged — callers + using typed Creative models should set timestamps on the model. """ - now = datetime.now(timezone.utc).isoformat() + now = _rfc3339_now() filled: list[Any] = [] for item in creatives: if isinstance(item, dict):