Skip to content

Commit b63c7e6

Browse files
bokelleyclaude
andauthored
fix(types): resolve capability sub-models via field annotation, not numbered names (#745)
* fix(types): resolve capability sub-models via field annotation, not numbered names ``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) <noreply@anthropic.com> * fix(server): emit RFC 3339 ``Z``-suffixed timestamps, not ``+00:00`` offsets 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b30743 commit b63c7e6

15 files changed

Lines changed: 78 additions & 47 deletions

SCHEMA_DELTAS.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,3 @@
11
# Generated-types delta
22

3-
## Field changes
4-
5-
- `extensions/extension_meta.py`
6-
- `AdcpExtensionFileSchema`: `-field_id`
3+
_No field-shape changes detected._

examples/sales_proposal_mode_seller/src/platform.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def create_media_buy(
155155
"media_buy_id": media_buy_id,
156156
"buyer_ref": getattr(req, "buyer_ref", None),
157157
"status": "active",
158-
"confirmed_at": datetime.now(timezone.utc).isoformat(),
158+
"confirmed_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
159159
"proposal_id": str(proposal_id) if proposal_id else None,
160160
"packages": [
161161
{

examples/sales_proposal_mode_seller/src/proposal_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -269,7 +269,7 @@ async def finalize_proposal(
269269
if pid in firm_cpm:
270270
entry["firm_cpm"] = firm_cpm[pid]
271271
expires_at = datetime.now(timezone.utc) + timedelta(hours=24)
272-
committed_payload["expires_at"] = expires_at.isoformat()
272+
committed_payload["expires_at"] = expires_at.isoformat().replace("+00:00", "Z")
273273
return FinalizeProposalSuccess(
274274
proposal=committed_payload,
275275
expires_at=expires_at,

src/adcp/server/helpers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ def cancel_media_buy_response(
402402
"media_buy_id": media_buy_id,
403403
"status": "canceled",
404404
"canceled_by": canceled_by,
405-
"canceled_at": canceled_at or datetime.now(timezone.utc).isoformat(),
405+
"canceled_at": canceled_at or datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
406406
"valid_actions": [],
407407
"sandbox": sandbox,
408408
}

src/adcp/server/proposal.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -301,7 +301,7 @@ def build(self) -> dict[str, Any]:
301301
if self._brief_alignment:
302302
proposal["brief_alignment"] = self._brief_alignment
303303
if self._expires_at:
304-
proposal["expires_at"] = self._expires_at.isoformat()
304+
proposal["expires_at"] = self._expires_at.isoformat().replace("+00:00", "Z")
305305
if self._budget_guidance:
306306
proposal["total_budget_guidance"] = self._budget_guidance
307307
if self._ext:

src/adcp/server/responses.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,20 @@ async def get_products():
2828

2929
from adcp.server.helpers import valid_actions_for_status
3030

31+
32+
def _rfc3339_now() -> str:
33+
"""Current UTC time as an RFC 3339 timestamp with ``Z`` suffix.
34+
35+
Python's :meth:`datetime.isoformat` emits ``+00:00`` for UTC, but
36+
several strict schema validators in the AdCP ecosystem — notably
37+
the ``zod.string().datetime()`` check that the AdCP storyboard
38+
runner uses — reject the offset form by default. Normalizing to
39+
the Zulu form (``...Z``) keeps response timestamps acceptable to
40+
every common validator without losing precision.
41+
"""
42+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
43+
44+
3145
_logger = logging.getLogger("adcp.server")
3246

3347

@@ -312,7 +326,7 @@ def media_buy_response(
312326
"media_buy_id": media_buy_id,
313327
"packages": _serialize(packages),
314328
"revision": revision if revision is not None else 1,
315-
"confirmed_at": confirmed_at or datetime.now(timezone.utc).isoformat(),
329+
"confirmed_at": confirmed_at or _rfc3339_now(),
316330
"sandbox": sandbox,
317331
}
318332
if buyer_ref is not None:
@@ -407,7 +421,7 @@ def delivery_response(
407421
currency: ISO 4217 currency code.
408422
sandbox: Whether this is simulated data.
409423
"""
410-
now = datetime.now(timezone.utc).isoformat()
424+
now = _rfc3339_now()
411425
return {
412426
"reporting_period": reporting_period or {"start": now, "end": now},
413427
"media_buy_deliveries": media_buy_deliveries,
@@ -465,14 +479,14 @@ def list_creatives_response(
465479
Timestamp defaults: every Creative item in the spec requires
466480
``created_date`` and ``updated_date`` (ISO 8601 UTC). For any dict
467481
item that omits either field, this helper fills it with the current
468-
UTC timestamp (``datetime.now(timezone.utc).isoformat()``). Both
469-
fields default to the same value when neither is provided, which
470-
matches the intuitive meaning for a freshly-listed item. Explicit
471-
caller-provided values are always preserved. Pydantic model items
472-
are passed through ``_serialize`` unchanged — callers using typed
473-
Creative models should set timestamps on the model.
482+
UTC timestamp via :func:`_rfc3339_now` (Zulu suffix, RFC 3339).
483+
Both fields default to the same value when neither is provided,
484+
which matches the intuitive meaning for a freshly-listed item.
485+
Explicit caller-provided values are always preserved. Pydantic
486+
model items are passed through ``_serialize`` unchanged — callers
487+
using typed Creative models should set timestamps on the model.
474488
"""
475-
now = datetime.now(timezone.utc).isoformat()
489+
now = _rfc3339_now()
476490
filled: list[Any] = []
477491
for item in creatives:
478492
if isinstance(item, dict):

src/adcp/types/_ergonomic.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ def _apply_coercion() -> None:
256256
PackageRequest,
257257
"creatives",
258258
Annotated[
259-
list[CreativeAsset] | None,
259+
Sequence[CreativeAsset] | None,
260260
BeforeValidator(coerce_subclass_list(CreativeAsset)),
261261
],
262262
)
@@ -281,7 +281,7 @@ def _apply_coercion() -> None:
281281
CreateMediaBuyRequest,
282282
"packages",
283283
Annotated[
284-
list[PackageRequest] | None,
284+
Sequence[PackageRequest] | None,
285285
BeforeValidator(coerce_subclass_list(PackageRequest)),
286286
],
287287
)
@@ -383,7 +383,7 @@ def _apply_coercion() -> None:
383383
ListCreativesResponse,
384384
"creatives",
385385
Annotated[
386-
list[Creative],
386+
Sequence[Creative],
387387
BeforeValidator(coerce_subclass_list(Creative)),
388388
],
389389
)

src/adcp/types/_generated.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
DO NOT EDIT MANUALLY.
1111
1212
Generated from: https://github.com/adcontextprotocol/adcp/tree/main/schemas
13-
Generation date: 2026-05-08 18:05:19 UTC
13+
Generation date: 2026-05-19 21:40:28 UTC
1414
"""
1515

1616
# ruff: noqa: E501, I001

src/adcp/types/capabilities.py

Lines changed: 40 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232

3333
from __future__ import annotations
3434

35+
from typing import get_args as _get_args
36+
3537
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
3638
A2ui,
3739
Adcp,
@@ -88,6 +90,9 @@
8890
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
8991
Account as CapabilitiesAccount,
9092
)
93+
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
94+
Adcp as _Adcp,
95+
)
9196

9297
# ``Capabilities`` (line 580 of the generated module) is the SI-block's
9398
# inner ``capabilities`` field type — modalities / components / commerce
@@ -109,31 +114,49 @@
109114
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
110115
Creative as CapabilitiesCreative,
111116
)
112-
113-
# ``Features2`` is the codegen name for the ``Signals.features`` type
114-
# (numbered because ``Features`` already names the media_buy features
115-
# block at line 142 of the generated module). Surface under a stable
116-
# adopter-facing name so signals declarations read cleanly.
117-
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
118-
Features2 as SignalsFeatures,
119-
)
120-
121-
# ``Idempotency`` ships as a ``oneOf`` on the wire (``IdempotencySupported``
122-
# vs ``IdempotencyUnsupported``) — the codegen names them ``Idempotency``
123-
# and ``Idempotency3`` (with the numbered variant covering the
124-
# ``supported: false`` arm). Surface the union halves under stable
125-
# semantic names so adopters can construct either side without remembering
126-
# which numbered variant is which.
127117
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
128118
Idempotency as IdempotencySupported,
129119
)
130120
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
131-
Idempotency3 as IdempotencyUnsupported,
121+
MediaBuy as CapabilitiesMediaBuy,
132122
)
133123
from adcp.types.generated_poc.bundled.protocol.get_adcp_capabilities_response import (
134-
MediaBuy as CapabilitiesMediaBuy,
124+
Signals as _Signals,
135125
)
136126

127+
# ``Signals.features`` and the unsupported arm of the ``Adcp.idempotency``
128+
# discriminated union are inline schemas the codegen materializes under
129+
# numbered class names (``Features<N>`` / ``Idempotency<N>``). Those
130+
# numbers are not stable across regens: ``datamodel-code-generator``
131+
# 0.56.1 assigns them from a global counter whose traversal order shifts
132+
# with both upstream schema layout AND filesystem-iteration order
133+
# (APFS-on-macOS vs ext4-on-Linux), so the same pinned generator produces
134+
# ``Features1`` in one environment and ``Features2`` in another. Reach
135+
# the classes via their parents' field annotation — both ``Signals`` and
136+
# ``Adcp`` are stable wire-spec class names, and the field annotations
137+
# carry the union arm types directly.
138+
_signals_features_arms = [
139+
arm for arm in _get_args(_Signals.model_fields["features"].annotation) if arm is not type(None)
140+
]
141+
if len(_signals_features_arms) != 1:
142+
raise RuntimeError(
143+
"capabilities: Signals.features annotation lost its concrete type "
144+
f"(got {_signals_features_arms!r})"
145+
)
146+
SignalsFeatures: type = _signals_features_arms[0]
147+
148+
_idempotency_arms = [
149+
arm
150+
for arm in _get_args(_Adcp.model_fields["idempotency"].annotation)
151+
if arm is not IdempotencySupported and arm is not type(None)
152+
]
153+
if len(_idempotency_arms) != 1:
154+
raise RuntimeError(
155+
"capabilities: expected exactly one non-supported Idempotency arm, "
156+
f"got {_idempotency_arms!r}"
157+
)
158+
IdempotencyUnsupported: type = _idempotency_arms[0]
159+
137160
__all__ = [
138161
"A2ui",
139162
"Adcp",

src/adcp/types/generated_poc/enums/daast_tracking_event.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# generated by datamodel-codegen:
22
# filename: enums/daast_tracking_event.json
3-
# timestamp: 2026-05-02T19:36:29+00:00
3+
# timestamp: 2026-05-19T21:40:22+00:00
44

55
from __future__ import annotations
66

0 commit comments

Comments
 (0)