Skip to content

feat: add creative format compatibility helpers#887

Merged
bokelley merged 1 commit into
mainfrom
creative-format-compatibility
May 27, 2026
Merged

feat: add creative format compatibility helpers#887
bokelley merged 1 commit into
mainfrom
creative-format-compatibility

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Summary

  • add canonical format compatibility helpers: upgrade_legacy_format_id, formats_are_equivalent, and format_is_supported
  • document and type revision / confirmed_at media-buy semantics across schemas and generated models
  • update README media-buy workflow to carry revision through update calls

Closes #885.
Closes #886.

Validation

  • uv run --extra dev python -m pytest tests/ -q
  • uv run --extra dev python -m pytest tests/test_canonical_formats_compatibility.py tests/test_canonical_formats_options.py tests/test_server_dx.py tests/test_public_api.py::test_public_api_surface_matches_snapshot -q
  • uv run --extra dev mypy src/adcp/
  • uv run --extra dev ruff check src/adcp/canonical_formats/compat_helpers.py src/adcp/canonical_formats/format_options.py src/adcp/canonical_formats/identity.py src/adcp/canonical_formats/__init__.py src/adcp/__init__.py tests/test_canonical_formats_compatibility.py tests/test_canonical_formats_options.py tests/test_server_dx.py
  • uv run --extra dev make validate-generated
  • git diff --check

@bokelley bokelley force-pushed the creative-format-compatibility branch from 5243789 to 309040f Compare May 27, 2026 10:11
@bokelley bokelley changed the title Add creative format compatibility helpers feat: add creative format compatibility helpers May 27, 2026
aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes May 27, 2026
Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Follow-ups noted below. Clean split — the new helpers are pure, the schema rewording is consistent across cache + generated arms + README, and media_buy_response()'s default path is unchanged so existing callers are untouched.

Things I checked

  • PR title carries feat: prefix — release-please will pick up the three new public exports.
  • Public-API surface: format_is_supported, formats_are_equivalent, upgrade_legacy_format_id all land in src/adcp/__init__.py and tests/fixtures/public_api_snapshot.json is updated to match. No drift.
  • Type-system layering still clean — the refactor extracts canonicalize_agent_url into src/adcp/canonical_formats/identity.py and updates both call sites in format_options.py; nothing new reaches into generated_poc/.
  • confirmed_at nullability flip on 3.1.0-beta.4 is consistent across schemas/cache/3.1.0-beta.4/{bundled/,}{core/tasks-get-response,media-buy/create-media-buy-response,media-buy/update-media-buy-response,media-buy/get-media-buys-response}.json and the matching generated Pydantic models. 3.0 stays "type": "string" — explicit null correctly remains invalid there.
  • media_buy_response(confirmed_at=_UNSET) preserves the old auto-_rfc3339_now() default; only an explicit None changes behavior. New ValueError on adcp_version="3.0" + confirmed_at=None is the right fail-closed choice.
  • ad-tech-protocol-expert: sound-with-caveats — canonical agent URL https://creative.adcontextprotocol.org matches _fixtures/v1-reference-formats.json; the "bare string upgrades / seller-namespaced FormatId stays put" asymmetry is the correct call (seller namespaces own their own ID semantics; SDK must not silently rebrand seller.example/display_300x250 as the AAO canonical template). tests/test_canonical_formats_compatibility.py:101 pins that contract.
  • code-reviewer: sound-with-caveats — no blockers, four minor findings rolled into follow-ups below.

