diff --git a/README.md b/README.md index 0bdd8eff..6a61ba32 100644 --- a/README.md +++ b/README.md @@ -916,7 +916,9 @@ async with ADCPClient(config) as client: if media_buy_result.success: media_buy_id = media_buy_result.data.media_buy_id - print(f"✅ Media buy created: {media_buy_id}") + revision = media_buy_result.data.revision + confirmed_at = media_buy_result.data.confirmed_at + print(f"✅ Media buy created: {media_buy_id} at {confirmed_at}") # 4. Update media buy if needed from adcp import UpdateMediaBuyPackagesRequest @@ -924,6 +926,7 @@ async with ADCPClient(config) as client: update_result = await client.update_media_buy( UpdateMediaBuyPackagesRequest( media_buy_id=media_buy_id, + revision=revision, # optimistic concurrency token from create/get/update packages=[{ "package_id": product.packages[0].package_id, "quantity": 1500000 # Increase budget @@ -932,9 +935,16 @@ async with ADCPClient(config) as client: ) if update_result.success: + revision = update_result.data.revision print("✅ Media buy updated") ``` +`revision` is the media-buy concurrency token. Read it from `create_media_buy`, +`get_media_buys`, or the last successful `update_media_buy`, then pass it on the +next mutating update so the seller can reject stale writes. `confirmed_at` is the +seller commitment timestamp and should remain stable across later pause/resume or +budget updates. + ### Complete Creative Workflow Build and deliver production-ready creatives: diff --git a/examples/sales_proposal_mode_seller/src/proposal_manager.py b/examples/sales_proposal_mode_seller/src/proposal_manager.py index acab5e23..463c4ae6 100644 --- a/examples/sales_proposal_mode_seller/src/proposal_manager.py +++ b/examples/sales_proposal_mode_seller/src/proposal_manager.py @@ -20,6 +20,7 @@ from typing import Any from adcp.decisioning import ( + AdcpError, CapabilityOverlap, FinalizeProposalRequest, FinalizeProposalSuccess, @@ -208,15 +209,27 @@ async def refine_products( {"ctv-premium-q2": 80.0, "display-run-q2": 20.0}, ) applied = [] - for entry in refines: + for index, entry in enumerate(refines): inner = getattr(entry, "root", entry) scope = getattr(inner, "scope", None) scope_str = str(getattr(scope, "value", scope)) if scope is not None else None if scope_str == "proposal": + proposal_id = str(getattr(inner, "proposal_id", PROPOSAL_ID)) + if proposal_id != PROPOSAL_ID: + raise AdcpError( + "PROPOSAL_NOT_FOUND", + message=( + f"Proposal {proposal_id!r} not found. Call get_products " + "with buying_mode='brief' or a valid refine sequence " + "to obtain a draft proposal_id before refining it." + ), + recovery="correctable", + field=f"refine[{index}].proposal_id", + ) applied.append( { "scope": "proposal", - "proposal_id": str(getattr(inner, "proposal_id", PROPOSAL_ID)), + "proposal_id": proposal_id, "status": "applied", "notes": "Adjusted CTV/display split per ask.", } diff --git a/examples/seller_agent.py b/examples/seller_agent.py index 5e0d8a59..0845e0ae 100644 --- a/examples/seller_agent.py +++ b/examples/seller_agent.py @@ -228,7 +228,7 @@ def _image_format_options( { "pricing_option_id": "po-cpm-homepage", "pricing_model": "cpm", - "floor_price": 15.00, + "fixed_price": 15.00, "currency": "USD", } ], @@ -260,7 +260,7 @@ def _image_format_options( { "pricing_option_id": "po-cpm-ros", "pricing_model": "cpm", - "floor_price": 5.00, + "fixed_price": 5.00, "currency": "USD", } ], @@ -295,7 +295,7 @@ def _image_format_options( { "pricing_option_id": "cpm_standard", "pricing_model": "cpm", - "floor_price": 5.00, + "fixed_price": 5.00, "currency": "USD", } ], @@ -327,7 +327,7 @@ def _image_format_options( { "pricing_option_id": "cpm_standard", "pricing_model": "cpm", - "floor_price": 8.00, + "fixed_price": 8.00, "currency": "USD", } ], @@ -359,7 +359,7 @@ def _image_format_options( { "pricing_option_id": "cpm_guaranteed", "pricing_model": "cpm", - "floor_price": 25.00, + "fixed_price": 25.00, "currency": "USD", } ], @@ -391,7 +391,7 @@ def _image_format_options( { "pricing_option_id": "cpm_standard", "pricing_model": "cpm", - "floor_price": 6.00, + "fixed_price": 6.00, "currency": "USD", } ], @@ -657,6 +657,12 @@ async def update_media_buy(self, params: dict[str, Any], context: Any = None) -> mb_id = params.get("media_buy_id") mb = media_buys.get(mb_id) if mb_id else None if not mb or not mb_id: + if any(pkg.get("package_id") for pkg in params.get("packages") or []): + return adcp_error( + "PACKAGE_NOT_FOUND", + f"Package not found in media buy {mb_id}", + field="package_id", + ) return adcp_error("MEDIA_BUY_NOT_FOUND", f"Media buy {mb_id} not found") if params.get("revision") and params["revision"] != mb.get("revision", 1): @@ -826,6 +832,13 @@ async def get_media_buy_delivery( "impressions": 45000, "clicks": 680, "spend": 540.00, + "viewability": { + "measurable_impressions": 42000, + "viewable_impressions": 31500, + "viewable_rate": 0.75, + "viewed_seconds": 12.5, + "standard": "mrc", + }, }, "by_package": [], } @@ -875,7 +888,13 @@ async def force_creative_status( ) -> dict[str, Any]: c = creatives.get(creative_id) if not c: - raise TestControllerError("NOT_FOUND", f"Creative {creative_id} not found") + c = { + "creative_id": creative_id, + "name": creative_id, + "format_id": {"agent_url": AGENT_URL, "id": "display_300x250"}, + "status": "unknown", + } + creatives[creative_id] = c prev = c.get("status", "unknown") if prev == "archived": raise TestControllerError( diff --git a/schemas/cache/3.1.0-beta.4/bundled/core/tasks-get-response.json b/schemas/cache/3.1.0-beta.4/bundled/core/tasks-get-response.json index fa700484..4dc48457 100644 --- a/schemas/cache/3.1.0-beta.4/bundled/core/tasks-get-response.json +++ b/schemas/cache/3.1.0-beta.4/bundled/core/tasks-get-response.json @@ -46502,9 +46502,12 @@ "$ref": "#/$defs/MediaBuyStatus" }, "confirmed_at": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "date-time", - "description": "ISO 8601 timestamp when this media buy was confirmed by the seller. A successful create_media_buy response constitutes order confirmation." + "description": "Seller commitment timestamp for this media buy. This is the time the seller confirmed the order, not a delivery-state timestamp; once set it remains stable across pause, resume, budget, and package updates. Pending/manual approval flows may leave it null until seller commitment happens." }, "creative_deadline": { "type": "string", @@ -46513,7 +46516,7 @@ }, "revision": { "type": "integer", - "description": "Initial revision number for this media buy. Use in subsequent update_media_buy requests for optimistic concurrency.", + "description": "Initial optimistic-concurrency revision for this media buy, usually 1 when the seller mints the buy synchronously. Clients should pass the last observed revision on update_media_buy.", "minimum": 1 }, "currency": { @@ -54506,7 +54509,7 @@ }, "revision": { "type": "integer", - "description": "Revision number after this update. Use this value in subsequent update_media_buy requests for optimistic concurrency.", + "description": "Optimistic-concurrency revision after this mutating update. Use this new value in the next update_media_buy request; reload the media buy and retry if a seller rejects an update because the supplied revision is stale.", "minimum": 1 }, "currency": { @@ -104973,4 +104976,4 @@ "generatedAt": "2026-05-26T03:04:14.411Z", "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." } -} \ No newline at end of file +} diff --git a/schemas/cache/3.1.0-beta.4/bundled/media-buy/create-media-buy-response.json b/schemas/cache/3.1.0-beta.4/bundled/media-buy/create-media-buy-response.json index 1a818341..ae7ef78c 100644 --- a/schemas/cache/3.1.0-beta.4/bundled/media-buy/create-media-buy-response.json +++ b/schemas/cache/3.1.0-beta.4/bundled/media-buy/create-media-buy-response.json @@ -1914,9 +1914,12 @@ "$ref": "#/$defs/MediaBuyStatus" }, "confirmed_at": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "date-time", - "description": "ISO 8601 timestamp when this media buy was confirmed by the seller. A successful create_media_buy response constitutes order confirmation." + "description": "Seller commitment timestamp for this media buy. This is the time the seller confirmed the order, not a delivery-state timestamp; once set it remains stable across pause, resume, budget, and package updates. Pending/manual approval flows may leave it null until seller commitment happens." }, "creative_deadline": { "type": "string", @@ -1925,7 +1928,7 @@ }, "revision": { "type": "integer", - "description": "Initial revision number for this media buy. Use in subsequent update_media_buy requests for optimistic concurrency.", + "description": "Initial optimistic-concurrency revision for this media buy, usually 1 when the seller mints the buy synchronously. Clients should pass the last observed revision on update_media_buy.", "minimum": 1 }, "currency": { @@ -9976,4 +9979,4 @@ "generatedAt": "2026-05-26T03:04:14.740Z", "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." } -} \ No newline at end of file +} diff --git a/schemas/cache/3.1.0-beta.4/bundled/media-buy/update-media-buy-response.json b/schemas/cache/3.1.0-beta.4/bundled/media-buy/update-media-buy-response.json index 5826e5ee..8ad52c98 100644 --- a/schemas/cache/3.1.0-beta.4/bundled/media-buy/update-media-buy-response.json +++ b/schemas/cache/3.1.0-beta.4/bundled/media-buy/update-media-buy-response.json @@ -440,7 +440,7 @@ }, "revision": { "type": "integer", - "description": "Revision number after this update. Use this value in subsequent update_media_buy requests for optimistic concurrency.", + "description": "Optimistic-concurrency revision after this mutating update. Use this new value in the next update_media_buy request; reload the media buy and retry if a seller rejects an update because the supplied revision is stale.", "minimum": 1 }, "currency": { @@ -7609,4 +7609,4 @@ "generatedAt": "2026-05-26T03:04:14.907Z", "note": "This is a bundled schema with all $ref resolved inline. For the modular version with references, use the parent directory." } -} \ No newline at end of file +} diff --git a/schemas/cache/3.1.0-beta.4/media-buy/create-media-buy-response.json b/schemas/cache/3.1.0-beta.4/media-buy/create-media-buy-response.json index 808bb60f..6301efd9 100644 --- a/schemas/cache/3.1.0-beta.4/media-buy/create-media-buy-response.json +++ b/schemas/cache/3.1.0-beta.4/media-buy/create-media-buy-response.json @@ -40,9 +40,12 @@ "description": "DEPRECATED in 3.1, removed in 3.2 (#4906). Use `media_buy_status` instead. Top-level `status` here collides with the envelope TaskStatus on flat-serialized MCP wire (see adcontextprotocol/adcp#4895). Buyers consuming 3.1+ responses MUST prefer `media_buy_status` when present; sellers MAY emit both during the deprecation window but MUST emit identical values when doing so \u2014 divergent emission is a conformance violation." }, "confirmed_at": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "date-time", - "description": "ISO 8601 timestamp when this media buy was confirmed by the seller. A successful create_media_buy response constitutes order confirmation." + "description": "Seller commitment timestamp for this media buy. This is the time the seller confirmed the order, not a delivery-state timestamp; once set it remains stable across pause, resume, budget, and package updates. Pending/manual approval flows may leave it null until seller commitment happens." }, "creative_deadline": { "type": "string", @@ -51,7 +54,7 @@ }, "revision": { "type": "integer", - "description": "Initial revision number for this media buy. Use in subsequent update_media_buy requests for optimistic concurrency.", + "description": "Initial optimistic-concurrency revision for this media buy, usually 1 when the seller mints the buy synchronously. Clients should pass the last observed revision on update_media_buy.", "minimum": 1 }, "currency": { @@ -222,4 +225,4 @@ } ], "properties": {} -} \ No newline at end of file +} diff --git a/schemas/cache/3.1.0-beta.4/media-buy/get-media-buys-response.json b/schemas/cache/3.1.0-beta.4/media-buy/get-media-buys-response.json index 8d75690e..c9a2f74e 100644 --- a/schemas/cache/3.1.0-beta.4/media-buy/get-media-buys-response.json +++ b/schemas/cache/3.1.0-beta.4/media-buy/get-media-buys-response.json @@ -80,9 +80,12 @@ "description": "ISO 8601 timestamp for creative upload deadline" }, "confirmed_at": { - "type": "string", + "type": [ + "string", + "null" + ], "format": "date-time", - "description": "ISO 8601 timestamp when the seller confirmed this media buy. A successful create_media_buy response constitutes order confirmation." + "description": "Seller commitment timestamp for this media buy. This is the time the seller confirmed the order, not a delivery-state timestamp; once set it remains stable across pause, resume, budget, and package updates. Pending/manual approval flows may leave it null until seller commitment happens." }, "cancellation": { "type": "object", @@ -111,7 +114,7 @@ }, "revision": { "type": "integer", - "description": "Current revision number. Pass this in update_media_buy for optimistic concurrency.", + "description": "Current optimistic-concurrency revision for this media buy. Pass this value in update_media_buy; reload and retry if the seller reports a stale revision conflict.", "minimum": 1 }, "created_at": { @@ -434,4 +437,4 @@ "media_buys" ], "additionalProperties": true -} \ No newline at end of file +} diff --git a/schemas/cache/3.1.0-beta.4/media-buy/update-media-buy-response.json b/schemas/cache/3.1.0-beta.4/media-buy/update-media-buy-response.json index 4a963e8d..9467a29d 100644 --- a/schemas/cache/3.1.0-beta.4/media-buy/update-media-buy-response.json +++ b/schemas/cache/3.1.0-beta.4/media-buy/update-media-buy-response.json @@ -33,7 +33,7 @@ }, "revision": { "type": "integer", - "description": "Revision number after this update. Use this value in subsequent update_media_buy requests for optimistic concurrency.", + "description": "Optimistic-concurrency revision after this mutating update. Use this new value in the next update_media_buy request; reload the media buy and retry if a seller rejects an update because the supplied revision is stale.", "minimum": 1 }, "currency": { @@ -202,4 +202,4 @@ } ], "properties": {} -} \ No newline at end of file +} diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index f83cee7d..e0146c85 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -42,6 +42,11 @@ verify_agent_authorization, verify_agent_for_property, ) +from adcp.canonical_formats import ( + format_is_supported, + formats_are_equivalent, + upgrade_legacy_format_id, +) from adcp.capabilities import ( # noqa: F401 FeatureResolver, build_synthetic_capabilities, @@ -819,6 +824,9 @@ def get_adcp_version() -> str: "Error", "Format", "FormatId", + "format_is_supported", + "formats_are_equivalent", + "upgrade_legacy_format_id", "FormatOptionReference", "AssetContentType", "Product", diff --git a/src/adcp/canonical_formats/__init__.py b/src/adcp/canonical_formats/__init__.py index a7042e35..9e9e24a9 100644 --- a/src/adcp/canonical_formats/__init__.py +++ b/src/adcp/canonical_formats/__init__.py @@ -26,6 +26,13 @@ Sellers call this before accepting a ``create_media_buy``. * :func:`find_declaration_by_kind` — looks up the matching declaration (with optional ``capability_id`` disambiguation). +* :func:`upgrade_legacy_format_id` — upgrades common legacy named + format IDs such as ``display_300x250`` to parameterized canonical + ``FormatId`` values. +* :func:`formats_are_equivalent` — family/equivalence comparison after + legacy upgrade. +* :func:`format_is_supported` — stricter product/capability gating comparison + after legacy upgrade. * :func:`load_default_registry` — loads the AAO-published v1↔v2 mapping registry from the bundled schema cache. * :class:`SdkAdvisory` — typed wrapper around the SDK-source ``Error`` @@ -48,6 +55,12 @@ from __future__ import annotations from adcp.canonical_formats.advisory import SDK_ID, SdkAdvisory, make_sdk_advisory +from adcp.canonical_formats.compat_helpers import ( + CANONICAL_CREATIVE_AGENT_URL, + format_is_supported, + formats_are_equivalent, + upgrade_legacy_format_id, +) from adcp.canonical_formats.format_options import ( FormatKindNotInClosedSetError, find_declaration_by_kind, @@ -98,6 +111,7 @@ "PixelTrackerDowngrade", "PixelTrackerUpgrade", "RegistryLoadError", + "CANONICAL_CREATIVE_AGENT_URL", "SDK_ID", "SdkAdvisory", "V1CatalogProjection", @@ -108,8 +122,10 @@ "check_narrows", "downgrade_pixel_tracker", "downgrade_pixel_trackers", + "format_is_supported", "find_declaration_by_kind", "find_declaration_by_v1_format_id", + "formats_are_equivalent", "glob_match", "group_declarations_by_product", "load_default_registry", @@ -122,5 +138,6 @@ "structural_match", "upgrade_v1_tracker", "upgrade_v1_trackers", + "upgrade_legacy_format_id", "validate_format_kind_in_options", ] diff --git a/src/adcp/canonical_formats/compat_helpers.py b/src/adcp/canonical_formats/compat_helpers.py new file mode 100644 index 00000000..e06d5826 --- /dev/null +++ b/src/adcp/canonical_formats/compat_helpers.py @@ -0,0 +1,145 @@ +"""Compatibility helpers for legacy and parameterized creative format IDs. + +These helpers cover the common migration case where older buyers or catalogs +refer to a named format such as ``display_300x250`` while newer schema surfaces +carry the canonical template ``display_image`` plus explicit dimensions. +""" + +from __future__ import annotations + +import re +from collections.abc import Mapping +from typing import Any + +from pydantic import ValidationError + +from adcp.canonical_formats.identity import canonicalize_agent_url +from adcp.types import FormatId + +CANONICAL_CREATIVE_AGENT_URL = "https://creative.adcontextprotocol.org" +"""Default ``agent_url`` for AdCP standard creative formats.""" + +_DISPLAY_SIZE_RE = re.compile( + r"^display_(?P[1-9][0-9]*)x(?P[1-9][0-9]*)(?:_image)?$" +) + + +def _coerce_format_id( + value: str | FormatId | Mapping[str, Any], + *, + default_agent_url: str, +) -> FormatId: + """Coerce supported helper inputs to the public ``FormatId`` model.""" + if isinstance(value, FormatId): + return value + if isinstance(value, str): + return FormatId.model_validate({"agent_url": default_agent_url, "id": value}) + if isinstance(value, Mapping): + body = dict(value) + body.setdefault("agent_url", default_agent_url) + try: + return FormatId.model_validate(body) + except ValidationError: + # Re-raise with the public model's normal validation details. + raise + raise TypeError("format id must be a string, FormatId, or mapping with at least an 'id' field") + + +def upgrade_legacy_format_id( + value: str | FormatId | Mapping[str, Any], + *, + default_agent_url: str = CANONICAL_CREATIVE_AGENT_URL, +) -> FormatId: + """Return ``value`` as a canonical, parameterized ``FormatId`` when known. + + The current canonical upgrade maps legacy display size IDs such as + ``display_300x250`` and ``display_300x250_image`` to + ``display_image`` with ``width=300`` and ``height=250``. Unknown IDs are + still returned as structured ``FormatId`` values so callers can compare + them consistently. + """ + is_bare_legacy_id = isinstance(value, str) + fid = _coerce_format_id(value, default_agent_url=default_agent_url) + match = _DISPLAY_SIZE_RE.fullmatch(fid.id) + if match is None: + return fid + default_fid = _coerce_format_id("__default__", default_agent_url=default_agent_url) + if not is_bare_legacy_id and canonicalize_agent_url(fid.agent_url) != canonicalize_agent_url( + default_fid.agent_url + ): + return fid + + return FormatId.model_validate( + { + "agent_url": str(fid.agent_url), + "id": "display_image", + "width": int(match.group("width")), + "height": int(match.group("height")), + "duration_ms": fid.duration_ms, + } + ) + + +def formats_are_equivalent( + a: str | FormatId | Mapping[str, Any], + b: str | FormatId | Mapping[str, Any], + *, + default_agent_url: str = CANONICAL_CREATIVE_AGENT_URL, +) -> bool: + """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 + supported fixed size or duration requires the request to state that value. + """ + left = upgrade_legacy_format_id(a, default_agent_url=default_agent_url) + right = upgrade_legacy_format_id(b, default_agent_url=default_agent_url) + if canonicalize_agent_url(left.agent_url) != canonicalize_agent_url(right.agent_url): + return False + if left.id != right.id: + return False + + for field in ("width", "height", "duration_ms"): + 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: + return False + return True + + +def format_is_supported( + requested: str | FormatId | Mapping[str, Any], + supported: str | FormatId | Mapping[str, Any], + *, + default_agent_url: str = CANONICAL_CREATIVE_AGENT_URL, +) -> bool: + """Return true when ``requested`` is acceptable for ``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``). + """ + req = upgrade_legacy_format_id(requested, default_agent_url=default_agent_url) + sup = upgrade_legacy_format_id(supported, default_agent_url=default_agent_url) + if not formats_are_equivalent(req, sup, default_agent_url=default_agent_url): + return False + + for field in ("width", "height", "duration_ms"): + supported_value = getattr(sup, field) + if supported_value is None: + continue + if getattr(req, field) != supported_value: + return False + return True + + +__all__ = [ + "CANONICAL_CREATIVE_AGENT_URL", + "format_is_supported", + "formats_are_equivalent", + "upgrade_legacy_format_id", +] diff --git a/src/adcp/canonical_formats/format_options.py b/src/adcp/canonical_formats/format_options.py index 1f1654ea..6dc0d519 100644 --- a/src/adcp/canonical_formats/format_options.py +++ b/src/adcp/canonical_formats/format_options.py @@ -24,44 +24,10 @@ from __future__ import annotations from collections.abc import Iterable -from urllib.parse import urlsplit, urlunsplit +from adcp.canonical_formats.identity import canonicalize_agent_url from adcp.types import CanonicalFormatKind, Error, FormatId, ProductFormatDeclaration -# Default ports per RFC 3986 §3.2.3 — stripped during canonicalization -# so ``https://x.example:443`` matches ``https://x.example``. -_DEFAULT_PORTS: dict[str, int] = {"http": 80, "https": 443} - - -def _canonicalize_agent_url(raw: str) -> str: - """Return ``raw`` with scheme + host lowercased and default port stripped. - - Per ``core/format-id.json`` (normative): callers MUST canonicalize - ``agent_url`` before comparing two ``FormatId`` values for identity. - Pydantic's ``AnyUrl`` does trailing-slash normalization but not - RFC 3986 §6 host-casefolding or default-port stripping — a seller - publishing ``"https://Creative.AdContextProtocol.org"`` would - silently miss-match a buyer's ``"https://creative.adcontextprotocol.org"`` - without this step. - - Non-throwing: malformed inputs round-trip as-is. The lookup is a - closed-set match, not a security check; we don't want to reject - here, just normalize what we can. - """ - try: - parts = urlsplit(raw) - except ValueError: - return raw - if not parts.scheme or not parts.hostname: - return raw - scheme = parts.scheme.lower() - host = parts.hostname.lower() - port = parts.port - if port is not None and port == _DEFAULT_PORTS.get(scheme): - port = None - netloc = host if port is None else f"{host}:{port}" - return urlunsplit((scheme, netloc, parts.path, parts.query, "")) - class FormatKindNotInClosedSetError(ValueError): """Raised when a ``format_kind`` is not in the product's ``format_options[]``. @@ -205,14 +171,18 @@ def find_declaration_by_v1_format_id( The matching declaration, or ``None`` when no declaration in the closed set asserts this v1 ref. ``None`` means the request should be rejected with ``UNSUPPORTED_FEATURE`` — the v1 - ``format_id`` is not a recognised entry for this product. + ``format_id`` is not a recognised entry for this product. If a + caller expected a legacy ID such as ``display_300x250`` to match a + parameterized canonical ref, use ``formats_are_equivalent`` or + ``format_is_supported`` from :mod:`adcp.canonical_formats` instead of + this closed-set v1 reference lookup. """ - target_url = _canonicalize_agent_url(str(format_id.agent_url)) + target_url = canonicalize_agent_url(format_id.agent_url) target_id = format_id.id for decl in format_options: refs = decl.v1_format_ref or [] for ref in refs: - ref_url = _canonicalize_agent_url(str(ref.agent_url)) + ref_url = canonicalize_agent_url(ref.agent_url) if ref_url == target_url and ref.id == target_id: return decl return None diff --git a/src/adcp/canonical_formats/identity.py b/src/adcp/canonical_formats/identity.py new file mode 100644 index 00000000..3fc84729 --- /dev/null +++ b/src/adcp/canonical_formats/identity.py @@ -0,0 +1,37 @@ +"""Format identity normalization helpers.""" + +from __future__ import annotations + +from urllib.parse import urlsplit, urlunsplit + +# Default ports per RFC 3986 §3.2.3 — stripped during canonicalization +# so ``https://x.example:443`` matches ``https://x.example``. +_DEFAULT_PORTS: dict[str, int] = {"http": 80, "https": 443} + + +def canonicalize_agent_url(raw: object) -> str: + """Return ``raw`` with scheme + host lowercased and default port stripped. + + Per ``core/format-id.json`` (normative): callers MUST canonicalize + ``agent_url`` before comparing two ``FormatId`` values for identity. + Pydantic's ``AnyUrl`` does trailing-slash normalization but not + RFC 3986 §6 host-casefolding or default-port stripping. + + Non-throwing: malformed inputs round-trip as-is. Identity comparison + should normalize what it can without turning lookup helpers into URL + validators. + """ + text = str(raw) + try: + parts = urlsplit(text) + except ValueError: + return text + if not parts.scheme or not parts.hostname: + return text + scheme = parts.scheme.lower() + host = parts.hostname.lower() + port = parts.port + if port is not None and port == _DEFAULT_PORTS.get(scheme): + port = None + netloc = host if port is None else f"{host}:{port}" + return urlunsplit((scheme, netloc, parts.path, parts.query, "")) diff --git a/src/adcp/decisioning/pg/proposal_store.py b/src/adcp/decisioning/pg/proposal_store.py index bc2d1332..69a13200 100644 --- a/src/adcp/decisioning/pg/proposal_store.py +++ b/src/adcp/decisioning/pg/proposal_store.py @@ -690,7 +690,7 @@ async def try_reserve_consumption( raise AdcpError( "PROPOSAL_NOT_FOUND", message=(f"Proposal {proposal_id!r} not found."), - recovery="terminal", + recovery="correctable", field="proposal_id", ) ( diff --git a/src/adcp/decisioning/proposal_dispatch.py b/src/adcp/decisioning/proposal_dispatch.py index eb6e96ad..6b01177a 100644 --- a/src/adcp/decisioning/proposal_dispatch.py +++ b/src/adcp/decisioning/proposal_dispatch.py @@ -204,7 +204,7 @@ async def maybe_intercept_finalize( "get_products with buying_mode='brief' or 'refine' to " "obtain a draft proposal_id before finalizing it." ), - recovery="terminal", + recovery="correctable", field=field_path, ) # Finalize requires a draft; finalizing an already-committed proposal diff --git a/src/adcp/decisioning/proposal_lifecycle.py b/src/adcp/decisioning/proposal_lifecycle.py index 6c0c7a1a..5fe982c0 100644 --- a/src/adcp/decisioning/proposal_lifecycle.py +++ b/src/adcp/decisioning/proposal_lifecycle.py @@ -68,7 +68,7 @@ async def enforce_proposal_expiry( Three failure modes mapped to spec error codes: * Record not found OR cross-tenant → ``PROPOSAL_NOT_FOUND`` - (recovery=terminal). The dispatch path supplies + (recovery=correctable). The dispatch path supplies ``expected_account_id`` from the authenticated principal so cross-tenant probes return the same error as missing IDs (no principal-enumeration via id probing). @@ -110,7 +110,7 @@ async def enforce_proposal_expiry( "refine=[{action:'finalize',...}] to obtain a committed " "proposal_id before referencing it on create_media_buy." ), - recovery="terminal", + recovery="correctable", field="proposal_id", ) if record.state != ProposalState.COMMITTED: diff --git a/src/adcp/decisioning/proposal_store.py b/src/adcp/decisioning/proposal_store.py index 8b62c916..a1ca68a6 100644 --- a/src/adcp/decisioning/proposal_store.py +++ b/src/adcp/decisioning/proposal_store.py @@ -561,7 +561,7 @@ async def try_reserve_consumption( raise AdcpError( "PROPOSAL_NOT_FOUND", message=(f"Proposal {proposal_id!r} not found."), - recovery="terminal", + recovery="correctable", field="proposal_id", ) if record.state != ProposalState.COMMITTED: diff --git a/src/adcp/server/helpers.py b/src/adcp/server/helpers.py index 648c339d..9ed63364 100644 --- a/src/adcp/server/helpers.py +++ b/src/adcp/server/helpers.py @@ -31,6 +31,7 @@ "VALIDATION_ERROR": {"recovery": "correctable", "message": "Request validation failed"}, "POLICY_VIOLATION": {"recovery": "correctable", "message": "Policy violation"}, "PRODUCT_NOT_FOUND": {"recovery": "correctable", "message": "Product not found"}, + "PROPOSAL_NOT_FOUND": {"recovery": "correctable", "message": "Proposal not found"}, "PRODUCT_UNAVAILABLE": {"recovery": "correctable", "message": "Product unavailable"}, "PRODUCT_EXPIRED": {"recovery": "correctable", "message": "Product expired"}, "PROPOSAL_EXPIRED": {"recovery": "correctable", "message": "Proposal expired"}, diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index 529776cb..6ee8c728 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -386,6 +386,8 @@ def products_response( # Media Buy Operations # ============================================================================ +_UNSET = object() + def media_buy_response( media_buy_id: str, @@ -395,7 +397,7 @@ def media_buy_response( status: str | None = None, valid_actions: list[str] | None = None, revision: int | None = None, - confirmed_at: str | None = None, + confirmed_at: str | None | object = _UNSET, adcp_version: str | None = None, sandbox: bool = True, ) -> dict[str, Any]: @@ -405,19 +407,37 @@ def media_buy_response( Matches CreateMediaBuyResponse1 (success) schema. Auto-populates valid_actions from status if not provided. - Auto-sets revision to 1 and confirmed_at to now if not provided. + Auto-sets revision to 1 and confirmed_at to now if omitted. Pass + ``confirmed_at=None`` explicitly only on AdCP 3.1+ shapes when the + commitment timestamp is unavailable; pre-confirmation workflows such as + IO signing or governance review should use the submitted task envelope + rather than a synchronous media-buy success. Treat + ``revision`` as the optimistic-concurrency token clients pass back on + ``update_media_buy``. Treat ``confirmed_at`` as the seller commitment + timestamp: once a buy is confirmed, pass the original value when rebuilding + later create/get-style media-buy objects rather than stamping the current + lifecycle transition time. + ``confirmed_at=None`` is only schema-valid for AdCP 3.1+ response shapes; + when ``adcp_version="3.0"`` is requested this helper raises ``ValueError`` + rather than emitting 3.0-invalid ``null``. Pass ``adcp_version="3.0"`` for the 3.0 top-level lifecycle status shape, or an exact 3.1+ supported version for the task-envelope shape (``status="completed"`` plus ``media_buy_status``). When omitted, the dispatcher projects by the buyer's requested version. """ + if adcp_version is not None and not _is_adcp_31_or_newer(adcp_version) and confirmed_at is None: + raise ValueError("confirmed_at=None is not valid for AdCP 3.0 media_buy_response") + resp: dict[str, Any] = { "media_buy_id": media_buy_id, "packages": _serialize(packages), "revision": revision if revision is not None else 1, - "confirmed_at": confirmed_at or _rfc3339_now(), "sandbox": sandbox, } + if confirmed_at is _UNSET: + resp["confirmed_at"] = _rfc3339_now() + else: + resp["confirmed_at"] = confirmed_at if buyer_ref is not None: resp["buyer_ref"] = buyer_ref if status is not None: @@ -458,7 +478,9 @@ def update_media_buy_response( """Build an update_media_buy success response. Matches UpdateMediaBuyResponse1 (success) schema. - Auto-populates valid_actions from status if not provided. + Auto-populates valid_actions from status if not provided. ``revision`` is + the new optimistic-concurrency token after the update; clients should use + it on their next mutating ``update_media_buy`` call. Pass ``adcp_version="3.0"`` for the 3.0 top-level lifecycle status shape, or an exact 3.1+ supported version for the task-envelope shape (``status="completed"`` plus ``media_buy_status``). When omitted, the diff --git a/src/adcp/types/generated_poc/bundled/core/tasks_get_response.py b/src/adcp/types/generated_poc/bundled/core/tasks_get_response.py index c116f78c..c7d726ad 100644 --- a/src/adcp/types/generated_poc/bundled/core/tasks_get_response.py +++ b/src/adcp/types/generated_poc/bundled/core/tasks_get_response.py @@ -52785,7 +52785,7 @@ class Result557(AdCPBaseModel): confirmed_at: Annotated[ AwareDatetime | None, Field( - description='ISO 8601 timestamp when this media buy was confirmed by the seller. A successful create_media_buy response constitutes order confirmation.' + description="Seller commitment timestamp for this media buy. This is the time the seller confirmed the order, not a delivery-state timestamp; once set it remains stable across pause, resume, budget, and package updates. Pending/manual approval flows may leave it null until seller commitment happens." ), ] = None creative_deadline: Annotated[ @@ -52794,7 +52794,7 @@ class Result557(AdCPBaseModel): revision: Annotated[ int | None, Field( - description='Initial revision number for this media buy. Use in subsequent update_media_buy requests for optimistic concurrency.', + description='Initial optimistic-concurrency revision for this media buy, usually 1 when the seller mints the buy synchronously. Clients should pass the last observed revision on update_media_buy.', ge=1, ), ] = None @@ -55036,7 +55036,7 @@ class Result568(AdCPBaseModel): revision: Annotated[ int | None, Field( - description='Revision number after this update. Use this value in subsequent update_media_buy requests for optimistic concurrency.', + description='Optimistic-concurrency revision after this mutating update. Use this new value in the next update_media_buy request; reload the media buy and retry if a seller rejects an update because the supplied revision is stale.', ge=1, ), ] = None diff --git a/src/adcp/types/generated_poc/bundled/media_buy/get_media_buys_response.py b/src/adcp/types/generated_poc/bundled/media_buy/get_media_buys_response.py index 7d93bfd5..41416548 100644 --- a/src/adcp/types/generated_poc/bundled/media_buy/get_media_buys_response.py +++ b/src/adcp/types/generated_poc/bundled/media_buy/get_media_buys_response.py @@ -3396,7 +3396,7 @@ class MediaBuy(AdCPBaseModel): confirmed_at: Annotated[ AwareDatetime | None, Field( - description='ISO 8601 timestamp when the seller confirmed this media buy. A successful create_media_buy response constitutes order confirmation.' + description="Seller commitment timestamp for this media buy. This is the time the seller confirmed the order, not a delivery-state timestamp; once set it remains stable across pause, resume, budget, and package updates. Pending/manual approval flows may leave it null until seller commitment happens." ), ] = None cancellation: Annotated[ @@ -3406,7 +3406,7 @@ class MediaBuy(AdCPBaseModel): revision: Annotated[ int | None, Field( - description='Current revision number. Pass this in update_media_buy for optimistic concurrency.', + description='Current optimistic-concurrency revision for this media buy. Pass this value in update_media_buy; reload and retry if the seller reports a stale revision conflict.', ge=1, ), ] = None diff --git a/src/adcp/types/generated_poc/media_buy/create_media_buy_response.py b/src/adcp/types/generated_poc/media_buy/create_media_buy_response.py index 4c30fff3..2a93bca0 100644 --- a/src/adcp/types/generated_poc/media_buy/create_media_buy_response.py +++ b/src/adcp/types/generated_poc/media_buy/create_media_buy_response.py @@ -4,15 +4,12 @@ from __future__ import annotations -from ..core.version_envelope import AdcpVersionEnvelope - - # Backward-compatible SDK response arms. Upstream beta 3 schemas collapse this # task response to the common protocol envelope, but the Python SDK keeps the # historical numbered variants as ergonomic construction/parsing aliases. -from typing import Any, Literal, TypeAlias +from typing import Annotated, Any, Literal, TypeAlias -from pydantic import ConfigDict, model_validator +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from adcp.types.media_buy_status_helpers import MEDIA_BUY_LEGACY_STATUS_VALUES, unwrap_enum_value @@ -20,9 +17,11 @@ from ..core import ext as ext_1 from ..core import package as package_1 from ..core.protocol_envelope import ProtocolEnvelope +from ..core.version_envelope import AdcpVersionEnvelope from ..enums import media_buy_status as media_buy_status_1 from ..enums import task_status as task_status_1 + class CreateMediaBuyResponse1(AdcpVersionEnvelope): model_config = ConfigDict(extra='allow') media_buy_id: str @@ -30,6 +29,29 @@ class CreateMediaBuyResponse1(AdcpVersionEnvelope): buyer_ref: str | None = None media_buy_status: media_buy_status_1.MediaBuyStatus | None = None status: Literal["completed"] + confirmed_at: Annotated[ + AwareDatetime | None, + Field( + description=( + "Seller commitment timestamp for this media buy. This is the " + "time the seller confirmed the order, not a delivery-state " + "timestamp; once set it should remain stable across pause, " + "resume, budget, and package updates. Pending/manual approval " + "flows may leave it null until seller commitment happens." + ) + ), + ] = None + revision: Annotated[ + int | None, + Field( + description=( + "Initial optimistic-concurrency revision for this media buy, " + "usually 1 when the seller mints the buy synchronously. Clients " + "should pass the last observed revision on update_media_buy." + ), + ge=1, + ), + ] = None @model_validator(mode='before') @classmethod 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 2ed42e1a..ef201589 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 @@ -337,7 +337,13 @@ class MediaBuy(AdCPBaseModel): confirmed_at: Annotated[ AwareDatetime | None, Field( - description='ISO 8601 timestamp when the seller confirmed this media buy. A successful create_media_buy response constitutes order confirmation.' + description=( + "Seller commitment timestamp for this media buy. This is the " + "time the seller confirmed the order, not a delivery-state " + "timestamp; once set it remains stable across pause, resume, " + "budget, and package updates. Pending/manual approval flows " + "may leave it null until seller commitment happens." + ) ), ] = None cancellation: Annotated[ @@ -347,7 +353,11 @@ class MediaBuy(AdCPBaseModel): revision: Annotated[ int | None, Field( - description='Current revision number. Pass this in update_media_buy for optimistic concurrency.', + description=( + "Current optimistic-concurrency revision for this media buy. " + "Pass this value in update_media_buy; reload and retry if the " + "seller reports a stale revision conflict." + ), ge=1, ), ] = None 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 b15c1477..c029f955 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 @@ -4,16 +4,13 @@ from __future__ import annotations -from ..core.version_envelope import AdcpVersionEnvelope - - # Backward-compatible SDK response arms. Upstream beta 3 schemas collapse this # task response to the common protocol envelope, but the Python SDK keeps the # historical numbered variants as ergonomic construction/parsing aliases. from collections.abc import Sequence -from typing import Any, Literal, TypeAlias +from typing import Annotated, Any, Literal, TypeAlias -from pydantic import ConfigDict, model_validator +from pydantic import ConfigDict, Field, model_validator from adcp.types.media_buy_status_helpers import MEDIA_BUY_LEGACY_STATUS_VALUES, unwrap_enum_value @@ -21,6 +18,7 @@ from ..core import ext as ext_1 from ..core import package as package_1 from ..core.protocol_envelope import ProtocolEnvelope +from ..core.version_envelope import AdcpVersionEnvelope from ..enums import media_buy_status as media_buy_status_1 from ..enums import task_status as task_status_1 @@ -33,6 +31,18 @@ class UpdateMediaBuyResponse1(AdcpVersionEnvelope): buyer_ref: str | None = None media_buy_status: media_buy_status_1.MediaBuyStatus | None = None status: Literal["completed"] + revision: Annotated[ + int | None, + Field( + description=( + "Optimistic-concurrency revision after this mutating update. " + "Use this new value in the next update_media_buy request; " + "reload the media buy and retry if a seller rejects an update " + "because the supplied revision is stale." + ), + ge=1, + ), + ] = None @model_validator(mode='before') @classmethod diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 05a3bb71..b298aa4d 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -421,6 +421,8 @@ "fetch_agent_authorizations", "fetch_agent_authorizations_from_directory", "filter_revoked_selectors", + "format_is_supported", + "formats_are_equivalent", "generate_webhook_challenge_value", "generate_webhook_idempotency_key", "generated", @@ -447,6 +449,7 @@ "test_agent_client", "test_agent_no_auth", "to_wire_dict", + "upgrade_legacy_format_id", "uses_deprecated_assets_field", "validate_adagents", "validate_adagents_domain", diff --git a/tests/test_canonical_formats_compatibility.py b/tests/test_canonical_formats_compatibility.py new file mode 100644 index 00000000..09c018b4 --- /dev/null +++ b/tests/test_canonical_formats_compatibility.py @@ -0,0 +1,112 @@ +"""Legacy/canonical creative format compatibility helper behaviour.""" + +from __future__ import annotations + +from adcp.canonical_formats import ( + CANONICAL_CREATIVE_AGENT_URL, + format_is_supported, + formats_are_equivalent, + upgrade_legacy_format_id, +) +from adcp.types import FormatId + + +def test_upgrade_legacy_display_size_to_parameterized_canonical_format_id() -> None: + upgraded = upgrade_legacy_format_id("display_300x250") + + assert upgraded == FormatId( + agent_url=CANONICAL_CREATIVE_AGENT_URL, + id="display_image", + width=300, + height=250, + ) + + +def test_formats_are_equivalent_matches_legacy_display_against_structured_canonical() -> None: + structured = { + "agent_url": CANONICAL_CREATIVE_AGENT_URL, + "id": "display_image", + "width": 300, + "height": 250, + } + + assert formats_are_equivalent("display_300x250", structured) + + +def test_formats_are_equivalent_rejects_conflicting_dimensions() -> None: + assert not formats_are_equivalent( + "display_300x250", + { + "agent_url": CANONICAL_CREATIVE_AGENT_URL, + "id": "display_image", + "width": 728, + "height": 90, + }, + ) + + +def test_format_is_supported_rejects_under_specified_request_for_fixed_product() -> None: + requested = { + "agent_url": CANONICAL_CREATIVE_AGENT_URL, + "id": "display_image", + } + supported = { + "agent_url": CANONICAL_CREATIVE_AGENT_URL, + "id": "display_image", + "width": 300, + "height": 250, + } + + assert formats_are_equivalent(requested, supported) + assert not format_is_supported(requested, supported) + + +def test_format_is_supported_allows_specific_request_for_broad_product() -> None: + requested = "display_300x250" + supported = { + "agent_url": CANONICAL_CREATIVE_AGENT_URL, + "id": "display_image", + } + + assert format_is_supported(requested, supported) + + +def test_canonical_format_helpers_canonicalize_agent_url_case_and_default_port() -> None: + seller = { + "agent_url": "https://Creative.AdContextProtocol.org:443/", + "id": "display_image", + "width": 300, + "height": 250, + } + + assert formats_are_equivalent("display_300x250", seller) + + +def test_canonical_format_helpers_keep_path_trailing_slash_significant() -> None: + without_slash = { + "agent_url": "https://seller.example/formats", + "id": "native_card", + } + with_slash = { + "agent_url": "https://seller.example/formats/", + "id": "native_card", + } + + assert not formats_are_equivalent(without_slash, with_slash) + + +def test_upgrade_legacy_display_size_does_not_rewrite_seller_owned_namespace() -> None: + seller_owned = FormatId(agent_url="https://seller.example/formats", id="display_300x250") + + upgraded = upgrade_legacy_format_id(seller_owned) + + assert upgraded == seller_owned + assert not formats_are_equivalent( + seller_owned, + { + "agent_url": CANONICAL_CREATIVE_AGENT_URL, + "id": "display_image", + "width": 300, + "height": 250, + }, + ) diff --git a/tests/test_proposal_lifecycle.py b/tests/test_proposal_lifecycle.py index f66e86f0..e8046240 100644 --- a/tests/test_proposal_lifecycle.py +++ b/tests/test_proposal_lifecycle.py @@ -3,7 +3,7 @@ Covers: * enforce_proposal_expiry (D7) — three failure modes: - - missing record → PROPOSAL_NOT_FOUND, recovery=terminal + - missing record → PROPOSAL_NOT_FOUND, recovery=correctable - cross-tenant probe → PROPOSAL_NOT_FOUND (not the raw record) - state != COMMITTED → PROPOSAL_NOT_COMMITTED, recovery=correctable - now > expires_at + grace → PROPOSAL_EXPIRED, recovery=terminal @@ -68,7 +68,7 @@ async def test_expiry_unknown_proposal_raises_not_found() -> None: expected_account_id="acct_a", ) assert exc.value.code == "PROPOSAL_NOT_FOUND" - assert exc.value.recovery == "terminal" + assert exc.value.recovery == "correctable" assert exc.value.field == "proposal_id" @@ -91,6 +91,7 @@ async def test_expiry_cross_tenant_returns_not_found() -> None: expected_account_id="acct_OTHER", ) assert exc.value.code == "PROPOSAL_NOT_FOUND" + assert exc.value.recovery == "correctable" @pytest.mark.asyncio diff --git a/tests/test_proposal_lifecycle_e2e.py b/tests/test_proposal_lifecycle_e2e.py index 10b3973e..bf14f782 100644 --- a/tests/test_proposal_lifecycle_e2e.py +++ b/tests/test_proposal_lifecycle_e2e.py @@ -194,6 +194,35 @@ async def test_refine_overwrites_draft( assert record.state == ProposalState.DRAFT +@pytest.mark.asyncio +async def test_refine_unknown_proposal_is_correctable_not_found( + handler: PlatformHandler, +) -> None: + """Storyboard requires unknown proposal refine references to classify as + buyer-correctable, not as internal persistence failures.""" + from adcp.types import GetProductsRequest + + refine_req = GetProductsRequest.model_validate( + { + "buying_mode": "refine", + "refine": [ + { + "scope": "proposal", + "proposal_id": "prop_unknown_proposal_not_found", + "ask": "Refine a proposal that does not exist.", + }, + ], + } + ) + + with pytest.raises(AdcpError) as exc: + await handler.get_products(refine_req, ToolContext()) + + assert exc.value.code == "PROPOSAL_NOT_FOUND" + assert exc.value.recovery == "correctable" + assert exc.value.field == "refine[0].proposal_id" + + # --------------------------------------------------------------------------- # Phase 3: finalize → framework intercepts; manager.finalize_proposal commits # --------------------------------------------------------------------------- diff --git a/tests/test_proposal_store.py b/tests/test_proposal_store.py index 7f5fd3b6..d3e5c001 100644 --- a/tests/test_proposal_store.py +++ b/tests/test_proposal_store.py @@ -680,7 +680,7 @@ def commit(self, proposal_id, *, expires_at, proposal_payload, expected_account_ def try_reserve_consumption(self, proposal_id, *, expected_account_id): record = self._records.get(proposal_id) if record is None or record.account_id != expected_account_id: - raise AdcpError("PROPOSAL_NOT_FOUND", message="not found", recovery="terminal") + raise AdcpError("PROPOSAL_NOT_FOUND", message="not found", recovery="correctable") return record def finalize_consumption(self, proposal_id, *, media_buy_id, expected_account_id): diff --git a/tests/test_server_dx.py b/tests/test_server_dx.py index 855a45e3..ab516842 100644 --- a/tests/test_server_dx.py +++ b/tests/test_server_dx.py @@ -218,6 +218,20 @@ def test_typed_success_does_not_infer_completed_lifecycle(self): assert enum_result.status == "completed" assert enum_result.media_buy_status is None + def test_typed_success_exposes_revision_and_confirmed_at_semantics(self): + from adcp.types import CreateMediaBuySuccessResponse + + result = CreateMediaBuySuccessResponse( + media_buy_id="mb-123", + packages=[], + revision=1, + confirmed_at="2026-05-27T12:00:00Z", + ) + + assert result.revision == 1 + assert result.confirmed_at is not None + assert result.confirmed_at.isoformat() == "2026-05-27T12:00:00+00:00" + class TestMediaBuyErrorResponse: def test_basic(self): @@ -289,6 +303,14 @@ def test_typed_success_does_not_infer_completed_lifecycle(self): assert enum_result.status == "completed" assert enum_result.media_buy_status is None + def test_typed_success_exposes_new_revision(self): + from adcp.types import UpdateMediaBuySuccessResponse + + result = UpdateMediaBuySuccessResponse(media_buy_id="mb-123", revision=2) + + assert result.status == "completed" + assert result.revision == 2 + def test_typed_submitted_response(self): from adcp import UpdateMediaBuyResponse3, UpdateMediaBuySubmittedResponse @@ -319,6 +341,49 @@ def test_basic(self): assert result["media_buys"] == buys assert result["sandbox"] is True + def test_confirmed_at_is_preserved_when_rebuilding_media_buy_snapshot(self): + created = media_buy_response( + "mb-1", + [], + status="active", + revision=1, + confirmed_at="2026-05-27T12:00:00Z", + ) + + updated = media_buy_response( + "mb-1", + [], + status="paused", + revision=2, + confirmed_at=created["confirmed_at"], + ) + + assert updated["revision"] == 2 + assert updated["confirmed_at"] == "2026-05-27T12:00:00Z" + + def test_media_buy_response_allows_explicit_null_confirmed_at_for_31_shape(self): + response = media_buy_response( + "mb-1", + [], + status="active", + confirmed_at=None, + adcp_version="3.1.0-beta.4", + ) + + assert response["confirmed_at"] is None + assert response["status"] == "completed" + assert response["media_buy_status"] == "active" + + def test_media_buy_response_rejects_null_confirmed_at_for_adcp_30_shape(self): + with pytest.raises(ValueError, match="confirmed_at=None is not valid for AdCP 3.0"): + media_buy_response( + "mb-1", + [], + status="active", + confirmed_at=None, + adcp_version="3.0", + ) + class TestDeliveryResponse: def test_spec_shape(self):