From 5748992a34880e9861f9ef613428e3e560dce89d Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 29 Aug 2026 00:26:06 +0200 Subject: [PATCH 1/2] feat(media-buy): add action rights assessment --- README.md | 1 + docs/media-buy-action-rights.md | 106 ++ examples/seller_agent.py | 158 +- scripts/post_generate_fixes.py | 180 ++ src/adcp/__init__.py | 120 ++ src/adcp/decisioning/update_media_buy.py | 175 +- src/adcp/media_buy_actions.py | 1445 +++++++++++++++++ src/adcp/server/responses.py | 1 + src/adcp/types/__init__.py | 32 + src/adcp/types/_eager.py | 32 + src/adcp/types/aliases.py | 16 + .../core/canonical_media_buy_action.py | 6 +- .../generated_poc/media_buy/change_term.py | 21 +- .../media_buy/change_term_constraints.py | 30 +- .../media_buy/commercial_terms.py | 50 +- src/adcp/types/media_buy.py | 32 + .../fixtures/media_buy_action_assessment.json | 119 ++ tests/fixtures/public_api_snapshot.json | 54 + tests/test_media_buy_actions.py | 867 ++++++++++ tests/test_seller_agent_storyboard.py | 155 ++ tests/test_server_dx.py | 1 + tests/test_update_media_buy_decompose.py | 70 +- tests/type_checks/media_buy_actions.py | 87 + 23 files changed, 3733 insertions(+), 25 deletions(-) create mode 100644 docs/media-buy-action-rights.md create mode 100644 src/adcp/media_buy_actions.py create mode 100644 tests/fixtures/media_buy_action_assessment.json create mode 100644 tests/test_media_buy_actions.py create mode 100644 tests/type_checks/media_buy_actions.py diff --git a/README.md b/README.md index 490967f5d..6a89e7f39 100644 --- a/README.md +++ b/README.md @@ -358,6 +358,7 @@ forward traffic degrades gracefully rather than failing. - **[Migrating from SDK 7 to 8](https://github.com/adcontextprotocol/adcp-client-python/blob/main/MIGRATION_v7_to_v8.md)** - Secure webhook defaults and telemetry changes - **[Migrating from AdCP 3.1 to 3.2 beta](MIGRATION_ADCP_3.1_TO_3.2.md)** - Compact lifecycle adoption and old/new compatibility matrix - **[Durable legacy purchase continuations](docs/legacy-purchase-continuations.md)** - Safe products-only compatibility redemption and crash recovery +- **[Media-buy action rights](docs/media-buy-action-rights.md)** - Assess product possibilities, accepted change rights, and currently available actions - **[Testing your AdCP server](docs/testing-your-adcp-server.md)** - In-process harness for unit tests plus storyboard-runner compliance grading - **[Universal macro translation](docs/universal-macro-translation.md)** - Producer-side pixel URL translation, trust boundary, and diagnostics - **[Multi-tenant contract](docs/multi-tenant-contract.md)** - Scope invariants every multi-tenant agent must satisfy diff --git a/docs/media-buy-action-rights.md b/docs/media-buy-action-rights.md new file mode 100644 index 000000000..c82e8d9da --- /dev/null +++ b/docs/media-buy-action-rights.md @@ -0,0 +1,106 @@ +# Media-buy action rights + +AdCP exposes three related action surfaces with different authority: + +1. `Product.allowed_actions` says what a product may support. It is advisory. +2. `Proposal.commercial_terms.change_terms` records the rights accepted in a deal. +3. `MediaBuy.available_actions` says which accepted rights are executable now. + +The SDK joins those surfaces without promoting product templates or legacy +compatibility fields into authority. + +## Buyer assessment + +```python +from adcp import assess_media_buy_action + +assessment = assess_media_buy_action( + "increase_budget", + product=product, + proposal=accepted_proposal, + media_buy=current_media_buy, + intent={ + "current_amount": "1000", + "result_amount": "1100", + "currency": "USD", + }, +) + +if assessment.status == "available_now": + print(assessment.task, assessment.mode) +else: + print(assessment.possible, assessment.promised, assessment.available) +``` + +The status is one of `available_now`, `wrong_status`, `not_negotiated`, +`unsupported_by_product`, `currently_unavailable`, or `legacy_unknown`. +Portable budget, flight, package-count, and effective-time bounds are checked +when the caller supplies enough current/result state. Opaque condition IDs and +contract references are never executed or interpreted by the SDK. + +For deprecated `update_media_buy` patches, +`assess_update_media_buy_actions()` first decomposes the patch into canonical +actions and then applies the same checks. Fine-grained beta.9 actions retain +coarse 3.x candidates so older seller declarations remain readable without +expanding authority. + +## Routing and races + +`route_media_buy_action()` selects the normal compact task. Operational +controls use `control_media_buy`, commercial amendments use +`refine_proposals`, and creative mutations use `sync_creatives`. Some actions +are valid through either control or refinement; an authoritative live action's +explicit `task` wins when the protocol permits it. + +`dispatch_media_buy_action()` is asynchronous and accepts an already validated +assessment plus the generated request model for that task. A +`seller_managed` action uses the ordinary asynchronous task lifecycle; it does +not introduce a seller-review MediaBuy status. + +Always send the latest MediaBuy `revision` and an idempotency key. If an +`ACTION_NOT_ALLOWED` race returns `currently_available_actions`, pass that echo +to `reassess_media_buy_action()` for an immediate explanation, then refresh the +full MediaBuy before retrying with its new revision. + +## Seller materialization and projection + +```python +from adcp import ChangeTermSelection, materialize_change_terms + +change_terms = materialize_change_terms( + product.allowed_actions, + [ + ChangeTermSelection( + action="increase_budget", + term_id="right_budget_1", + service_mode="seller_managed", + allowed_statuses=("active",), + ) + ], +) +``` + +Only explicit selections become binding terms. The builder rejects duplicate +actions and term IDs, expanded status scopes, unadvertised modes, and +action/constraint mismatches. + +Use `project_available_actions()` to derive the current surface from accepted +terms. Optional authorization, delegation, seller-policy, product, and +resolved-condition gates only narrow the result. Conditions must be explicitly +resolved to `True`; missing or indeterminate condition state fails closed. + +## Version behavior + +| Wire version | Projection | +|---|---| +| AdCP 3.1.19 | `terms_ref` compatibility alias; no inferred proposal identity | +| Early AdCP 3.2 beta | explicit `task`, legacy `terms_ref`, `requires_approval` compatibility mode | +| AdCP 3.2 beta.9+ | explicit `task`, `seller_managed`, and `change_term_id` | + +An arbitrary inbound 3.1 `terms_ref` remains opaque even when its text matches +a proposal term ID. When a 3.2 payload contains both aliases, unequal values +fail closed. + +The language-neutral fixture at +`tests/fixtures/media_buy_action_assessment.json` defines normalized buyer +results for cross-SDK parity. diff --git a/examples/seller_agent.py b/examples/seller_agent.py index 37daedbd9..3974caf69 100644 --- a/examples/seller_agent.py +++ b/examples/seller_agent.py @@ -21,12 +21,20 @@ from datetime import datetime, timezone from typing import Any -from adcp import Creative, Format, Product +from adcp import ( + ActionAvailabilityStatus, + Creative, + Format, + Product, + assess_update_media_buy_actions, + project_available_actions, +) from adcp.canonical_formats import ( CanonicalFormatLegacyResolutionContext, LegacyFormatConversionContext, migrated_format_option_id, ) +from adcp.decisioning import assert_media_buy_transition from adcp.server import ( INSECURE_ALLOW_ALL, ADCPHandler, @@ -452,6 +460,24 @@ def _resolve_available_actions( return available +def _change_terms_for_buy(media_buy: dict[str, Any]) -> list[dict[str, Any]] | None: + proposal = media_buy.get("accepted_proposal") + if not isinstance(proposal, dict): + return None + commercial_terms = proposal.get("commercial_terms") + if not isinstance(commercial_terms, dict) or "change_terms" not in commercial_terms: + return None + change_terms = commercial_terms.get("change_terms") + return change_terms if isinstance(change_terms, list) else [] + + +def _available_actions_for_buy(media_buy: dict[str, Any]) -> list[dict[str, Any]]: + change_terms = _change_terms_for_buy(media_buy) + if change_terms is not None: + return project_available_actions(change_terms, media_buy["status"]).to_wire() + return _resolve_available_actions(media_buy.get("packages", []), media_buy["status"]) + + def _attempted_action_for_update( params: dict[str, Any], mb: dict[str, Any], @@ -485,13 +511,14 @@ def _action_not_allowed_response( attempted_action: str, reason: str, currently_available_actions: list[dict[str, Any]], + compact: bool = False, ) -> dict[str, Any]: recovery = ( "terminal" if reason in {"not_supported_on_product", "not_supported_on_buy"} else "correctable" ) - return { + response: dict[str, Any] = { "errors": [ { "code": "ACTION_NOT_ALLOWED", @@ -505,6 +532,32 @@ def _action_not_allowed_response( } ] } + if compact: + response["status"] = "failed" + return response + + +def _requote_required_response( + *, + field: str, + change_term_id: str, + constraint: str, +) -> dict[str, Any]: + return { + "status": "failed", + "errors": [ + { + "code": "REQUOTE_REQUIRED", + "message": "Requested change exceeds the accepted commercial envelope", + "recovery": "correctable", + "details": { + "envelope_field": field, + "change_term_id": change_term_id, + "constraint": constraint, + }, + } + ], + } def _products_for_request(params: dict[str, Any]) -> list[dict[str, Any]]: @@ -993,11 +1046,107 @@ async def get_media_buys(self, params: dict[str, Any], context: Any = None) -> d } if mb.get("context") is not None: result["context"] = mb["context"] - if mb.get("available_actions"): - result["available_actions"] = mb["available_actions"] + available_actions = _available_actions_for_buy(mb) + if available_actions: + result["available_actions"] = available_actions + if mb.get("accepted_proposal") is not None: + accepted_proposal = deepcopy(mb["accepted_proposal"]) + result["accepted_proposal"] = accepted_proposal + result["accepted_proposal_id"] = accepted_proposal["proposal_id"] + result["accepted_proposal_terms_digest"] = accepted_proposal["terms_digest"] results.append(result) return media_buys_response(results) + async def control_media_buy( + self, params: dict[str, Any], context: Any = None + ) -> dict[str, Any]: + mb_id = params.get("media_buy_id") + mb = media_buys.get(mb_id) if isinstance(mb_id, str) else None + if mb is None or not isinstance(mb_id, str): + error = adcp_error("MEDIA_BUY_NOT_FOUND", "Media buy not found") + return {"status": "failed", **error} + + revision = mb.get("revision", 1) + if params.get("revision") != revision: + error = adcp_error("CONFLICT", "Revision mismatch - refetch and retry") + return {"status": "failed", **error} + + current = deepcopy(mb) + current["available_actions"] = _available_actions_for_buy(mb) + proposal = mb.get("accepted_proposal") + assessments = assess_update_media_buy_actions( + params, + current, + proposal=proposal, + ) + attempted = assessments[0] if assessments else None + if attempted is None: + error = adcp_error("INVALID_REQUEST", "No supported control field supplied") + return {"status": "failed", **error} + + violated = next( + (check for check in attempted.constraints if check.outcome.value == "violated"), + None, + ) + if violated is not None: + return _requote_required_response( + field=( + "total_budget.amount" + if attempted.action in {"increase_budget", "decrease_budget"} + else violated.field or "control" + ), + change_term_id=attempted.change_term_id or "unknown", + constraint=violated.constraint, + ) + + if attempted.status is not ActionAvailabilityStatus.available_now: + term = next( + ( + value + for value in (_change_terms_for_buy(mb) or []) + if value.get("action") == attempted.action + ), + None, + ) + if ( + term is not None + and term.get("allowed_statuses") + and mb["status"] not in term["allowed_statuses"] + ): + reason = "wrong_status" + elif term is not None and term.get("conditions"): + reason = "condition_unresolved" + else: + reason = "not_supported_on_buy" + return _action_not_allowed_response( + attempted_action=attempted.action, + reason=reason, + currently_available_actions=current["available_actions"], + compact=True, + ) + + if params.get("paused") is True: + assert_media_buy_transition(mb["status"], "paused", media_buy_id=mb_id) + mb["status"] = "paused" + elif params.get("paused") is False: + assert_media_buy_transition(mb["status"], "active", media_buy_id=mb_id) + mb["status"] = "active" + elif params.get("canceled") is True: + assert_media_buy_transition(mb["status"], "canceled", media_buy_id=mb_id) + mb["status"] = "canceled" + if "total_budget" in params: + mb["total_budget"] = deepcopy(params["total_budget"]) + + mb["revision"] = revision + 1 + mb["available_actions"] = _available_actions_for_buy(mb) + return { + "status": "completed", + "media_buy_id": mb_id, + "revision": mb["revision"], + "media_buy_status": mb["status"], + "available_actions": mb["available_actions"], + } + async def update_media_buy(self, params: dict[str, Any], context: Any = None) -> dict[str, Any]: mb_id = params.get("media_buy_id") mb = media_buys.get(mb_id) if mb_id else None @@ -1550,6 +1699,7 @@ async def seed_media_buy( data.setdefault("packages", []) data.setdefault("confirmed_at", _now_z()) data.setdefault("revision", 1) + data["available_actions"] = _available_actions_for_buy(data) media_buys[mb_id] = data return {"media_buy_id": mb_id} diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index 387528bde..eabfd24fa 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -4973,6 +4973,185 @@ def preserve_request_signing_operation_strings() -> None: print(f" {target.relative_to(OUTPUT_DIR)}: preserved request-signing strings") +def enforce_change_term_runtime_constraints() -> None: + """Preserve beta.9 change-right verifier constraints in Pydantic models. + + datamodel-code-generator represents the constraint ``oneOf`` arms but + drops each arm's nested ``anyOf(required=...)`` rule, along with the + schema's ``x-adcp-validation`` cross-field assertions. Restore those + checks so direct Python model construction cannot create a proposal that + the released JSON Schema or seller resolver would reject. + """ + + action_path = OUTPUT_DIR / "core" / "canonical_media_buy_action.py" + if action_path.exists(): + source = action_path.read_text() + for action_type in ("Action", "Action2", "Action3"): + source = source.replace( + f" action: {action_type} | None = None\n", + f" action: {action_type}\n", + ) + action_path.write_text(source) + print(" core/canonical_media_buy_action.py: restored required action fields") + + constraints_path = OUTPUT_DIR / "media_buy" / "change_term_constraints.py" + if constraints_path.exists(): + source = constraints_path.read_text() + source = source.replace( + "from pydantic import AwareDatetime, ConfigDict, Field, RootModel", + "from pydantic import AwareDatetime, ConfigDict, Field, RootModel, model_validator", + ) + required_fields = { + "MediaBuyChangeTermConstraints1": ( + "max_delta_amount", + "max_delta_percent", + "min_result_amount", + "max_result_amount", + ), + "MediaBuyChangeTermConstraints2": ( + "max_change", + "earliest_result", + "latest_result", + "minimum_notice", + ), + "MediaBuyChangeTermConstraints3": ( + "max_additions", + "max_removals", + "max_result_count", + ), + "MediaBuyChangeTermConstraints4": ( + "minimum_notice", + "earliest_effective_at", + "latest_effective_at", + ), + } + for class_name, fields in required_fields.items(): + marker = f"class {class_name}(AdCPBaseModel):" + start = source.find(marker) + if start < 0: + continue + next_class = source.find("\n\nclass ", start + len(marker)) + end = len(source) if next_class < 0 else next_class + block = source[start:end] + if "def _require_portable_bound" in block: + continue + field_tuple = repr(fields) + validator = f""" + + @model_validator(mode='after') + def _require_portable_bound(self) -> {class_name}: + if not any(getattr(self, name) is not None for name in {field_tuple}): + raise ValueError('at least one portable constraint bound is required') + return self +""" + source = source[:end] + validator + source[end:] + constraints_path.write_text(source) + print(" media_buy/change_term_constraints.py: restored anyOf required bounds") + + term_path = OUTPUT_DIR / "media_buy" / "change_term.py" + if term_path.exists(): + source = term_path.read_text() + source = source.replace( + "from pydantic import ConfigDict, Field, RootModel", + "from pydantic import ConfigDict, Field, RootModel, model_validator", + ) + class_start = source.find("class MediaBuyChangeTerm(AdCPBaseModel):") + if class_start >= 0 and "def _validate_constraint_action" not in source[class_start:]: + source = ( + source.rstrip() + + """ + + @model_validator(mode='after') + def _validate_constraint_action(self) -> MediaBuyChangeTerm: + if self.constraints is None: + return self + kind = self.constraints.kind + allowed = { + 'budget': { + 'increase_budget', 'decrease_budget', 'reallocate_budget', + 'update_budget_allocation', 'update_spend_target', + }, + 'flight': {'extend_flight', 'shorten_flight', 'update_flight_dates'}, + 'package_count': {'add_packages', 'remove_packages'}, + 'effective_timing': {'pause', 'resume', 'cancel'}, + } + action = self.action.value + if action not in allowed.get(kind, set()): + raise ValueError('constraint kind is incompatible with action') + return self +""" + + "\n" + ) + term_path.write_text(source) + print(" media_buy/change_term.py: restored constraint/action compatibility") + + terms_path = OUTPUT_DIR / "media_buy" / "commercial_terms.py" + if terms_path.exists(): + source = terms_path.read_text() + source = source.replace( + "from pydantic import AwareDatetime, ConfigDict, Field", + "from pydantic import AwareDatetime, ConfigDict, Field, model_validator", + ) + class_start = source.find("class CommercialTerms(AdCPBaseModel):") + if class_start >= 0 and "def _validate_change_term_set" not in source[class_start:]: + source = ( + source.rstrip() + + """ + + @model_validator(mode='after') + def _validate_change_term_set(self) -> CommercialTerms: + if self.change_terms is None: + return self + actions = [term.action.value for term in self.change_terms] + term_ids = [term.term_id for term in self.change_terms] + if len(set(actions)) != len(actions): + raise ValueError('change_terms must be uniquely keyed by action') + if len(set(term_ids)) != len(term_ids): + raise ValueError('change_terms term_id values must be unique') + currencies = set() + for purchase in self.purchases: + if purchase.pricing is None: + raise ValueError('accepted commercial-term purchases require resolved pricing') + currencies.add(purchase.pricing.currency) + for term in self.change_terms: + if term.constraints is None: + continue + constraint = term.constraints.root + if constraint.kind == 'budget': + money_fields = ( + constraint.max_delta_amount, + constraint.min_result_amount, + constraint.max_result_amount, + ) + if any(money is not None and money.currency not in currencies for money in money_fields): + raise ValueError('change-term monetary constraint currency must match purchases') + if ( + constraint.min_result_amount is not None + and constraint.max_result_amount is not None + and constraint.min_result_amount.amount > constraint.max_result_amount.amount + ): + raise ValueError('change-term minimum result exceeds maximum result') + elif constraint.kind == 'flight': + if ( + constraint.earliest_result is not None + and constraint.latest_result is not None + and constraint.earliest_result > constraint.latest_result + ): + raise ValueError('change-term earliest result exceeds latest result') + elif constraint.kind == 'effective_timing' and ( + constraint.earliest_effective_at is not None + and constraint.latest_effective_at is not None + and constraint.earliest_effective_at > constraint.latest_effective_at + ): + raise ValueError('change-term earliest effective time exceeds latest time') + return self +""" + + "\n" + ) + terms_path.write_text(source) + print(" media_buy/commercial_terms.py: restored change-term set invariants") + + def main(): """Apply all post-generation fixes.""" print("Applying post-generation fixes...") @@ -5007,6 +5186,7 @@ def main(): fix_audience_evidence_attestation_subject, fix_legacy_purchase_accepted_losses, preserve_request_signing_operation_strings, + enforce_change_term_runtime_constraints, inject_literal_discriminator_defaults, widen_extension_point_lists_to_sequence, fix_canceled_literal_defaults, diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 7a9b3b321..fa9c4e70e 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -105,6 +105,30 @@ def _resolve_version() -> str: "DEFAULT_ACCEPTANCE_CATALOG_MAX_BYTES", "DEFAULT_ACCEPTANCE_CATALOG_TIMEOUT_SECONDS", ), + "adcp.media_buy_actions": ( + "ActionAvailabilityStatus", + "ActionDiagnostic", + "ActionDiagnosticCode", + "ActionDispatchClient", + "ActionIntent", + "ActionKnowledge", + "ActionTask", + "ChangeTermSelection", + "ConstraintCheck", + "ConstraintOutcome", + "MediaBuyActionAssessment", + "MediaBuyActionError", + "MediaBuyActionProjection", + "ProjectedMediaBuyAction", + "assess_media_buy_action", + "assess_update_media_buy_actions", + "dispatch_media_buy_action", + "evaluate_action_constraints", + "materialize_change_terms", + "project_available_actions", + "reassess_media_buy_action", + "route_media_buy_action", + ), "adcp.adagents": ( "AdagentsCacheEntry", "AdagentsEntryError", @@ -259,6 +283,22 @@ def _resolve_version() -> str: "AcceptancePolicyProfileIds", "AcceptancePolicyRequirement", "AcceptancePolicyRule", + "CanonicalMediaBuyAction", + "CanonicalMediaBuyActionMode", + "CanonicalMediaBuyActionName", + "CanonicalProductAction", + "CommercialTerms", + "MediaBuyAvailableAction", + "MediaBuyActionMode", + "MediaBuyChangeTerm", + "MediaBuyChangeTermConstraints", + "MediaBuyChangeTermId", + "MediaBuyValidAction", + "BudgetChangeConstraints", + "EffectiveTimingChangeConstraints", + "FlightChangeConstraints", + "PackageCountChangeConstraints", + "ProductAllowedAction", "RegistryAcceptancePolicyProfileReference", "BuyProductsRequest", "BuyProductsResponse", @@ -923,6 +963,46 @@ def get_adcp_version() -> str: "AcceptancePolicyRequirement", "AcceptancePolicyRule", "RegistryAcceptancePolicyProfileReference", + # Proposal-bound MediaBuy action assessment and seller projection + "ActionAvailabilityStatus", + "ActionDiagnostic", + "ActionDiagnosticCode", + "ActionDispatchClient", + "ActionIntent", + "ActionKnowledge", + "ActionTask", + "ChangeTermSelection", + "ConstraintCheck", + "ConstraintOutcome", + "MediaBuyActionAssessment", + "MediaBuyActionError", + "MediaBuyActionProjection", + "ProjectedMediaBuyAction", + "assess_media_buy_action", + "assess_update_media_buy_actions", + "dispatch_media_buy_action", + "evaluate_action_constraints", + "materialize_change_terms", + "project_available_actions", + "reassess_media_buy_action", + "route_media_buy_action", + # Generated beta.9 action-contract models + "CanonicalMediaBuyAction", + "CanonicalMediaBuyActionMode", + "CanonicalMediaBuyActionName", + "CanonicalProductAction", + "CommercialTerms", + "MediaBuyAvailableAction", + "MediaBuyActionMode", + "MediaBuyChangeTerm", + "MediaBuyChangeTermConstraints", + "MediaBuyChangeTermId", + "MediaBuyValidAction", + "BudgetChangeConstraints", + "EffectiveTimingChangeConstraints", + "FlightChangeConstraints", + "PackageCountChangeConstraints", + "ProductAllowedAction", # Buyer OAuth authorization-code helpers "InMemoryPendingOAuthFlowStore", "OAuthAuthorizationError", @@ -1660,6 +1740,30 @@ def get_adcp_version() -> str: FeedStateStore, RefreshResult, ) + from adcp.media_buy_actions import ( # noqa: F401 + ActionAvailabilityStatus, + ActionDiagnostic, + ActionDiagnosticCode, + ActionDispatchClient, + ActionIntent, + ActionKnowledge, + ActionTask, + ChangeTermSelection, + ConstraintCheck, + ConstraintOutcome, + MediaBuyActionAssessment, + MediaBuyActionError, + MediaBuyActionProjection, + ProjectedMediaBuyAction, + assess_media_buy_action, + assess_update_media_buy_actions, + dispatch_media_buy_action, + evaluate_action_constraints, + materialize_change_terms, + project_available_actions, + reassess_media_buy_action, + route_media_buy_action, + ) from adcp.oauth import ( InMemoryPendingOAuthFlowStore, OAuthAuthorizationError, @@ -1769,9 +1873,14 @@ def get_adcp_version() -> str: BrandSource, # Creative Operations BriefAsset, + BudgetChangeConstraints, BuyingMode, BuyProductsRequest, BuyProductsResponse, + CanonicalMediaBuyAction, + CanonicalMediaBuyActionMode, + CanonicalMediaBuyActionName, + CanonicalProductAction, CardAsset, # Catalog types Catalog, @@ -1785,6 +1894,7 @@ def get_adcp_version() -> str: CatalogType, CheckGovernanceRequest, CheckGovernanceResponse, + CommercialTerms, CompatibilityPurchaseCoordinatorInput, ComplyTestControllerRequest, ComplyTestControllerResponse, @@ -1827,6 +1937,7 @@ def get_adcp_version() -> str: DeviceType, DownstreamConnectionRequirement, Duration, + EffectiveTimingChangeConstraints, # Common data types Error, ErrorCode, @@ -1834,6 +1945,7 @@ def get_adcp_version() -> str: ExtensionObject, FeedFormat, FlatRatePricingOption, + FlightChangeConstraints, Format, FormatOptionReference, GeneratedTaskStatus, @@ -1930,10 +2042,16 @@ def get_adcp_version() -> str: MarkdownAsset, McpWebhookPayload, MediaBuy, + MediaBuyActionMode, + MediaBuyAvailableAction, + MediaBuyChangeTerm, + MediaBuyChangeTermConstraints, + MediaBuyChangeTermId, MediaBuyDeliveryStatus, MediaBuyDeliveryWebhookResult, MediaBuyPackage, MediaBuyStatus, + MediaBuyValidAction, MediaChannel, NotificationConfig, OfferingAssetConstraint, @@ -1943,6 +2061,7 @@ def get_adcp_version() -> str: # Format overlays Overlay, Package, + PackageCountChangeConstraints, PackageRequest, PackageSignalTargeting, PackageSignalTargetingGroup, @@ -1958,6 +2077,7 @@ def get_adcp_version() -> str: PricingCurrency, PricingModel, Product, + ProductAllowedAction, ProductFilters, ProductFormatDeclaration, ProductSignalTargetingOption, diff --git a/src/adcp/decisioning/update_media_buy.py b/src/adcp/decisioning/update_media_buy.py index bf26b1372..b23db4aed 100644 --- a/src/adcp/decisioning/update_media_buy.py +++ b/src/adcp/decisioning/update_media_buy.py @@ -74,22 +74,28 @@ def is_allowed_by( } _TOP_LEVEL_KNOWN_MUTATION_FIELDS = { + "bidding", + "budget_allocation", + "budget_cap_timezone", "canceled", "cancellation_reason", + "daily_budget_cap", "end_time", "invoice_recipient", + "name", "new_packages", "packages", + "pacing", "paused", "push_notification_config", "reporting_webhook", "start_time", + "total_budget", } _TOP_LEVEL_UNMAPPED_MUTATION_FIELDS = ( "invoice_recipient", "push_notification_config", - "reporting_webhook", ) _PACKAGE_METADATA_FIELDS = {"context", "ext", "package_id"} @@ -97,11 +103,14 @@ def is_allowed_by( _PACKAGE_KNOWN_MUTATION_FIELDS = { "bid_price", "budget", + "bidding", "canceled", "cancellation_reason", "catalogs", + "catalog_ids", "creative_assignments", "creatives", + "daily_budget_cap", "end_time", "impressions", "keyword_targets_add", @@ -109,18 +118,14 @@ def is_allowed_by( "negative_keywords_add", "negative_keywords_remove", "optimization_goals", + "min_spend_target", "pacing", "paused", "start_time", "targeting_overlay", } -_PACKAGE_COARSE_FIELDS = { - "bid_price", - "catalogs", - "impressions", - "optimization_goals", -} +_PACKAGE_COARSE_FIELDS: frozenset[str] = frozenset() _ACTION_CANDIDATES: dict[str, tuple[str, ...]] = { "add_packages": ("add_packages", "update_packages"), @@ -137,6 +142,13 @@ def is_allowed_by( "shorten_flight": ("shorten_flight", "update_dates", "update_packages"), "sync_creatives": ("sync_creatives", "update_packages"), "update_budget": ("update_budget", "update_packages"), + "update_budget_allocation": ( + "update_budget_allocation", + "update_budget", + "update_packages", + ), + "update_bidding": ("update_bidding", "update_packages"), + "update_catalog_assignments": ("update_catalog_assignments", "update_packages"), "update_creative_assignments": ( "update_creative_assignments", "sync_creatives", @@ -149,8 +161,14 @@ def is_allowed_by( "update_targeting", "update_packages", ), + "update_impression_goal": ("update_impression_goal", "update_packages"), + "update_keywords": ("update_keywords", "update_targeting", "update_packages"), + "update_name": ("update_name",), + "update_optimization_goals": ("update_optimization_goals", "update_packages"), "update_packages": ("update_packages",), "update_pacing": ("update_pacing", "update_packages"), + "update_reporting_webhook": ("update_reporting_webhook",), + "update_spend_target": ("update_spend_target", "update_packages"), "update_targeting": ("update_targeting", "update_packages"), } @@ -174,6 +192,16 @@ def decompose_update_media_buy( mutations: list[UpdateMediaBuyMutation] = [] + if "name" in patch_dict: + mutations.append( + _mutation( + "update_name", + ("name",), + before=current_dict.get("name"), + after=patch_dict["name"], + ) + ) + if patch_dict.get("paused") is True: mutations.append( _mutation( @@ -248,6 +276,59 @@ def decompose_update_media_buy( ) ) + if "total_budget" in patch_dict: + budget_before = current_dict.get("total_budget") + action, resolution = _budget_action(budget_before, patch_dict["total_budget"]) + mutations.append( + _mutation( + action, + ("total_budget",), + before=budget_before, + after=patch_dict["total_budget"], + resolution=resolution, + ) + ) + + if "daily_budget_cap" in patch_dict: + cap_before = current_dict.get("daily_budget_cap") + action, resolution = _budget_cap_action(cap_before, patch_dict["daily_budget_cap"]) + mutations.append( + _mutation( + action, + ("daily_budget_cap",), + before=cap_before, + after=patch_dict["daily_budget_cap"], + resolution=resolution, + ) + ) + + for field_name, action in ( + ("budget_allocation", "update_budget_allocation"), + ("pacing", "update_pacing"), + ("bidding", "update_bidding"), + ("reporting_webhook", "update_reporting_webhook"), + ): + if field_name in patch_dict: + mutations.append( + _mutation( + action, + (field_name,), + before=current_dict.get(field_name), + after=patch_dict[field_name], + raw=patch_dict[field_name], + ) + ) + + if "budget_cap_timezone" in patch_dict: + mutations.append( + _mutation( + "update_pacing", + ("budget_cap_timezone",), + before=current_dict.get("budget_cap_timezone"), + after=patch_dict["budget_cap_timezone"], + ) + ) + packages = _as_sequence_of_mappings(patch_dict.get("packages")) reallocation = _package_budget_reallocation(packages, current_packages) if reallocation is not None: @@ -436,6 +517,20 @@ def _decompose_package_patch( ) ) + if "daily_budget_cap" in package_patch: + cap_before = current_package.get("daily_budget_cap") if current_package else None + action, resolution = _budget_cap_action(cap_before, package_patch["daily_budget_cap"]) + mutations.append( + _mutation( + action, + (_package_path(index, "daily_budget_cap"),), + package_id=package_id, + before=cap_before, + after=package_patch["daily_budget_cap"], + resolution=resolution, + ) + ) + date_fields = tuple( field_name for field_name in ("start_time", "end_time") if field_name in package_patch ) @@ -470,6 +565,54 @@ def _decompose_package_patch( ) ) + if "bidding" in package_patch or "bid_price" in package_patch: + bidding_fields = tuple( + _package_path(index, field_name) + for field_name in ("bidding", "bid_price") + if field_name in package_patch + ) + mutations.append( + _mutation( + "update_bidding", + bidding_fields, + package_id=package_id, + before={ + field_name: current_package[field_name] + for field_name in ("bidding", "bid_price") + if current_package and field_name in current_package + } + or None, + after={ + field_name: package_patch[field_name] + for field_name in ("bidding", "bid_price") + if field_name in package_patch + }, + ) + ) + + for group_fields, action in ( + (("catalog_ids", "catalogs"), "update_catalog_assignments"), + (("optimization_goals",), "update_optimization_goals"), + (("impressions",), "update_impression_goal"), + (("min_spend_target",), "update_spend_target"), + ): + present = tuple(field_name for field_name in group_fields if field_name in package_patch) + if present: + mutations.append( + _mutation( + action, + tuple(_package_path(index, field_name) for field_name in present), + package_id=package_id, + before={ + field_name: current_package[field_name] + for field_name in present + if current_package and field_name in current_package + } + or None, + after={field_name: package_patch[field_name] for field_name in present}, + ) + ) + mutations.extend( _decompose_package_targeting( package_patch, @@ -553,7 +696,7 @@ def _decompose_package_targeting( after = {field_name: package_patch[field_name] for field_name in incremental_fields} mutations.append( _mutation( - "update_targeting", + "update_keywords", tuple(_package_path(index, field_name) for field_name in incremental_fields), package_id=package_id, after=after, @@ -672,6 +815,8 @@ def _date_action( def _budget_action(before: Any, after: Any) -> tuple[str, UpdateMutationResolution]: + if before is not None and after is None: + return "increase_budget", "fine" comparison = _compare_ordered(before, after) if comparison is None: return "update_budget", "coarse" @@ -682,6 +827,14 @@ def _budget_action(before: Any, after: Any) -> tuple[str, UpdateMutationResoluti return "update_budget", "coarse" +def _budget_cap_action(before: Any, after: Any) -> tuple[str, UpdateMutationResolution]: + if before is None and after is not None: + return "decrease_budget", "fine" + if before is not None and after is None: + return "increase_budget", "fine" + return _budget_action(before, after) + + def _mutation( action: str, field_paths: tuple[str, ...], @@ -734,9 +887,9 @@ def _normalize_value(value: Any) -> Any: return None model_dump = getattr(value, "model_dump", None) if callable(model_dump): - return _normalize_value(model_dump(mode="json", exclude_none=True)) + return _normalize_value(model_dump(mode="json", exclude_unset=True)) if isinstance(value, Mapping): - return {str(key): _normalize_value(item) for key, item in value.items() if item is not None} + return {str(key): _normalize_value(item) for key, item in value.items()} if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray): return [_normalize_value(item) for item in value] return value @@ -869,6 +1022,8 @@ def _compare_ordered(before: Any, after: Any) -> int | None: def _ordered_value(value: Any) -> Decimal | datetime | None: + if isinstance(value, Mapping) and "amount" in value: + return _ordered_value(value["amount"]) if isinstance(value, bool): return None if isinstance(value, int | float | Decimal): diff --git a/src/adcp/media_buy_actions.py b/src/adcp/media_buy_actions.py new file mode 100644 index 000000000..5ddad0912 --- /dev/null +++ b/src/adcp/media_buy_actions.py @@ -0,0 +1,1445 @@ +"""Buyer and seller helpers for proposal-bound MediaBuy change rights. + +The helpers in this module deliberately keep three protocol surfaces separate: + +* product ``allowed_actions`` are advisory possibilities; +* accepted proposal ``commercial_terms.change_terms`` are binding rights; and +* MediaBuy ``available_actions`` are the seller's current-state projection. + +Legacy fields are readable, but never promoted into proposal authority. In +particular, a 3.1 ``terms_ref`` is opaque unless this module itself emitted it +as the compatibility projection of a known ``change_term_id``. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterable, Mapping, Sequence +from datetime import datetime, timedelta, timezone +from decimal import Decimal, InvalidOperation +from typing import Any, Protocol + +from pydantic import BaseModel, ConfigDict, Field, RootModel, ValidationError, model_validator + +from adcp.decisioning.state_machines import MEDIA_BUY_TRANSITIONS +from adcp.decisioning.update_media_buy import ( + UNKNOWN_UPDATE_ACTION, + UpdateMediaBuyMutation, + decompose_update_media_buy, +) +from adcp.types import MediaBuyChangeTerm +from adcp.types._str_enum import StrEnum + +_ACTION_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,127}$") +_TERM_ID_RE = re.compile(r"^[A-Za-z0-9_.:-]+$") +_NON_TERMINAL_STATUSES = frozenset( + status for status, transitions in MEDIA_BUY_TRANSITIONS.items() if transitions +) +_TERMINAL_STATUSES = frozenset( + status for status, transitions in MEDIA_BUY_TRANSITIONS.items() if not transitions +) + +_CONTROL_ACTIONS = frozenset( + { + "pause", + "resume", + "cancel", + "update_name", + "increase_budget", + "decrease_budget", + "reallocate_budget", + "update_budget_allocation", + "update_targeting", + "update_pacing", + "update_bidding", + "update_frequency_caps", + "update_catalog_assignments", + "update_keywords", + "update_optimization_goals", + "update_impression_goal", + "update_spend_target", + "update_reporting_webhook", + "remove_packages", + } +) +_REFINEMENT_ACTIONS = frozenset( + { + "cancel", + "extend_flight", + "shorten_flight", + "update_flight_dates", + "increase_budget", + "decrease_budget", + "reallocate_budget", + "update_budget_allocation", + "update_targeting", + "update_pacing", + "update_bidding", + "update_frequency_caps", + "add_packages", + "remove_packages", + } +) +_CREATIVE_ACTIONS = frozenset( + {"replace_creative", "update_creative_assignments", "remove_creative"} +) +_CANONICAL_ACTIONS = _CONTROL_ACTIONS | _REFINEMENT_ACTIONS | _CREATIVE_ACTIONS + +_ALLOWED_TASKS_BY_ACTION: dict[str, frozenset[ActionTask]] + +_LEGACY_ROLLUPS: dict[str, frozenset[str]] = { + "update_budget": frozenset( + {"increase_budget", "decrease_budget", "reallocate_budget", "update_budget_allocation"} + ), + "update_dates": frozenset({"extend_flight", "shorten_flight", "update_flight_dates"}), + "update_packages": frozenset( + { + "update_targeting", + "update_pacing", + "update_bidding", + "update_frequency_caps", + "reallocate_budget", + "remove_packages", + } + ), + "sync_creatives": _CREATIVE_ACTIONS, +} + +_CONSTRAINT_ACTIONS: dict[str, frozenset[str]] = { + "budget": frozenset( + { + "increase_budget", + "decrease_budget", + "reallocate_budget", + "update_budget_allocation", + "update_spend_target", + } + ), + "flight": frozenset({"extend_flight", "shorten_flight", "update_flight_dates"}), + "package_count": frozenset({"add_packages", "remove_packages"}), + "effective_timing": frozenset({"pause", "resume", "cancel"}), +} + + +class ActionKnowledge(StrEnum): + """Whether a protocol surface proves, disproves, or cannot answer a fact.""" + + yes = "yes" + no = "no" + unknown = "unknown" + + +class ActionAvailabilityStatus(StrEnum): + """Normalized buyer-facing action assessment result.""" + + available_now = "available_now" + wrong_status = "wrong_status" + not_negotiated = "not_negotiated" + unsupported_by_product = "unsupported_by_product" + currently_unavailable = "currently_unavailable" + legacy_unknown = "legacy_unknown" + + +class ActionTask(StrEnum): + """Compact-lifecycle task used to exercise an action.""" + + control_media_buy = "control_media_buy" + refine_proposals = "refine_proposals" + sync_creatives = "sync_creatives" + + +class ConstraintOutcome(StrEnum): + """Result of preflighting one portable change-term constraint.""" + + satisfied = "satisfied" + violated = "violated" + unknown = "unknown" + + +class ActionDiagnosticCode(StrEnum): + """Stable machine-readable explanations emitted by the resolver.""" + + alias_mismatch = "alias_mismatch" + condition_unresolved = "condition_unresolved" + constraint_violated = "constraint_violated" + duplicate_action = "duplicate_action" + duplicate_term_id = "duplicate_term_id" + invalid_projection = "invalid_projection" + legacy_coarse_action = "legacy_coarse_action" + legacy_terms_unknown = "legacy_terms_unknown" + missing_change_term_link = "missing_change_term_link" + mode_mismatch = "mode_mismatch" + route_mismatch = "route_mismatch" + sla_mismatch = "sla_mismatch" + unknown_action = "unknown_action" + + +class ActionDiagnostic(BaseModel): + """Bounded diagnostic suitable for UI, logs, and agent reasoning.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + code: ActionDiagnosticCode + field: str | None = Field(default=None, max_length=128, pattern=r"^[A-Za-z0-9_.\[\]-]+$") + detail: str | None = Field( + default=None, + max_length=128, + pattern=r"^[A-Za-z][A-Za-z0-9_.:-]{0,127}$", + ) + + +class ConstraintCheck(BaseModel): + """Evaluation of one portable constraint field.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + kind: str = Field(max_length=32, pattern=r"^[a-z_]+$") + constraint: str = Field(max_length=64, pattern=r"^[a-z_]+$") + outcome: ConstraintOutcome + field: str | None = Field(default=None, max_length=128, pattern=r"^[A-Za-z0-9_.\[\]-]+$") + + +class ActionIntent(BaseModel): + """Portable current/result state used to preflight typed constraints. + + Callers may provide this directly. :func:`assess_update_media_buy_actions` + derives the same fields from an update patch when enough current state is + available. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + current_amount: Decimal | None = None + result_amount: Decimal | None = None + currency: str | None = Field(default=None, pattern=r"^[A-Z]{3}$") + current_time: datetime | None = None + result_time: datetime | None = None + effective_at: datetime | None = None + additions: int | None = Field(default=None, ge=0) + removals: int | None = Field(default=None, ge=0) + current_package_count: int | None = Field(default=None, ge=0) + result_package_count: int | None = Field(default=None, ge=0) + field: str | None = Field(default=None, max_length=128, pattern=r"^[A-Za-z0-9_.\[\]-]+$") + + +class MediaBuyActionAssessment(BaseModel): + """The joined possible/promised/current answer for one action.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + action: str = Field(max_length=128, pattern=r"^[A-Za-z][A-Za-z0-9_.:-]{0,127}$") + status: ActionAvailabilityStatus + possible: ActionKnowledge + promised: ActionKnowledge + available: ActionKnowledge + task: ActionTask | None = None + mode: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_.:-]*$") + change_term_id: str | None = Field(default=None, max_length=255, pattern=r"^[A-Za-z0-9_.:-]+$") + async_processing: bool = False + constraints: tuple[ConstraintCheck, ...] = () + diagnostics: tuple[ActionDiagnostic, ...] = () + + +class ChangeTermSelection(BaseModel): + """A seller's explicit decision to bind one advertised product action.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + action: str = Field(max_length=128, pattern=r"^[A-Za-z][A-Za-z0-9_.:-]{0,127}$") + term_id: str = Field(max_length=255, pattern=r"^[A-Za-z0-9_.:-]+$") + service_mode: str | None = Field( + default=None, max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_.:-]*$" + ) + allowed_statuses: tuple[str, ...] | None = None + conditions: tuple[str, ...] | None = None + terms_ref: str | None = Field(default=None, min_length=1, max_length=1000) + description: str | None = Field(default=None, min_length=1, max_length=1000) + + @model_validator(mode="after") + def _validate_values(self) -> ChangeTermSelection: + if self.allowed_statuses is not None: + if not self.allowed_statuses or len(set(self.allowed_statuses)) != len( + self.allowed_statuses + ): + raise ValueError("allowed_statuses must be non-empty and unique") + if any(status not in _NON_TERMINAL_STATUSES for status in self.allowed_statuses): + raise ValueError("allowed_statuses may contain only non-terminal statuses") + if self.conditions is not None: + if not self.conditions or len(set(self.conditions)) != len(self.conditions): + raise ValueError("conditions must be non-empty and unique") + if any( + re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,199}", condition) is None + for condition in self.conditions + ): + raise ValueError("condition identifiers must use the protocol token grammar") + return self + + +class ProjectedMediaBuyAction(BaseModel): + """Version-adaptable seller projection for one currently available action.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + action: str = Field(max_length=128, pattern=r"^[A-Za-z][A-Za-z0-9_.:-]{0,127}$") + mode: str = Field(max_length=64, pattern=r"^[A-Za-z][A-Za-z0-9_.:-]*$") + task: ActionTask | None = None + sla: dict[str, object] | None = None + change_term_id: str | None = Field(default=None, max_length=255, pattern=r"^[A-Za-z0-9_.:-]+$") + terms_ref: str | None = Field(default=None, max_length=1000) + + +class MediaBuyActionProjection(BaseModel): + """Seller current-state projection plus any fail-closed omissions.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + actions: tuple[ProjectedMediaBuyAction, ...] = () + diagnostics: tuple[ActionDiagnostic, ...] = () + + def to_wire(self) -> list[dict[str, object]]: + """Return the JSON-ready ``available_actions`` array.""" + + return [action.model_dump(mode="json", exclude_none=True) for action in self.actions] + + +class MediaBuyActionError(ValueError): + """A safe local validation error for invalid seller helper inputs.""" + + def __init__(self, code: str, field: str | None = None) -> None: + self.code = code + self.field = field + suffix = f" at {field}" if field is not None else "" + super().__init__(f"media-buy action validation failed: {code}{suffix}") + + +class ActionDispatchClient(Protocol): + """Minimal client surface consumed by :func:`dispatch_media_buy_action`.""" + + async def execute_task( + self, + task_name: str, + request: BaseModel, + *, + options: Any | None = None, + ) -> Any: ... + + +def route_media_buy_action(action: str) -> ActionTask | None: + """Return the canonical compact task for an in-envelope action. + + Flight and package-addition changes require proposal refinement; creative + lifecycle changes use ``sync_creatives``; the remaining accepted controls + use ``control_media_buy``. Unknown future actions fail closed with + ``None`` so callers do not guess a route. + """ + + if action in _CONTROL_ACTIONS: + return ActionTask.control_media_buy + if action in _REFINEMENT_ACTIONS: + return ActionTask.refine_proposals + if action in _CREATIVE_ACTIONS: + return ActionTask.sync_creatives + return None + + +_ALLOWED_TASKS_BY_ACTION = { + action: frozenset( + task + for task, actions in ( + (ActionTask.control_media_buy, _CONTROL_ACTIONS), + ( + ActionTask.refine_proposals, + _REFINEMENT_ACTIONS, + ), + (ActionTask.sync_creatives, _CREATIVE_ACTIONS), + ) + if action in actions + ) + for action in _CANONICAL_ACTIONS +} + + +async def dispatch_media_buy_action( + client: ActionDispatchClient, + assessment: MediaBuyActionAssessment, + request: BaseModel, + *, + options: Any | None = None, +) -> Any: + """Dispatch an already-assessed action through its canonical task. + + The caller still supplies the task-specific generated request model. This + helper owns only the action-to-task decision and refuses unavailable or + unknown actions. ``seller_managed`` uses the same ordinary async task + lifecycle as every other mode; no internal approval workflow is inferred. + """ + + if assessment.status is not ActionAvailabilityStatus.available_now: + raise MediaBuyActionError("action_not_available", "status") + if assessment.task is None: + raise MediaBuyActionError("unknown_action_route", "task") + return await client.execute_task(assessment.task.value, request, options=options) + + +def assess_media_buy_action( + action: str, + *, + product: Any | None = None, + proposal: Any | None = None, + media_buy: Any | None = None, + intent: ActionIntent | Mapping[str, Any] | None = None, + now: datetime | None = None, +) -> MediaBuyActionAssessment: + """Join product, accepted proposal, and live-buy action surfaces. + + Inputs may be generated Pydantic models or wire mappings. Unknown future + enum values and legacy coarse actions are retained as unknown evidence but + are never treated as authority. + """ + + if _ACTION_RE.fullmatch(action) is None: + raise MediaBuyActionError("invalid_action", "action") + + diagnostics: list[ActionDiagnostic] = [] + possible = _assess_product_support(action, product, diagnostics) + change_terms_present, change_terms = _extract_change_terms(proposal, media_buy) + term, term_diagnostics = _find_change_term(action, change_terms) + diagnostics.extend(term_diagnostics) + + if not change_terms_present: + promised = ActionKnowledge.unknown + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.legacy_terms_unknown)) + elif term is None: + promised = ActionKnowledge.no + else: + promised = ActionKnowledge.yes + + status_value = _string_field(media_buy, "status") + wrong_status = False + if term is not None and status_value is not None: + allowed_statuses = _string_sequence(term.get("allowed_statuses")) + wrong_status = status_value in _TERMINAL_STATUSES or ( + bool(allowed_statuses) and status_value not in allowed_statuses + ) + + live_state, live_action = _assess_live_action(action, media_buy, diagnostics) + task: ActionTask | None = None + mode: str | None = None + change_term_id: str | None = None + constraints: tuple[ConstraintCheck, ...] = () + + if live_action is not None: + task = _task_field(live_action) if "task" in live_action else route_media_buy_action(action) + mode = _string_value(live_action.get("mode")) + change_term_id = _string_value(live_action.get("change_term_id")) + if term is not None and not _projection_matches_term( + action, live_action, term, task, diagnostics + ): + live_state = ActionKnowledge.no + elif term is not None: + task = route_media_buy_action(action) + mode = _string_value(term.get("service_mode")) + change_term_id = _string_value(term.get("term_id")) + + if term is not None and intent is not None: + parsed_intent = ( + intent if isinstance(intent, ActionIntent) else ActionIntent.model_validate(intent) + ) + constraints = evaluate_action_constraints( + action, + term.get("constraints"), + parsed_intent, + now=now, + ) + if any(check.outcome is ConstraintOutcome.violated for check in constraints): + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.constraint_violated)) + live_state = ActionKnowledge.no + + integrity_failure = any( + diagnostic.code + in { + ActionDiagnosticCode.alias_mismatch, + ActionDiagnosticCode.duplicate_action, + ActionDiagnosticCode.duplicate_term_id, + ActionDiagnosticCode.invalid_projection, + ActionDiagnosticCode.missing_change_term_link, + ActionDiagnosticCode.mode_mismatch, + ActionDiagnosticCode.route_mismatch, + ActionDiagnosticCode.sla_mismatch, + } + for diagnostic in diagnostics + ) + + if action not in _CANONICAL_ACTIONS: + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.unknown_action)) + final_status = ActionAvailabilityStatus.currently_unavailable + live_state = ActionKnowledge.unknown + elif integrity_failure: + final_status = ActionAvailabilityStatus.currently_unavailable + live_state = ActionKnowledge.no + elif possible is ActionKnowledge.no: + final_status = ActionAvailabilityStatus.unsupported_by_product + elif promised is ActionKnowledge.no: + final_status = ActionAvailabilityStatus.not_negotiated + elif promised is ActionKnowledge.unknown: + final_status = ActionAvailabilityStatus.legacy_unknown + elif wrong_status: + final_status = ActionAvailabilityStatus.wrong_status + live_state = ActionKnowledge.no + elif live_state is ActionKnowledge.yes: + final_status = ActionAvailabilityStatus.available_now + elif live_state is ActionKnowledge.unknown: + final_status = ActionAvailabilityStatus.legacy_unknown + else: + final_status = ActionAvailabilityStatus.currently_unavailable + + return MediaBuyActionAssessment( + action=action, + status=final_status, + possible=possible, + promised=promised, + available=live_state, + task=task, + mode=mode, + change_term_id=change_term_id, + async_processing=mode in {"seller_managed", "requires_approval"}, + constraints=constraints, + diagnostics=tuple(_dedupe_diagnostics(diagnostics)), + ) + + +def assess_update_media_buy_actions( + patch: Any, + current_media_buy: Any, + *, + product: Any | None = None, + proposal: Any | None = None, + now: datetime | None = None, +) -> tuple[MediaBuyActionAssessment, ...]: + """Assess every logical action represented by an update compatibility patch.""" + + assessments: list[MediaBuyActionAssessment] = [] + for mutation in decompose_update_media_buy(patch, current_media_buy): + if mutation.action == UNKNOWN_UPDATE_ACTION: + continue + intent = _intent_from_mutation(mutation, current_media_buy, now=now) + assessments.append( + assess_media_buy_action( + mutation.action, + product=product, + proposal=proposal, + media_buy=current_media_buy, + intent=intent, + now=now, + ) + ) + return tuple(assessments) + + +def evaluate_action_constraints( + action: str, + constraints: Any | None, + intent: ActionIntent, + *, + now: datetime | None = None, +) -> tuple[ConstraintCheck, ...]: + """Evaluate portable constraints without interpreting opaque conditions.""" + + constraint = _as_mapping(constraints) + if not constraint: + return () + kind = _string_value(constraint.get("kind")) + if kind is None or action not in _CONSTRAINT_ACTIONS.get(kind, frozenset()): + return ( + ConstraintCheck( + kind=kind or "unknown", + constraint="kind", + outcome=ConstraintOutcome.unknown, + field=intent.field, + ), + ) + + if kind == "budget": + return _evaluate_budget_constraints(constraint, intent) + if kind == "flight": + return _evaluate_flight_constraints(constraint, intent, now=now) + if kind == "package_count": + return _evaluate_package_constraints(constraint, intent) + if kind == "effective_timing": + return _evaluate_effective_timing_constraints(constraint, intent, now=now) + return () + + +def reassess_media_buy_action( + action: str, + *, + currently_available_actions: Iterable[Any], + media_buy: Any, + product: Any | None = None, + proposal: Any | None = None, + intent: ActionIntent | Mapping[str, Any] | None = None, + now: datetime | None = None, +) -> MediaBuyActionAssessment: + """Reassess against an authoritative action-set echo after a race. + + ``ACTION_NOT_ALLOWED`` may carry ``currently_available_actions`` resolved + at rejection time. This helper replaces only the stale action projection; + proposal rights and all other MediaBuy state remain unchanged. Callers + should still refresh the full MediaBuy before retrying with a new revision. + """ + + refreshed = dict(_as_mapping(media_buy)) + refreshed["available_actions"] = [ + dict(_as_mapping(value)) for value in currently_available_actions + ] + return assess_media_buy_action( + action, + product=product, + proposal=proposal, + media_buy=refreshed, + intent=intent, + now=now, + ) + + +def materialize_change_terms( + product_actions: Iterable[Any], + selections: Iterable[ChangeTermSelection | Mapping[str, Any]], +) -> tuple[MediaBuyChangeTerm, ...]: + """Materialize explicitly selected product templates as binding terms. + + Product declarations are never copied wholesale. Every returned term + requires a :class:`ChangeTermSelection`, and all portable bounds, SLA, and + status scope are copied from (or narrowed relative to) that template. + """ + + product_index = _unique_action_index(product_actions, field="product_actions") + parsed = [ + ( + value + if isinstance(value, ChangeTermSelection) + else ChangeTermSelection.model_validate(value) + ) + for value in selections + ] + if len({selection.action for selection in parsed}) != len(parsed): + raise MediaBuyActionError("duplicate_action", "selections") + if len({selection.term_id for selection in parsed}) != len(parsed): + raise MediaBuyActionError("duplicate_term_id", "selections") + + terms: list[MediaBuyChangeTerm] = [] + for index, selection in enumerate(parsed): + product_action = product_index.get(selection.action) + if product_action is None: + raise MediaBuyActionError("action_not_advertised", f"selections[{index}].action") + if selection.action not in _CANONICAL_ACTIONS: + raise MediaBuyActionError("unknown_action", f"selections[{index}].action") + + modes = _string_sequence(product_action.get("modes")) + mode = selection.service_mode + if mode is None: + if len(modes) != 1: + raise MediaBuyActionError( + "service_mode_required", f"selections[{index}].service_mode" + ) + mode = modes[0] + if mode not in modes: + raise MediaBuyActionError( + "service_mode_not_advertised", f"selections[{index}].service_mode" + ) + + product_statuses = _string_sequence(product_action.get("allowed_statuses")) + selected_statuses = selection.allowed_statuses + if ( + selected_statuses is not None + and product_statuses + and not set(selected_statuses) <= set(product_statuses) + ): + raise MediaBuyActionError( + "status_scope_expanded", f"selections[{index}].allowed_statuses" + ) + statuses = selected_statuses or (tuple(product_statuses) if product_statuses else None) + + constraint = _as_mapping(product_action.get("constraints")) or None + if constraint is not None: + kind = _string_value(constraint.get("kind")) + if selection.action not in _CONSTRAINT_ACTIONS.get(kind or "", frozenset()): + raise MediaBuyActionError( + "constraint_action_mismatch", + f"product_actions[{index}].constraints", + ) + + term_payload: dict[str, Any] = { + "term_id": selection.term_id, + "action": selection.action, + "service_mode": mode, + } + if statuses is not None: + term_payload["allowed_statuses"] = list(statuses) + sla = _as_mapping(product_action.get("sla")) + if sla: + term_payload["processing_sla"] = sla + if constraint is not None: + term_payload["constraints"] = constraint + if selection.conditions is not None: + term_payload["conditions"] = list(selection.conditions) + terms_ref = selection.terms_ref or _string_value(product_action.get("terms_ref")) + if terms_ref is not None: + term_payload["terms_ref"] = terms_ref + if selection.description is not None: + term_payload["description"] = selection.description + terms.append(MediaBuyChangeTerm.model_validate(term_payload)) + return tuple(terms) + + +def project_available_actions( + change_terms: Iterable[Any] | None, + status: str, + *, + product_actions: Iterable[Any] | None = None, + authorized_actions: Iterable[str] | None = None, + delegated_actions: Iterable[str] | None = None, + policy_actions: Iterable[str] | None = None, + resolved_conditions: Mapping[str, bool] | None = None, + protocol_version: str = "3.2", +) -> MediaBuyActionProjection: + """Derive a fail-closed seller ``available_actions`` projection. + + ``None`` for an optional narrowing set means that gate has already been + satisfied or is not applicable. An explicit empty set denies every + action. Conditions must be explicitly resolved ``True``; absent, false, + or unknown conditions omit the action. + """ + + diagnostics: list[ActionDiagnostic] = [] + if change_terms is None: + return MediaBuyActionProjection( + diagnostics=(ActionDiagnostic(code=ActionDiagnosticCode.legacy_terms_unknown),) + ) + if status not in _NON_TERMINAL_STATUSES: + return MediaBuyActionProjection() + + terms: list[Mapping[str, Any]] = [] + for index, raw_term in enumerate(change_terms): + try: + parsed_term = MediaBuyChangeTerm.model_validate(raw_term) + except ValidationError: + diagnostics.append( + ActionDiagnostic( + code=ActionDiagnosticCode.invalid_projection, + field=f"change_terms[{index}]", + ) + ) + continue + terms.append(parsed_term.model_dump(mode="python", by_alias=True, exclude_unset=True)) + seen_actions: set[str] = set() + seen_ids: set[str] = set() + product_index = ( + _unique_action_index(product_actions, field="product_actions") + if product_actions is not None + else None + ) + gates = [ + set(values) if values is not None else None + for values in (authorized_actions, delegated_actions, policy_actions) + ] + supports_link = _supports_change_term_id(protocol_version) + supports_task = _supports_action_task(protocol_version) + actions: list[ProjectedMediaBuyAction] = [] + + for index, term in enumerate(terms): + action = _string_value(term.get("action")) + term_id = _string_value(term.get("term_id")) + if action is None or term_id is None or _TERM_ID_RE.fullmatch(term_id) is None: + diagnostics.append( + ActionDiagnostic( + code=ActionDiagnosticCode.invalid_projection, + field=f"change_terms[{index}]", + ) + ) + continue + if action in seen_actions: + diagnostics.append(_diagnostic(ActionDiagnosticCode.duplicate_action, detail=action)) + continue + if term_id in seen_ids: + diagnostics.append(_diagnostic(ActionDiagnosticCode.duplicate_term_id, detail=term_id)) + continue + seen_actions.add(action) + seen_ids.add(term_id) + + task = route_media_buy_action(action) + if task is None: + diagnostics.append(_diagnostic(ActionDiagnosticCode.unknown_action, detail=action)) + continue + allowed_statuses = _string_sequence(term.get("allowed_statuses")) + if allowed_statuses and status not in allowed_statuses: + continue + if any(gate is not None and action not in gate for gate in gates): + continue + + product_action = product_index.get(action) if product_index is not None else None + if product_index is not None and product_action is None: + continue + mode = _string_value(term.get("service_mode")) + if mode is None: + diagnostics.append( + ActionDiagnostic( + code=ActionDiagnosticCode.invalid_projection, + field=f"change_terms[{index}].service_mode", + ) + ) + continue + if product_action is not None: + if mode not in _string_sequence(product_action.get("modes")): + continue + product_statuses = _string_sequence(product_action.get("allowed_statuses")) + if product_statuses and status not in product_statuses: + continue + + conditions = _string_sequence(term.get("conditions")) + if conditions and ( + resolved_conditions is None + or any(resolved_conditions.get(condition) is not True for condition in conditions) + ): + diagnostics.append( + _diagnostic(ActionDiagnosticCode.condition_unresolved, detail=action) + ) + continue + + constraint = _as_mapping(term.get("constraints")) + if constraint: + kind = _string_value(constraint.get("kind")) + if action not in _CONSTRAINT_ACTIONS.get(kind or "", frozenset()): + diagnostics.append( + _diagnostic(ActionDiagnosticCode.invalid_projection, detail=action) + ) + continue + + wire_mode = "requires_approval" if not supports_link and mode == "seller_managed" else mode + action_payload: dict[str, Any] = { + "action": action, + "mode": wire_mode, + "sla": _as_mapping(term.get("processing_sla")) or None, + } + if supports_task: + action_payload["task"] = task + if supports_link: + action_payload["change_term_id"] = term_id + else: + # This is an explicit adapter-generated alias. Arbitrary inbound + # 3.1 terms_ref values are never interpreted this way. + action_payload["terms_ref"] = term_id + actions.append(ProjectedMediaBuyAction.model_validate(action_payload)) + + return MediaBuyActionProjection( + actions=tuple(actions), diagnostics=tuple(_dedupe_diagnostics(diagnostics)) + ) + + +def _assess_product_support( + action: str, + product: Any | None, + diagnostics: list[ActionDiagnostic], +) -> ActionKnowledge: + if product is None: + return ActionKnowledge.unknown + product_mapping = _as_mapping(product) + if not _has_field(product, "allowed_actions"): + return ActionKnowledge.unknown + declarations = _as_sequence(product_mapping.get("allowed_actions")) + declared = {_string_field(value, "action") for value in declarations} + if action in declared: + return ActionKnowledge.yes + for coarse, fine_actions in _LEGACY_ROLLUPS.items(): + if coarse in declared and action in fine_actions: + diagnostics.append( + _diagnostic(ActionDiagnosticCode.legacy_coarse_action, detail=coarse) + ) + return ActionKnowledge.unknown + return ActionKnowledge.no + + +def _extract_change_terms( + proposal: Any | None, + media_buy: Any | None, +) -> tuple[bool, list[Mapping[str, Any]]]: + source = proposal + if source is None and media_buy is not None: + media_mapping = _as_mapping(media_buy) + source = media_mapping.get("accepted_proposal") + if source is None: + return False, [] + source_mapping = _as_mapping(source) + if "commercial_terms" in source_mapping: + source = source_mapping.get("commercial_terms") + if source is None: + return False, [] + terms_mapping = _as_mapping(source) + if not _has_field(source, "change_terms"): + return False, [] + return True, [_as_mapping(value) for value in _as_sequence(terms_mapping.get("change_terms"))] + + +def _find_change_term( + action: str, + terms: Sequence[Mapping[str, Any]], +) -> tuple[Mapping[str, Any] | None, list[ActionDiagnostic]]: + matches = [term for term in terms if _string_value(term.get("action")) == action] + diagnostics: list[ActionDiagnostic] = [] + if len(matches) > 1: + diagnostics.append(_diagnostic(ActionDiagnosticCode.duplicate_action, detail=action)) + return None, diagnostics + ids = [_string_value(term.get("term_id")) for term in terms] + concrete_ids = [value for value in ids if value is not None] + if len(set(concrete_ids)) != len(concrete_ids): + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.duplicate_term_id)) + return None, diagnostics + return (matches[0] if matches else None), diagnostics + + +def _assess_live_action( + action: str, + media_buy: Any | None, + diagnostics: list[ActionDiagnostic], +) -> tuple[ActionKnowledge, Mapping[str, Any] | None]: + if media_buy is None: + return ActionKnowledge.unknown, None + media_mapping = _as_mapping(media_buy) + if _has_field(media_buy, "available_actions"): + matches = [ + _as_mapping(value) + for value in _as_sequence(media_mapping.get("available_actions")) + if _string_field(value, "action") == action + ] + if len(matches) > 1: + diagnostics.append(_diagnostic(ActionDiagnosticCode.duplicate_action, detail=action)) + return ActionKnowledge.no, None + return (ActionKnowledge.yes, matches[0]) if matches else (ActionKnowledge.no, None) + + valid_actions = _string_sequence(media_mapping.get("valid_actions")) + if action in valid_actions or any( + coarse in valid_actions and action in fine for coarse, fine in _LEGACY_ROLLUPS.items() + ): + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.legacy_coarse_action)) + return ActionKnowledge.unknown, None + + +def _projection_matches_term( + action: str, + live_action: Mapping[str, Any], + term: Mapping[str, Any], + task: ActionTask | None, + diagnostics: list[ActionDiagnostic], +) -> bool: + valid = True + term_id = _string_value(term.get("term_id")) + change_term_id = _string_value(live_action.get("change_term_id")) + terms_ref = _string_value(live_action.get("terms_ref")) + if change_term_id is not None and terms_ref is not None and change_term_id != terms_ref: + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.alias_mismatch)) + valid = False + if change_term_id is not None and change_term_id != term_id: + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.missing_change_term_link)) + valid = False + if change_term_id is None and terms_ref is not None: + # An arbitrary legacy terms_ref stays opaque. It neither proves nor + # disproves the proposal link. + pass + elif change_term_id is None: + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.missing_change_term_link)) + valid = False + + live_mode = _string_value(live_action.get("mode")) + term_mode = _string_value(term.get("service_mode")) + modes_match = live_mode == term_mode or ( + term_mode == "seller_managed" + and live_mode == "requires_approval" + and change_term_id is None + ) + if not modes_match: + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.mode_mismatch)) + valid = False + + live_task = _string_value(live_action.get("task")) + if "task" in live_action and live_task is None: + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.route_mismatch)) + valid = False + elif live_task is not None: + try: + parsed_task = ActionTask(live_task) + except ValueError: + parsed_task = None + if ( + parsed_task is None + or task is None + or parsed_task is not task + or parsed_task not in _ALLOWED_TASKS_BY_ACTION.get(action, frozenset()) + ): + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.route_mismatch)) + valid = False + + if _normalized_value(live_action.get("sla")) != _normalized_value(term.get("processing_sla")): + diagnostics.append(ActionDiagnostic(code=ActionDiagnosticCode.sla_mismatch)) + valid = False + return valid + + +def _evaluate_budget_constraints( + constraint: Mapping[str, Any], intent: ActionIntent +) -> tuple[ConstraintCheck, ...]: + checks: list[ConstraintCheck] = [] + current = intent.current_amount + result = intent.result_amount + delta = abs(result - current) if current is not None and result is not None else None + + if "max_delta_amount" in constraint: + money = _as_mapping(constraint.get("max_delta_amount")) + limit = _decimal(money.get("amount")) + outcome = _money_outcome( + delta, + limit, + intent.currency, + _string_value(money.get("currency")), + ) + checks.append(_check("budget", "max_delta_amount", outcome, intent.field)) + if "max_delta_percent" in constraint: + limit_percent = _decimal(constraint.get("max_delta_percent")) + if current is None or result is None or limit_percent is None: + outcome = ConstraintOutcome.unknown + elif current == 0: + outcome = ConstraintOutcome.satisfied if result == 0 else ConstraintOutcome.violated + else: + percent = abs(result - current) * Decimal(100) / abs(current) + outcome = ( + ConstraintOutcome.satisfied + if percent <= limit_percent + else ConstraintOutcome.violated + ) + checks.append(_check("budget", "max_delta_percent", outcome, intent.field)) + for name, comparator in ( + ("min_result_amount", "minimum"), + ("max_result_amount", "maximum"), + ): + if name not in constraint: + continue + money = _as_mapping(constraint.get(name)) + bound = _decimal(money.get("amount")) + currency_matches = _currencies_match(intent.currency, _string_value(money.get("currency"))) + if result is None or bound is None or currency_matches is None: + outcome = ConstraintOutcome.unknown + elif not currency_matches: + outcome = ConstraintOutcome.violated + elif comparator == "minimum": + outcome = ConstraintOutcome.satisfied if result >= bound else ConstraintOutcome.violated + else: + outcome = ConstraintOutcome.satisfied if result <= bound else ConstraintOutcome.violated + checks.append(_check("budget", name, outcome, intent.field)) + return tuple(checks) + + +def _evaluate_flight_constraints( + constraint: Mapping[str, Any], + intent: ActionIntent, + *, + now: datetime | None, +) -> tuple[ConstraintCheck, ...]: + checks: list[ConstraintCheck] = [] + current = _aware(intent.current_time) + result = _aware(intent.result_time) + if "max_change" in constraint: + maximum = _duration(constraint.get("max_change")) + if current is None or result is None or maximum is None: + outcome = ConstraintOutcome.unknown + else: + outcome = ( + ConstraintOutcome.satisfied + if abs(result - current) <= maximum + else ConstraintOutcome.violated + ) + checks.append(_check("flight", "max_change", outcome, intent.field)) + for name, lower in (("earliest_result", True), ("latest_result", False)): + if name not in constraint: + continue + bound = _datetime(constraint.get(name)) + if result is None or bound is None: + outcome = ConstraintOutcome.unknown + elif (result >= bound) if lower else (result <= bound): + outcome = ConstraintOutcome.satisfied + else: + outcome = ConstraintOutcome.violated + checks.append(_check("flight", name, outcome, intent.field)) + if "minimum_notice" in constraint: + minimum = _duration(constraint.get("minimum_notice")) + effective = result + clock = _aware(now or datetime.now(timezone.utc)) + if minimum is None or effective is None or clock is None: + outcome = ConstraintOutcome.unknown + else: + outcome = ( + ConstraintOutcome.satisfied + if effective - clock >= minimum + else ConstraintOutcome.violated + ) + checks.append(_check("flight", "minimum_notice", outcome, intent.field)) + return tuple(checks) + + +def _evaluate_package_constraints( + constraint: Mapping[str, Any], intent: ActionIntent +) -> tuple[ConstraintCheck, ...]: + checks: list[ConstraintCheck] = [] + for name, actual in ( + ("max_additions", intent.additions), + ("max_removals", intent.removals), + ("max_result_count", intent.result_package_count), + ): + if name not in constraint: + continue + limit = constraint.get(name) + if not isinstance(limit, int) or actual is None: + outcome = ConstraintOutcome.unknown + else: + outcome = ConstraintOutcome.satisfied if actual <= limit else ConstraintOutcome.violated + checks.append(_check("package_count", name, outcome, intent.field)) + return tuple(checks) + + +def _evaluate_effective_timing_constraints( + constraint: Mapping[str, Any], + intent: ActionIntent, + *, + now: datetime | None, +) -> tuple[ConstraintCheck, ...]: + checks: list[ConstraintCheck] = [] + effective = _aware(intent.effective_at) + clock = _aware(now or datetime.now(timezone.utc)) + if "minimum_notice" in constraint: + minimum = _duration(constraint.get("minimum_notice")) + if effective is None or clock is None or minimum is None: + outcome = ConstraintOutcome.unknown + else: + outcome = ( + ConstraintOutcome.satisfied + if effective - clock >= minimum + else ConstraintOutcome.violated + ) + checks.append(_check("effective_timing", "minimum_notice", outcome, intent.field)) + for name, lower in (("earliest_effective_at", True), ("latest_effective_at", False)): + if name not in constraint: + continue + bound = _datetime(constraint.get(name)) + if effective is None or bound is None: + outcome = ConstraintOutcome.unknown + elif (effective >= bound) if lower else (effective <= bound): + outcome = ConstraintOutcome.satisfied + else: + outcome = ConstraintOutcome.violated + checks.append(_check("effective_timing", name, outcome, intent.field)) + return tuple(checks) + + +def _intent_from_mutation( + mutation: UpdateMediaBuyMutation, + current_media_buy: Any, + *, + now: datetime | None, +) -> ActionIntent: + current = _as_mapping(current_media_buy) + current_amount: Decimal | None = None + result_amount: Decimal | None = None + currency: str | None = None + current_time: datetime | None = None + result_time: datetime | None = None + additions: int | None = None + removals: int | None = None + result_count: int | None = None + effective_at: datetime | None = None + + if mutation.action in _CONSTRAINT_ACTIONS["budget"]: + current_amount = _amount(mutation.before) + result_amount = _amount(mutation.after) + currency = _money_currency(mutation.after) or _money_currency(mutation.before) + if ( + isinstance(mutation.before, Mapping) + and isinstance(mutation.after, Mapping) + and "amount" not in mutation.before + and "amount" not in mutation.after + ): + current_amount = sum( + ((_decimal(value) or Decimal(0)) for value in mutation.before.values()), + start=Decimal(0), + ) + result_amount = sum( + ((_decimal(value) or Decimal(0)) for value in mutation.after.values()), + start=Decimal(0), + ) + elif mutation.action in _CONSTRAINT_ACTIONS["flight"]: + current_time = _time_from_mutation(mutation.before) + result_time = _time_from_mutation(mutation.after) + elif mutation.action == "add_packages": + additions = len(_as_sequence(mutation.after)) + elif mutation.action == "remove_packages": + removals = 1 + elif mutation.action in _CONSTRAINT_ACTIONS["effective_timing"]: + effective_at = now or datetime.now(timezone.utc) + + packages = _as_sequence(current.get("packages")) + current_count = len( + [package for package in packages if _string_field(package, "status") != "canceled"] + ) + if additions is not None: + result_count = current_count + additions + elif removals is not None: + result_count = max(0, current_count - removals) + + return ActionIntent( + current_amount=current_amount, + result_amount=result_amount, + currency=currency or _string_value(current.get("currency")), + current_time=current_time, + result_time=result_time, + effective_at=effective_at, + additions=additions, + removals=removals, + current_package_count=current_count, + result_package_count=result_count, + field=mutation.field_paths[0] if mutation.field_paths else None, + ) + + +def _unique_action_index(values: Iterable[Any], *, field: str) -> dict[str, Mapping[str, Any]]: + result: dict[str, Mapping[str, Any]] = {} + for index, value in enumerate(values): + mapped = _as_mapping(value) + action = _string_value(mapped.get("action")) + if action is None: + raise MediaBuyActionError("missing_action", f"{field}[{index}].action") + if action in result: + raise MediaBuyActionError("duplicate_action", f"{field}[{index}].action") + result[action] = mapped + return result + + +def _supports_change_term_id(version: str) -> bool: + match = re.fullmatch(r"v?(\d+)\.(\d+)(?:\.\d+)?(?:-beta\.(\d+))?", version) + if match is None: + raise MediaBuyActionError("invalid_protocol_version", "protocol_version") + major, minor = int(match.group(1)), int(match.group(2)) + beta = int(match.group(3)) if match.group(3) is not None else None + if (major, minor) < (3, 2): + return False + return beta is None or beta >= 9 + + +def _supports_action_task(version: str) -> bool: + match = re.fullmatch(r"v?(\d+)\.(\d+)(?:\.\d+)?(?:-beta\.(\d+))?", version) + if match is None: + raise MediaBuyActionError("invalid_protocol_version", "protocol_version") + return (int(match.group(1)), int(match.group(2))) >= (3, 2) + + +def _task_field(value: Mapping[str, Any]) -> ActionTask | None: + raw = _string_value(value.get("task")) + if raw is None: + return None + try: + return ActionTask(raw) + except ValueError: + return None + + +def _as_mapping(value: Any) -> Mapping[str, Any]: + if value is None: + return {} + if isinstance(value, RootModel): + return _as_mapping(value.root) + if isinstance(value, BaseModel): + return value.model_dump(mode="python", by_alias=True, exclude_unset=True) + if isinstance(value, Mapping): + return value + return {} + + +def _as_sequence(value: Any) -> list[Any]: + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return list(value) + return [] + + +def _has_field(value: Any, field: str) -> bool: + if isinstance(value, BaseModel): + return field in value.model_fields_set + return isinstance(value, Mapping) and field in value + + +def _string_value(value: Any) -> str | None: + if isinstance(value, RootModel): + return _string_value(value.root) + if isinstance(value, StrEnum): + return str(value.value) + if isinstance(value, str): + return value + return None + + +def _string_field(value: Any, field: str) -> str | None: + return _string_value(_as_mapping(value).get(field)) + + +def _string_sequence(value: Any) -> tuple[str, ...]: + result: list[str] = [] + for item in _as_sequence(value): + string = _string_value(item) + if string is not None: + result.append(string) + return tuple(result) + + +def _normalized_value(value: Any) -> Any: + if isinstance(value, RootModel): + return _normalized_value(value.root) + if isinstance(value, BaseModel): + return value.model_dump(mode="json", exclude_none=True) + if isinstance(value, Mapping): + return {str(key): _normalized_value(item) for key, item in sorted(value.items())} + if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + return [_normalized_value(item) for item in value] + if isinstance(value, StrEnum): + return value.value + if isinstance(value, datetime): + return value.isoformat() + return value + + +def _check(kind: str, name: str, outcome: ConstraintOutcome, field: str | None) -> ConstraintCheck: + return ConstraintCheck(kind=kind, constraint=name, outcome=outcome, field=field) + + +def _diagnostic( + code: ActionDiagnosticCode, + *, + detail: str | None = None, + field: str | None = None, +) -> ActionDiagnostic: + safe_detail = detail if detail is not None and _ACTION_RE.fullmatch(detail) else None + return ActionDiagnostic(code=code, detail=safe_detail, field=field) + + +def _decimal(value: Any) -> Decimal | None: + if isinstance(value, bool) or value is None: + return None + try: + return Decimal(str(value)) + except (InvalidOperation, ValueError): + return None + + +def _amount(value: Any) -> Decimal | None: + mapped = _as_mapping(value) + if mapped: + return _decimal(mapped.get("amount")) + return _decimal(value) + + +def _money_currency(value: Any) -> str | None: + return _string_value(_as_mapping(value).get("currency")) + + +def _currencies_match(left: str | None, right: str | None) -> bool | None: + if left is None or right is None: + return None + return left == right + + +def _money_outcome( + actual: Decimal | None, + limit: Decimal | None, + actual_currency: str | None, + limit_currency: str | None, +) -> ConstraintOutcome: + currencies_match = _currencies_match(actual_currency, limit_currency) + if actual is None or limit is None or currencies_match is None: + return ConstraintOutcome.unknown + if not currencies_match: + return ConstraintOutcome.violated + return ConstraintOutcome.satisfied if actual <= limit else ConstraintOutcome.violated + + +def _duration(value: Any) -> timedelta | None: + mapped = _as_mapping(value) + interval = mapped.get("interval") + unit = _string_value(mapped.get("unit")) + if not isinstance(interval, int) or isinstance(interval, bool) or interval < 1: + return None + if unit == "seconds": + return timedelta(seconds=interval) + if unit == "minutes": + return timedelta(minutes=interval) + if unit == "hours": + return timedelta(hours=interval) + if unit == "days": + return timedelta(days=interval) + return None + + +def _datetime(value: Any) -> datetime | None: + if isinstance(value, datetime): + return _aware(value) + if isinstance(value, str): + try: + return _aware(datetime.fromisoformat(value.replace("Z", "+00:00"))) + except ValueError: + return None + return None + + +def _aware(value: datetime | None) -> datetime | None: + if value is None or value.tzinfo is None or value.utcoffset() is None: + return None + return value.astimezone(timezone.utc) + + +def _time_from_mutation(value: Any) -> datetime | None: + mapped = _as_mapping(value) + if mapped: + for field in ("end_time", "start_time"): + if field in mapped: + return _datetime(mapped[field]) + return None + return _datetime(value) + + +def _dedupe_diagnostics(values: Iterable[ActionDiagnostic]) -> list[ActionDiagnostic]: + result: list[ActionDiagnostic] = [] + seen: set[tuple[str, str | None, str | None]] = set() + for value in values: + key = (value.code.value, value.field, value.detail) + if key not in seen: + seen.add(key) + result.append(value) + return result + + +__all__ = [ + "ActionAvailabilityStatus", + "ActionDiagnostic", + "ActionDiagnosticCode", + "ActionDispatchClient", + "ActionIntent", + "ActionKnowledge", + "ActionTask", + "ChangeTermSelection", + "ConstraintCheck", + "ConstraintOutcome", + "MediaBuyActionAssessment", + "MediaBuyActionError", + "MediaBuyActionProjection", + "ProjectedMediaBuyAction", + "assess_media_buy_action", + "assess_update_media_buy_actions", + "dispatch_media_buy_action", + "evaluate_action_constraints", + "materialize_change_terms", + "project_available_actions", + "route_media_buy_action", +] diff --git a/src/adcp/server/responses.py b/src/adcp/server/responses.py index 83cb135c4..856d81f9b 100644 --- a/src/adcp/server/responses.py +++ b/src/adcp/server/responses.py @@ -593,6 +593,7 @@ def media_buys_response( { "media_buys": _serialize_canonical(media_buys), "sandbox": sandbox, + "status": "completed", }, media_buys, ) diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 87e19b6f8..7027d5da1 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -66,6 +66,22 @@ "AcceptancePolicyProfileIds", "AcceptancePolicyRequirement", "AcceptancePolicyRule", + "CanonicalMediaBuyAction", + "CanonicalMediaBuyActionMode", + "CanonicalMediaBuyActionName", + "CanonicalProductAction", + "CommercialTerms", + "MediaBuyAvailableAction", + "MediaBuyActionMode", + "MediaBuyChangeTerm", + "MediaBuyChangeTermConstraints", + "MediaBuyChangeTermId", + "MediaBuyValidAction", + "BudgetChangeConstraints", + "EffectiveTimingChangeConstraints", + "FlightChangeConstraints", + "PackageCountChangeConstraints", + "ProductAllowedAction", "BuyProductsRequest", "BuyProductsResponse", "ControlMediaBuyRequest", @@ -1189,6 +1205,7 @@ def __dir__() -> list[str]: BrandSource, BriefAsset, BriefFormatAsset, + BudgetChangeConstraints, BudgetTooLowDetails, BuildCreativeCreative, BusinessEntity, @@ -1219,6 +1236,10 @@ def __dir__() -> list[str]: CanonicalFormatResponsiveCreative, CanonicalFormatSponsoredPlacement, CanonicalFormatVastVideo, + CanonicalMediaBuyAction, + CanonicalMediaBuyActionMode, + CanonicalMediaBuyActionName, + CanonicalProductAction, CanonicalProjectionReference, CanonicalProposalTotalBudgetGuidance, CanonicalSlotOverride, @@ -1248,6 +1269,7 @@ def __dir__() -> list[str]: CollectionListChangedWebhook, CollectionListFilters, Colors, + CommercialTerms, CompatibilityPurchaseCoordinatorInput, ComplyErrorResponse, ComplyListScenariosResponse, @@ -1352,6 +1374,7 @@ def __dir__() -> list[str]: DownstreamConnectionRequirement, Duration, DurationUnit, + EffectiveTimingChangeConstraints, Error, ErrorCode, EventType, @@ -1361,6 +1384,7 @@ def __dir__() -> list[str]: FieldModel, FlatFeeVendorPricingOption, FlatRatePricingOption, + FlightChangeConstraints, Fonts, ForecastableMetric, ForecastMethod, @@ -1565,12 +1589,18 @@ def __dir__() -> list[str]: McpWebhookPayload, MeasurementPeriod, MediaBuy, + MediaBuyActionMode, + MediaBuyAvailableAction, + MediaBuyChangeTerm, + MediaBuyChangeTermConstraints, + MediaBuyChangeTermId, MediaBuyDelivery, MediaBuyDeliveryStatus, MediaBuyDeliveryWebhookResult, MediaBuyFeatures, MediaBuyPackage, MediaBuyStatus, + MediaBuyValidAction, MediaChannel, MediaSubAsset, Member, @@ -1593,6 +1623,7 @@ def __dir__() -> list[str]: OverlayUnit, Pacing, Package, + PackageCountChangeConstraints, PackageRequest, PackageSignalTargeting, PackageSignalTargetingGroup, @@ -1635,6 +1666,7 @@ def __dir__() -> list[str]: PrimaryCountry, Product, ProductAllocation, + ProductAllowedAction, ProductCard, ProductCardDetailed, ProductCatalog, diff --git a/src/adcp/types/_eager.py b/src/adcp/types/_eager.py index fb1aff848..da54678bd 100644 --- a/src/adcp/types/_eager.py +++ b/src/adcp/types/_eager.py @@ -98,6 +98,10 @@ ByPackageItem, CalibrateContentRequest, CalibrateContentResponse, + CanonicalMediaBuyAction, + CanonicalMediaBuyActionMode, + CanonicalMediaBuyActionName, + CanonicalProductAction, Catalog, CatalogAction, CatalogFieldBinding, @@ -113,6 +117,7 @@ CollectionListChangedWebhook, CollectionListFilters, Colors, + CommercialTerms, CompatibilityPurchaseCoordinatorInput, ComplyTestControllerRequest, ComplyTestControllerResponse, @@ -252,11 +257,17 @@ McpWebhookPayload, MeasurementPeriod, MediaBuy, + MediaBuyActionMode, + MediaBuyAvailableAction, + MediaBuyChangeTerm, + MediaBuyChangeTermConstraints, + MediaBuyChangeTermId, MediaBuyDelivery, MediaBuyDeliveryWebhookResult, MediaBuyFeatures, MediaBuyPackage, MediaBuyStatus, + MediaBuyValidAction, MediaChannel, Metadata, NotificationConfig, @@ -288,6 +299,7 @@ PricingCurrency, PricingModel, PrimaryCountry, + ProductAllowedAction, ProductCard, ProductCardDetailed, ProductSignalTargetingOption, @@ -514,6 +526,7 @@ BrandIdentity, BriefAsset, BriefFormatAsset, + BudgetChangeConstraints, # Cross-module name collision aliases (#911, Step 2) BuildCreativeCreative, CalibrateContentErrorResponse, @@ -575,7 +588,9 @@ Destination, DownstreamConnectionRequiredForItem, DurationUnit, + EffectiveTimingChangeConstraints, FlatFeeVendorPricingOption, + FlightChangeConstraints, FormatAssetUnion, GetAccountFinancialsErrorResponse, GetAccountFinancialsResponse1, @@ -650,6 +665,7 @@ MissingMetric, NotificationAuthentication, OverlayUnit, + PackageCountChangeConstraints, PercentOfMediaVendorPricingOption, PerUnitVendorPricingOption, PixelTrackerAsset, @@ -1035,6 +1051,22 @@ def __init__(self, *args: object, **kwargs: object) -> None: "AcceptancePolicyProfileIds", "AcceptancePolicyRequirement", "AcceptancePolicyRule", + "CanonicalMediaBuyAction", + "CanonicalMediaBuyActionMode", + "CanonicalMediaBuyActionName", + "CanonicalProductAction", + "CommercialTerms", + "MediaBuyAvailableAction", + "MediaBuyActionMode", + "MediaBuyChangeTerm", + "MediaBuyChangeTermConstraints", + "MediaBuyChangeTermId", + "MediaBuyValidAction", + "BudgetChangeConstraints", + "EffectiveTimingChangeConstraints", + "FlightChangeConstraints", + "PackageCountChangeConstraints", + "ProductAllowedAction", "RegistryAcceptancePolicyProfileReference", "BuyProductsRequest", "BuyProductsResponse", diff --git a/src/adcp/types/aliases.py b/src/adcp/types/aliases.py index a3db482e7..4c27f1300 100644 --- a/src/adcp/types/aliases.py +++ b/src/adcp/types/aliases.py @@ -2062,6 +2062,18 @@ class UnknownGroupAsset(_BaseGroupAsset): from adcp.types.generated_poc.core.duration import ( Unit as DurationUnit, ) +from adcp.types.generated_poc.media_buy.change_term_constraints import ( + MediaBuyChangeTermConstraints1 as BudgetChangeConstraints, +) +from adcp.types.generated_poc.media_buy.change_term_constraints import ( + MediaBuyChangeTermConstraints2 as FlightChangeConstraints, +) +from adcp.types.generated_poc.media_buy.change_term_constraints import ( + MediaBuyChangeTermConstraints3 as PackageCountChangeConstraints, +) +from adcp.types.generated_poc.media_buy.change_term_constraints import ( + MediaBuyChangeTermConstraints4 as EffectiveTimingChangeConstraints, +) from adcp.types.generated_poc.core.media_buy import ( MediaBuy as CoreMediaBuy, ) @@ -2305,6 +2317,10 @@ class UnknownGroupAsset(_BaseGroupAsset): "ProviderRegistrationTmpxMacro", # Unit "DurationUnit", + "BudgetChangeConstraints", + "FlightChangeConstraints", + "PackageCountChangeConstraints", + "EffectiveTimingChangeConstraints", "OverlayUnit", "RealEstateUnit", "VehicleUnit", diff --git a/src/adcp/types/generated_poc/core/canonical_media_buy_action.py b/src/adcp/types/generated_poc/core/canonical_media_buy_action.py index 98fad2462..9d957d2de 100644 --- a/src/adcp/types/generated_poc/core/canonical_media_buy_action.py +++ b/src/adcp/types/generated_poc/core/canonical_media_buy_action.py @@ -59,17 +59,17 @@ class Action3(StrEnum): class CanonicalMediaBuyAction1(CanonicalMediaBuyActionFields): task: Literal['control_media_buy'] = 'control_media_buy' - action: Action | None = None + action: Action class CanonicalMediaBuyAction2(CanonicalMediaBuyActionFields): task: Literal['refine_proposals'] = 'refine_proposals' - action: Action2 | None = None + action: Action2 class CanonicalMediaBuyAction3(CanonicalMediaBuyActionFields): task: Literal['sync_creatives'] = 'sync_creatives' - action: Action3 | None = None + action: Action3 class CanonicalMediaBuyAction( diff --git a/src/adcp/types/generated_poc/media_buy/change_term.py b/src/adcp/types/generated_poc/media_buy/change_term.py index 0cf150d4d..41167de2a 100644 --- a/src/adcp/types/generated_poc/media_buy/change_term.py +++ b/src/adcp/types/generated_poc/media_buy/change_term.py @@ -8,7 +8,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import ConfigDict, Field, RootModel +from pydantic import ConfigDict, Field, RootModel, model_validator from ..core import ext as ext_1 from ..core import sla_window @@ -77,3 +77,22 @@ class MediaBuyChangeTerm(AdCPBaseModel): ), ] = None ext: ext_1.ExtensionObject | None = None + + @model_validator(mode='after') + def _validate_constraint_action(self) -> MediaBuyChangeTerm: + if self.constraints is None: + return self + kind = self.constraints.kind + allowed = { + 'budget': { + 'increase_budget', 'decrease_budget', 'reallocate_budget', + 'update_budget_allocation', 'update_spend_target', + }, + 'flight': {'extend_flight', 'shorten_flight', 'update_flight_dates'}, + 'package_count': {'add_packages', 'remove_packages'}, + 'effective_timing': {'pause', 'resume', 'cancel'}, + } + action = self.action.value + if action not in allowed.get(kind, set()): + raise ValueError('constraint kind is incompatible with action') + return self diff --git a/src/adcp/types/generated_poc/media_buy/change_term_constraints.py b/src/adcp/types/generated_poc/media_buy/change_term_constraints.py index 66b419702..95fb7fc10 100644 --- a/src/adcp/types/generated_poc/media_buy/change_term_constraints.py +++ b/src/adcp/types/generated_poc/media_buy/change_term_constraints.py @@ -7,7 +7,7 @@ from typing import Any, Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, RootModel +from pydantic import AwareDatetime, ConfigDict, Field, RootModel, model_validator, model_validator, model_validator from ..core import duration @@ -32,6 +32,13 @@ class MediaBuyChangeTermConstraints3(AdCPBaseModel): ] = None + @model_validator(mode='after') + def _require_portable_bound(self) -> MediaBuyChangeTermConstraints3: + if not any(getattr(self, name) is not None for name in ('max_additions', 'max_removals', 'max_result_count')): + raise ValueError('at least one portable constraint bound is required') + return self + + class Money(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -66,6 +73,13 @@ class MediaBuyChangeTermConstraints1(AdCPBaseModel): ] = None + @model_validator(mode='after') + def _require_portable_bound(self) -> MediaBuyChangeTermConstraints1: + if not any(getattr(self, name) is not None for name in ('max_delta_amount', 'max_delta_percent', 'min_result_amount', 'max_result_amount')): + raise ValueError('at least one portable constraint bound is required') + return self + + class MediaBuyChangeTermConstraints2(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -93,6 +107,13 @@ class MediaBuyChangeTermConstraints2(AdCPBaseModel): ] = None + @model_validator(mode='after') + def _require_portable_bound(self) -> MediaBuyChangeTermConstraints2: + if not any(getattr(self, name) is not None for name in ('max_change', 'earliest_result', 'latest_result', 'minimum_notice')): + raise ValueError('at least one portable constraint bound is required') + return self + + class MediaBuyChangeTermConstraints4(AdCPBaseModel): model_config = ConfigDict( extra='forbid', @@ -108,6 +129,13 @@ class MediaBuyChangeTermConstraints4(AdCPBaseModel): latest_effective_at: AwareDatetime | None = None + @model_validator(mode='after') + def _require_portable_bound(self) -> MediaBuyChangeTermConstraints4: + if not any(getattr(self, name) is not None for name in ('minimum_notice', 'earliest_effective_at', 'latest_effective_at')): + raise ValueError('at least one portable constraint bound is required') + return self + + class MediaBuyChangeTermConstraints( RootModel[ MediaBuyChangeTermConstraints1 diff --git a/src/adcp/types/generated_poc/media_buy/commercial_terms.py b/src/adcp/types/generated_poc/media_buy/commercial_terms.py index 853505916..6cce2d04d 100644 --- a/src/adcp/types/generated_poc/media_buy/commercial_terms.py +++ b/src/adcp/types/generated_poc/media_buy/commercial_terms.py @@ -7,7 +7,7 @@ from typing import Annotated from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field +from pydantic import AwareDatetime, ConfigDict, Field, model_validator from ..core import ( bidding_policy, @@ -118,3 +118,51 @@ class CommercialTerms(AdCPBaseModel): min_length=1, ), ] = None + + @model_validator(mode='after') + def _validate_change_term_set(self) -> CommercialTerms: + if self.change_terms is None: + return self + actions = [term.action.value for term in self.change_terms] + term_ids = [term.term_id for term in self.change_terms] + if len(set(actions)) != len(actions): + raise ValueError('change_terms must be uniquely keyed by action') + if len(set(term_ids)) != len(term_ids): + raise ValueError('change_terms term_id values must be unique') + currencies = set() + for purchase in self.purchases: + if purchase.pricing is None: + raise ValueError('accepted commercial-term purchases require resolved pricing') + currencies.add(purchase.pricing.currency) + for term in self.change_terms: + if term.constraints is None: + continue + constraint = term.constraints.root + if constraint.kind == 'budget': + money_fields = ( + constraint.max_delta_amount, + constraint.min_result_amount, + constraint.max_result_amount, + ) + if any(money is not None and money.currency not in currencies for money in money_fields): + raise ValueError('change-term monetary constraint currency must match purchases') + if ( + constraint.min_result_amount is not None + and constraint.max_result_amount is not None + and constraint.min_result_amount.amount > constraint.max_result_amount.amount + ): + raise ValueError('change-term minimum result exceeds maximum result') + elif constraint.kind == 'flight': + if ( + constraint.earliest_result is not None + and constraint.latest_result is not None + and constraint.earliest_result > constraint.latest_result + ): + raise ValueError('change-term earliest result exceeds latest result') + elif constraint.kind == 'effective_timing' and ( + constraint.earliest_effective_at is not None + and constraint.latest_effective_at is not None + and constraint.earliest_effective_at > constraint.latest_effective_at + ): + raise ValueError('change-term earliest effective time exceeds latest time') + return self diff --git a/src/adcp/types/media_buy.py b/src/adcp/types/media_buy.py index b596b5da5..cc67b3150 100644 --- a/src/adcp/types/media_buy.py +++ b/src/adcp/types/media_buy.py @@ -43,6 +43,10 @@ "AcceptancePolicyProfileIds", "AcceptancePolicyRequirement", "AcceptancePolicyRule", + "CanonicalMediaBuyAction", + "CanonicalMediaBuyActionMode", + "CanonicalMediaBuyActionName", + "CanonicalProductAction", "RegistryAcceptancePolicyProfileReference", "CompatibilityPurchaseCoordinatorInput", "ControlMediaBuyRequest", @@ -52,6 +56,7 @@ "CreateMediaBuySuccessResponse", "CreateMediaBuySubmittedResponse", "CreateMediaBuyErrorResponse", + "CommercialTerms", "UpdateMediaBuyRequest", "UpdateMediaBuyResponse", "UpdateMediaBuySuccessResponse", @@ -66,6 +71,16 @@ "GetMediaBuyArtifactsRequest", "GetMediaBuyArtifactsResponse", "MediaBuy", + "MediaBuyAvailableAction", + "MediaBuyActionMode", + "MediaBuyChangeTerm", + "MediaBuyChangeTermConstraints", + "MediaBuyChangeTermId", + "MediaBuyValidAction", + "BudgetChangeConstraints", + "EffectiveTimingChangeConstraints", + "FlightChangeConstraints", + "PackageCountChangeConstraints", "MediaBuyPackage", "MediaBuyDelivery", "MediaBuyDeliveryStatus", @@ -90,6 +105,7 @@ "Totals", "AggregatedTotals", "Proposal", + "ProductAllowedAction", "Results", ] @@ -121,9 +137,15 @@ AcceptProposalResponse, AggregatedTotals, AssignedPackage, + BudgetChangeConstraints, BuyProductsRequest, BuyProductsResponse, ByPackageItem, + CanonicalMediaBuyAction, + CanonicalMediaBuyActionMode, + CanonicalMediaBuyActionName, + CanonicalProductAction, + CommercialTerms, CompatibilityPurchaseCoordinatorInput, ControlMediaBuyRequest, ControlMediaBuyResponse, @@ -139,6 +161,8 @@ DeliveryMetrics, DeliveryStatus, DeliveryType, + EffectiveTimingChangeConstraints, + FlightChangeConstraints, FrequencyCap, FrequencyCapScope, GetMediaBuyArtifactsRequest, @@ -150,17 +174,25 @@ ListProductsRequest, ListProductsResponse, MediaBuy, + MediaBuyActionMode, + MediaBuyAvailableAction, + MediaBuyChangeTerm, + MediaBuyChangeTermConstraints, + MediaBuyChangeTermId, MediaBuyDelivery, MediaBuyDeliveryStatus, MediaBuyFeatures, MediaBuyPackage, MediaBuyStatus, + MediaBuyValidAction, OptimizationGoal, Overlay, Pacing, Package, + PackageCountChangeConstraints, PackageRequest, PackageUpdate, + ProductAllowedAction, Proposal, RefineProposalsRequest, RefineProposalsResponse, diff --git a/tests/fixtures/media_buy_action_assessment.json b/tests/fixtures/media_buy_action_assessment.json new file mode 100644 index 000000000..3719e5eb6 --- /dev/null +++ b/tests/fixtures/media_buy_action_assessment.json @@ -0,0 +1,119 @@ +{ + "schema_version": 1, + "cases": [ + { + "name": "available_self_serve_pause", + "action": "pause", + "product": { + "allowed_actions": [ + {"action": "pause", "modes": ["self_serve"]} + ] + }, + "proposal": { + "commercial_terms": { + "change_terms": [ + { + "term_id": "right_pause_active", + "action": "pause", + "service_mode": "self_serve", + "allowed_statuses": ["active"] + } + ] + } + }, + "media_buy": { + "status": "active", + "available_actions": [ + { + "action": "pause", + "mode": "self_serve", + "task": "control_media_buy", + "change_term_id": "right_pause_active" + } + ] + }, + "expected": { + "status": "available_now", + "possible": "yes", + "promised": "yes", + "available": "yes", + "task": "control_media_buy", + "mode": "self_serve", + "change_term_id": "right_pause_active" + } + }, + { + "name": "explicitly_not_negotiated", + "action": "pause", + "product": { + "allowed_actions": [ + {"action": "pause", "modes": ["self_serve"]} + ] + }, + "proposal": {"commercial_terms": {"change_terms": []}}, + "media_buy": {"status": "active", "available_actions": []}, + "expected": { + "status": "not_negotiated", + "possible": "yes", + "promised": "no", + "available": "no", + "task": null, + "mode": null, + "change_term_id": null + } + }, + { + "name": "legacy_unknown_rights", + "action": "pause", + "product": null, + "proposal": {"commercial_terms": {}}, + "media_buy": {"status": "active", "valid_actions": ["pause"]}, + "expected": { + "status": "legacy_unknown", + "possible": "unknown", + "promised": "unknown", + "available": "unknown", + "task": null, + "mode": null, + "change_term_id": null + } + }, + { + "name": "mismatched_aliases_fail_closed", + "action": "pause", + "product": null, + "proposal": { + "commercial_terms": { + "change_terms": [ + { + "term_id": "right_pause", + "action": "pause", + "service_mode": "self_serve" + } + ] + } + }, + "media_buy": { + "status": "active", + "available_actions": [ + { + "action": "pause", + "mode": "self_serve", + "task": "control_media_buy", + "change_term_id": "right_pause", + "terms_ref": "different" + } + ] + }, + "expected": { + "status": "currently_unavailable", + "possible": "unknown", + "promised": "yes", + "available": "no", + "task": "control_media_buy", + "mode": "self_serve", + "change_term_id": "right_pause" + } + } + ] +} diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index b6f83d37d..f27b21375 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -46,6 +46,13 @@ "AcquireRightsResponse", "AcquireRightsResponse1", "AcquireRightsResponse1", + "ActionAvailabilityStatus", + "ActionDiagnostic", + "ActionDiagnosticCode", + "ActionDispatchClient", + "ActionIntent", + "ActionKnowledge", + "ActionTask", "ActivateSignalErrorResponse", "ActivateSignalRequest", "ActivateSignalResponse", @@ -92,6 +99,7 @@ "BrandRegistryItem", "BrandSource", "BriefAsset", + "BudgetChangeConstraints", "BuyProductsRequest", "BuyProductsResponse", "BuyingMode", @@ -99,6 +107,10 @@ "CalibrateContentErrorResponse", "CalibrateContentResponse1", "CalibrateContentSuccessResponse", + "CanonicalMediaBuyAction", + "CanonicalMediaBuyActionMode", + "CanonicalMediaBuyActionName", + "CanonicalProductAction", "CardAsset", "Catalog", "CatalogAction", @@ -110,15 +122,19 @@ "CatalogRequirements", "CatalogType", "ChangeHandler", + "ChangeTermSelection", "CheckGovernanceRequest", "CheckGovernanceResponse", "Checkpoint", + "CommercialTerms", "CompatibilityPurchaseCoordinatorInput", "ComplyTestControllerRequest", "ComplyTestControllerResponse", "ComplyTestControllerResponse1", "ConfigurationError", "ConsentBasis", + "ConstraintCheck", + "ConstraintOutcome", "ContentIdType", "ContextMatchRequest", "ContextMatchResponse", @@ -173,6 +189,7 @@ "DomainLookupResult", "DownstreamConnectionRequirement", "Duration", + "EffectiveTimingChangeConstraints", "EntryErrorKind", "Error", "ErrorCode", @@ -192,6 +209,7 @@ "FeedStateStore", "FileCursorStore", "FlatRatePricingOption", + "FlightChangeConstraints", "Format", "FormatOptionReference", "GeneratedTaskStatus", @@ -329,10 +347,19 @@ "MarkdownAsset", "McpWebhookPayload", "MediaBuy", + "MediaBuyActionAssessment", + "MediaBuyActionError", + "MediaBuyActionMode", + "MediaBuyActionProjection", + "MediaBuyAvailableAction", + "MediaBuyChangeTerm", + "MediaBuyChangeTermConstraints", + "MediaBuyChangeTermId", "MediaBuyDeliveryStatus", "MediaBuyDeliveryWebhookResult", "MediaBuyPackage", "MediaBuyStatus", + "MediaBuyValidAction", "MediaChannel", "Member", "MemoryBackend", @@ -352,6 +379,7 @@ "OptimizationGoal", "Overlay", "Package", + "PackageCountChangeConstraints", "PackageRequest", "PackageSignalTargeting", "PackageSignalTargetingGroup", @@ -379,9 +407,11 @@ "PricingModel", "PricingOption", "Product", + "ProductAllowedAction", "ProductFilters", "ProductFormatDeclaration", "ProductSignalTargetingOption", + "ProjectedMediaBuyAction", "Property", "PropertyActivity", "PropertyId", @@ -575,6 +605,8 @@ "WholesaleFeedWebhook", "ZipAsset", "aliases", + "assess_media_buy_action", + "assess_update_media_buy_actions", "challenge_webhook_destination", "complete_oauth_authorization", "create_a2a_webhook_payload", @@ -584,8 +616,10 @@ "creative_agent", "detect_publisher_properties_divergence", "discover_oauth_metadata", + "dispatch_media_buy_action", "domain_matches", "encode_unreserved", + "evaluate_action_constraints", "extract_webhook_result_data", "fetch_adagents", "fetch_adagents_with_cache", @@ -613,8 +647,12 @@ "identifiers_match", "inject_trace_headers", "is_tracing_available", + "materialize_change_terms", "normalize_assets_required", + "project_available_actions", + "reassess_media_buy_action", "resolve_properties_for_agent", + "route_media_buy_action", "sign_legacy_webhook", "sign_webhook", "start_oauth_authorization", @@ -724,6 +762,7 @@ "BrandSource", "BriefAsset", "BriefFormatAsset", + "BudgetChangeConstraints", "BudgetTooLowDetails", "BuildCreativeCreative", "BusinessEntity", @@ -754,6 +793,10 @@ "CanonicalFormatResponsiveCreative", "CanonicalFormatSponsoredPlacement", "CanonicalFormatVastVideo", + "CanonicalMediaBuyAction", + "CanonicalMediaBuyActionMode", + "CanonicalMediaBuyActionName", + "CanonicalProductAction", "CanonicalProjectionReference", "CanonicalProposalTotalBudgetGuidance", "CanonicalSlotOverride", @@ -783,6 +826,7 @@ "CollectionListChangedWebhook", "CollectionListFilters", "Colors", + "CommercialTerms", "CompatibilityPurchaseCoordinatorInput", "ComplyErrorResponse", "ComplyListScenariosResponse", @@ -887,6 +931,7 @@ "DownstreamConnectionRequirement", "Duration", "DurationUnit", + "EffectiveTimingChangeConstraints", "Error", "ErrorCode", "EventType", @@ -896,6 +941,7 @@ "FieldModel", "FlatFeeVendorPricingOption", "FlatRatePricingOption", + "FlightChangeConstraints", "Fonts", "ForecastMethod", "ForecastPoint", @@ -1100,12 +1146,18 @@ "McpWebhookPayload", "MeasurementPeriod", "MediaBuy", + "MediaBuyActionMode", + "MediaBuyAvailableAction", + "MediaBuyChangeTerm", + "MediaBuyChangeTermConstraints", + "MediaBuyChangeTermId", "MediaBuyDelivery", "MediaBuyDeliveryStatus", "MediaBuyDeliveryWebhookResult", "MediaBuyFeatures", "MediaBuyPackage", "MediaBuyStatus", + "MediaBuyValidAction", "MediaChannel", "MediaSubAsset", "Member", @@ -1128,6 +1180,7 @@ "OverlayUnit", "Pacing", "Package", + "PackageCountChangeConstraints", "PackageRequest", "PackageSignalTargeting", "PackageSignalTargetingGroup", @@ -1170,6 +1223,7 @@ "PrimaryCountry", "Product", "ProductAllocation", + "ProductAllowedAction", "ProductCard", "ProductCardDetailed", "ProductCatalog", diff --git a/tests/test_media_buy_actions.py b/tests/test_media_buy_actions.py new file mode 100644 index 000000000..d7e4b210d --- /dev/null +++ b/tests/test_media_buy_actions.py @@ -0,0 +1,867 @@ +from __future__ import annotations + +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import pytest +from pydantic import BaseModel, ValidationError + +from adcp.media_buy_actions import ( + ActionAvailabilityStatus, + ActionDiagnosticCode, + ActionIntent, + ActionKnowledge, + ActionTask, + ChangeTermSelection, + ConstraintOutcome, + MediaBuyActionError, + assess_media_buy_action, + assess_update_media_buy_actions, + dispatch_media_buy_action, + evaluate_action_constraints, + materialize_change_terms, + project_available_actions, + reassess_media_buy_action, + route_media_buy_action, +) +from adcp.validation.schema_loader import get_named_validator + +NOW = datetime(2026, 8, 28, 12, 0, tzinfo=timezone.utc) +GOLDEN_FIXTURE = Path(__file__).parent / "fixtures" / "media_buy_action_assessment.json" +ACTION_SCHEMA = ( + Path(__file__).parent.parent + / "schemas" + / "cache" + / "3.2.0-beta.9" + / "core" + / "canonical-media-buy-action.json" +) + + +def _product(*actions: dict[str, Any]) -> dict[str, Any]: + return {"allowed_actions": list(actions)} + + +def _term( + action: str, + *, + term_id: str | None = None, + mode: str = "self_serve", + statuses: list[str] | None = None, + constraints: dict[str, Any] | None = None, + conditions: list[str] | None = None, + sla: dict[str, Any] | None = None, +) -> dict[str, Any]: + value: dict[str, Any] = { + "term_id": term_id or f"right_{action}", + "action": action, + "service_mode": mode, + } + if statuses is not None: + value["allowed_statuses"] = statuses + if constraints is not None: + value["constraints"] = constraints + if conditions is not None: + value["conditions"] = conditions + if sla is not None: + value["processing_sla"] = sla + return value + + +def _proposal(*terms: dict[str, Any], present: bool = True) -> dict[str, Any]: + commercial_terms: dict[str, Any] = {} + if present: + commercial_terms["change_terms"] = list(terms) + return {"commercial_terms": commercial_terms} + + +def _buy( + status: str, + *actions: dict[str, Any], + proposal: dict[str, Any] | None = None, +) -> dict[str, Any]: + value: dict[str, Any] = {"status": status, "available_actions": list(actions)} + if proposal is not None: + value["accepted_proposal"] = proposal + return value + + +def _live( + action: str, + *, + term_id: str | None = None, + mode: str = "self_serve", + task: str | None = None, + terms_ref: str | None = None, + sla: dict[str, Any] | None = None, +) -> dict[str, Any]: + value: dict[str, Any] = { + "action": action, + "mode": mode, + "change_term_id": term_id or f"right_{action}", + } + if task is not None: + value["task"] = task + if terms_ref is not None: + value["terms_ref"] = terms_ref + if sla is not None: + value["sla"] = sla + return value + + +def _commercial_terms(*change_terms: dict[str, Any]) -> dict[str, Any]: + return { + "brand": {"domain": "example.com"}, + "purchases": [ + { + "product_id": "product-1", + "pricing_option_id": "price-1", + "pricing": { + "pricing_option_id": "price-1", + "pricing_model": "cpm", + "currency": "USD", + }, + } + ], + "start_time": "asap", + "end_time": "2026-12-01T00:00:00Z", + "change_terms": list(change_terms), + } + + +@pytest.mark.parametrize( + ("action", "task"), + [ + ("pause", ActionTask.control_media_buy), + ("increase_budget", ActionTask.control_media_buy), + ("remove_packages", ActionTask.control_media_buy), + ("extend_flight", ActionTask.refine_proposals), + ("add_packages", ActionTask.refine_proposals), + ("replace_creative", ActionTask.sync_creatives), + ("future_action", None), + ], +) +def test_route_media_buy_action(action: str, task: ActionTask | None) -> None: + assert route_media_buy_action(action) is task + + +def test_default_routes_track_released_canonical_action_schema() -> None: + schema = json.loads(ACTION_SCHEMA.read_text()) + expected: dict[str, ActionTask] = {} + for arm in schema["oneOf"]: + properties = arm["properties"] + task = ActionTask(properties["task"]["const"]) + for action in properties["action"]["enum"]: + expected.setdefault(action, task) + + assert {action: route_media_buy_action(action) for action in expected} == expected + + +def test_assessment_joins_possible_promised_and_available() -> None: + term = _term("pause", statuses=["active"]) + result = assess_media_buy_action( + "pause", + product=_product({"action": "pause", "modes": ["self_serve"]}), + proposal=_proposal(term), + media_buy=_buy( + "active", + _live("pause", task="control_media_buy"), + ), + ) + + assert result.status is ActionAvailabilityStatus.available_now + assert (result.possible, result.promised, result.available) == ( + ActionKnowledge.yes, + ActionKnowledge.yes, + ActionKnowledge.yes, + ) + assert result.task is ActionTask.control_media_buy + assert result.change_term_id == "right_pause" + + +def test_cross_sdk_action_assessment_golden_fixture() -> None: + fixture = json.loads(GOLDEN_FIXTURE.read_text()) + + assert fixture["schema_version"] == 1 + for case in fixture["cases"]: + result = assess_media_buy_action( + case["action"], + product=case["product"], + proposal=case["proposal"], + media_buy=case["media_buy"], + ) + normalized = result.model_dump( + mode="json", + include={ + "status", + "possible", + "promised", + "available", + "task", + "mode", + "change_term_id", + }, + ) + assert normalized == case["expected"], case["name"] + + +def test_product_can_narrow_an_accepted_live_right() -> None: + result = assess_media_buy_action( + "pause", + product=_product({"action": "resume", "modes": ["self_serve"]}), + proposal=_proposal(_term("pause")), + media_buy=_buy("active", _live("pause")), + ) + + assert result.status is ActionAvailabilityStatus.unsupported_by_product + assert result.possible is ActionKnowledge.no + + +def test_absent_change_terms_is_legacy_unknown_even_with_flat_valid_action() -> None: + result = assess_media_buy_action( + "pause", + proposal=_proposal(present=False), + media_buy={"status": "active", "valid_actions": ["pause"]}, + ) + + assert result.status is ActionAvailabilityStatus.legacy_unknown + assert result.promised is ActionKnowledge.unknown + assert {item.code for item in result.diagnostics} >= { + ActionDiagnosticCode.legacy_terms_unknown, + ActionDiagnosticCode.legacy_coarse_action, + } + + +def test_explicit_change_terms_omission_is_not_negotiated() -> None: + result = assess_media_buy_action( + "pause", + proposal=_proposal(_term("resume")), + media_buy=_buy("active", _live("pause")), + ) + + assert result.status is ActionAvailabilityStatus.not_negotiated + assert result.promised is ActionKnowledge.no + + +def test_wrong_status_is_distinct_from_current_unavailability() -> None: + result = assess_media_buy_action( + "increase_budget", + proposal=_proposal(_term("increase_budget", statuses=["active"])), + media_buy=_buy("paused"), + ) + + assert result.status is ActionAvailabilityStatus.wrong_status + assert result.promised is ActionKnowledge.yes + assert result.available is ActionKnowledge.no + + +def test_negotiated_but_omitted_live_action_is_currently_unavailable() -> None: + result = assess_media_buy_action( + "pause", + proposal=_proposal(_term("pause", statuses=["active"])), + media_buy=_buy("active"), + ) + + assert result.status is ActionAvailabilityStatus.currently_unavailable + + +def test_mismatched_dual_aliases_fail_closed() -> None: + result = assess_media_buy_action( + "pause", + proposal=_proposal(_term("pause", term_id="right_pause")), + media_buy=_buy( + "active", + _live("pause", term_id="right_pause", terms_ref="different"), + ), + ) + + assert result.status is ActionAvailabilityStatus.currently_unavailable + assert ActionDiagnosticCode.alias_mismatch in {item.code for item in result.diagnostics} + + +def test_protocol_allowed_refinement_route_is_accepted_for_overlapping_action() -> None: + result = assess_media_buy_action( + "increase_budget", + proposal=_proposal(_term("increase_budget")), + media_buy=_buy( + "active", + _live("increase_budget", task="refine_proposals"), + ), + ) + + assert result.status is ActionAvailabilityStatus.available_now + assert result.task is ActionTask.refine_proposals + + +def test_unknown_live_route_fails_closed() -> None: + result = assess_media_buy_action( + "pause", + proposal=_proposal(_term("pause")), + media_buy=_buy("active", _live("pause", task="delete_everything")), + ) + + assert result.status is ActionAvailabilityStatus.currently_unavailable + assert result.task is None + assert ActionDiagnosticCode.route_mismatch in {item.code for item in result.diagnostics} + + +def test_seller_managed_action_on_wrong_task_fails_closed() -> None: + result = assess_media_buy_action( + "extend_flight", + proposal=_proposal(_term("extend_flight", mode="seller_managed")), + media_buy=_buy( + "active", + _live( + "extend_flight", + mode="seller_managed", + task="control_media_buy", + ), + ), + ) + + assert result.status is ActionAvailabilityStatus.currently_unavailable + assert ActionDiagnosticCode.route_mismatch in {item.code for item in result.diagnostics} + + +def test_legacy_terms_ref_is_not_promoted_without_accepted_terms() -> None: + result = assess_media_buy_action( + "pause", + proposal=_proposal(present=False), + media_buy={ + "status": "active", + "available_actions": [ + {"action": "pause", "mode": "self_serve", "terms_ref": "right_pause"} + ], + }, + ) + + assert result.status is ActionAvailabilityStatus.legacy_unknown + assert result.change_term_id is None + + +def test_unknown_future_action_round_trips_without_becoming_available() -> None: + result = assess_media_buy_action( + "future_action_v4", + proposal=_proposal(_term("future_action_v4")), + media_buy=_buy("active", _live("future_action_v4")), + ) + + assert result.action == "future_action_v4" + assert result.status is ActionAvailabilityStatus.currently_unavailable + assert result.available is ActionKnowledge.unknown + assert ActionDiagnosticCode.unknown_action in {item.code for item in result.diagnostics} + + +def test_untrusted_unknown_action_is_not_echoed_in_diagnostic_detail() -> None: + action = "future\nsecret" + result = project_available_actions( + [{"term_id": "right", "action": action, "service_mode": "self_serve"}], + "active", + ) + + assert result.actions == () + assert result.diagnostics[0].detail is None + + +def test_seller_projection_rejects_schema_invalid_term() -> None: + result = project_available_actions( + [{"term_id": "right", "action": "pause", "service_mode": "future_mode"}], + "active", + ) + + assert result.actions == () + assert result.diagnostics[0].code is ActionDiagnosticCode.invalid_projection + + +def test_materialize_change_terms_requires_explicit_selection_and_copies_bounds() -> None: + terms = materialize_change_terms( + [ + { + "action": "increase_budget", + "modes": ["self_serve", "seller_managed"], + "allowed_statuses": ["active", "paused"], + "sla": {"response_max": "PT30M"}, + "constraints": {"kind": "budget", "max_delta_percent": 20}, + "terms_ref": "https://seller.example/terms/budget", + }, + {"action": "pause", "modes": ["self_serve"]}, + ], + [ + ChangeTermSelection( + action="increase_budget", + term_id="right_increase", + service_mode="seller_managed", + allowed_statuses=("active",), + conditions=("account_in_good_standing",), + ) + ], + ) + + assert len(terms) == 1 + assert terms[0].model_dump(mode="json", exclude_none=True) == { + "term_id": "right_increase", + "action": "increase_budget", + "service_mode": "seller_managed", + "allowed_statuses": ["active"], + "processing_sla": {"response_max": "PT30M"}, + "conditions": ["account_in_good_standing"], + "constraints": {"kind": "budget", "max_delta_percent": 20.0}, + "terms_ref": "https://seller.example/terms/budget", + } + + +def test_materialize_requires_mode_when_product_has_multiple() -> None: + with pytest.raises(MediaBuyActionError, match="service_mode_required"): + materialize_change_terms( + [{"action": "increase_budget", "modes": ["self_serve", "seller_managed"]}], + [{"action": "increase_budget", "term_id": "right_increase"}], + ) + + +def test_materialize_cannot_expand_status_scope_or_constraint_kind() -> None: + with pytest.raises(MediaBuyActionError, match="status_scope_expanded"): + materialize_change_terms( + [ + { + "action": "pause", + "modes": ["self_serve"], + "allowed_statuses": ["active"], + } + ], + [ + { + "action": "pause", + "term_id": "right_pause", + "allowed_statuses": ["active", "paused"], + } + ], + ) + + +@pytest.mark.parametrize( + "constraint_type", + [ + "BudgetChangeConstraints", + "FlightChangeConstraints", + "PackageCountChangeConstraints", + "EffectiveTimingChangeConstraints", + ], +) +def test_generated_constraint_models_require_a_portable_bound(constraint_type: str) -> None: + from adcp import types + + model = getattr(types, constraint_type) + with pytest.raises(ValidationError, match="portable constraint bound"): + model() + + +def test_generated_canonical_action_requires_action_field() -> None: + from adcp.types import CanonicalMediaBuyAction + + with pytest.raises(ValidationError): + CanonicalMediaBuyAction.model_validate({"task": "control_media_buy", "mode": "self_serve"}) + + +def test_generated_change_term_rejects_incompatible_constraint_kind() -> None: + from adcp.types import MediaBuyChangeTerm + + with pytest.raises(ValidationError, match="incompatible with action"): + MediaBuyChangeTerm.model_validate( + { + "term_id": "right_pause", + "action": "pause", + "service_mode": "self_serve", + "constraints": {"kind": "budget", "max_delta_percent": 10}, + } + ) + + +def test_generated_commercial_terms_reject_duplicate_rights_and_bad_currency() -> None: + from adcp.types import CommercialTerms + + with pytest.raises(ValidationError, match="uniquely keyed by action"): + CommercialTerms.model_validate( + _commercial_terms( + _term("pause", term_id="right-a"), + _term("pause", term_id="right-b"), + ) + ) + + with pytest.raises(ValidationError, match="currency must match purchases"): + CommercialTerms.model_validate( + _commercial_terms( + _term( + "increase_budget", + constraints={ + "kind": "budget", + "max_delta_amount": {"amount": 100, "currency": "EUR"}, + }, + ) + ) + ) + + with pytest.raises(MediaBuyActionError, match="constraint_action_mismatch"): + materialize_change_terms( + [ + { + "action": "pause", + "modes": ["self_serve"], + "constraints": {"kind": "budget", "max_delta_percent": 5}, + } + ], + [{"action": "pause", "term_id": "right_pause"}], + ) + + +def _scenario_terms() -> list[dict[str, Any]]: + return [ + _term("pause", term_id="right_pause_active", statuses=["active"]), + _term("resume", term_id="right_resume_paused", statuses=["paused"]), + _term( + "increase_budget", + term_id="right_increase_active", + mode="seller_managed", + statuses=["active"], + constraints={"kind": "budget", "max_delta_percent": 20}, + conditions=["account_in_good_standing"], + sla={"response_max": "PT30M", "completion_max": "PT24H"}, + ), + _term( + "decrease_budget", + term_id="right_decrease_paused", + statuses=["paused"], + constraints={"kind": "budget", "max_delta_percent": 50}, + ), + _term("extend_flight", term_id="right_extend_active", statuses=["active"]), + ] + + +def test_seller_projection_matches_released_active_storyboard() -> None: + result = project_available_actions(_scenario_terms(), "active") + + assert result.to_wire() == [ + { + "action": "pause", + "mode": "self_serve", + "task": "control_media_buy", + "change_term_id": "right_pause_active", + }, + { + "action": "extend_flight", + "mode": "self_serve", + "task": "refine_proposals", + "change_term_id": "right_extend_active", + }, + ] + assert ActionDiagnosticCode.condition_unresolved in {item.code for item in result.diagnostics} + + +def test_seller_projection_matches_released_paused_storyboard() -> None: + result = project_available_actions(_scenario_terms(), "paused") + + assert result.to_wire() == [ + { + "action": "resume", + "mode": "self_serve", + "task": "control_media_buy", + "change_term_id": "right_resume_paused", + }, + { + "action": "decrease_budget", + "mode": "self_serve", + "task": "control_media_buy", + "change_term_id": "right_decrease_paused", + }, + ] + + +def test_projection_gates_authorization_delegation_policy_and_product() -> None: + terms = [_term("pause"), _term("resume", statuses=["paused"])] + + assert ( + project_available_actions( + terms, + "active", + authorized_actions=[], + ).actions + == () + ) + assert ( + project_available_actions( + terms, + "active", + delegated_actions=["resume"], + ).actions + == () + ) + assert ( + project_available_actions( + terms, + "active", + policy_actions=["resume"], + ).actions + == () + ) + assert ( + project_available_actions( + terms, + "active", + product_actions=[{"action": "resume", "modes": ["self_serve"]}], + ).actions + == () + ) + + +@pytest.mark.parametrize("status", ["completed", "rejected", "canceled", "future_status"]) +def test_terminal_or_unknown_status_projects_no_actions(status: str) -> None: + assert project_available_actions([_term("cancel")], status).actions == () + + +def test_resolved_condition_allows_seller_managed_standard_async_action() -> None: + result = project_available_actions( + _scenario_terms(), + "active", + resolved_conditions={"account_in_good_standing": True}, + ) + increase = next(action for action in result.actions if action.action == "increase_budget") + assert increase.mode == "seller_managed" + assert increase.task is ActionTask.control_media_buy + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ( + "3.1.19", + { + "action": "increase_budget", + "mode": "requires_approval", + "sla": {"response_max": "PT30M"}, + "terms_ref": "right_increase", + }, + ), + ( + "3.2.0-beta.6", + { + "action": "increase_budget", + "mode": "requires_approval", + "task": "control_media_buy", + "sla": {"response_max": "PT30M"}, + "terms_ref": "right_increase", + }, + ), + ( + "3.2.0-beta.9", + { + "action": "increase_budget", + "mode": "seller_managed", + "task": "control_media_buy", + "sla": {"response_max": "PT30M"}, + "change_term_id": "right_increase", + }, + ), + ], +) +def test_projection_version_matrix(version: str, expected: dict[str, Any]) -> None: + result = project_available_actions( + [ + _term( + "increase_budget", + term_id="right_increase", + mode="seller_managed", + sla={"response_max": "PT30M"}, + ) + ], + "active", + protocol_version=version, + ) + wire = result.to_wire()[0] + assert wire == expected + + schema_version = "3.1" if version == "3.1.19" else version + validator = get_named_validator("core/media-buy-available-action.json", version=schema_version) + assert validator is not None + assert list(validator.iter_errors(wire)) == [] + + +@pytest.mark.parametrize( + ("constraint", "intent", "outcomes"), + [ + ( + { + "kind": "budget", + "max_delta_amount": {"amount": 200, "currency": "USD"}, + "max_delta_percent": 20, + "min_result_amount": {"amount": 500, "currency": "USD"}, + "max_result_amount": {"amount": 1200, "currency": "USD"}, + }, + ActionIntent(current_amount=1000, result_amount=1100, currency="USD"), + [ConstraintOutcome.satisfied] * 4, + ), + ( + {"kind": "flight", "max_change": {"interval": 7, "unit": "days"}}, + ActionIntent( + current_time="2026-09-01T00:00:00Z", + result_time="2026-09-05T00:00:00Z", + ), + [ConstraintOutcome.satisfied], + ), + ( + {"kind": "package_count", "max_additions": 2, "max_result_count": 5}, + ActionIntent(additions=3, current_package_count=3, result_package_count=6), + [ConstraintOutcome.violated, ConstraintOutcome.violated], + ), + ( + { + "kind": "effective_timing", + "minimum_notice": {"interval": 2, "unit": "days"}, + }, + ActionIntent(effective_at="2026-08-29T12:00:00Z"), + [ConstraintOutcome.violated], + ), + ], +) +def test_portable_constraint_evaluation( + constraint: dict[str, Any], + intent: ActionIntent, + outcomes: list[ConstraintOutcome], +) -> None: + action = { + "budget": "increase_budget", + "flight": "extend_flight", + "package_count": "add_packages", + "effective_timing": "pause", + }[constraint["kind"]] + + result = evaluate_action_constraints(action, constraint, intent, now=NOW) + assert [check.outcome for check in result] == outcomes + + +def test_opaque_or_insufficient_constraints_remain_unknown() -> None: + result = evaluate_action_constraints( + "pause", + {"kind": "budget", "max_delta_percent": 10}, + ActionIntent(), + ) + assert result[0].outcome is ConstraintOutcome.unknown + + +def test_update_patch_preflights_binding_budget_constraint() -> None: + proposal = _proposal( + _term( + "decrease_budget", + constraints={"kind": "budget", "max_delta_percent": 50}, + ) + ) + current = { + "status": "paused", + "currency": "USD", + "total_budget": 1000, + "packages": [], + "available_actions": [_live("decrease_budget", task="control_media_buy")], + } + result = assess_update_media_buy_actions( + {"total_budget": {"amount": 100, "currency": "USD"}}, + current, + proposal=proposal, + now=NOW, + ) + + assert result[0].status is ActionAvailabilityStatus.currently_unavailable + assert result[0].constraints[0].outcome is ConstraintOutcome.violated + + +def test_action_not_allowed_echo_reassesses_stale_projection() -> None: + proposal = _proposal(_term("pause"), _term("resume")) + stale = _buy("active", _live("pause", task="control_media_buy")) + + refreshed = reassess_media_buy_action( + "pause", + currently_available_actions=[_live("resume", task="control_media_buy")], + media_buy=stale, + proposal=proposal, + ) + + assert refreshed.status is ActionAvailabilityStatus.currently_unavailable + assert refreshed.available is ActionKnowledge.no + + +def test_mixed_patch_keeps_available_and_blocked_actions_distinct() -> None: + proposal = _proposal( + _term("pause", statuses=["active"]), + _term("decrease_budget", statuses=["active"]), + ) + current = { + "status": "active", + "total_budget": {"amount": 1000, "currency": "USD"}, + "packages": [], + "available_actions": [_live("pause", task="control_media_buy")], + } + + results = assess_update_media_buy_actions( + { + "paused": True, + "total_budget": {"amount": 900, "currency": "USD"}, + }, + current, + proposal=proposal, + ) + + assert [(result.action, result.status) for result in results] == [ + ("pause", ActionAvailabilityStatus.available_now), + ("decrease_budget", ActionAvailabilityStatus.currently_unavailable), + ] + + +class _Request(BaseModel): + value: str + + +class _Client: + def __init__(self) -> None: + self.calls: list[tuple[str, BaseModel, Any]] = [] + + async def execute_task( + self, + task_name: str, + request: BaseModel, + *, + options: Any | None = None, + ) -> str: + self.calls.append((task_name, request, options)) + return "ok" + + +@pytest.mark.asyncio +async def test_dispatch_uses_assessed_route_and_standard_async_lifecycle() -> None: + assessment = assess_media_buy_action( + "increase_budget", + proposal=_proposal(_term("increase_budget", mode="seller_managed")), + media_buy=_buy( + "active", + _live( + "increase_budget", + mode="seller_managed", + task="control_media_buy", + ), + ), + ) + client = _Client() + request = _Request(value="x") + + assert assessment.async_processing is True + assert await dispatch_media_buy_action(client, assessment, request) == "ok" + assert client.calls == [("control_media_buy", request, None)] + + +@pytest.mark.asyncio +async def test_dispatch_refuses_unavailable_action() -> None: + assessment = assess_media_buy_action( + "pause", + proposal=_proposal(_term("pause")), + media_buy=_buy("active"), + ) + with pytest.raises(MediaBuyActionError, match="action_not_available"): + await dispatch_media_buy_action(_Client(), assessment, _Request(value="x")) diff --git a/tests/test_seller_agent_storyboard.py b/tests/test_seller_agent_storyboard.py index a75db6513..07883528a 100644 --- a/tests/test_seller_agent_storyboard.py +++ b/tests/test_seller_agent_storyboard.py @@ -24,6 +24,8 @@ import seller_agent as _sa # noqa: E402 (path manipulation above is intentional) +from adcp.validation.schema_loader import get_named_validator # noqa: E402 + # --------------------------------------------------------------------------- # Fixture: reset module-level globals before every test so tests are isolated. # --------------------------------------------------------------------------- @@ -784,3 +786,156 @@ def test_health_fields_tracks_impairment_lifecycle() -> None: reopened = _sa._health_fields_for_media_buy("mb-1", mb)["impairments"][0] assert reopened["impairment_id"] != first["impairment_id"] assert reopened["observed_at"] == "2026-05-23T10:10:00Z" + + +@pytest.mark.asyncio +async def test_beta9_change_rights_state_projection_storyboard() -> None: + """Run the released beta.9 active-to-paused change-rights scenario.""" + + seller = _seller() + await _store().seed_media_buy( + media_buy_id="change_rights_state_buy", + fixture={ + "status": "active", + "currency": "USD", + "total_budget": 10000, + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2099-12-31T23:59:59Z", + "packages": [ + { + "package_id": "pkg", + "product_id": "product", + "pricing_option_id": "price", + "budget": 10000, + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2099-12-31T23:59:59Z", + } + ], + "accepted_proposal": { + "proposal_id": "accepted", + "proposal_kind": "new_media_buy", + "proposal_status": "accepted", + "accepted_at": "2026-01-01T00:00:00Z", + "media_buy_id": "change_rights_state_buy", + "name": "State-scoped change rights", + "commercial_terms": { + "brand": {"domain": "example.com"}, + "purchases": [ + { + "product_id": "product", + "pricing_option_id": "price", + "pricing": { + "pricing_option_id": "price", + "pricing_model": "cpm", + "currency": "USD", + "fixed_price": 12, + }, + "budget": 10000, + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2099-12-31T23:59:59Z", + } + ], + "start_time": "2026-01-01T00:00:00Z", + "end_time": "2099-12-31T23:59:59Z", + "total_budget": {"amount": 10000, "currency": "USD"}, + "change_terms": [ + { + "term_id": "right_pause_active", + "action": "pause", + "service_mode": "self_serve", + "allowed_statuses": ["active"], + }, + { + "term_id": "right_resume_paused", + "action": "resume", + "service_mode": "self_serve", + "allowed_statuses": ["paused"], + }, + { + "term_id": "right_increase_active", + "action": "increase_budget", + "service_mode": "seller_managed", + "allowed_statuses": ["active"], + "conditions": ["account_in_good_standing"], + "constraints": {"kind": "budget", "max_delta_percent": 20}, + }, + { + "term_id": "right_decrease_paused", + "action": "decrease_budget", + "service_mode": "self_serve", + "allowed_statuses": ["paused"], + "constraints": {"kind": "budget", "max_delta_percent": 50}, + }, + { + "term_id": "right_extend_active", + "action": "extend_flight", + "service_mode": "self_serve", + "allowed_statuses": ["active"], + }, + ], + }, + "terms_digest": "sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + }, + }, + ) + + active_response = await seller.get_media_buys({"media_buy_ids": ["change_rights_state_buy"]}) + validator = get_named_validator( + "media-buy/get-media-buys-response.json", version="3.2.0-beta.9" + ) + assert validator is not None + assert list(validator.iter_errors(active_response)) == [] + control_validator = get_named_validator( + "media-buy/control-media-buy-response.json", version="3.2.0-beta.9" + ) + assert control_validator is not None + active = active_response["media_buys"][0] + assert [item["action"] for item in active["available_actions"]] == [ + "pause", + "extend_flight", + ] + + condition_error = await seller.control_media_buy( + { + "media_buy_id": "change_rights_state_buy", + "revision": 1, + "total_budget": {"amount": 11000, "currency": "USD"}, + } + ) + assert list(control_validator.iter_errors(condition_error)) == [] + assert condition_error["errors"][0]["details"]["reason"] == "condition_unresolved" + + paused = await seller.control_media_buy( + {"media_buy_id": "change_rights_state_buy", "revision": 1, "paused": True} + ) + assert list(control_validator.iter_errors(paused)) == [] + assert paused["media_buy_status"] == "paused" + assert [item["action"] for item in paused["available_actions"]] == [ + "resume", + "decrease_budget", + ] + + wrong_status = await seller.control_media_buy( + { + "media_buy_id": "change_rights_state_buy", + "revision": 2, + "total_budget": {"amount": 11000, "currency": "USD"}, + } + ) + assert list(control_validator.iter_errors(wrong_status)) == [] + assert wrong_status["errors"][0]["details"]["reason"] == "wrong_status" + assert ( + wrong_status["errors"][0]["details"]["currently_available_actions"] + == paused["available_actions"] + ) + + outside_bound = await seller.control_media_buy( + { + "media_buy_id": "change_rights_state_buy", + "revision": 2, + "total_budget": {"amount": 1000, "currency": "USD"}, + } + ) + assert list(control_validator.iter_errors(outside_bound)) == [] + assert outside_bound["errors"][0]["code"] == "REQUOTE_REQUIRED" + assert outside_bound["errors"][0]["details"]["constraint"] == "max_delta_percent" diff --git a/tests/test_server_dx.py b/tests/test_server_dx.py index e456787ad..33ecf6c9b 100644 --- a/tests/test_server_dx.py +++ b/tests/test_server_dx.py @@ -372,6 +372,7 @@ def test_basic(self): result = media_buys_response(buys) assert result["media_buys"] == buys assert result["sandbox"] is True + assert result["status"] == "completed" def test_confirmed_at_is_preserved_when_rebuilding_media_buy_snapshot(self): created = media_buy_response( diff --git a/tests/test_update_media_buy_decompose.py b/tests/test_update_media_buy_decompose.py index ca5083394..24351a114 100644 --- a/tests/test_update_media_buy_decompose.py +++ b/tests/test_update_media_buy_decompose.py @@ -67,6 +67,10 @@ def test_decompose_update_media_buy_uses_current_state_for_budget_actions() -> N {"packages": [{"package_id": "pkg_1", "budget": 80.0}]}, current, ) + remove_cap = decompose_update_media_buy( + {"packages": [{"package_id": "pkg_1", "budget": None}]}, + current, + ) assert [mutation.action for mutation in increase] == ["increase_budget"] assert increase[0].before == 100.0 @@ -77,6 +81,7 @@ def test_decompose_update_media_buy_uses_current_state_for_budget_actions() -> N "update_packages", ) assert [mutation.action for mutation in decrease] == ["decrease_budget"] + assert [mutation.action for mutation in remove_cap] == ["increase_budget"] def test_decompose_update_media_buy_detects_budget_reallocation_batch() -> None: @@ -136,10 +141,15 @@ def test_decompose_update_media_buy_splits_targeting_and_frequency_cap() -> None ) assert [mutation.action for mutation in mutations] == [ - "update_targeting", + "update_keywords", "update_targeting", "update_frequency_caps", ] + assert mutations[0].allowed_action_candidates == ( + "update_keywords", + "update_targeting", + "update_packages", + ) assert mutations[1].field_paths == ("packages[0].targeting_overlay.geo_country_any_of",) assert mutations[2].field_paths == ("packages[0].targeting_overlay.frequency_cap",) @@ -183,24 +193,74 @@ def test_decompose_update_media_buy_keeps_unmapped_fields_visible() -> None: ) assert [mutation.action for mutation in mutations] == [ - UNKNOWN_UPDATE_ACTION, + "update_reporting_webhook", UNKNOWN_UPDATE_ACTION, UNKNOWN_UPDATE_ACTION, ] assert [mutation.field_paths for mutation in mutations] == [ - ("packages[0].seller_extension",), ("reporting_webhook",), + ("packages[0].seller_extension",), ("custom_field",), ] -def test_disallowed_update_media_buy_mutations_ignores_unknown_action_mappings() -> None: +def test_disallowed_update_media_buy_mutations_checks_known_and_ignores_unknown() -> None: patch = { "reporting_webhook": {"url": "https://example.com/reports"}, "packages": [{"package_id": "pkg_1", "seller_extension": True}], } - assert disallowed_update_media_buy_mutations(patch, allowed_actions=()) == [] + blocked = disallowed_update_media_buy_mutations(patch, allowed_actions=()) + + assert [mutation.action for mutation in blocked] == ["update_reporting_webhook"] + + +def test_decompose_beta9_compact_control_fields() -> None: + current = { + "name": "Old", + "total_budget": {"amount": 1000, "currency": "USD"}, + "daily_budget_cap": 100, + "budget_cap_timezone": "UTC", + "packages": [ + { + "package_id": "pkg_1", + "catalog_ids": ["cat-old"], + "daily_budget_cap": 50, + "impressions": 1000, + } + ], + } + patch = { + "name": "New", + "total_budget": {"amount": 1200, "currency": "USD"}, + "daily_budget_cap": None, + "budget_cap_timezone": "Europe/Rome", + "budget_allocation": {"mode": "fixed"}, + "pacing": "even", + "bidding": {"strategy": "maximize_reach"}, + "packages": [ + { + "package_id": "pkg_1", + "catalog_ids": ["cat-new"], + "daily_budget_cap": None, + "optimization_goals": [{"metric": "clicks"}], + "impressions": 2000, + "min_spend_target": 50, + } + ], + } + + assert requested_update_media_buy_actions(patch, current) == ( + "update_name", + "increase_budget", + "update_budget_allocation", + "update_pacing", + "update_bidding", + "update_catalog_assignments", + "update_optimization_goals", + "update_impression_goal", + "update_spend_target", + ) def test_requested_actions_are_ordered_and_deduplicated() -> None: diff --git a/tests/type_checks/media_buy_actions.py b/tests/type_checks/media_buy_actions.py new file mode 100644 index 000000000..13ac22141 --- /dev/null +++ b/tests/type_checks/media_buy_actions.py @@ -0,0 +1,87 @@ +"""Strict adopter-facing type checks for proposal-bound action helpers.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from typing_extensions import assert_type + +from adcp import ( + ActionAvailabilityStatus, + ActionIntent, + ActionTask, + BudgetChangeConstraints, + CanonicalMediaBuyActionMode, + CanonicalMediaBuyActionName, + CanonicalProductAction, + ChangeTermSelection, + Duration, + FlightChangeConstraints, + MediaBuyActionAssessment, + MediaBuyActionMode, + MediaBuyActionProjection, + MediaBuyChangeTerm, + MediaBuyValidAction, + PackageCountChangeConstraints, + ProductAllowedAction, + assess_media_buy_action, + materialize_change_terms, + project_available_actions, + route_media_buy_action, +) +from adcp.types import DurationUnit +from adcp.types.media_buy import EffectiveTimingChangeConstraints + +product_action = CanonicalProductAction( + action=CanonicalMediaBuyActionName.pause, + modes=[CanonicalMediaBuyActionMode.self_serve], +) +legacy_product_action = ProductAllowedAction( + action=MediaBuyValidAction.pause, + modes=[MediaBuyActionMode.self_serve], +) +terms = materialize_change_terms( + [product_action], + [ChangeTermSelection(action="pause", term_id="right_pause")], +) +assert_type(terms, tuple[MediaBuyChangeTerm, ...]) + +term = MediaBuyChangeTerm( + term_id="right_pause", + action=CanonicalMediaBuyActionName.pause, + service_mode=CanonicalMediaBuyActionMode.self_serve, +) +projection = project_available_actions([term], "active") +assert_type(projection, MediaBuyActionProjection) + +assessment = assess_media_buy_action( + "pause", + proposal={"commercial_terms": {"change_terms": [term]}}, + media_buy={"status": "active", "available_actions": projection.to_wire()}, + intent=ActionIntent(effective_at=datetime(2026, 8, 29, tzinfo=timezone.utc)), +) +assert_type(assessment, MediaBuyActionAssessment) +assert_type(assessment.status, ActionAvailabilityStatus) +assert_type(route_media_buy_action("pause"), ActionTask | None) + +assert_type( + BudgetChangeConstraints(kind="budget", max_delta_percent=20), + BudgetChangeConstraints, +) +assert_type( + FlightChangeConstraints(kind="flight", max_change=Duration(interval=1, unit=DurationUnit.days)), + FlightChangeConstraints, +) +assert_type( + PackageCountChangeConstraints(kind="package_count", max_additions=2), + PackageCountChangeConstraints, +) +assert_type( + EffectiveTimingChangeConstraints( + kind="effective_timing", + minimum_notice=Duration(interval=1, unit=DurationUnit.hours), + ), + EffectiveTimingChangeConstraints, +) + +_ = legacy_product_action From 4a00e9fd48cb954ed494827568d161abb81b8f49 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Sat, 29 Aug 2026 00:35:45 +0200 Subject: [PATCH 2/2] fix(codegen): keep change term validators idempotent --- scripts/post_generate_fixes.py | 11 +++++--- src/adcp/media_buy_actions.py | 3 +- .../media_buy/change_term_constraints.py | 2 +- tests/test_code_generation.py | 28 +++++++++++++++++++ 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/scripts/post_generate_fixes.py b/scripts/post_generate_fixes.py index eabfd24fa..9a72f297e 100644 --- a/scripts/post_generate_fixes.py +++ b/scripts/post_generate_fixes.py @@ -4997,10 +4997,13 @@ def enforce_change_term_runtime_constraints() -> None: constraints_path = OUTPUT_DIR / "media_buy" / "change_term_constraints.py" if constraints_path.exists(): source = constraints_path.read_text() - source = source.replace( - "from pydantic import AwareDatetime, ConfigDict, Field, RootModel", - "from pydantic import AwareDatetime, ConfigDict, Field, RootModel, model_validator", - ) + pydantic_import = "from pydantic import AwareDatetime, ConfigDict, Field, RootModel" + if f"{pydantic_import}, model_validator" not in source: + source = source.replace( + pydantic_import, + f"{pydantic_import}, model_validator", + 1, + ) required_fields = { "MediaBuyChangeTermConstraints1": ( "max_delta_amount", diff --git a/src/adcp/media_buy_actions.py b/src/adcp/media_buy_actions.py index 5ddad0912..48e140f12 100644 --- a/src/adcp/media_buy_actions.py +++ b/src/adcp/media_buy_actions.py @@ -321,7 +321,8 @@ async def execute_task( request: BaseModel, *, options: Any | None = None, - ) -> Any: ... + ) -> Any: + raise NotImplementedError def route_media_buy_action(action: str) -> ActionTask | None: diff --git a/src/adcp/types/generated_poc/media_buy/change_term_constraints.py b/src/adcp/types/generated_poc/media_buy/change_term_constraints.py index 95fb7fc10..a2c01c3e7 100644 --- a/src/adcp/types/generated_poc/media_buy/change_term_constraints.py +++ b/src/adcp/types/generated_poc/media_buy/change_term_constraints.py @@ -7,7 +7,7 @@ from typing import Any, Annotated, Literal from adcp.types.base import AdCPBaseModel -from pydantic import AwareDatetime, ConfigDict, Field, RootModel, model_validator, model_validator, model_validator +from pydantic import AwareDatetime, ConfigDict, Field, RootModel, model_validator from ..core import duration diff --git a/tests/test_code_generation.py b/tests/test_code_generation.py index 0681d4ea5..9fa3091db 100644 --- a/tests/test_code_generation.py +++ b/tests/test_code_generation.py @@ -166,6 +166,34 @@ def test_post_generate_legacy_purchase_losses_restore_array_constraints(tmp_path assert "def _accepted_losses_match_schema(" in fixed +def test_post_generate_change_term_constraint_import_is_idempotent(tmp_path, monkeypatch): + """Repeated post-generation fixes never duplicate model_validator imports.""" + from scripts import post_generate_fixes + + generated_dir = tmp_path / "generated_poc" + target = generated_dir / "media_buy" / "change_term_constraints.py" + target.parent.mkdir(parents=True) + target.write_text( + "from pydantic import AwareDatetime, ConfigDict, Field, RootModel\n\n" + "class MediaBuyChangeTermConstraints1(AdCPBaseModel):\n" + " max_delta_amount: object | None = None\n\n" + "class MediaBuyChangeTermConstraints2(AdCPBaseModel):\n" + " max_change: object | None = None\n\n" + "class MediaBuyChangeTermConstraints3(AdCPBaseModel):\n" + " max_additions: int | None = None\n\n" + "class MediaBuyChangeTermConstraints4(AdCPBaseModel):\n" + " minimum_notice: object | None = None\n" + ) + monkeypatch.setattr(post_generate_fixes, "OUTPUT_DIR", generated_dir) + + post_generate_fixes.enforce_change_term_runtime_constraints() + first = target.read_text() + post_generate_fixes.enforce_change_term_runtime_constraints() + + assert target.read_text() == first + assert first.count("model_validator") == 5 + + def test_post_generate_preserves_request_signing_operation_strings(tmp_path, monkeypatch): """Constrained operation names remain plain strings after validation.""" from scripts import post_generate_fixes