Follow-ups (non-blocking — file as issues)

  • media_buy_response guard is narrower than its docstring. src/adcp/server/responses.py raises only when adcp_version is not None and not _is_adcp_31_or_newer(...). The adcp_version=None (dispatcher-projected) path lets confirmed_at=None through into the response dict. If a downstream 3.0 projection then emits that as null, we've handed the buyer a 3.0-invalid payload. Tighten the gate or document that the dispatcher is the only safe None consumer.
  • Legacy upgrade regex covers display sizes only. _DISPLAY_SIZE_RE in src/adcp/canonical_formats/compat_helpers.py matches display_NxN(_image)?. The shipped src/adcp/canonical_formats/_fixtures/v1-reference-formats.json also defines display_NxN_html, display_NxN_generative, and video_{1920x1080,1280x720,1080x1920,1080x1080}. Adopters with legacy v1 catalogs will reasonably expect those to upgrade too. Follow-up issue, not a block.
  • upgrade_legacy_format_id propagates duration_ms into the upgraded display ID. src/adcp/canonical_formats/compat_helpers.py:73 copies fid.duration_ms through unconditionally — a caller passing a legacy display ID with a stray duration_ms ends up with a nonsensical display_image + duration. Either drop duration_ms when the display regex matched, or document the caller contract.
  • Hand-maintained fields inside generated_poc/. CreateMediaBuyResponse1.revision/confirmed_at and UpdateMediaBuyResponse1.revision are added directly in src/adcp/types/generated_poc/media_buy/{create,update}_media_buy_response.py. The file header already notes these are SDK-maintained backward-compat arms, but a regeneration could silently clobber them. Worth a per-field # manual: not in codegen source marker so future-you doesn't have to git-archaeology the comment.

Minor nits (non-blocking)

  1. Dead try/except in _coerce_format_id. src/adcp/canonical_formats/compat_helpers.py:43 catches ValidationError only to bare-raise it — the wrapper adds nothing. Drop the except.
  2. _coerce_format_id("__default__", ...) round-trip. src/adcp/canonical_formats/compat_helpers.py:67 performs a full Pydantic model_validate purely to read back the agent URL the caller passed in. canonicalize_agent_url(default_agent_url) is equivalent and avoids coupling the helper to the FormatId id regex accepting __default__.

Approving on the strength of the layering hygiene plus the 3.0/3.1 wire compat being handled symmetrically across schema cache, generated models, server-DX helper, and tests.

aao-ipr-bot[bot]
aao-ipr-bot Bot previously approved these changes May 27, 2026
Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Follow-ups noted below. Track A is purely additive public surface; Track B narrows wire shape inside 3.1.0-beta.4 where confirmed_at was never in required[]feat: with no ! is the right semver signal.

Things I checked

  • Public surface diff matches snapshot. format_is_supported / formats_are_equivalent / upgrade_legacy_format_id land in tests/fixtures/public_api_snapshot.json exactly once; CANONICAL_CREATIVE_AGENT_URL is exported only from adcp.canonical_formats, not top-level (intentional — matches snapshot). No public-API removal anywhere.
  • 3.0 fail-closed on confirmed_at=None. media_buy_response at src/adcp/server/responses.py:430 raises ValueError when adcp_version="3.0" is paired with explicit None — correct, since 3.0's schema is type: "string" (no null arm). tests/test_server_dx.py covers both the 3.1+ allow path and the 3.0 reject path.
  • Sentinel default for confirmed_at. Grep across src/, tests/, examples/ confirms no in-repo caller passed confirmed_at=None explicitly to media_buy_response — the soft behavior change (None used to auto-populate, now passes through) breaks nobody in the tree.
  • Schema/generated alignment. confirmed_at flipped to ["string","null"] in create-media-buy-response, get-media-buys-response, tasks-get-response bundled+modular; not in update-media-buy-response (correct — updates don't re-confirm). New revision / confirmed_at fields on hand-curated CreateMediaBuyResponse1 / UpdateMediaBuyResponse1 are concrete optional scalars, so aliases.py / _ergonomic.py / _forward_compat.py need no updates.
  • canonicalize_agent_url extraction. Moved out of format_options.py into identity.py; call sites in find_declaration_by_v1_format_id rewired; not re-exported from canonical_formats/__init__.py, so it stays effectively internal.
  • Seller-owned namespace guard. upgrade_legacy_format_id correctly refuses to rewrite display_300x250 when the input is a structured FormatId whose agent_url is not the canonical AAO host. Covered by test_upgrade_legacy_display_size_does_not_rewrite_seller_owned_namespace.
  • feat: not feat!:. confirmed_at was never in required[] on 3.1, so widening to nullable is a beta-internal refinement, not a wire-break. Net-additive helpers, no public removal. release-please will cut a minor.

Follow-ups (non-blocking — file as issues)

  • upgrade_legacy_format_id name overpromises coverage. Regex is ^display_(\d+)x(\d+)(?:_image)?$ — display-image-by-size only. Misses load-bearing v1 ids: display_300x250_html, display_300x250_generative, video_1920x1080, video_vast_30s. Either rename to upgrade_legacy_display_size_id, or extend the regex with test coverage. ad-tech-protocol-expert flagged this against _fixtures/v1-reference-formats.json:3338.
  • Hardcoded v1↔v2 mapping diverges from the registry. registries/v1-canonical-mapping.json defines resolution-order via the catalog's canonical: annotation, not by enumerated literals. A future AAO catalog change to display_300x250_image's canonical: annotation silently won't reach this helper. Consider deriving the upgrade from the loaded v1 reference catalog and falling back to the regex only as a heuristic — plus surfacing FORMAT_PROJECTION_FAILED errors via the SDK advisory channel when the regex path fires.
  • display_1x1 is a 1x1 tracker, not a display_image. The regex matches it but projecting to display_image 1x1 is semantically wrong (it's a pixel/tag). Either exclude sub-threshold sizes or whitelist IAB standard sizes.
  • canonicalize_agent_url doesn't normalize path dot-segments. Spec rule (per core/format-id.json:11) calls for lowercase scheme/host, default-port strip, and path dot-segment normalization. PR does the first two; https://creative.adcontextprotocol.org/./ mis-matches today. Low impact in practice.
  • CreateMediaBuyResponse1.revision is int | None but the README treats it as always-present. README's update example reads revision = media_buy_result.data.revision and passes it unconditionally into UpdateMediaBuyPackagesRequest(revision=revision, ...). In pending/manual-approval flows the seller will legitimately omit revision (consistent with omitting confirmed_at), making the buyer send revision=None to the seller. Guard the README example with if revision is not None: ..., or land an SDK helper that handles the unconfirmed-yet path.
  • Snapshot test for ergonomic alias vs. generated drift. The hand-curated CreateMediaBuyResponse1 / UpdateMediaBuyResponse1 now carry confirmed_at / revision fields with their own descriptions. Next make validate-generated against a re-pulled schema could drift. Worth a test that asserts the ergonomic alias optional-ness agrees with the corresponding Result557-style auto-generated arm.

Minor nits (non-blocking)

  1. _coerce_format_id(\"__default__\", ...) for the default-URL probe. src/adcp/canonical_formats/compat_helpers.py:75 constructs a throwaway FormatId just to read back default_fid.agent_url. Works today because FormatId.id's regex allows underscores, but it couples this helper to that regex forever. One-line refactor: cache _CANONICAL_DEFAULT_NORMALIZED = canonicalize_agent_url(CANONICAL_CREATIVE_AGENT_URL) at module load and compare against it directly — no dummy validation per call.
  2. Dead try/except ValidationError: raise. src/adcp/canonical_formats/compat_helpers.py:42-46 catches and re-raises identically. Drop the block.
  3. confirmed_at: str | None | object = _UNSET. src/adcp/server/responses.py:398 collapses to object for mypy — downstream callers lose static checking. A Literal[_UNSET] or a dedicated sentinel singleton type would preserve the hint.
  4. Generated-model description drift. src/adcp/types/generated_poc/media_buy/create_media_buy_response.py says "should remain stable" while the upstream schema says "remains stable". Cosmetic, but a strict regenerate would overwrite.

Approving.

Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Follow-ups noted below. The new helpers operate on the typed FormatId model (not string-blob compares), pull canonicalization into a dedicated identity.py module, and the PROPOSAL_NOT_FOUND recovery flip brings the proposal path into alignment with how MEDIA_BUY_NOT_FOUND already classifies — the right shape on all three.

Things I checked

  • New public exports (format_is_supported, formats_are_equivalent, upgrade_legacy_format_id, CANONICAL_CREATIVE_AGENT_URL) are additive — tests/fixtures/public_api_snapshot.json updated to match.
  • confirmed_at cache widening from required string to [\"string\",\"null\"] is non-breaking: the field was never in any required[] array in 3.1.0-beta.4 — adopters parsing existing responses still see strings on the happy path.
  • Generated types (generated_poc/bundled/**) carry description-only diffs consistent with a regeneration pass.
  • Seller-owned namespace guard at compat_helpers.py:65-71 holds: structured input carrying a non-canonical agent_url round-trips untouched (test_upgrade_legacy_display_size_does_not_rewrite_seller_owned_namespace), only bare-string legacy IDs auto-tag canonical.
  • canonicalize_agent_url does RFC 3986 §6 host-casefolding + default-port stripping — test_canonical_format_helpers_canonicalize_agent_url_case_and_default_port pins this.
  • Security: `security-reviewer` confirmed the terminal→correctable flip on PROPOSAL_NOT_FOUND introduces no new oracle — the cross-tenant branch and the missing-record branch raise byte-identical errors (same code, message template, field, now same recovery). The store-layer `get()` collapses cross-tenant to `None` at `proposal_store.py:489` before the recovery field is ever set.

Follow-ups (non-blocking — file as issues)

  1. `media_buy_response` behavior change on `confirmed_at=None`. Old path was `confirmed_at or _rfc3339_now()` — every `None` got silently auto-filled. New path raises `ValueError` when `adcp_version="3.0"` and preserves `None` on 3.1+. Existing callers that explicitly passed `None` hit new behavior. The `feat:` prefix without `!` is borderline-defensible because the omitted-kwarg path is unchanged, but worth calling out in the next release notes.
  2. 3.0 helper sharpness (per `ad-tech-protocol-expert`). In 3.0, `confirmed_at` is optional but non-nullable — the schema-valid projection for "unknown commitment time" is to omit the key, not auto-fill `now()`. Auto-fill is pre-existing behavior, not new here, but it is "a real lie about seller commitment" for pending/manual-approval buys. Consider an `omit_when_null` path so adopters running 3.0 and 3.1 in one code path don't conditional at every call site.
  3. `revision` description should name the error code. Per `ad-tech-protocol-expert`: AdCP 3.1.0-beta.4 uses `CONFLICT` for stale-revision rejection (the SDK helper catalog already registers it: "Revision conflict - refetch and retry"). The README addition and the regenerated `Field(description=...)` blobs say "if a seller rejects an update because the supplied revision is stale" without naming `CONFLICT`. Adopters reading the docstring won't know which `error.code` to switch on.
  4. Docstring drift at `src/adcp/decisioning/proposal_lifecycle.py:71`. The class docstring still asserts `(recovery=terminal)` for the cross-tenant probe case. The raise underneath now emits `correctable`. Stale wording on the surface that documents the anti-enumeration property.
  5. Catalog inconsistency on `PROPOSAL_EXPIRED`. `src/adcp/decisioning/proposal_lifecycle.py:148` raises with `recovery="terminal"`; `src/adcp/server/helpers.py:37` lists `PROPOSAL_EXPIRED` as `correctable`. One should give.
  6. Hand-maintained shim in `generated_poc/`. `CreateMediaBuyResponse1` and `UpdateMediaBuyResponse1` gained `confirmed_at` and `revision` fields. The in-file comment explicitly calls these out as hand-maintained back-compat aliases, but the file header still names `datamodel-codegen` — confirm the regen script preserves the shim class additions, or move the shims to a non-`generated_poc/` module so the boundary is unambiguous.
  7. Docs drift on new public exports. `format_is_supported` / `formats_are_equivalent` / `upgrade_legacy_format_id` aren't mentioned in `README.md`, `AGENTS.md`, or `llms.txt`. The README's media-buy section gained a revision/confirmed_at paragraph but the format-compat helpers are the headline feature in the PR title and don't appear in the user-facing docs.
  8. Rate-limit guidance for `correctable` `*_NOT_FOUND` codes (per `security-reviewer` Low). No new oracle, but `correctable` signals retry middleware to keep going — adopters should cap automatic retries on `*_NOT_FOUND` and rate-limit emissions per principal at the HTTP front. Worth a sentence in the `_CORRECTABLE_ERRORS` documentation.

Minor nits (non-blocking)

  1. Throwaway `FormatId` to read a URL we already have. `compat_helpers.py:65` builds `default_fid = _coerce_format_id("default", default_agent_url=default_agent_url)` just to compare `canonicalize_agent_url(fid.agent_url)` against `canonicalize_agent_url(default_fid.agent_url)`. `default_agent_url` is already a plain string in scope — `canonicalize_agent_url(default_agent_url)` is the same answer without minting a model instance whose only purpose is to be discarded. A notable choice.
  2. `str | None | object = _UNSET` collapses type narrowing. `src/adcp/server/responses.py:400`. Every `str` and `None` is an `object`, so mypy/pyright will accept anything. Standard pattern: `class _UnsetType: ...; _UNSET: Final = _UnsetType()` and annotate `str | None | _UnsetType`.
  3. `duration_ms` propagated through a `display_image` upgrade. `compat_helpers.py:75`. A legacy `display_300x250` ID carrying a `duration_ms` is incoherent — either drop it for display upgrades or assert `duration_ms is None` before forwarding.
  4. `try/except ValidationError: raise` is a no-op. `compat_helpers.py:33-39`. Drop the wrapper or comment what it's defending.
  5. `TypeError` message at `compat_helpers.py:43` should name the offending type to aid debugging.

LGTM. Follow-ups noted below.

Copy link
Copy Markdown

@aao-ipr-bot aao-ipr-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM on the helper surface and the description-tightening. Holding for one missing artifact: the schemas/cache/ edits need a schemas/patches/ entry per the documented policy.

Things I checked

  • src/adcp/canonical_formats/compat_helpers.py:73 upgrades display_300x250id="display_image". Verified display_image is a real FormatId.id (not a fabricated kind) — present at src/adcp/canonical_formats/_fixtures/v1-reference-formats.json under agent_url: https://creative.adcontextprotocol.org/. Helper isn't inventing an ID. The format_kind enum at core/canonical-format-kind.json is a separate axis (12-member closed enum) — not what this helper traffics in.
  • formats_are_equivalent vs format_is_supported semantics: equivalence is family-match with non-conflicting params (omitted = unspecified), gating requires the request to state every fixed param the supported side declares. Tests at tests/test_canonical_formats_compatibility.py:62 and :84 exercise both directions. Right shape.
  • identity.py:12 extraction of canonicalize_agent_url. Host-casefold, default-port strip, malformed pass-through preserved. The find_declaration_by_v1_format_id callers in format_options.py:184/188 migrated cleanly.
  • New public exports format_is_supported, formats_are_equivalent, upgrade_legacy_format_id — additive, feat: semver is right. Snapshot fixture tests/fixtures/public_api_snapshot.json updated.
  • src/adcp/types/generated_poc/media_buy/{create,update}_media_buy_response.py hand edits adding confirmed_at/revision to CreateMediaBuyResponse1 / UpdateMediaBuyResponse1. The file-level docstring at lines 10-12 explicitly carves these out as SDK-maintained arms ("Backward-compatible SDK response arms... not regenerated"). Hand edit is legitimate here.
  • Schema widening confirmed_at: string[string, null] is non-breaking (the field is not in the required list at schemas/cache/3.1.0-beta.4/media-buy/create-media-buy-response.json:104). Wire-shape relaxation, not a contract break.
  • Four recovery=\"terminal\"recovery=\"correctable\" flips on PROPOSAL_NOT_FOUND across proposal_store.py, pg/proposal_store.py, proposal_dispatch.py, proposal_lifecycle.py, plus the matching test updates and the new e2e test_refine_unknown_proposal_is_correctable_not_found. Cross-tenant probe still returns the same code with the same recovery, so the principal-enumeration defense (proposal_lifecycle.py:68-72 docstring) is intact.

Major (resolve before merge or in a fast follow-up)

  1. schemas/cache/3.1.0-beta.4/** hand-edited without a schemas/patches/ entry. Six cache JSONs touched (media-buy/create-media-buy-response.json, media-buy/update-media-buy-response.json, media-buy/get-media-buys-response.json, and their bundled/ siblings, plus bundled/core/tasks-get-response.json). generatedAt timestamp unchanged (2026-05-26T03:04:14.740Z) and the narrow 6-file scope rules out a fresh sync. Project policy at schemas/patches/README.md:123-125 is explicit: "Don't edit schemas/cache/ without a corresponding .patch file. Next regen overwrites the edit." The README cites PR #791 as the exact failure mode — silent revert on regen while the Pydantic-model layer keeps the new shape. CI's schema-check job skips drift validation on beta versions (.github/workflows/ci.yml gates on is_prerelease != 'true'), so this slips through automated gates today and surfaces on the next stable-version pin. Fix: add schemas/patches/0N-confirmed-at-nullable.patch with the documented Patch / Reason / Filed / Upstream status / Drop when header, and either (a) confirm upstream 3.1.0-beta.4 actually permits null here (then the patch documents "upstream landed, will go away on next regen") or (b) flag this as a forward-looking patch pending the upstream adcp PR.

Follow-ups (non-blocking — file as issues)

  1. examples/seller_agent.py:660-666 returns PACKAGE_NOT_FOUND when the media buy itself is missing. Branch fires whenever any package in params[\"packages\"] has a package_id AND the buy doesn't exist. Misleading: buyers retry against a different package_id and never recover. Check mb_id in media_buys first; reserve PACKAGE_NOT_FOUND for the existing in-loop site where the buy is found but the package isn't. Lower stakes because this is example/test-controller code, but it's the storyboard buyers learn from.
  2. src/adcp/server/responses.py media_buy_response(confirmed_at: str | None | object = _UNSET). Type annotation widens to object, which mypy will not narrow at call sites — a caller passing confirmed_at=42 type-checks. Prefer a typed sentinel (class _UnsetType: ...; _UNSET: Final = _UnsetType()) and annotate str | None | _UnsetType = _UNSET.

Minor nits (non-blocking)

  1. src/adcp/canonical_formats/compat_helpers.py _coerce_format_id. The try: return FormatId.model_validate(body) except ValidationError: raise block is a no-op — bare raise adds nothing over letting the exception propagate. Drop the try/except.
  2. src/adcp/canonical_formats/compat_helpers.py:74 upgrade_legacy_format_id. default_fid = _coerce_format_id(\"__default__\", default_agent_url=default_agent_url) is a throwaway pydantic round-trip to read back default_agent_url. Inline as canonicalize_agent_url(default_agent_url).
  3. src/adcp/canonical_formats/__init__.py __all__ ordering. New entries break the file's local alphabetical-within-case grouping (CANONICAL_CREATIVE_AGENT_URL after RegistryLoadError, format_is_supported before find_declaration_by_kind, upgrade_legacy_format_id after upgrade_v1_trackers). Snapshot fixture is sorted so this doesn't fail tests — just file-local consistency.
  4. src/adcp/server/responses.py media_buy_response docstring. Repeats the "confirmed_at=None is only schema-valid for AdCP 3.1+" guidance twice.

Add the patches file and I'll approve.

@bokelley bokelley merged commit 7f26ad1 into main May 27, 2026
25 checks passed
@bokelley bokelley deleted the creative-format-compatibility branch May 27, 2026 11:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Document revision and confirmed_at semantics in media buy response models Add canonical creative format compatibility helpers

1 participant