diff --git a/.mypy-untyped-defs-baseline b/.mypy-untyped-defs-baseline index 2c36bbdaa0..0d389107a3 100644 --- a/.mypy-untyped-defs-baseline +++ b/.mypy-untyped-defs-baseline @@ -1 +1 @@ -227 +212 diff --git a/.ruff-complexity-baseline b/.ruff-complexity-baseline index bdb3591baa..287e328e0d 100644 --- a/.ruff-complexity-baseline +++ b/.ruff-complexity-baseline @@ -1,5 +1,5 @@ { - "C901": 183, - "PLR0912": 134, - "PLR0915": 108 + "C901": 181, + "PLR0912": 133, + "PLR0915": 106 } diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 39a09f80f2..0288f9b69f 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -58,6 +58,10 @@ services: SUPER_ADMIN_EMAILS: ${SUPER_ADMIN_EMAILS:-} ADCP_TESTING: ${ADCP_TESTING:-true} ADCP_AUTH_TEST_MODE: ${ADCP_AUTH_TEST_MODE:-true} + # Exact callback host admitted by the development-only protocol webhook + # seam. Standalone host tests override this with host.docker.internal; + # the in-network runner uses its stable "tests" alias. + ADCP_WEBHOOK_TEST_HOST: ${ADCP_WEBHOOK_TEST_HOST:-tests} # The SERVER must know about the pinned in-network creative agent: sync/ # create flows resolve format specs server-side, and references carry the # PUBLIC canonical agent_url — without this, the registry's connection @@ -180,6 +184,9 @@ services: # network alias (requires `docker compose run --use-aliases`). 'tests' is # not 'localhost', so the server does NOT rewrite it to host.docker.internal. ADCP_WEBHOOK_HOST: tests + # Exact in-network callback host allowed only by the development-mode + # protocol-webhook validator. Production has no private-host override. + ADCP_WEBHOOK_TEST_HOST: tests # Pinned reference creative agent, reachable in-network by service name # (no host :9999). Clears the 18 test_creative_agent_live integration tests. CREATIVE_AGENT_URL: http://creative-agent:8080/api/creative-agent diff --git a/docker-compose.yml b/docker-compose.yml index eca3fd0367..6d08e274cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -86,6 +86,12 @@ services: ADCP_SALES_PORT: "8080" # Enable test login mode for development ADCP_AUTH_TEST_MODE: "true" + # Declare development EXPLICITLY. Security gates that relax for development fail + # closed on an absent or unrecognised value, so the relaxation has to be stated + # rather than inferred from omission — see + # WebhookURLValidator._require_https_for_callback, which requires HTTPS for AdCP + # callbacks unless this reads "development". docker-compose.e2e.yml already sets it. + ENVIRONMENT: development # Development settings (venv at /opt/venv in image — see Dockerfile UV_PROJECT_ENVIRONMENT) PYTHONPATH: "/app" PYTHONUNBUFFERED: "1" diff --git a/docs/adcp-spec-version.md b/docs/adcp-spec-version.md index 792192164f..a0a344528a 100644 --- a/docs/adcp-spec-version.md +++ b/docs/adcp-spec-version.md @@ -41,24 +41,18 @@ uv run python -c "import adcp; print(adcp.get_adcp_spec_version())" across `pyproject.toml`, the test's `EXPECTED_SPEC_VERSION` constant, and this document. -## Behavior target vs SDK pin - -The SDK **pin** (3.1.0-beta.3) fixes the request/response *type shapes* we -build against. It does **not** always fix the graded *behavior*. One field -diverges deliberately: the `media_buy_status` dual-emit on -create-/update-media-buy responses. - -- **beta.3 storyboard** (`dist/compliance/3.1.0-beta.3/.../pending_creatives_to_start.yaml`, - ~L131-134) grades the body `status` as `field_value_or_absent` that MUST equal - `media_buy_status` — the deprecated "both identical" model (#4908). -- **Target GA** — graded by the published **3.1.0** compliance - (`dist/compliance/3.1.0/.../pending_creatives_to_start.yaml`, ~L146-153; - `3.1.1` is byte-identical for this storyboard) — grades `media_buy_status` - as `field_value` (the DOMAIN status) and the top-level `status` as - `field_value` `'completed'` (the PROTOCOL `TaskStatus`, protocol envelope). - The two are DIFFERENT namespaces and are NOT identical. - -Our wire already implements the divergent (target GA) model: +## Behavior sources vs SDK pin + +The pin and the behavior target are the same version (3.1.1). What differs is +the ROLE of each artifact: the SDK **pin** fixes the request/response *type +shapes* we build against, and it does not replace the authoritative prose and +compliance storyboards for protocol behavior. For example, the published +**3.1.1** `pending_creatives_to_start.yaml` storyboard grades `media_buy_status` +as `field_value` (the DOMAIN status) and the top-level `status` as +`field_value` `'completed'` (the PROTOCOL `TaskStatus`, protocol envelope). +The two are DIFFERENT namespaces and are NOT identical. + +Our wire implements that two-namespace model: `TaskResultEnvelope._serialize` sets the top-level `status` to the protocol `TaskStatus`, while the domain status survives under `media_buy_status` (`src/core/schemas/_base.py` `_mirror_media_buy_status`). The dual-emit @@ -66,7 +60,7 @@ validator only backfills the deprecated **body** `status` from the domain `media_buy_status` for the deprecation window; it does not touch the wire top-level `status`. -**Known SDK type defect (SDK not authoritative):** adcp 5.7 types the response +**Historical SDK type defect (SDK not authoritative):** adcp 5.7 typed the response `status` as `MediaBuyStatus | None`, but the wire top-level `status` carries a protocol `TaskStatus` (`submitted` / `completed`). This is fine because that protocol value lives on `TaskResultEnvelope.status` (typed `str`), never on the @@ -77,6 +71,67 @@ SDK-typed body field. Grounding for the divergent behavior is the value-pinned `tests/bdd/steps/domain/uc002_create_media_buy.py` (see PR #1417). `tests/unit/test_adcp_spec_version.py` only guards the SDK pin, not this behavior. +## Authentication error classification + +The immutable AdCP **v3.1.1** source is tag `v3.1.1`, commit +`467fd93d77112baf9e094e18980119edcd3a4d07`. Its +`static/schemas/source/enums/error-code.json` metadata requires sellers to +return: + +- `AUTH_MISSING` when no standard `Authorization` header was included + (`correctable`: provide credentials and retry). +- `AUTH_INVALID` when an `Authorization` header was present but rejected + (`terminal`, except the spec's one-time OAuth refresh allowance). + +Prebid Sales Agent applies that split at every A2A, MCP, and REST wire +boundary. The legacy `x-adcp-auth` extension remains accepted as an input +channel, but it does not change the standard header-presence classifier. +Direct `_impl` helpers retain deprecated `AUTH_REQUIRED` only where no wire +credential-presence information exists. + +This behavior is **ungraded** by the official conformance storyboards: +`dist/compliance/3.1.1/universal/error-compliance.yaml` contains no +`AUTH_*` scenario. The two codes are covered by the repository's cross-transport +wire tests, and by the pinned fixture at +`tests/fixtures/adcp_schemas_pinned/enums/error-code.json`. + +### What the pinned fixture does and does not vendor + +The fixture tree is vendored at commit `04f59d2d5` (the v3.1 cut, 2026-05-13), **not** +at `v3.1.1`. The two AUTH entries above are transplanted from `467fd93d` and declared as +a supplement in `tests/fixtures/adcp_schemas_pinned/_refresh.py`, because production +emits both codes and the recovery-conformance guard resolves them against this file. + +So the fixture is **not** a complete v3.1.1 vocabulary: it carries the base commit's +codes plus exactly that declared supplement, and it lags published 3.1.1 by a number of +codes and several `enumMetadata` suggestion texts. That gap is real and tracked +separately — the point of declaring it is that the tree no longer claims a fidelity it +does not have. + +`tests/unit/test_pinned_schema_provenance.py` enforces the claim against the bytes: +`PINNED_SHA` must match the generated `_manifest.py`, every vendored file must match its +recorded digest, and the error-code enum must equal the base vocabulary plus exactly the +declared supplement. Advancing the pin therefore requires actually re-running +`_refresh.py` — editing the constant alone fails. + +## Protocol callback URL transport policy + +The pinned AdCP **v3.1.1** source defines +`static/schemas/source/core/push-notification-config.json#properties/url` as a +generic URI. It does not mandate acceptance of plaintext HTTP callback URLs. +The authoritative +`dist/compliance/3.1.1/universal/webhook-emission.yaml` storyboard (version +1.3.0) uses an operator-supplied **HTTPS** receiver in `proxy_url` mode and +grades payload idempotency, operation correlation, retry behavior, and RFC 9421 +signing. It does not grade whether an implementation accepts HTTP callback +registration. + +Prebid Sales Agent therefore treats HTTPS-only production callbacks as an +implementation security policy: production rejects HTTP before persisting or +sending a callback, while the exact-host development seam remains available +for the Docker E2E receiver. This restriction is **ungraded** by the 3.1.1 +storyboard and does not replace its signing or idempotency requirements. + ## Wire negotiation AdCP wire values for `adcp_version` are release-precision (`"3.0"`, diff --git a/docs/test-obligations/BR-UC-011-manage-accounts.md b/docs/test-obligations/BR-UC-011-manage-accounts.md index cce03ba056..c0079c4f0a 100644 --- a/docs/test-obligations/BR-UC-011-manage-accounts.md +++ b/docs/test-obligations/BR-UC-011-manage-accounts.md @@ -115,7 +115,7 @@ High impact. Account management is a new protocol domain in adcp 3.x. The schema **Layer** behavioral **Given** no authentication **When** the buyer sends `sync_accounts` -**Then** the request is rejected with AUTH_REQUIRED +**Then** every wire transport rejects the request with AUTH_MISSING **Business Rule:** BR-12 **Priority:** P0 @@ -209,14 +209,14 @@ High impact. Account management is a new protocol domain in adcp 3.x. The schema **Business Rule:** BR-10 **Priority:** P0 -### Extension A: AUTH_REQUIRED +### Extension A: Authentication errors #### Scenario: Missing auth token on sync_accounts **Obligation ID** UC-011-EXT-A-01 **Layer** behavioral **Given** no Bearer token in request **When** the buyer sends `sync_accounts` -**Then** the response is error variant with `AUTH_REQUIRED` +**Then** every wire transport returns an error variant with `AUTH_MISSING` **And** no accounts are modified **And** context is echoed **Business Rule:** BR-12, POST-F1 @@ -227,7 +227,7 @@ High impact. Account management is a new protocol domain in adcp 3.x. The schema **Layer** behavioral **Given** an expired Bearer token **When** the buyer sends `sync_accounts` -**Then** the response is error variant with `AUTH_REQUIRED` +**Then** every wire transport returns an error variant with `AUTH_INVALID` **Priority:** P1 #### Scenario: Malformed auth token on sync_accounts @@ -235,7 +235,7 @@ High impact. Account management is a new protocol domain in adcp 3.x. The schema **Layer** behavioral **Given** a malformed Bearer token **When** the buyer sends `sync_accounts` -**Then** the response is error variant with `AUTH_REQUIRED` +**Then** every wire transport returns an error variant with `AUTH_INVALID` **Priority:** P1 ### Extension B: SYNC_PARTIAL_FAILURE @@ -394,7 +394,7 @@ High impact. Account management is a new protocol domain in adcp 3.x. The schema **Obligation ID** UC-011-EXT-G-03 **Layer** schema **Given** an unauthenticated buyer sending sync_accounts with `context: {"trace": "t1"}` -**When** the AUTH_REQUIRED error is returned +**When** the wire transport returns AUTH_MISSING **Then** the error response includes `context: {"trace": "t1"}` **Business Rule:** POST-F3 **Priority:** P1 @@ -430,7 +430,7 @@ High impact. Account management is a new protocol domain in adcp 3.x. The schema #### Scenario: sync-accounts-response error variant **Obligation ID** UC-011-SCHEMA-03 **Layer** schema -**Given** an operation-level error response (e.g., AUTH_REQUIRED) +**Given** an operation-level error response (e.g., AUTH_MISSING, AUTH_INVALID, or AUTH_REQUIRED) **When** serialized **Then** it validates against `sync-accounts-response.json` (error oneOf variant) **And** has errors array diff --git a/docs/test-obligations/UC-002-create-media-buy.md b/docs/test-obligations/UC-002-create-media-buy.md index 466c534fcb..569c880cf0 100644 --- a/docs/test-obligations/UC-002-create-media-buy.md +++ b/docs/test-obligations/UC-002-create-media-buy.md @@ -768,7 +768,8 @@ Source: UC-002-ext-c.md, BR-RULE-013 **Layer** schema **Given** `start_time` is an ISO 8601 datetime that is in the past **When** the system validates timing -**Then** it returns error: "Invalid start time: {value}. Start time cannot be in the past." +**Then** it returns `INVALID_REQUEST` with static message "Start time cannot be in the past." +**And** the error identifies `field: start_time`, includes a corrective suggestion, and does not echo request timestamps **Business Rule:** BR-RULE-013 INV-2 **Priority:** P0 @@ -777,7 +778,8 @@ Source: UC-002-ext-c.md, BR-RULE-013 **Layer** schema **Given** `end_time` is before or equal to `start_time` **When** the system validates timing -**Then** it returns error: "Invalid time range: end time ({end}) must be after start time ({start})." +**Then** it returns `INVALID_REQUEST` with static message "End time must be after start time." +**And** the error identifies `field: end_time`, includes a corrective suggestion, and does not echo request timestamps **Business Rule:** BR-RULE-013 INV-3 **Priority:** P0 diff --git a/docs/test-obligations/bdd-traceability.yaml b/docs/test-obligations/bdd-traceability.yaml index 54e25e8325..ebc781cf9d 100644 --- a/docs/test-obligations/bdd-traceability.yaml +++ b/docs/test-obligations/bdd-traceability.yaml @@ -876,6 +876,12 @@ mappings: upstream_refs: ["BR-UC-002-ext-d"] business_rules: [] status: new + - adcp_scenario_id: "T-UC-002-ext-nl-unsupported" + adcp_feature: "BR-UC-002-create-media-buy.feature" + obligation_id: null + upstream_refs: ["transport-errors.mdx#Layer-Separation"] + business_rules: [] + status: new - adcp_scenario_id: "T-UC-002-ext-e" adcp_feature: "BR-UC-002-create-media-buy.feature" obligation_id: null diff --git a/docs/test-obligations/business-rules.md b/docs/test-obligations/business-rules.md index a3376934ed..3aaf6a0ce5 100644 --- a/docs/test-obligations/business-rules.md +++ b/docs/test-obligations/business-rules.md @@ -931,16 +931,17 @@ Then an empty accounts array is returned (not an error) ### BR-RULE-055: Account Operation Authentication Policy **Obligation ID** BR-RULE-055-01 **Layer** behavioral -**Invariant:** sync_accounts requires valid auth. list_accounts works without auth but scopes results. Unauthenticated list returns empty array. +**Invariant:** sync_accounts and list_accounts require valid auth. list_accounts scopes results to accounts visible to the authenticated agent. +**Grounded in:** dist/docs/3.1.1/accounts/tasks/list_accounts.mdx:8 ("Returns all accounts the authenticated agent can operate..."); dist/docs/3.1.1/protocol/required-tasks.mdx:118 (list_accounts discovers "seller-assigned accounts" for a resolved credential, unlike the plain "Required" no-auth-caveat discovery tasks in the same table). Not a single explicit "MUST require authentication" sentence — inferred consistently across both passages from a task that is meaningless without a resolved identity to scope against. **Scenario:** ```gherkin -Given no valid authentication -When sync_accounts is called -Then AUTH_REQUIRED error is returned - Given no authentication -When list_accounts is called -Then an empty accounts array is returned (not an error) +When sync_accounts or list_accounts is called +Then every wire transport returns AUTH_MISSING + +Given rejected authentication credentials +When sync_accounts or list_accounts is called +Then every wire transport returns AUTH_INVALID ``` **Priority:** P0 **Affected by 3.6:** Yes -- accounts domain is new in v3 diff --git a/docs/test-obligations/constraints.md b/docs/test-obligations/constraints.md index 5a8e237db4..dcf7da499d 100644 --- a/docs/test-obligations/constraints.md +++ b/docs/test-obligations/constraints.md @@ -1635,11 +1635,14 @@ Then status=pending_approval with setup.message ### account_auth_policy: Account Authentication Policy **Obligation ID** CONSTR-ACCOUNT-AUTH-POLICY-01 **Layer** behavioral -**Requirement:** sync_accounts requires valid auth. list_accounts allows anonymous (empty results). +**Requirement:** sync_accounts and list_accounts require valid authentication. **Scenario:** ```gherkin -Given no auth on sync_accounts -Then AUTH_REQUIRED error +Given no auth on sync_accounts or list_accounts +Then every wire transport returns AUTH_MISSING + +Given rejected auth on sync_accounts or list_accounts +Then every wire transport returns AUTH_INVALID ``` **Priority:** P0 **Affected by 3.6:** Yes -- accounts domain is new in v3 diff --git a/src/a2a_server/adcp_a2a_server.py b/src/a2a_server/adcp_a2a_server.py index 2c4c49a097..e5be20bb87 100644 --- a/src/a2a_server/adcp_a2a_server.py +++ b/src/a2a_server/adcp_a2a_server.py @@ -8,11 +8,12 @@ import json import logging import uuid -from collections.abc import AsyncGenerator, Awaitable, Callable +from collections.abc import AsyncGenerator, Awaitable, Callable, Iterator +from contextlib import contextmanager # Import core functions for direct calls (raw functions without FastMCP decorators) from datetime import UTC, datetime -from typing import Any +from typing import TYPE_CHECKING, Any from a2a.server.context import ServerCallContext from a2a.server.events.event_queue import Event @@ -36,11 +37,11 @@ ListTasksRequest, ListTasksResponse, Message, - MethodNotFoundError, Part, SendMessageRequest, SubscribeToTaskRequest, Task, + TaskNotCancelableError, TaskNotFoundError, TaskPushNotificationConfig, TaskState, @@ -54,25 +55,30 @@ from google.protobuf import json_format, struct_pb2 from src.core.audit_logger import get_audit_logger -from src.core.auth import AUTH_REQUIRED_SUGGESTION from src.core.auth_context import AUTH_CONTEXT_STATE_KEY +from src.core.auth_policy import AUTH_OPTIONAL_SKILLS from src.core.database.models import PushNotificationConfig as DBPushNotificationConfig from src.core.database.repositories import PushNotificationConfigUoW +from src.core.database.repositories.workflow import TERMINAL_STEP_STATUSES, WorkflowRepository from src.core.domain_config import get_a2a_server_url from src.core.exceptions import ( AdCPAuthenticationError, - AdCPAuthRequiredError, + AdCPAuthInvalidError, + AdCPAuthMissingError, AdCPCapabilityNotSupportedError, + AdCPConfigurationError, AdCPError, AdCPValidationError, build_two_layer_error_envelope, - normalize_to_adcp_error, + classify_auth_credentials_error, + safe_adcp_error, ) from src.core.resolved_identity import ResolvedIdentity from src.core.schema_helpers import coerce_creative_filters, to_account_reference, to_brand_reference from src.core.schemas import CreativeStatusEnum +from src.core.schemas.creative import AssignCreativeRequest, CreateCreativeRequest from src.core.tool_context import ToolContext -from src.core.tool_error_logging import record_boundary_error +from src.core.tool_error_logging import best_effort_boundary_identity, record_boundary_error from src.core.tools import ( create_media_buy_raw as core_create_media_buy_tool, ) @@ -119,6 +125,11 @@ ) from src.services.protocol_webhook_service import get_protocol_webhook_service +if TYPE_CHECKING: + from sqlalchemy.orm import Session + + from src.core.database.models import WorkflowStep + logger = logging.getLogger(__name__) @@ -170,83 +181,368 @@ def _dict_to_struct(d: dict) -> struct_pb2.Struct: return s -# ADCP Discovery Skills: Skills that don't require authentication -# Per AdCP spec section 3.2, these endpoints allow optional authentication for public discovery. -# IMPORTANT: This is the single source of truth for auth-optional skills in A2A. -# Add new skills here ONLY if they meet AdCP discovery endpoint requirements: +# AdCP discovery skills that don't require authentication. +# dist/docs/3.1.1/protocol/required-tasks.mdx documents get_products, +# get_adcp_capabilities, and list_creative_formats as plain "Required" discovery +# tasks with no auth caveat. list_accounts is deliberately NOT in this set: +# dist/docs/3.1.1/accounts/tasks/list_accounts.mdx:8 scopes it to "the +# authenticated agent", and required-tasks.mdx:118 ties it to discovering +# "seller-assigned accounts" for a resolved credential — a question that has +# no meaning without an authenticated identity to scope against, unlike the +# genuinely public data the four skills below return. +# The transport-neutral set in auth_policy is the single source of truth. +# Add new skills there ONLY if they meet AdCP discovery endpoint requirements: # 1. Return only public/non-sensitive data # 2. Support tenant-level access control (e.g., brand_manifest_policy) # 3. Never expose user-specific or transactional data # 4. Must be safe to call without authentication -DISCOVERY_SKILLS = frozenset( - { - "get_adcp_capabilities", # Agent capabilities (always public per AdCP spec) - "list_accounts", # Account discovery (public, returns empty for unauthed per BR-RULE-055) - "list_creative_formats", # Creative specifications (always public) - "list_authorized_properties", # Property catalog (always public) - "get_products", # Conditional: depends on tenant brand_manifest_policy setting - } -) +DISCOVERY_SKILLS = AUTH_OPTIONAL_SKILLS + +# Skills whose result can be NON-TERMINAL (submitted, awaiting an out-of-band decision). +# This set governs the push-notification injection: a skill that can answer +# asynchronously is exactly the one whose buyer needs a completion callback. +_ASYNC_TASK_SKILLS = frozenset({"create_media_buy", "sync_creatives", "update_media_buy"}) + +# The subset whose outer ``task_*`` id is threaded through to the durable workflow step, +# so ``tasks/get`` reconciles against it, ``tasks/cancel`` resolves it, and +# ``resolve_webhook_task_id`` keys the completion webhook on it. Derived from the set +# above rather than maintained as a second literal. +# +# ``create_media_buy`` and ``update_media_buy`` bear the id: each call produces at most +# ONE approval step, so the buyer's id names exactly one durable row. +# +# ``sync_creatives`` does NOT, and must not: it creates one workflow step PER CREATIVE +# (``_create_sync_workflow_steps`` loops the creatives needing approval). Stamping the +# single outer id on every one of them makes the key ambiguous — the lookup resolves an +# arbitrary one of N, so a cancel would report CANCELED while the other N-1 kept running +# and later fired their own webhooks. With no id persisted, a sync task resolves to no +# durable step and ``tasks/cancel`` returns an honest ``TaskNotCancelableError``; the +# completion webhook keys on ``step_id`` instead, which is the pre-existing behavior and +# is graded by ``tests/unit/test_creative_webhook_correlation.py``. +_TASK_ID_BEARING_SKILLS = _ASYNC_TASK_SKILLS - {"sync_creatives"} + + +def _sanitized_envelope(exc: Exception) -> tuple[AdCPError, dict[str, Any]]: + """THE A2A sanitize→envelope composition: ``(safe_adcp_error(exc), its two-layer envelope)``. + + Single home for the pipeline every A2A error surface uses — the top-level + ``_internal_error_for`` (→ JSON-RPC ``InternalError``), the per-skill + ``_build_error_envelope`` (→ failed-Task artifact), and the per-skill seam (which + re-raises the sanitized error and lets the dispatcher envelope it). Internal/infra + errors — the SERVICE_UNAVAILABLE family AND terminal ``CONFIGURATION_ERROR`` (whose + secret-decryption raise sites can interpolate a connection string) — are scrubbed to + a generic message with wire code/recovery preserved; client-correctable typed errors + pass through unchanged. The policy lives in ``src/core/exceptions.py`` so the webhook + push path (``ContextManager.audit_workflow_step_failure``) shares one definition. + New buyer-facing error surfaces must use this policy rather than adding another + normalizer that trusts a typed message verbatim. + """ + sanitized = safe_adcp_error(exc) + return sanitized, build_two_layer_error_envelope(sanitized) + + +def _enveloped_invalid_request(exc: AdCPError) -> InvalidRequestError: + """JSON-RPC ``InvalidRequestError`` carrying ``exc``'s sanitized two-layer envelope in ``data``. + + Spec (AdCP 3.1.1 ``building/operating/transport-errors.mdx``): JSON-RPC ``error.data`` is a + sanctioned transport-envelope location, and ``error.data.adcp_error`` is a MUST-check in the + client detection order — so a protocol-level rejection can stay a JSON-RPC error AND still let + a buyer branch on the AdCP code. "Stays on the JSON-RPC wire" and "carries the envelope in + ``data``" are orthogonal; every AdCP-layer rejection routed through this helper does both. + (On the v0.3 method aliases the compat adapter drops ``data`` regardless — see + ``_internal_error_for`` for the measurement and #1670.) + + Twelve raises in this module deliberately BYPASS this helper and ship envelope-free, + because each signals a transport-protocol condition with no corresponding AdCP wire code. + All twelve, not the subset this docstring used to name: + + - ``TaskNotFoundError`` × 4 — unknown/unowned task id on tasks/get, tasks/cancel and the + two push-config lookups. + - ``UnsupportedOperationError`` × 3 — task listing/subscription and the extended agent card. + - ``InvalidParamsError`` × 3 — a required JSON-RPC parameter is absent (``id``, ``url``). + Note this same TYPE also ships WITH an envelope when the rejection is AdCP-layer rather + than protocol-layer (the SSRF webhook-URL check routes through this helper), so the type + alone does not tell you the shape — the layer does. + - ``TaskNotCancelableError`` × 3 — a terminal task cannot be canceled, and (the third) + a task with no durable workflow step behind it cannot be canceled either: cancelling + only this process's in-memory copy would report a stop that did not happen. + + Bypassing also skips ``record_boundary_error``, so none of the thirteen produces a server + log, activity-feed row or audit row, where REST's ``_envelope_response`` records the + analogous not-found. That is deliberate for buyer-correctable protocol outcomes (an + unknown task id is not a seller-side incident) but it is a real observability gap for the + operator; widening it is tracked separately rather than folded in here. + + Both wire layers carry the SAME sanitized message — the JSON-RPC ``message`` is taken from the + envelope — so ``error.message`` and ``error.data.adcp_error.message`` can never disagree. + """ + sanitized, envelope = _sanitized_envelope(exc) + return InvalidRequestError(message=sanitized.message, data=envelope) + + +def _a2a_auth_headers(context: ServerCallContext | None) -> dict[str, str]: + """Return a mutable copy of the headers captured by the A2A auth middleware.""" + auth_ctx = context.state.get(AUTH_CONTEXT_STATE_KEY) if context is not None else None + return dict(auth_ctx.headers) if auth_ctx else {} + + +def _no_usable_identity_error( + identity: ResolvedIdentity | None, +) -> AdCPAuthMissingError | AdCPAuthInvalidError | None: + """Classify a missing identity separately from rejected credentials.""" + if identity is None: + return AdCPAuthMissingError("Authentication required for skill invocation") + if not identity.principal_id: + return AdCPAuthInvalidError("Authentication credentials were rejected.") + return None + + +def _enveloped_auth_error( + auth_error: AdCPAuthenticationError, +) -> InvalidRequestError: + """THE single source for every A2A auth rejection's observability and envelope. + + Covers five auth raises: the missing-token, resolution-failure, and invalid-principal arms of + ``_resolve_a2a_identity`` (inherited by tasks/get, tasks/cancel and the four + push-notification-config methods), the ``message/send`` pre-dispatch check, and the standalone + ``_handle_explicit_skill`` identity guard. Before this, only ``message/send`` carried the + envelope, so a buyer got it there and nowhere else. Missing credentials emit AUTH_MISSING with + correctable recovery; rejected credentials emit AUTH_INVALID with terminal recovery. + (``_resolve_a2a_identity`` has a SIXTH raise — no resolvable tenant for + an otherwise-authenticated principal — that is a seller-side config failure, not an auth + failure, and deliberately routes through ``_enveloped_invalid_request`` with + ``AdCPConfigurationError`` instead; it is excluded from this helper by design, not by omission.) + + Takes the TYPED error rather than a message + optional cause: the wire code, recovery, and + pinned-spec ``suggestion`` then all come from the class defaults (one definition, never re-specified + per raise site), no argument can be silently discarded, and a non-auth ``AdCPError`` cannot be + adopted by a function documented as the authentication-envelope source — that would emit a different wire + code from here. Non-auth rejections use ``_enveloped_invalid_request`` directly. + """ + return _recorded_a2a_auth_rejection(auth_error) + + +def _recorded_a2a_auth_rejection( + error: AdCPError, + *, + principal_id: str | None = None, +) -> InvalidRequestError: + """Record one pre-handler A2A auth rejection, then build its wire envelope. + + Authentication failures remain tenant-unscoped because client-controlled + routing headers do not attest tenant ownership. The helper accepts + ``AdCPError`` because the tenant-resolution failure is a seller-side + ``AdCPConfigurationError`` rather than a buyer auth error; that path may + retain its already-authenticated principal for the privileged server log. + """ + record_boundary_error( + "a2a", + "authentication", + error, + tenant_id=None, + principal_id=principal_id, + ) + return _enveloped_invalid_request(error) def _internal_error_for(operation: str, exc: Exception) -> InternalError: - """Canonical InternalError shape for non-skill A2A boundary failures. - - Skill handlers raise typed ``AdCPError`` (or untyped exceptions that the - dispatcher normalizes), and ``_handle_explicit_skill`` → ``on_message_send`` - surface those as a two-layer envelope on a failed Task's DataPart. Non-skill - paths (``on_message_send`` fallthrough, NL handlers) historically picked their - own prefixes (``"Message processing failed: "``, ``"Error in ..."``) - for semantically identical untyped failures — divergence on the buyer- - facing wire message for the same condition. - - Use this helper at every non-skill ``InternalError(...)`` raise site that - is NOT a deliberate protocol-level convention (see push-notif handlers - below). The canonical prefix is ``"{operation} failed: {exc}"`` so - storyboard runners can parse the failure uniformly. - - The four ``on_*_task_push_notification_config`` JSON-RPC protocol methods use - this helper too — they have no async Task to carry a DataPart, so the two-layer - envelope rides in the error's ``data`` field (``error.data["errors"][0]["code"]`` - / ``error.data["adcp_error"]``). ``InternalError`` stays an ``A2AError`` so the - SDK's ``JsonRpcDispatcher`` serializes it as a structured JSON-RPC error; raising - a non-``A2AError`` (e.g. ``AdCPAdapterError``) would hit the dispatcher's - ``except Exception`` branch and be flattened to a bare ``InternalError`` with no - envelope. + """Canonical JSON-RPC ``InternalError`` for A2A boundary failures — SANITIZED. + + Security (transport-errors.mdx "Security Considerations" § Seller Requirements): raw exception text may + contain credentials, connection strings, SQL, hostnames, filesystem paths, or + upstream responses and MUST NOT reach the client. So: + + - A TYPED ``AdCPError`` passes through with its own wire code, but the message + placed on the JSON-RPC layer is the SANITIZED one from ``_sanitized_envelope`` — + a typed internal-bucket error (``AdCPAdapterError`` et al.) can interpolate + ``str(e)`` into its message, so the ORIGINAL ``exc.message`` must never be + used here (it would leak through ``error.message`` even while ``error.data`` + is scrubbed). + - An UNTYPED exception is replaced with a generic message; the raw ``str(exc)`` is + NEVER placed on the wire (callers log it server-side via ``record_boundary_error``). + The envelope CODE comes from ``safe_adcp_error``, which normalizes semantics before + scrubbing presentation — so a mapped built-in keeps its client-correctable code + (``ValueError`` → VALIDATION_ERROR, ``PermissionError`` → AUTH_REQUIRED) and only a + genuinely unmapped exception falls through to ``SERVICE_UNAVAILABLE``. + + ``InternalError`` stays an ``A2AError`` so the SDK's ``JsonRpcDispatcher`` + serializes it as a structured JSON-RPC error (the four + ``on_*_task_push_notification_config`` methods, the durable ``on_get_task`` / + ``on_cancel_task`` boundaries, and the untyped-crash branch of + ``on_message_send``, all raise through here). The two-layer envelope is attached + as the error's ``data`` (``error.data["adcp_error"]`` / ``error.data["errors"][0]``). + + WHETHER THE BUYER RECEIVES THAT ``data`` DEPENDS ON THE METHOD NAME THEY CALLED. + The app builds its A2A routes with ``enable_v0_3_compat=True`` (``src/app.py``), and + dispatch is selected by method name, so the v0.3 aliases reach + ``a2a/compat/v0_3/jsonrpc_adapter.py``. That adapter's ``handle_request`` has no + ``except A2AError`` arm — only ``except Exception -> CoreInternalError(message=str(e))``, + which takes no ``data`` — so on those names the error is REBUILT as ``-32603`` with + ``data: null`` and only the (already-sanitized) message survives. Measured on an auth + rejection: ``GetTask``/``CancelTask`` return ``-32600`` + envelope; ``tasks/get`` / + ``tasks/cancel`` return ``-32603`` + ``data: null``. The installed ``adcp`` client emits + the v0.3 names, so this is the common path, not a fringe one. + + Nothing leaks — the flattened message is the scrubbed text — but the buyer loses the + machine-readable code they are told to branch on. Raising the typed error here is still + correct: it is what will surface the envelope once the compat adapter maps ``A2AError``. + Tracked in #1670; graded in both directions by ``_TASK_METHOD_DISPATCH`` in + ``tests/unit/test_a2a_transport_contract.py``, so the day that lands, this docstring + goes red with it. + """ + adcp_error, envelope = _sanitized_envelope(exc) + if isinstance(exc, AdCPError): + # adcp_error.message, NOT exc.message: the sanitized message (identical for + # client-correctable errors, scrubbed for the internal bucket). + message = f"{operation} failed: {adcp_error.message}" + else: + message = f"Internal error during {operation}" + return InternalError(message=message, data=envelope) + + +def _record_a2a_boundary_error(op_key: str, identity: ResolvedIdentity | ToolContext | None, exc: Exception) -> None: + """Record an A2A boundary failure with one canonical identity scope. + + Accepts a ``ToolContext`` as well as a ``ResolvedIdentity``: the push-config handlers + hold a resolved tool context rather than a bare identity, and both expose the + ``tenant_id``/``principal_id`` that ``best_effort_boundary_identity`` reads. The two + signatures must stay in step — ``_boundary_internal_error`` forwards straight to here, + so a narrower type on this side rejects exactly the callers that helper exists to serve. """ - return InternalError( - message=f"{operation} failed: {exc}", - data=build_two_layer_error_envelope(normalize_to_adcp_error(exc)), + tenant_id, principal_id = best_effort_boundary_identity(lambda: identity, transport="a2a") + record_boundary_error( + "a2a", + op_key, + exc, + tenant_id=tenant_id, + principal_id=principal_id, ) +def _a2a_activity_scope(identity: ResolvedIdentity | None) -> tuple[str | None, str | None]: + """Derive one fail-closed identity scope for every A2A activity record.""" + return best_effort_boundary_identity(lambda: identity, transport="a2a") + + +def _boundary_internal_error( + op_key: str, + op_label: str, + identity: ResolvedIdentity | ToolContext | None, + exc: Exception, +) -> InternalError: + """THE single untyped-crash boundary arm shared by every A2A request handler. + + Log server-side (``record_boundary_error``) using ONE canonical identity-scope + sentinel, then build the sanitized JSON-RPC ``InternalError`` + (``_internal_error_for``). + + The message/get/cancel handlers and four push-notification-config handlers + previously open-coded this arm. ``on_message_send``'s copy had already drifted + onto a different missing-identity sentinel + (``"unknown"``/``"unknown"``) from ``on_get_task``/``on_cancel_task``'s + (``None``/``"anonymous"``), so the SAME unresolved-identity event logged under two + different sentinels — un-greppable, and the tenant column swung by handler. This + is the single edit that keeps the sentinel from drifting again on the next handler. + + Returns (never raises) so each caller keeps its own ``raise ... from exc`` chaining. + """ + _record_a2a_boundary_error(op_key, identity, exc) + return _internal_error_for(op_label, exc) + + class AdCPRequestHandler(RequestHandler): """Request handler for AdCP A2A operations supporting JSON-RPC 2.0.""" def __init__(self): """Initialize the AdCP A2A request handler.""" self.tasks: dict[str, Task] = {} # In-memory task storage + # Owner (tenant_id, principal_id) of each in-memory task. tasks/get and + # tasks/cancel authorize the CALLER against this before serving or mutating + # an in-memory entry — the map key (task id) is bearer-ish and must not by + # itself grant a same-tenant sibling principal access. See + # _authorized_in_memory_task / _remember_task. + self._task_owner: dict[str, tuple[str, str]] = {} self._task_push_configs: dict[str, TaskPushNotificationConfig] = {} logger.info("AdCP Request Handler initialized for direct function calls") + def _remember_task(self, task_id: str, task: Task, identity: ResolvedIdentity | None) -> None: + """Store an in-memory task together with its owner (tenant, principal). + + Only records an owner when identity carries BOTH tenant and principal. An + ownerless task is never served through the memory path (fail closed), which + is correct for synchronous discovery responses that are returned inline and + never polled. + """ + self.tasks[task_id] = task + if identity is not None and identity.tenant_id and identity.principal_id: + self._task_owner[task_id] = (identity.tenant_id, identity.principal_id) + else: + self._task_owner.pop(task_id, None) + + def _forget_task(self, task_id: str) -> None: + """Drop an in-memory task and all its side state (owner + push config).""" + self.tasks.pop(task_id, None) + self._task_owner.pop(task_id, None) + self._task_push_configs.pop(task_id, None) + + def _authorized_in_memory_task(self, task_id: str, identity: ResolvedIdentity | None) -> Task | None: + """Return the in-memory task ONLY when ``identity`` is its recorded owner. + + Fails closed: an unresolved identity, an ownerless task, or an owner + mismatch (a same-tenant sibling principal, or a cross-tenant caller) all + yield None so the caller can neither read nor mutate another principal's + in-memory task. + """ + task = self.tasks.get(task_id) + if task is None or identity is None: + return None + owner = self._task_owner.get(task_id) + if owner is None: + return None + if (identity.tenant_id, identity.principal_id) != owner: + return None + return task + @staticmethod def _build_error_envelope(exc: Exception) -> dict[str, Any]: """Build a spec-compliant two-layer envelope for any exception. - Single source of truth for "wrap-arbitrary-exception → wire envelope" - used by both the per-skill dispatcher (``_build_failed_skill_result``) - and the top-level ``on_message_send`` error handler. Delegates to - ``normalize_to_adcp_error`` for the type→AdCPError mapping - (``ValueError → AdCPValidationError``, ``PermissionError → - AdCPAuthorizationError``, arbitrary ``Exception → - AdCPError(INTERNAL_ERROR)``) so the wire output stays in - ``WIRE_STANDARD_CODES`` (SDK ``STANDARD_ERROR_CODES`` plus the - pinned-spec supplement) and the envelope shape never degrades to a - flat ``{"error": "..."}`` dict the storyboard runner would synthesize - as ``MCP_ERROR``. + The failed-Task-artifact entry into the shared ``_sanitized_envelope`` + composition (consumed by ``_failed_task_artifact`` and + ``_build_failed_skill_result``); the JSON-RPC entry is + ``_internal_error_for``. Same policy on both: a TYPED ``AdCPError`` keeps + its controlled message + wire code, while any UNTYPED exception becomes a + generic ``AdCPError`` and its raw ``str(exc)`` is NEVER placed on the wire. + (It deliberately does NOT use ``normalize_to_adcp_error``, which maps + ``Exception → AdCPError(str(exc))`` and would leak credentials/SQL/hostnames + through the per-skill failed-Task artifact.) The wire output stays in + ``WIRE_STANDARD_CODES`` (SDK ``STANDARD_ERROR_CODES`` plus the pinned-spec + supplement) and the envelope shape stays a two-layer ``errors[]`` structure, + never a flat ``{"error": "..."}`` dict the storyboard runner would treat as + ``MCP_ERROR``. """ - return build_two_layer_error_envelope(normalize_to_adcp_error(exc)) + _sanitized, envelope = _sanitized_envelope(exc) + return envelope + + @staticmethod + def _failed_task_artifact(exc: Exception) -> "Artifact": + """The ``processing_error`` artifact for a failed Task. + + Per the A2A binding for errors, a failed artifact carries BOTH a + human-readable TextPart and the authoritative structured DataPart (the + two-layer AdCP envelope) — not a DataPart alone. The strict reader REQUIRES + exactly that shape (one DataPart AND one TextPart), and every failed-artifact + emitter — this one, the per-skill ``error_result``, and the durable rebuild in + ``_durable_result_artifact`` — must satisfy it.""" + envelope = AdCPRequestHandler._build_error_envelope(exc) + errors = envelope.get("errors") or [] + text = errors[0].get("message") if errors else "Request failed." + return Artifact( + artifact_id="error_1", + name="processing_error", + parts=[Part(text=text), Part(data=_dict_to_value(envelope))], + ) @staticmethod def _build_failed_skill_result(skill_name: str, exc: Exception) -> dict[str, Any]: @@ -264,6 +560,86 @@ def _build_failed_skill_result(skill_name: str, exc: Exception) -> dict[str, Any "success": False, } + @staticmethod + async def _dispatch_under_sanitize_seam( + operation: str, identity: ResolvedIdentity | ToolContext | None, handler_coro: Awaitable[Any] + ) -> Any: + """Await ``handler_coro`` with the boundary's provenance policy applied to failures. + + The seam every buyer-reachable handler call goes through — explicit-skill dispatch + and natural-language routing alike — so a client-correctable failure reaches the + outer boundary already typed, and is framed as an application-layer failed Task + rather than a transport-layer JSON-RPC error. + + Catches exactly ``(AdCPError, ValueError, PermissionError)``. NOT ``Exception``: + AdCP 3.1.1 ``transport-errors.mdx`` §"Layer Separation" classifies an internal crash + as a TRANSPORT-layer event, so a genuine crash must keep falling through to the + JSON-RPC ``InternalError`` arm. Widening this would also re-expose the raw-text leak + that arm exists to prevent. + + ``safe_adcp_error`` decides SEMANTICS and MESSAGE TRUST separately: ``ValueError`` → + VALIDATION_ERROR, ``PermissionError`` → AUTH_REQUIRED, native ``AdCPError`` + unchanged, while a raw built-in's untrusted ``str(e)`` is scrubbed. Re-raising the + *normalized* error instead would hand the outer sanitizer a trusted + ``AdCPValidationError`` and let a secret survive. ``record_boundary_error`` receives + the ORIGINAL exception, so raw diagnostics stay in the privileged server log while + tenant-visible sinks get the scrubbed copy. + """ + try: + return await handler_coro + except A2AError: + # Already a properly-formatted transport error. + raise + except (AdCPError, ValueError, PermissionError) as e: + _record_a2a_boundary_error(operation, identity, e) + sanitized = safe_adcp_error(e) + if sanitized is not e: + raise sanitized from e + raise + + def _mark_task_failed(self, task: Task) -> None: + """Mark a task FAILED. No webhook — the caller returns this terminal Task + synchronously in the response, and AdCP 3.1.1 a2a-guide.mdx + ("Webhook Trigger Rules") says a push notification is + NOT sent when the initial response is already terminal (the buyer already + has the result). Webhooks fire only for genuinely async transitions + (initial response ``working``/``submitted`` → later terminal); those must + carry the Task's structured artifacts (see ``_send_protocol_webhook``).""" + task.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_FAILED)) + + @staticmethod + def _task_artifacts_data(task: Task) -> list[tuple[str, dict[str, Any]]]: + """Every artifact DataPart as an ordered ``(artifact_name, decoded_data)``. + + Single decoder for A2A DataPart → dict (protobuf ``Value`` → JSON), shared + by completed-status detection and the webhook payload builder. Returns a + LIST, not a name-keyed dict, as a general safety property: identically-named + artifacts are all preserved — none silently overwrites another. (The explicit- + skill path emits one artifact per Task under the single-skill gate; the + general shape guards any other producer.)""" + pairs: list[tuple[str, dict[str, Any]]] = [] + for artifact in task.artifacts: + for part in artifact.parts: + if part.HasField("data"): + pairs.append((artifact.name, json.loads(json_format.MessageToJson(part.data)))) + return pairs + + @staticmethod + def _webhook_result_data(task: Task) -> dict[str, Any]: + """Pack all artifact data into one dict for ``create_a2a_webhook_payload``. + + The library renders a single artifact from this dict, so we key by artifact + name but DE-COLLIDE duplicates (``name``, ``name#2``, …) as a general safety + property — preserving every artifact's data on the wire rather than + overwriting, whatever the producing path.""" + result_data: dict[str, Any] = {} + for name, data in AdCPRequestHandler._task_artifacts_data(task): + key, n = name, 2 + while key in result_data: + key, n = f"{name}#{n}", n + 1 + result_data[key] = data + return result_data + def _get_auth_token(self, context: ServerCallContext | None = None) -> str | None: """Extract Bearer token from ServerCallContext. @@ -281,11 +657,12 @@ def _resolve_a2a_identity( require_valid_token: bool = True, context: ServerCallContext | None = None, ) -> ResolvedIdentity: - """Resolve identity at the A2A transport boundary — called ONCE per request. + """Make the authoritative A2A authentication decision for a request. This is the A2A equivalent of REST's _resolve_auth(). It calls - resolve_identity() once and returns the result. All downstream handlers - receive the pre-resolved identity instead of re-resolving from auth_token. + resolve_identity() once and returns the result. Auth-error observability + stays tenant-unscoped and never validates the token again. All + downstream handlers receive the pre-resolved identity. Args: auth_token: Bearer token from Authorization header (None for unauthenticated) @@ -301,11 +678,12 @@ def _resolve_a2a_identity( from src.core.resolved_identity import resolve_identity from src.core.testing_hooks import AdCPTestContext - auth_ctx = context.state.get(AUTH_CONTEXT_STATE_KEY) if context is not None else None - headers = auth_ctx.headers if auth_ctx else {} + headers = _a2a_auth_headers(context) if require_valid_token and not auth_token: - raise InvalidRequestError(message="Missing authentication token") + raise _enveloped_auth_error( + classify_auth_credentials_error(headers, missing_message="Missing authentication token"), + ) # Extract testing context from A2A request headers (same as MCP does) testing_context = AdCPTestContext.from_headers(headers) @@ -319,15 +697,39 @@ def _resolve_a2a_identity( testing_context=testing_context, ) except AdCPAuthenticationError as e: - raise InvalidRequestError(message=str(e)) from e + # Preserve the cause server-side while classifying the wire code from + # the standard Authorization header, as required by AdCP 3.1.1. + raise _enveloped_auth_error( + classify_auth_credentials_error( + headers, + missing_message="Authentication credentials are required via the Authorization header.", + ), + ) from e if require_valid_token: if not identity.principal_id: - raise InvalidRequestError(message="Authentication token is invalid or expired.") + raise _enveloped_auth_error( + classify_auth_credentials_error( + headers, + missing_message="Authentication credentials are required via the Authorization header.", + invalid_message="Authentication token is invalid or expired.", + ), + ) if not identity.tenant: - raise InvalidRequestError( - message=f"Unable to determine tenant from authentication. Principal: {identity.principal_id}" + # The credentials were VALID — the principal authenticated and only the tenant + # lookup failed. That is a seller-side configuration failure, not a buyer auth + # problem, so it must NOT emit AUTH_REQUIRED (which tells the buyer to fix its + # credentials). CONFIGURATION_ERROR is terminal per the pinned 3.1.1 enum, and + # being an internal wire code its message is scrubbed at the boundary — which is + # also what keeps the principal id off the wire, so it is logged here instead. + logger.error( + "[A2A AUTH] authenticated principal has no resolvable tenant: principal=%s", + identity.principal_id, + ) + raise _recorded_a2a_auth_rejection( + AdCPConfigurationError("Unable to determine tenant for the authenticated principal."), + principal_id=identity.principal_id, ) tenant_id = identity.tenant_id or identity.tenant.get("tenant_id", "unknown") @@ -343,6 +745,18 @@ def _resolve_a2a_identity( return identity + def _authenticated_tool_context(self, context: ServerCallContext | None, tool_name: str) -> ToolContext: + """Auth preamble shared by the four push-notification-config methods. + + ``token → identity → ToolContext`` was byte-identical in all four. No missing-token + pre-check: ``_resolve_a2a_identity`` (``require_valid_token=True`` by default) already + raises the single enveloped auth error for exactly that condition, so each of these + methods inherit the shared ``AUTH_MISSING``/``AUTH_INVALID`` envelope source. + """ + auth_token = self._get_auth_token(context) + identity = self._resolve_a2a_identity(auth_token, context=context) + return self._make_tool_context(identity, tool_name) + def _make_tool_context( self, identity: ResolvedIdentity, tool_name: str, context_id: str | None = None ) -> ToolContext: @@ -376,15 +790,15 @@ def _make_tool_context( def _log_a2a_operation( self, operation: str, - tenant_id: str, - principal_id: str, + tenant_id: str | None, + principal_id: str | None, success: bool = True, details: dict[str, Any] | None = None, error: str | None = None, ): """Log A2A operations to audit system for visibility in activity feed.""" try: - if not tenant_id: + if not tenant_id or not principal_id: return audit_logger = get_audit_logger("A2A", tenant_id) @@ -405,8 +819,6 @@ async def _send_protocol_webhook( self, task: Task, status: str, - result: dict[str, Any] | None = None, - error: str | None = None, ): """Send protocol-level push notification if configured. @@ -415,6 +827,13 @@ async def _send_protocol_webhook( - Intermediate states (working, input-required, submitted): Send TaskStatusUpdateEvent Uses create_a2a_webhook_payload from adcp library to automatically select correct type. + + In-process callers notify only the non-terminal ``submitted`` transition + (immediate terminal responses are returned synchronously and do not notify — + see ``_mark_task_failed``; the later async terminal transition is notified by + the durable workflow path in ``ContextManager``). The payload's result data is + always read off the Task's own artifacts (``_webhook_result_data``), never a + caller-supplied dict — one source for what a subscriber sees. """ try: # Check if task has push notification config stored @@ -453,11 +872,12 @@ async def _send_protocol_webhook( logger.warning("Unknown status '%s', defaulting to 'working'", status) status_enum = GeneratedTaskStatus.working - # Build result data for the webhook payload - # Include error information in result if status is failed - result_data: dict[str, Any] = result or {} - if error and status == "failed": - result_data["error"] = error + # Build result data for the webhook payload. ``create_a2a_webhook_payload`` + # renders its artifact FROM this dict, so we pass the Task's own structured + # artifact data — EVERY artifact, de-colliding duplicate names — never a + # lossy ``{"error": "..."}``, a single stale DataPart, an empty dict, or a + # name-overwritten sibling. + result_data: dict[str, Any] = self._webhook_result_data(task) # Use create_a2a_webhook_payload to get the correct payload type: # - Task for final states (completed, failed, canceled) @@ -506,7 +926,7 @@ async def on_message_send( Returns: Task object or Message response """ - logger.info("Handling message/send request: %s", params) + logger.info("Handling message/send request") # Parse message for both text and structured data parts message = params.message @@ -527,9 +947,7 @@ async def on_message_send( # Support both "input" (A2A spec) and "parameters" (legacy) for skill params params_data = data.get("input") or data.get("parameters", {}) skill_invocations.append({"skill": data["skill"], "parameters": params_data}) - logger.info( - f"Found explicit skill invocation: {data['skill']} with params: {list(params_data.keys())}" - ) + logger.info("Found explicit skill invocation") # Combine text for natural language fallback combined_text = " ".join(text_parts).strip().lower() @@ -553,7 +971,10 @@ async def on_message_send( "invocation_type": "explicit_skill" if skill_invocations else "natural_language", } if skill_invocations: - task_metadata["skills_requested"] = [inv["skill"] for inv in skill_invocations] + registered_skills = set(self._skill_handler_map()) + task_metadata["skills_requested"] = [ + inv["skill"] if inv["skill"] in registered_skills else "unsupported_skill" for inv in skill_invocations + ] task = Task( id=task_id, @@ -563,6 +984,11 @@ async def on_message_send( ) self.tasks[task_id] = task + # Initialized before the try so the outer error handler can always read + # it — a failure during auth-token extraction (before resolution) must + # not turn into a NameError inside the except block. + identity: ResolvedIdentity | None = None + try: # Get authentication token auth_token = self._get_auth_token(context) @@ -577,20 +1003,16 @@ async def on_message_send( if non_discovery_skills: requires_auth = True - # Require authentication for non-public skills. Stay a JSON-RPC - # InvalidRequestError (protocol-level rejection, top-level error), but - # carry the two-layer envelope in ``data`` so the buyer-facing - # AUTH_REQUIRED code + AUTH_REQUIRED_SUGGESTION reach the A2A wire — - # matching REST's no-identity envelope (auth_context.py), which the - # bare A2AError previously dropped. (#1417) + # Require authentication for non-public skills. Stays a JSON-RPC + # InvalidRequestError (protocol-level rejection) while carrying the two-layer + # envelope in ``data`` — via the same _enveloped_auth_error source every other + # A2A auth raise uses, so the code/recovery/suggestion and both layers' message + # cannot drift between message/send and the rest. if requires_auth and not auth_token: - raise InvalidRequestError( - message="Missing authentication token - Bearer token required in Authorization header", - data=build_two_layer_error_envelope( - AdCPAuthRequiredError( - "Authentication required - Bearer token required in Authorization header", - suggestion=AUTH_REQUIRED_SUGGESTION, - ) + raise _enveloped_auth_error( + classify_auth_credentials_error( + _a2a_auth_headers(context), + missing_message="Authentication required - Bearer token required in Authorization header", ), ) @@ -606,10 +1028,12 @@ async def on_message_send( if push_notification_config: self._task_push_configs[task_id] = push_notification_config - # ── Transport boundary: resolve identity ONCE ── - # Like REST's _resolve_auth(), identity is resolved here and passed - # to all downstream handlers. No handler should call resolve_identity(). - identity: ResolvedIdentity | None = None + # ── Transport boundary: make one authoritative auth decision ── + # Like REST's _resolve_auth(), identity is resolved here and passed to + # all downstream handlers. Auth-error recording stays unscoped, and + # no handler validates the credential a second time. + # (``identity`` is declared before the enclosing ``try`` above so the + # outer error handler can always read it — not re-declared here.) if auth_token: identity = self._resolve_a2a_identity(auth_token, require_valid_token=requires_auth, context=context) elif not requires_auth: @@ -618,12 +1042,31 @@ async def on_message_send( # Route: Handle explicit skill invocations first, then natural language fallback if skill_invocations: - # Process explicit skill invocations + # Reject a multi-skill batch BEFORE executing ANY skill. Aggregating + # divergent per-skill outcomes into one Task is incoherent when a skill + # has real side effects: e.g. create_media_buy persists a pending + # (submitted) workflow while a sibling fails, which would terminalize + # the Task as failed even though the accepted work keeps running. Until + # per-skill child Tasks exist (tracked as a follow-up), one skill per + # message is the contract. Raised as a typed application error → + # failed Task (UNSUPPORTED_FEATURE); no skill runs, so no side effects. + if len(skill_invocations) > 1: + multi_skill_error = AdCPCapabilityNotSupportedError( + message="Batching multiple skills in one message is not supported; send one skill per message." + ) + # Recorded HERE, at the raise site. This is the one raise reaching the + # outer ``except AdCPError`` that does not pass through + # ``_dispatch_under_sanitize_seam``, and that arm no longer records — + # so an unrecorded raise would vanish from the observability sinks. + _record_a2a_boundary_error("message_processing", identity, multi_skill_error) + raise multi_skill_error + + # Process the single explicit skill invocation. results = [] for invocation in skill_invocations: skill_name = invocation["skill"] parameters = invocation["parameters"] - logger.info("Processing explicit skill: %s with parameters: %s", skill_name, parameters) + logger.info("Processing explicit skill invocation") try: result = await self._handle_explicit_skill( @@ -631,6 +1074,7 @@ async def on_message_send( parameters, identity, push_notification_config=push_notification_config, + task_id=task_id, ) results.append({"skill": skill_name, "result": result, "success": True}) except A2AError: @@ -649,7 +1093,8 @@ async def on_message_send( # except branch (with audit log + activity feed); duplicating # the logger call here would produce two messages for the # same failure. - results.append(self._build_failed_skill_result(skill_name, e)) + safe_skill_name = skill_name if skill_name in self._skill_handler_map() else "unsupported_skill" + results.append(self._build_failed_skill_result(safe_skill_name, e)) except Exception as e: # Untyped fallthrough — same envelope shape as the AdCPError # branch so storyboard runners can `JSON.parse` the DataPart @@ -661,31 +1106,12 @@ async def on_message_send( # (AdCPError/ValueError/PermissionError) failures were already # recorded inside _handle_explicit_skill, so this only fires for # genuinely-unexpected exceptions that escaped it. - record_boundary_error( - "a2a", - skill_name, - e, - tenant_id=getattr(identity, "tenant_id", None), - principal_id=getattr(identity, "principal_id", None) or "anonymous", - ) + _record_a2a_boundary_error(skill_name, identity, e) results.append(self._build_failed_skill_result(skill_name, e)) - # Check for submitted status (manual approval required) - return early without artifacts - # Per AdCP spec, async operations should return Task with status=submitted and no artifacts - for res in results: - if res["success"] and isinstance(res["result"], dict): - result_status = res["result"].get("status") - if result_status == "submitted": - task.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_SUBMITTED)) - del task.artifacts[:] # No artifacts for pending tasks - logger.info( - f"Task {task_id} requires manual approval, returning status=submitted with no artifacts" - ) - # Send protocol-level webhook notification - await self._send_protocol_webhook(task, status="submitted") - self.tasks[task_id] = task - return task - + # Create artifacts for ALL skill results FIRST, before any status + # decision. A mixed submitted+failed batch must never lose a failure + # envelope to an early return — status is decided below by precedence. # Create artifacts for all skill results with human-readable text for i, res in enumerate(results): if res["success"]: @@ -705,19 +1131,25 @@ async def on_message_send( ) # Generate human-readable text from response __str__() - # Per A2A spec, use TextPart + DataPart pattern (not description field) + # Per A2A spec, use TextPart + DataPart pattern (not description field). + # A FAILED artifact carries the error message as its TextPart (A2A + # error binding: TextPart + DataPart), never a DataPart alone. # - # The text is READ from the payload, never re-derived from it: - # _stamp_a2a_protocol_fields already stamped str(response) onto - # artifact_data["message"] at serialization time. An outbound - # payload is finished — feeding it back through Model(**data) - # to recover the same string handed pydantic before-validators - # a reference to the dict about to go on the wire, and one of - # them mutated it in place (the list_creatives format_id + # On both arms the text is READ from the payload, never re-derived + # from it: _stamp_a2a_protocol_fields already stamped str(response) + # onto artifact_data["message"] at serialization time, and a failure + # envelope already carries its buyer-facing text in errors[0]. + # An outbound payload is finished — feeding it back through + # Model(**data) to recover the same string handed pydantic + # before-validators a reference to the dict about to go on the wire, + # and one of them mutated it in place (the list_creatives format_id # bare-string defect). Nothing rebuilds an outbound payload. text_message = None if res["success"] and isinstance(artifact_data, dict): text_message = artifact_data.get("message") + elif not res["success"] and isinstance(artifact_data, dict): + errors = artifact_data.get("errors") or [] + text_message = errors[0].get("message") if errors else "Skill invocation failed." # Build parts list per A2A spec: optional text Part + required data Part parts = [] @@ -733,70 +1165,76 @@ async def on_message_send( ) ) - # Check if any skills failed and determine task status - failed_skills = [res["skill"] for res in results if not res["success"]] - successful_skills = [res["skill"] for res in results if res["success"]] - - if failed_skills and not successful_skills: - # All skills failed - mark task as failed - task.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_FAILED)) - - # Send protocol-level webhook notification for failure - error_messages = [ - res["error_envelope"]["errors"][0]["message"] for res in results if not res["success"] - ] - await self._send_protocol_webhook(task, status="failed", error="; ".join(error_messages)) + # The single-skill gate above guarantees exactly one result; route its + # outcome: failed → submitted → completed. + outcome = results[0] + + if not outcome["success"]: + # Terminal-failed: the failed skill's two-layer envelope rides in the + # Task body. Immediate terminal response returned synchronously → no + # webhook (a2a-guide.mdx terminal-state rule). Remember the task + # under its owner (like the submitted/successful branches) so the + # buyer can poll tasks/get on a failed explicit skill — a failed Task + # is a Task-layer outcome, and leaving it unremembered both diverges + # from the NL-failed path and strands an ownerless entry in the + # in-memory maps. + self._mark_task_failed(task) + self._remember_task(task_id, task, identity) + return task + if isinstance(outcome["result"], dict) and outcome["result"].get("status") == "submitted": + # Pending approval → non-terminal SUBMITTED. An async op keeps the + # "no artifacts until approved" convention. Non-terminal initial + # response → notify. + task.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_SUBMITTED)) + del task.artifacts[:] + await self._send_protocol_webhook(task, status="submitted") + self._remember_task(task_id, task, identity) return task - elif successful_skills: - # Log successful skill invocations with rich context - try: - tenant_id = (identity.tenant_id or "unknown") if identity else "unknown" - principal_id = (identity.principal_id or "unknown") if identity else "unknown" - - # Extract meaningful details from results - log_details = {"skills": successful_skills, "count": len(successful_skills)} - - # Add context from the first successful skill - first_result = next((r for r in results if r["success"]), None) - if first_result and "result" in first_result: - result_data = first_result["result"] - - # Extract budget and package info for create_media_buy - if "create_media_buy" in first_result["skill"]: - if isinstance(result_data, dict): - if "total_budget" in result_data: - log_details["total_budget"] = result_data["total_budget"] - if "packages" in result_data: - log_details["package_count"] = len(result_data["packages"]) - if "media_buy_id" in result_data: - log_details["media_buy_id"] = result_data["media_buy_id"] - - # Extract product count for get_products - elif "get_products" in first_result["skill"]: - if isinstance(result_data, dict) and "products" in result_data: - log_details["product_count"] = len(result_data["products"]) - - # Extract creative count for sync_creatives - elif "sync_creatives" in first_result["skill"]: - if isinstance(result_data, dict) and "creatives" in result_data: - log_details["creative_count"] = len(result_data["creatives"]) - - self._log_a2a_operation( - "explicit_skill_invocation", - tenant_id, - principal_id, - True, - log_details, - ) - except Exception as e: - logger.warning("Could not log skill invocations: %s", e) + + # Completed synchronously — log the successful invocation with rich context. + try: + tenant_id, principal_id = _a2a_activity_scope(identity) + + log_details = {"skills": [outcome["skill"]], "count": 1} + result_data = outcome.get("result") + + # Extract budget and package info for create_media_buy + if "create_media_buy" in outcome["skill"]: + if isinstance(result_data, dict): + if "total_budget" in result_data: + log_details["total_budget"] = result_data["total_budget"] + if "packages" in result_data: + log_details["package_count"] = len(result_data["packages"]) + if "media_buy_id" in result_data: + log_details["media_buy_id"] = result_data["media_buy_id"] + + # Extract product count for get_products + elif "get_products" in outcome["skill"]: + if isinstance(result_data, dict) and "products" in result_data: + log_details["product_count"] = len(result_data["products"]) + + # Extract creative count for sync_creatives + elif "sync_creatives" in outcome["skill"]: + if isinstance(result_data, dict) and "creatives" in result_data: + log_details["creative_count"] = len(result_data["creatives"]) + + self._log_a2a_operation( + "explicit_skill_invocation", + tenant_id, + principal_id, + True, + log_details, + ) + except Exception as e: + logger.warning("Could not log skill invocations: %s", e) # Natural language fallback (existing keyword-based routing) elif any(word in combined_text for word in ["product", "inventory", "available", "catalog"]): - result = await self._get_products(combined_text, identity) - tenant_id = (identity.tenant_id or "unknown") if identity else "unknown" - principal_id = (identity.principal_id or "unknown") if identity else "unknown" + result = await self._dispatch_under_sanitize_seam( + "get_products", identity, self._get_products(combined_text, identity) + ) + tenant_id, principal_id = _a2a_activity_scope(identity) self._log_a2a_operation( "get_products", @@ -818,12 +1256,10 @@ async def on_message_send( ) elif any(word in combined_text for word in ["price", "pricing", "cost", "cpm", "budget"]): # Redirect pricing queries to get_products which has real price_guidance - result = await self._handle_get_products_skill( - {"brief": combined_text}, - identity, + result = await self._dispatch_under_sanitize_seam( + "get_products", identity, self._handle_get_products_skill({"brief": combined_text}, identity) ) - tenant_id = (identity.tenant_id or "unknown") if identity else "unknown" - principal_id = (identity.principal_id or "unknown") if identity else "unknown" + tenant_id, principal_id = _a2a_activity_scope(identity) self._log_a2a_operation( "get_products", @@ -846,9 +1282,10 @@ async def on_message_send( ) elif any(word in combined_text for word in ["target", "audience"]): # Redirect targeting queries to get_adcp_capabilities which has real targeting info - result = await self._handle_get_adcp_capabilities_skill({}, identity) - tenant_id = (identity.tenant_id or "unknown") if identity else "unknown" - principal_id = (identity.principal_id or "unknown") if identity else "unknown" + result = await self._dispatch_under_sanitize_seam( + "get_adcp_capabilities", identity, self._handle_get_adcp_capabilities_skill({}, identity) + ) + tenant_id, principal_id = _a2a_activity_scope(identity) self._log_a2a_operation( "get_adcp_capabilities", @@ -872,10 +1309,13 @@ async def on_message_send( # ``_create_media_buy`` is an NL stub that always raises # ``AdCPCapabilityNotSupportedError`` — the explicit-skill # path is the spec contract for media buy creation. The - # outer error handler at on_message_send catches the raise - # and attaches a spec-compliant two-layer envelope to the - # failed Task artifact. - await self._create_media_buy(combined_text, identity) + # outer error handler at on_message_send catches the raise, + # attaches a spec-compliant two-layer envelope to the failed + # Task artifact, and returns that failed Task (never a + # JSON-RPC error). + await self._dispatch_under_sanitize_seam( + "create_media_buy", identity, self._create_media_buy(combined_text, identity) + ) else: # General help response capabilities = { @@ -892,8 +1332,7 @@ async def on_message_send( "How do I create a media buy?", ], } - tenant_id = (identity.tenant_id or "unknown") if identity else "unknown" - principal_id = (identity.principal_id or "unknown") if identity else "unknown" + tenant_id, principal_id = _a2a_activity_scope(identity) self._log_a2a_operation( "get_capabilities", @@ -916,76 +1355,83 @@ async def on_message_send( task_state = TaskState.TASK_STATE_COMPLETED task_status_str = "completed" - result_data = {} - if task.artifacts: - # Extract result from artifacts — part.data is a protobuf Value - for artifact in task.artifacts: - if artifact.parts: - for part in artifact.parts: - if part.HasField("data"): - data_dict = json.loads(json_format.MessageToJson(part.data)) - result_data[artifact.name] = data_dict - - # Check if this is a sync_creatives response with pending creatives - if artifact.name == "result" and isinstance(data_dict, dict): - creatives = data_dict.get("creatives", []) - if any( - c.get("status") == CreativeStatusEnum.pending_review.value - for c in creatives - if isinstance(c, dict) - ): - task_state = TaskState.TASK_STATE_SUBMITTED - task_status_str = "submitted" - - # Check for explicit status field (e.g., create_media_buy returns this) - result_status = data_dict.get("status") - if result_status == "submitted": - task_state = TaskState.TASK_STATE_SUBMITTED - task_status_str = "submitted" + # Single DataPart decode via the shared helper (consolidated decoder). + for artifact_name, data_dict in self._task_artifacts_data(task): + # sync_creatives returns a "result" artifact whose creatives may be + # pending review → the task is non-terminal (submitted), not completed. + if artifact_name == "result" and isinstance(data_dict, dict): + creatives = data_dict.get("creatives", []) + if any( + c.get("status") == CreativeStatusEnum.pending_review.value + for c in creatives + if isinstance(c, dict) + ): + task_state = TaskState.TASK_STATE_SUBMITTED + task_status_str = "submitted" + + # Explicit status field (e.g. create_media_buy returns this). + if data_dict.get("status") == "submitted": + task_state = TaskState.TASK_STATE_SUBMITTED + task_status_str = "submitted" # Mark task with appropriate status task.status.CopyFrom(TaskStatus(state=task_state)) - # Send protocol-level webhook notification if configured - await self._send_protocol_webhook(task, status=task_status_str) + # Notify ONLY for a non-terminal (submitted) initial response. An + # immediately-completed task is returned synchronously in this response, + # and AdCP 3.1.1 a2a-guide.mdx ("Webhook Trigger Rules") says no webhook is + # sent when the initial response is already terminal — the buyer already + # has the result. Only the + # sync_creatives-pending → submitted transition reaches here as + # non-terminal (create_media_buy submitted returns earlier). + if task_status_str == "submitted": + await self._send_protocol_webhook(task, status="submitted") except A2AError: - # Re-raise A2AError as-is (will be caught by JSON-RPC handler) + # Transport-layer failure (missing auth, invalid request, …) → JSON-RPC + # error, NOT a Task-layer outcome. The provisional WORKING task + push + # config stored before dispatch (and before identity resolution) must not + # survive as ownerless, unservable orphans that grow the maps on repeated + # invalid requests — drop them, mirroring the untyped-crash path below. + self._forget_task(task_id) raise - except Exception as e: - # Use identity resolved at transport boundary (if available) - err_tenant_id = (identity.tenant_id or "unknown") if identity else "unknown" - err_principal_id = (identity.principal_id or "unknown") if identity else "unknown" - - record_boundary_error( - "a2a", - "message_processing", - e, - tenant_id=err_tenant_id, - principal_id=err_principal_id, - ) - - # Send protocol-level webhook notification for failure if configured - task.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_FAILED)) - # Attach error to task artifacts as a spec-compliant two-layer - # envelope (same shape as failed-skill DataParts) so storyboard - # runners can ``JSON.parse`` the artifact uniformly regardless of - # which failure path produced it. + except AdCPError as e: + # TYPED application/task failure → failed Task carrying the two-layer + # envelope (transport-errors.mdx "Layer Separation"). The AdCPError + # message is CONTROLLED (e.g. "Unknown skill 'x'", "brief must not be + # empty"), so it is client-safe to surface. Immediate terminal response + # returned synchronously below → no webhook (a2a-guide.mdx). Falls through + # to the shared store-and-return. + # + # This arm is pure FRAMING — it no longer records. Every raise that can reach + # it now records itself exactly once, with the ORIGINAL exception, at its own + # raise site: explicit-skill dispatch and NL routing via + # ``_dispatch_under_sanitize_seam``, the multi-skill rejection inline above. + # Recording again here would double-count, and would relabel the specific + # operation the seam already logged as the generic ``message_processing``. del task.artifacts[:] - task.artifacts.append( - Artifact( - artifact_id="error_1", - name="processing_error", - parts=[Part(data=_dict_to_value(self._build_error_envelope(e)))], - ) - ) - - await self._send_protocol_webhook(task, status="failed") - - # Raise A2A error instead of creating failed task - raise _internal_error_for("message processing", e) - - self.tasks[task_id] = task + task.artifacts.append(self._failed_task_artifact(e)) + self._mark_task_failed(task) + except Exception as e: + # UNTYPED internal crash. The spec table classifies an internal crash as + # a TRANSPORT-layer error, and the security requirements forbid exposing + # raw internals (credentials, SQL, hostnames, paths, upstream responses). + # So we log the raw exception SERVER-SIDE only (record_boundary_error) and + # raise a SANITIZED JSON-RPC InternalError whose client-facing envelope + # carries NO raw exception text. Never build a failed-Task envelope from + # ``str(exc)`` here — that is the leak fixed by this branch. + # NOTE the deliberate split: an untyped crash INSIDE a skill handler is a + # task-layer outcome (the dispatch loop wraps it via + # ``_build_failed_skill_result`` → sanitized failed Task), while a crash in + # THIS boundary — before/after dispatch — is transport-layer (JSON-RPC). + # This path yields a JSON-RPC InternalError (transport-layer), NOT a + # Task-layer outcome — so the provisional WORKING task stored before + # dispatch must not survive as a retrievable orphan. Drop it (and its + # push config) before raising so ``tasks/get`` returns nothing. + self._forget_task(task_id) + raise _boundary_internal_error("message_processing", "message processing", identity, e) from e + + self._remember_task(task_id, task, identity) return task async def on_message_send_stream( @@ -1010,39 +1456,32 @@ async def on_message_send_stream( # result is already Task | Message — yield it directly yield result - def _get_task_or_raise(self, task_id: str) -> Task: - """Return the in-memory task, or raise ``TaskNotFoundError``. + # Terminal persisted workflow-step status → A2A TaskState, for the durable + # tasks/get fallback. Non-terminal steps (in_progress, approved, …) surface as + # WORKING. + _STEP_STATUS_TO_TASK_STATE = { + "completed": TaskState.TASK_STATE_COMPLETED, + "rejected": TaskState.TASK_STATE_REJECTED, + "failed": TaskState.TASK_STATE_FAILED, + "canceled": TaskState.TASK_STATE_CANCELED, + } - A bare ``None`` return makes the SDK synthesize a generic internal error; - the A2A spec defines ``TaskNotFoundError`` for an unknown task id, so - raising it is the correct thing to do here and is what an A2A client - should be able to react to precisely. + # Step statuses that are final outcomes — a buyer's tasks/cancel cannot undo + # work that already completed/failed/was rejected (or was already canceled). + # Single source of truth is the repository's TERMINAL_STEP_STATUSES (the atomic + # cancel guard's vocabulary); the state mapping above must cover exactly that + # set, checked at import time so the two can't silently drift. + _TERMINAL_STEP_STATUSES = TERMINAL_STEP_STATUSES + if frozenset(_STEP_STATUS_TO_TASK_STATE) != _TERMINAL_STEP_STATUSES: + raise RuntimeError("A2A step->TaskState mapping out of sync with WorkflowRepository.TERMINAL_STEP_STATUSES") - What a client sees TODAY is still ``-32603``, not the spec's ``-32001``: - this app builds its A2A routes with ``enable_v0_3_compat=True`` - (``src/app.py:306``), so requests dispatch through - ``a2a.compat.v0_3.jsonrpc_adapter``, whose ``handle_request`` ends in a - bare ``except Exception -> CoreInternalError`` with no ``A2AError -> code`` - mapping — the mapping the SDK's own main dispatcher performs. Returning - ``None`` produces the same ``-32603`` there, so the code cannot be fixed - at this layer (#1670). Raising the right type is still correct and is what - will surface ``-32001`` the moment that gap closes; the xfail'd - live-server test pins the current reality. - - The requested id is put on both the message and structured ``data``. - Only the message reaches a client today: the same compat adapter that - flattens the code to ``-32603`` rebuilds the error as - ``CoreInternalError(message=str(e))``, which drops ``data`` — driving - the real route returns ``data: null``. Populating it is still correct - and becomes readable when #1670 closes, the same as the code. - - Shared by ``on_get_task`` and ``on_cancel_task`` so both surface the - same error. - """ - task = self.tasks.get(task_id) - if task is None: - raise TaskNotFoundError(message=f"Task not found: {task_id}", data={"task_id": task_id}) - return task + _TERMINAL_TASK_STATES = frozenset(_STEP_STATUS_TO_TASK_STATE.values()) + + # Reverse of the mapping above, for rendering an in-memory Task's terminal + # TaskState back into the same lowercase vocabulary the durable leg's step-status + # string already uses — so a cancel refusal reads identically ("current state: + # completed") regardless of which leg (in-memory vs durable) refused it. + _TASK_STATE_TO_STEP_STATUS = {v: k for k, v in _STEP_STATUS_TO_TASK_STATE.items()} async def on_get_task( self, @@ -1051,10 +1490,169 @@ async def on_get_task( ) -> Task: """Handle 'tasks/get' method to retrieve task status. - Raises ``TaskNotFoundError`` for an unknown task id — see - ``_get_task_or_raise`` (and #1670 for why the wire code is still -32603). + Identity is resolved ONCE and gates BOTH stores: the in-memory task is + served only to its recorded owner (``_authorized_in_memory_task``), and the + durable step lookup is tenant+principal-scoped — so a same-tenant sibling + principal who learns a task id can read neither. + + The persisted workflow step is the source of truth for an async task's + outcome: the admin decision that terminalizes it runs in a DIFFERENT + process, so this process's in-memory entry can be stale forever (a + SUBMITTED/WORKING task whose workflow already completed). A poll therefore + returns the owned in-memory task only when IT is already terminal; + otherwise the durable step is consulted and, if it reached a terminal + status, wins (and reconciles the owned in-memory entry). The durable + fallback also serves polls after a restart, when the map is empty. + + Raises ``TaskNotFoundError`` when neither store has the task (unknown id, + or not owned by the caller) — a bare ``None`` return would make the SDK + synthesize a generic internal error instead of the spec's not-found + signal. What a client sees TODAY is still ``-32603``, not the spec's + ``-32001``: this app builds its A2A routes with ``enable_v0_3_compat=True`` + (``src/app.py``), so requests dispatch through + ``a2a.compat.v0_3.jsonrpc_adapter``, whose ``handle_request`` ends in a + bare ``except Exception -> CoreInternalError`` with no ``A2AError -> code`` + mapping — the mapping the SDK's own main dispatcher performs. Raising the + right type is still correct and will surface ``-32001`` once the + compatibility adapter preserves typed A2A error codes. + """ + task_id = params.id + identity: ResolvedIdentity | None = None + try: + identity = self._durable_lookup_identity(context) + owned = self._authorized_in_memory_task(task_id, identity) + if owned is not None and owned.status.state in self._TERMINAL_TASK_STATES: + return owned + durable = self._durable_task_from_step(task_id, identity) + if durable is not None and durable.status.state in self._TERMINAL_TASK_STATES: + if owned is not None: + # Reconcile only our OWN entry — never write a map key we don't own. + self._remember_task(task_id, durable, identity) + return durable + # No terminal durable outcome: the richer owned in-memory task (metadata, + # artifacts) beats the durable WORKING skeleton. + found = owned if owned is not None else durable + if found is None: + raise TaskNotFoundError(message=f"Task not found: {task_id}", data={"task_id": task_id}) + return found + except A2AError: + raise + except Exception as e: + # The durable lookup touches the DB; an untyped failure here must not + # escape to the SDK dispatcher, which would echo str(exc) verbatim on + # the JSON-RPC wire. Mirror every sibling handler's boundary arm. + raise _boundary_internal_error("get_task", "get task", identity, e) from e + + def _durable_lookup_identity(self, context: ServerCallContext | None) -> ResolvedIdentity | None: + """Resolve the caller's identity for a durable (cross-process) task lookup. + + A restart-surviving lookup needs a tenant AND principal scope, so identity + is resolved from the request's own auth (the buyer who created the task + authenticated). Missing or invalid authentication remains a transport-layer + ``InvalidRequestError``; it must not be downgraded to a task-not-found result. + Returns None only when a resolved identity is unexpectedly incomplete — the + durable lookup must then be refused rather than risk serving or mutating + another tenant's (or same-tenant sibling principal's) task. + """ + auth_token = self._get_auth_token(context) + identity = self._resolve_a2a_identity(auth_token, require_valid_token=True, context=context) + if identity is None or not identity.tenant_id or not identity.principal_id: + return None + return identity + + @contextmanager + def _owned_durable_step( + self, task_id: str, identity: ResolvedIdentity | None + ) -> Iterator[tuple["Session", "WorkflowRepository", "WorkflowStep"] | None]: + """Shared preamble for durable (cross-process) task ops carrying an outer ``task_*`` id. + + Identity guard → tenant-scoped session + ``WorkflowRepository`` → the principal-owned step + carrying ``task_id``. Yields ``(session, repo, step)``, or ``None`` when identity is + unresolved/non-owning or no persisted step matches. The caller performs any mutation and + ``commit()``/``rollback()`` inside the ``with`` block (the session stays open for its body). + """ + if identity is None or identity.tenant_id is None or identity.principal_id is None: + yield None + return + + # get_db_session stays function-local: this repo's tests patch it at its SOURCE + # module (20+ call sites across tests/), which only takes effect when the name is + # re-resolved per call rather than bound once at import time. + from src.core.database.database_session import get_db_session + + with get_db_session() as session: + repo = WorkflowRepository(session, identity.tenant_id) + step = repo.get_by_external_task_id(task_id, principal_id=identity.principal_id) + yield (session, repo, step) if step is not None else None + + def _durable_task_from_step(self, task_id: str, identity: ResolvedIdentity | None) -> Task | None: + """Rebuild a terminal Task from the workflow step that stored this transport id. + + ``identity`` is the caller's resolved identity (see ``_durable_lookup_identity``); + the lookup is tenant+principal-scoped, so an unresolved or non-owning identity + yields None. Callers resolve identity once and pass it here. + + A FAILED step is rebuilt with the SAME error framing the synchronous paths emit — + an ``error_result`` artifact carrying a human-readable TextPart alongside the + authoritative envelope DataPart (see ``_failed_task_artifact``). A buyer polling + an async failure must not receive a differently-shaped artifact than the one they + would have received had the same failure surfaced synchronously. + """ + recovery_media_buy_id: str | None = None + with self._owned_durable_step(task_id, identity) as owned: + if owned is not None: + _session, repo, step = owned + if step.status == "approved" and step.tool_name == "create_media_buy": + mappings = repo.get_mappings_for_step(step.step_id) + media_buy_mapping = next((m for m in mappings if m.object_type == "media_buy"), None) + if media_buy_mapping is not None: + recovery_media_buy_id = media_buy_mapping.object_id + + if recovery_media_buy_id is not None and identity is not None and identity.tenant_id is not None: + from src.core.workflow_finalization import reconcile_claimed_media_buy_approval_step + + reconcile_claimed_media_buy_approval_step( + tenant_id=identity.tenant_id, + media_buy_id=recovery_media_buy_id, + ) + + with self._owned_durable_step(task_id, identity) as owned: + if owned is None: + return None + _session, _repo, step = owned + state = self._STEP_STATUS_TO_TASK_STATE.get(step.status, TaskState.TASK_STATE_WORKING) + task = Task(id=task_id, context_id=step.context_id, status=TaskStatus(state=state)) + if step.response_data: + task.artifacts.append( + self._durable_result_artifact( + task_id, step.response_data, failed=state == TaskState.TASK_STATE_FAILED + ) + ) + return task + + @staticmethod + def _durable_result_artifact(task_id: str, response_data: dict[str, Any], *, failed: bool) -> "Artifact": + """The stored-result artifact for a durably-rebuilt Task. + + Success keeps the ``media_buy_result`` DataPart. Failure mirrors the synchronous + error binding: an ``error_result`` artifact whose TextPart is the envelope's + human-readable message and whose DataPart is the two-layer envelope + ``audit_workflow_step_failure`` persisted — one framing for a failed artifact, + whether the buyer saw it synchronously or by polling. """ - return self._get_task_or_raise(params.id) + if not failed: + return Artifact( + artifact_id=f"{task_id}_result", + name="media_buy_result", + parts=[Part(data=_dict_to_value(response_data))], + ) + errors = response_data.get("errors") or [] + text = (errors[0].get("message") if errors else None) or "Request failed." + return Artifact( + artifact_id=f"{task_id}_result", + name="error_result", + parts=[Part(text=text), Part(data=_dict_to_value(response_data))], + ) async def on_cancel_task( self, @@ -1063,16 +1661,108 @@ async def on_cancel_task( ) -> Task: """Handle 'tasks/cancel' method to cancel a task. - Raises ``TaskNotFoundError`` for an unknown task id — cancelling a task - that does not exist is the same not-found condition as get, not a silent - no-op. See ``_get_task_or_raise`` (and #1670 for why the wire code is - still -32603). + Mirrors ``on_get_task``'s durability: the in-memory task is + resolved first, then the persisted workflow step carrying the buyer's outer + ``task_*`` id — so a cancel still lands after a restart or in a different + process than the create. A task/step already in a terminal state cannot be + canceled; the durable check runs even on an in-memory hit so a stale + WORKING task can't cancel a workflow that was approved out-of-band. + + Identity is resolved ONCE and gates both stores: only the recorded owner + can observe or mutate the in-memory task, and the durable cancel is + tenant+principal-scoped — a same-tenant sibling principal can neither + terminalize the in-memory task nor cancel the workflow. + + Grounding: ``tasks/cancel`` semantics are A2A-protocol-native (A2A spec + Task Management: ``TaskNotCancelableError`` for tasks already in a + terminal state; the SDK ``default_request_handler`` is the reference + cross-check). AdCP 3.1.1 prose defines no cancel contract of its own — + a2a-guide.mdx "Webhook Trigger Rules" lists ``canceled`` among the final + states ("Cancellation confirmed"). Storyboard: ungraded. + + Raises ``TaskNotFoundError`` when neither store has the task (unknown id, + or not owned by the caller) — cancelling a task that does not exist is the + same not-found condition as get, not a silent no-op. The compatibility adapter + still emits ``-32603`` rather than the spec's ``-32001`` for typed A2A errors. + + A durable counterpart is REQUIRED to report success. If the durable cancel + resolves nothing, an in-memory hit raises ``TaskNotCancelableError`` rather than + stamping CANCELED: the in-memory task is this process's view, so cancelling only + that would tell the buyer the work stopped while the workflow step kept running + and later fired its own completed webhook. Only ``_TASK_ID_BEARING_SKILLS`` + persist their outer id, so a missing durable counterpart means the task is not + cancellable through this id — which is the honest answer for a ``sync_creatives`` + task, whose N per-creative steps no single id can name. """ - task = self._get_task_or_raise(params.id) - # CopyFrom mutates the stored Task in place — self.tasks already holds - # this exact reference, so re-storing it would rebind the same object. - task.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_CANCELED)) - return task + task_id = params.id + identity: ResolvedIdentity | None = None + try: + identity = self._durable_lookup_identity(context) + owned = self._authorized_in_memory_task(task_id, identity) + if owned is not None and owned.status.state in self._TERMINAL_TASK_STATES: + current_status = self._TASK_STATE_TO_STEP_STATUS.get(owned.status.state, "unknown") + raise TaskNotCancelableError(message=f"Task cannot be canceled - current state: {current_status}") + durable = self._durable_cancel_step(task_id, identity) + if durable is None: + # No durable counterpart: REFUSE rather than report a cancellation that + # did not happen. An in-memory hit used to be enough to stamp CANCELED + # here, which made the answer a lie whenever the work outlived the + # request — the buyer was told the task was canceled while the workflow + # step kept running and later fired its own completed webhook. A task we + # cannot resolve durably is one we cannot cancel, and saying so is the + # only honest answer available at this boundary. + if owned is not None: + raise TaskNotCancelableError( + message=("Task cannot be canceled - no durable workflow step is associated with this task id") + ) + raise TaskNotFoundError(message=f"Task not found: {task_id}", data={"task_id": task_id}) + if owned is not None: + owned.status.CopyFrom(TaskStatus(state=TaskState.TASK_STATE_CANCELED)) + self._remember_task(task_id, owned, identity) + return owned + return durable + except A2AError: + raise + except Exception as e: + # The durable cancel touches the DB; an untyped failure must not escape + # to the SDK dispatcher (which echoes str(exc) on the JSON-RPC wire). + raise _boundary_internal_error("cancel_task", "cancel task", identity, e) from e + + def _durable_cancel_step(self, task_id: str, identity: ResolvedIdentity | None) -> Task | None: + """Durably cancel the workflow step carrying this outer task id. + + Tenant- AND principal-scoped via ``identity`` (see ``_durable_lookup_identity``). + Returns None when identity is unresolved/non-owning or no persisted step + matches. Raises ``TaskNotCancelableError`` when the step is not in a + CANCELLABLE status — i.e. already terminal, ``approved``, OR ``in_progress`` + (irreversible ad-server work has begun or is underway): an approved or + executing media buy cannot be canceled. + + The transition itself is a single conditional UPDATE + (``cancel_if_cancellable`` — ``WHERE status IN cancellable``) so a concurrent + approval/execution that commits ``approved``/``in_progress``/``completed`` after + our read cannot be overwritten — the zero-row outcome is reported as + ``TaskNotCancelableError`` with the fresh status, and the decision stands. + """ + with self._owned_durable_step(task_id, identity) as owned: + if owned is None: + return None + session, repo, step = owned + # cancel_if_cancellable refuses to cancel an ``approved`` OR ``in_progress`` step: + # once approved (or once execution has started its adapter side-effects), irreversible + # ad-server work is underway, so a cancel must not strand a real order behind a + # canceled task. + if not repo.cancel_if_cancellable(step.step_id, completed_at=datetime.now(UTC)): + session.rollback() + fresh = repo.get_by_step_id(step.step_id) + current = fresh.status if fresh is not None else "unknown" + raise TaskNotCancelableError(message=f"Task cannot be canceled - current state: {current}") + session.commit() + return Task( + id=task_id, + context_id=step.context_id, + status=TaskStatus(state=TaskState.TASK_STATE_CANCELED), + ) async def on_list_tasks( self, @@ -1102,11 +1792,7 @@ async def on_get_task_push_notification_config( """ tool_context = None try: - auth_token = self._get_auth_token(context) - if not auth_token: - raise InvalidRequestError(message="Missing authentication token") - identity = self._resolve_a2a_identity(auth_token, context=context) - tool_context = self._make_tool_context(identity, "get_push_notification_config") + tool_context = self._authenticated_tool_context(context, "get_push_notification_config") config_id = params.get("id") if isinstance(params, dict) else getattr(params, "id", None) if not config_id: @@ -1144,14 +1830,12 @@ async def on_get_task_push_notification_config( except A2AError: raise except Exception as e: - record_boundary_error( - "a2a", + raise _boundary_internal_error( "get_push_notification_config", + "get push notification config", + tool_context, e, - tenant_id=tool_context.tenant_id if tool_context else None, - principal_id=tool_context.principal_id if tool_context else None, - ) - raise _internal_error_for("get push notification config", e) from e + ) from e async def on_create_task_push_notification_config( self, @@ -1165,11 +1849,7 @@ async def on_create_task_push_notification_config( """ tool_context = None try: - auth_token = self._get_auth_token(context) - if not auth_token: - raise InvalidRequestError(message="Missing authentication token") - identity = self._resolve_a2a_identity(auth_token, context=context) - tool_context = self._make_tool_context(identity, "set_push_notification_config") + tool_context = self._authenticated_tool_context(context, "set_push_notification_config") # In a2a-sdk 1.0, TaskPushNotificationConfig is a flat protobuf message # with fields: tenant, id, task_id, url, token, authentication @@ -1180,7 +1860,6 @@ async def on_create_task_push_notification_config( if not url: raise InvalidParamsError(message="Missing required parameter: url") - _reject_unsafe_a2a_webhook_url(url) auth_type = None @@ -1226,14 +1905,12 @@ async def on_create_task_push_notification_config( except A2AError: raise except Exception as e: - record_boundary_error( - "a2a", + raise _boundary_internal_error( "create_push_notification_config", + "set push notification config", + tool_context, e, - tenant_id=tool_context.tenant_id if tool_context else None, - principal_id=tool_context.principal_id if tool_context else None, - ) - raise _internal_error_for("set push notification config", e) from e + ) from e async def on_list_task_push_notification_configs( self, @@ -1246,11 +1923,7 @@ async def on_list_task_push_notification_configs( """ tool_context = None try: - auth_token = self._get_auth_token(context) - if not auth_token: - raise InvalidRequestError(message="Missing authentication token") - identity = self._resolve_a2a_identity(auth_token, context=context) - tool_context = self._make_tool_context(identity, "list_push_notification_configs") + tool_context = self._authenticated_tool_context(context, "list_push_notification_configs") with PushNotificationConfigUoW(tool_context.tenant_id) as uow: assert uow.push_notification_configs is not None @@ -1284,14 +1957,12 @@ async def on_list_task_push_notification_configs( except A2AError: raise except Exception as e: - record_boundary_error( - "a2a", + raise _boundary_internal_error( "list_push_notification_configs", + "list push notification configs", + tool_context, e, - tenant_id=tool_context.tenant_id if tool_context else None, - principal_id=tool_context.principal_id if tool_context else None, - ) - raise _internal_error_for("list push notification configs", e) from e + ) from e async def on_delete_task_push_notification_config( self, @@ -1304,11 +1975,7 @@ async def on_delete_task_push_notification_config( """ tool_context = None try: - auth_token = self._get_auth_token(context) - if not auth_token: - raise InvalidRequestError(message="Missing authentication token") - identity = self._resolve_a2a_identity(auth_token, context=context) - tool_context = self._make_tool_context(identity, "delete_push_notification_config") + tool_context = self._authenticated_tool_context(context, "delete_push_notification_config") config_id = params.id if not config_id: @@ -1329,14 +1996,12 @@ async def on_delete_task_push_notification_config( except A2AError: raise except Exception as e: - record_boundary_error( - "a2a", + raise _boundary_internal_error( "delete_push_notification_config", + "delete push notification config", + tool_context, e, - tenant_id=tool_context.tenant_id if tool_context else None, - principal_id=tool_context.principal_id if tool_context else None, - ) - raise _internal_error_for("delete push notification config", e) from e + ) from e async def on_get_extended_agent_card( self, @@ -1411,12 +2076,50 @@ def _serialize_for_a2a(response: AdCPBaseModel | dict) -> dict[str, Any]: return AdCPRequestHandler._stamp_a2a_protocol_fields(response) + def _skill_handler_map(self) -> dict[str, Callable[..., Awaitable[Any]]]: + """Explicit-skill dispatch registry: skill name → bound handler. + + The single source of truth for which skills A2A dispatches. Exposed as a + method so the transport-contract suite can assert a registry↔test bijection + (every registered skill is exercised on the wire). Handler signatures are + heterogeneous (discovery skills accept ``identity: ResolvedIdentity | None``; + the rest require non-None), so dispatch is typed dynamically — the + non-discovery guard in ``_handle_explicit_skill`` enforces identity first. + """ + return { + # Core AdCP Discovery Skills + "get_adcp_capabilities": self._handle_get_adcp_capabilities_skill, + # Core AdCP Media Buy Skills + "get_products": self._handle_get_products_skill, + "create_media_buy": self._handle_create_media_buy_skill, + # Discovery Skills + "list_creative_formats": self._handle_list_creative_formats_skill, + "list_accounts": self._handle_list_accounts_skill, + "sync_accounts": self._handle_sync_accounts_skill, + "list_authorized_properties": self._handle_list_authorized_properties_skill, + # Media Buy Management Skills + "update_media_buy": self._handle_update_media_buy_skill, + "get_media_buys": self._handle_get_media_buys_skill, + "get_media_buy_delivery": self._handle_get_media_buy_delivery_skill, + "update_performance_index": self._handle_update_performance_index_skill, + # AdCP Spec Creative Management (centralized library approach) + "sync_creatives": self._handle_sync_creatives_skill, + "list_creatives": self._handle_list_creatives_skill, + "create_creative": self._handle_create_creative_skill, + "assign_creative": self._handle_assign_creative_skill, + # Creative Management & Approval + "approve_creative": self._handle_approve_creative_skill, + "get_media_buy_status": self._handle_get_media_buy_status_skill, + "optimize_media_buy": self._handle_optimize_media_buy_skill, + } + async def _handle_explicit_skill( self, skill_name: str, parameters: dict, identity: ResolvedIdentity | None, push_notification_config: TaskPushNotificationConfig | None = None, + task_id: str | None = None, ) -> dict: """Handle explicit AdCP skill invocations. @@ -1443,7 +2146,7 @@ async def _handle_explicit_skill( # Inject push_notification_config into parameters for skills that need it # Serialize protobuf to dict at the transport boundary — _impl accepts dict - if push_notification_config and skill_name in ("create_media_buy", "sync_creatives"): + if push_notification_config and skill_name in _ASYNC_TASK_SKILLS: pnc_dict = json_format.MessageToDict(push_notification_config) # Translate A2A protobuf authentication.scheme (singular) → AdCP schemes (plural list). # A2A's protobuf AuthenticationInfo uses a single `scheme` field; AdCP's @@ -1459,86 +2162,51 @@ async def _handle_explicit_skill( compat_result = normalize_request_params(skill_name, parameters) parameters = compat_result.params - logger.info("Handling explicit skill: %s with parameters: %s", skill_name, list(parameters.keys())) - # Validate identity for non-discovery skills - if skill_name not in DISCOVERY_SKILLS and (identity is None or not identity.principal_id): - raise InvalidRequestError(message="Authentication required for skill invocation") - - # Map skill names to handlers. Handler signatures are heterogeneous - # (discovery skills accept ``identity: ResolvedIdentity | None``; the rest - # require non-None), so the dispatch is typed dynamically — the non-discovery - # guard above enforces a non-None identity before the call. - skill_handlers: dict[str, Callable[..., Awaitable[Any]]] = { - # Core AdCP Discovery Skills - "get_adcp_capabilities": self._handle_get_adcp_capabilities_skill, - # Core AdCP Media Buy Skills - "get_products": self._handle_get_products_skill, - "create_media_buy": self._handle_create_media_buy_skill, - # ✅ NEW: Missing AdCP Discovery Skills (CRITICAL for protocol compliance) - "list_creative_formats": self._handle_list_creative_formats_skill, - "list_accounts": self._handle_list_accounts_skill, - "sync_accounts": self._handle_sync_accounts_skill, - "list_authorized_properties": self._handle_list_authorized_properties_skill, - # ✅ NEW: Missing Media Buy Management Skills (CRITICAL for campaign lifecycle) - "update_media_buy": self._handle_update_media_buy_skill, - "get_media_buys": self._handle_get_media_buys_skill, - "get_media_buy_delivery": self._handle_get_media_buy_delivery_skill, - "update_performance_index": self._handle_update_performance_index_skill, - # AdCP Spec Creative Management (centralized library approach) - "sync_creatives": self._handle_sync_creatives_skill, - "list_creatives": self._handle_list_creatives_skill, - "create_creative": self._handle_create_creative_skill, - "assign_creative": self._handle_assign_creative_skill, - # Creative Management & Approval - "approve_creative": self._handle_approve_creative_skill, - "get_media_buy_status": self._handle_get_media_buy_status_skill, - "optimize_media_buy": self._handle_optimize_media_buy_skill, - # Note: signals skills removed - should come from dedicated signals agents - # Note: legacy get_pricing/get_targeting removed - use get_products and get_adcp_capabilities instead - } - - if skill_name not in skill_handlers: - available_skills = list(skill_handlers.keys()) - raise MethodNotFoundError(message=f"Unknown skill '{skill_name}'. Available skills: {available_skills}") + if skill_name not in DISCOVERY_SKILLS and (auth_error := _no_usable_identity_error(identity)) is not None: + raise _enveloped_auth_error(auth_error) + + skill_handlers = self._skill_handler_map() + + # Defensive about identity shape — test fixtures sometimes pass a string or + # partially-built identity; the canonical recorder handles None internally. + operation = skill_name if skill_name in skill_handlers else "unsupported_skill" + + async def _invoke() -> Any: + # An unknown SKILL is an application-layer failure — the JSON-RPC method + # (message/send) is valid; routing failed inside skill dispatch. Per AdCP + # transport-errors.mdx "Layer Separation" (present since 3.0.0), it belongs in the + # task body as a failed Task with a two-layer envelope, NOT a JSON-RPC + # MethodNotFoundError (reserved for unknown JSON-RPC methods). Raised + # INSIDE the seam so the boundary observability records it exactly + # once (an unknown skill must not bypass record_boundary_error); the outer + # dispatcher's `except AdCPError` re-wraps it into a failed-skill result, + # preserving accumulated results from earlier skills. + if skill_name not in skill_handlers: + raise AdCPCapabilityNotSupportedError( + message="The requested skill is not supported. Call discovery to list available skills." + ) - try: + logger.info("Handling explicit skill: %s", skill_name) handler = skill_handlers[skill_name] # Handlers return raw Pydantic models (or raise typed AdCPError on validation failure) if skill_name == "create_media_buy": - result = await handler(parameters, identity, raw_wire_payload=raw_wire_payload) + result = await handler(parameters, identity, raw_wire_payload=raw_wire_payload, a2a_task_id=task_id) + elif skill_name in _TASK_ID_BEARING_SKILLS: + # create_media_buy is caught by the branch above (it also needs the raw + # wire payload); the other id-bearing skill takes the outer id as a plain + # keyword. See _TASK_ID_BEARING_SKILLS for which skills bear the id and + # why sync_creatives does not. + result = await handler(parameters, identity, a2a_task_id=task_id) else: result = await handler(parameters, identity) # Serialize at the boundary — models become dicts with protocol fields return self._serialize_for_a2a(result) - except A2AError: - # Re-raise A2AError as-is (already properly formatted) - raise - except (AdCPError, ValueError, PermissionError) as e: - # Normalize ValueError/PermissionError to typed AdCPError via the - # shared normalize_to_adcp_error() helper — same mapping the MCP - # and REST boundaries apply. The outer dispatcher's `except - # AdCPError` branch wraps the result into a failed Task with the - # two-layer envelope. - normalized = normalize_to_adcp_error(e) - - # Defensive about identity shape — test fixtures sometimes pass a - # string or partially-built identity instead of ResolvedIdentity. - # record_boundary_error handles None tenant_id internally. - record_boundary_error( - "a2a", - skill_name, - normalized, - tenant_id=getattr(identity, "tenant_id", None), - principal_id=getattr(identity, "principal_id", None) or "anonymous", - ) - if normalized is not e: - raise normalized from e - raise - # Untyped exceptions fall through to the dispatcher's `except Exception` - # at the call site, which routes them through `_build_failed_skill_result` - # for uniform envelope shape. No catch-all here. + # Untyped exceptions are NOT caught by the seam: they fall through to the + # dispatcher's `except Exception` at the call site, which routes them through + # `_build_failed_skill_result` for uniform envelope shape. + return await self._dispatch_under_sanitize_seam(operation, identity, _invoke()) async def _handle_get_products_skill(self, parameters: dict, identity: ResolvedIdentity | None) -> Any: """Handle explicit get_products skill invocation. @@ -1579,6 +2247,7 @@ async def _handle_create_media_buy_skill( parameters: dict, identity: ResolvedIdentity, raw_wire_payload: dict | None = None, + a2a_task_id: str | None = None, ) -> dict: """Handle explicit create_media_buy skill invocation. @@ -1643,7 +2312,7 @@ async def _handle_create_media_buy_skill( ) # Validate via the shared boundary so every A2A handler emits the same - # field + message + buyer-facing suggestion (AdCP POST-F3, #1417): + # field + message + buyer-facing suggestion (AdCP POST-F3): # idempotency_key_missing / duplicate_product_id rejections include a # non-empty suggestion derived by adcp_validation_boundary. with adcp_validation_boundary(): @@ -1661,23 +2330,39 @@ async def _handle_create_media_buy_skill( push_notification_config=push_notification_config, reporting_webhook=params.get("reporting_webhook"), context=params.get("context"), + # Same omission the update handler had: MCP and REST both forward ext. + ext=params.get("ext"), # Wrap for boundary-pattern consistency with delivery/sync_creatives. A crash is # structurally impossible here (create_media_buy_raw re-coerces via # CreateMediaBuyRequest), and to_account_reference is idempotent on an already # typed/dict account — but resolving at the boundary keeps all three handlers uniform. account=to_account_reference(params.get("account")), idempotency_key=params.get("idempotency_key"), + # Audited against create_media_buy_raw's signature by + # test_create_skill_forwards_every_live_raw_parameter: the only buyer-facing + # parameter not forwarded is ``paused``, and that omission is deliberate and + # matches the REST route — pause-on-create is accepted for AdCP 3.1.1 shape + # compatibility but is not honored by _impl (see #1619), so forwarding it + # would advertise an effect the request does not have. identity=identity, # The DataPart params AS SENT (pre-normalization, pre-mutation) are # the idempotency payload-hash input; the post-processed dict is the # fallback only for direct handler callers. raw_wire_payload=raw_wire_payload if raw_wire_payload is not None else params, + # Persist the outer A2A task id on the workflow step so the completion + # webhook / tasks/get correlate to the id the buyer holds. + external_task_id=a2a_task_id, ) return response async def _handle_sync_creatives_skill(self, parameters: dict, identity: ResolvedIdentity) -> dict: - """Handle explicit sync_creatives skill invocation (AdCP spec endpoint).""" + """Handle explicit sync_creatives skill invocation (AdCP spec endpoint). + + Takes no outer ``a2a_task_id``: a sync creates one workflow step per creative, so + the single buyer-facing id cannot name one durable row. See + ``_TASK_ID_BEARING_SKILLS``. + """ # DEBUG: Log incoming parameters logger.info("[A2A sync_creatives] Received parameters keys: %s", list(parameters.keys())) logger.info("[A2A sync_creatives] assignments param: %s", parameters.get("assignments")) @@ -1692,6 +2377,7 @@ async def _handle_sync_creatives_skill(self, parameters: dict, identity: Resolve raise AdCPValidationError( "Missing required parameter: 'creatives'", suggestion="Required: ['creatives']", + _wire_safe_message=True, ) # Construct typed models at the A2A boundary (Pydantic validation at entry). @@ -1758,23 +2444,16 @@ async def _handle_list_creatives_skill(self, parameters: dict, identity: Resolve async def _handle_create_creative_skill(self, parameters: dict, identity: ResolvedIdentity) -> dict: """Handle explicit create_creative skill invocation.""" - tool_context = self._make_tool_context(identity, "create_creative") - - # Map A2A parameters - format_id, content_uri, and name are required. - # Raise typed AdCPValidationError so the outer dispatcher emits a two-layer envelope. - required_params = ["format_id", "content_uri", "name"] - missing_params = [param for param in required_params if param not in parameters] - - if missing_params: - raise AdCPValidationError( - f"Missing required parameters: {missing_params}", - suggestion=f"Required: {required_params}", - ) + # Project Pydantic failures through the shared safe boundary. This keeps + # declared request paths actionable without echoing rejected values or + # raw validator text onto the A2A wire. + with adcp_validation_boundary(context="create_creative request"): + CreateCreativeRequest.model_validate(parameters) # TODO: Implement create_creative tool # Call core function with individual parameters # response = core_create_creative_tool(...) - raise UnsupportedOperationError(message="create_creative skill not yet implemented") + raise AdCPCapabilityNotSupportedError(message="create_creative skill not yet implemented") async def _handle_get_creatives_skill(self, parameters: dict, identity: ResolvedIdentity) -> dict: """Handle explicit get_creatives skill invocation.""" @@ -1790,22 +2469,12 @@ async def _handle_get_creatives_skill(self, parameters: dict, identity: Resolved # include_assignments=parameters.get("include_assignments", False), # identity=identity, # ) - raise UnsupportedOperationError(message="get_creatives skill not yet implemented") + raise AdCPCapabilityNotSupportedError(message="get_creatives skill not yet implemented") async def _handle_assign_creative_skill(self, parameters: dict, identity: ResolvedIdentity) -> dict: """Handle explicit assign_creative skill invocation.""" - tool_context = self._make_tool_context(identity, "assign_creative") - - # Map A2A parameters - media_buy_id, package_id, and creative_id are required. - # Raise typed AdCPValidationError so the outer dispatcher emits a two-layer envelope. - required_params = ["media_buy_id", "package_id", "creative_id"] - missing_params = [param for param in required_params if param not in parameters] - - if missing_params: - raise AdCPValidationError( - f"Missing required parameters: {missing_params}", - suggestion=f"Required: {required_params}", - ) + with adcp_validation_boundary(context="assign_creative request"): + AssignCreativeRequest.model_validate(parameters) # TODO: Implement assign_creative tool # identity already resolved at transport boundary @@ -1819,21 +2488,21 @@ async def _handle_assign_creative_skill(self, parameters: dict, identity: Resolv # override_click_url=parameters.get("override_click_url"), # identity=identity, # ) - raise UnsupportedOperationError(message="assign_creative skill not yet implemented") + raise AdCPCapabilityNotSupportedError(message="assign_creative skill not yet implemented") async def _handle_approve_creative_skill(self, parameters: dict, identity: ResolvedIdentity) -> dict: """Handle explicit approve_creative skill invocation.""" - raise UnsupportedOperationError(message="approve_creative skill not yet implemented") + raise AdCPCapabilityNotSupportedError(message="approve_creative skill not yet implemented") # Signals skill handlers removed - should come from dedicated signals agents async def _handle_get_media_buy_status_skill(self, parameters: dict, identity: ResolvedIdentity) -> dict: """Handle explicit get_media_buy_status skill invocation.""" - raise UnsupportedOperationError(message="get_media_buy_status skill not yet implemented") + raise AdCPCapabilityNotSupportedError(message="get_media_buy_status skill not yet implemented") async def _handle_optimize_media_buy_skill(self, parameters: dict, identity: ResolvedIdentity) -> dict: """Handle explicit optimize_media_buy skill invocation.""" - raise UnsupportedOperationError(message="optimize_media_buy skill not yet implemented") + raise AdCPCapabilityNotSupportedError(message="optimize_media_buy skill not yet implemented") async def _handle_get_adcp_capabilities_skill(self, parameters: dict, identity: ResolvedIdentity | None) -> Any: """Handle explicit get_adcp_capabilities skill invocation (CRITICAL AdCP discovery endpoint). @@ -1861,26 +2530,15 @@ async def _handle_list_creative_formats_skill(self, parameters: dict, identity: """ # Identity already resolved at transport boundary (on_message_send) - # Build request from parameters (all optional). - from src.core.tools.creative_formats import build_list_creative_formats_request + # Validate the complete parameter object so unknown wire fields are + # rejected instead of being silently dropped by individual .get() + # calls. This is the same request model used by REST and MCP. + from src.core.schemas import ListCreativeFormatsRequest # Same context string as the REST route's boundary so buyer-invalid # input produces a byte-identical envelope on every transport (klkg). with adcp_validation_boundary(context="list_creative_formats request"): - req = build_list_creative_formats_request( - format_ids=parameters.get("format_ids"), - output_format_ids=parameters.get("output_format_ids"), - input_format_ids=parameters.get("input_format_ids"), - is_responsive=parameters.get("is_responsive"), - name_search=parameters.get("name_search"), - asset_types=parameters.get("asset_types"), - wcag_level=parameters.get("wcag_level"), - min_width=parameters.get("min_width"), - max_width=parameters.get("max_width"), - min_height=parameters.get("min_height"), - max_height=parameters.get("max_height"), - context=parameters.get("context"), - ) + req = ListCreativeFormatsRequest.model_validate(parameters) # Call core function with identity response = core_list_creative_formats_tool(req=req, identity=identity) @@ -1890,8 +2548,8 @@ async def _handle_list_creative_formats_skill(self, parameters: dict, identity: async def _handle_list_accounts_skill(self, parameters: dict, identity: ResolvedIdentity | None) -> Any: """Handle explicit list_accounts skill invocation. - Authentication is OPTIONAL per BR-RULE-055 — unauthenticated calls - return an empty account list. + Authentication is REQUIRED per BR-RULE-055 because account visibility + is scoped to the authenticated principal. """ from src.core.schemas.account import ListAccountsRequest @@ -1954,7 +2612,13 @@ async def _handle_list_authorized_properties_skill( return response - async def _handle_update_media_buy_skill(self, parameters: dict, identity: ResolvedIdentity) -> dict: + async def _handle_update_media_buy_skill( + self, + parameters: dict, + identity: ResolvedIdentity, + *, + a2a_task_id: str | None = None, + ) -> dict: """Handle explicit update_media_buy skill invocation (CRITICAL for campaign management).""" # Identity already resolved at transport boundary (on_message_send) @@ -1974,6 +2638,7 @@ async def _handle_update_media_buy_skill(self, parameters: dict, identity: Resol raise AdCPValidationError( "Missing required parameter: media_buy_id", suggestion="Provide the media_buy_id of the media buy to update", + _wire_safe_message=True, ) # Validate top-level fields via typed model (packages validated by _raw @@ -1987,7 +2652,12 @@ async def _handle_update_media_buy_skill(self, parameters: dict, identity: Resol context=params.get("context"), ) - # Call core function with validated fields + raw nested structures and identity + # Call core function with validated fields + raw nested structures and identity. + # The forwarded set is the REST route's set (PUT /media-buys/{id}) exactly — see + # test_update_skill_forwards_every_live_raw_parameter, which derives the expected + # keywords from update_media_buy_raw's own signature so a parameter added there can + # no longer be silently dropped on A2A alone. Two earlier rounds each restored only + # the parameters a reviewer happened to name, which is why this is now signature-derived. response = core_update_media_buy_tool( media_buy_id=req.media_buy_id or "", paused=req.paused, @@ -1997,7 +2667,27 @@ async def _handle_update_media_buy_skill(self, parameters: dict, identity: Resol packages=params.get("packages"), push_notification_config=params.get("push_notification_config"), context=params.get("context"), + # Forwarded for parity with the MCP wrapper and the REST route. Dropping + # them here made an A2A update silently non-idempotent, with no reporting + # webhook and no extension object, for a request that carried all three. + reporting_webhook=params.get("reporting_webhook"), + ext=params.get("ext"), + idempotency_key=params.get("idempotency_key"), + # Legacy date aliases and the flight-level economics fields. The REST body + # accepts and forwards all five; A2A dropped them, so a buyer rescheduling a + # flight or changing currency/pacing/daily cap over A2A had those edits + # silently discarded while the same payload applied over REST. + flight_start_date=params.get("flight_start_date"), + flight_end_date=params.get("flight_end_date"), + currency=params.get("currency"), + pacing=params.get("pacing"), + daily_budget=params.get("daily_budget"), + # targeting_overlay and creatives are DELIBERATELY not forwarded, matching the + # REST route's identical omission: update_media_buy_raw accepts both in its + # signature but drops them before _build_update_request, so forwarding them + # would be a silent no-op that reads like working plumbing (see #1417). identity=identity, + external_task_id=a2a_task_id, ) return response @@ -2304,25 +2994,13 @@ def create_agent_card() -> AgentCard: description="Search and query creative library with advanced filtering (AdCP spec)", tags=["creative", "library", "search", "adcp", "spec"], ), - # Creative Management & Approval - AgentSkill( - id="approve_creative", - name="approve_creative", - description="Review and approve/reject creative assets (admin only)", - tags=["creative", "approval", "review", "adcp"], - ), - AgentSkill( - id="get_media_buy_status", - name="get_media_buy_status", - description="Check status and performance of media buys", - tags=["status", "performance", "tracking", "adcp"], - ), - AgentSkill( - id="optimize_media_buy", - name="optimize_media_buy", - description="Optimize media buy performance and targeting", - tags=["optimization", "performance", "targeting", "adcp"], - ), + # Note: approve_creative, get_media_buy_status, and optimize_media_buy are + # deliberately NOT advertised. Their handlers unconditionally + # raise UNSUPPORTED_FEATURE, so advertising them would promise capabilities + # the agent does not provide. They stay registered in _skill_handler_map and + # remain reachable-but-unsupported (structured UNSUPPORTED_FEATURE failed + # Task) if a buyer invokes them by name — they are just no longer offered on + # the card. The test oracle (SKILL_METADATA) marks them advertised: False. # Note: signals skills removed - should come from dedicated signals agents # Note: legacy get_pricing/get_targeting removed - use get_products and get_adcp_capabilities instead ], diff --git a/src/adapters/broadstreet/adapter.py b/src/adapters/broadstreet/adapter.py index dfb08d887d..470a6fe2bd 100644 --- a/src/adapters/broadstreet/adapter.py +++ b/src/adapters/broadstreet/adapter.py @@ -166,7 +166,7 @@ def _extract_campaign_id(self, media_buy_id: str) -> str: AdCPValidationError: If media_buy_id is empty. """ if not media_buy_id: - raise AdCPValidationError("media_buy_id cannot be empty") + raise AdCPValidationError("media_buy_id cannot be empty", _wire_safe_message=True) if media_buy_id.startswith("bs_"): return media_buy_id[3:] # Remove "bs_" prefix @@ -735,9 +735,15 @@ def update_media_buy( # Budget update: persist to database (Broadstreet has no budget API) if action == "update_package_budget": if not package_id: - raise AdCPValidationError("package_id is required for update_package_budget action", field="package_id") + raise AdCPValidationError( + "package_id is required for update_package_budget action", + field="package_id", + _wire_safe_message=True, + ) if budget is None: - raise AdCPValidationError("budget is required for update_package_budget action", field="budget") + raise AdCPValidationError( + "budget is required for update_package_budget action", field="budget", _wire_safe_message=True + ) with get_db_session() as session: repo = MediaBuyRepository(session, self.tenant_id) @@ -764,12 +770,15 @@ def update_media_buy( if action == "update_package_impressions": if not package_id: raise AdCPValidationError( - "package_id is required for update_package_impressions action", field="package_id" + "package_id is required for update_package_impressions action", + field="package_id", + _wire_safe_message=True, ) if budget is None: raise AdCPValidationError( "budget (impressions) is required for update_package_impressions action", field="budget", + _wire_safe_message=True, ) with get_db_session() as session: diff --git a/src/adapters/mock_ad_server.py b/src/adapters/mock_ad_server.py index dd29238ced..65f19af852 100644 --- a/src/adapters/mock_ad_server.py +++ b/src/adapters/mock_ad_server.py @@ -808,6 +808,7 @@ def _create_media_buy_immediate( raise AdCPValidationError( "Simulated error: Invalid targeting parameters", field="targeting", + _wire_safe_message=True, ) if self._should_force_error("inventory_unavailable"): diff --git a/src/admin/blueprints/creatives.py b/src/admin/blueprints/creatives.py index 9f4afc62a4..839e109422 100644 --- a/src/admin/blueprints/creatives.py +++ b/src/admin/blueprints/creatives.py @@ -22,7 +22,7 @@ ) from src.core.database.repositories.creative import CreativeRepository from src.core.schemas.creative import SyncCreativeResult, SyncCreativesResponse -from src.core.webhook_validator import validate_webhook_task_type +from src.core.webhook_validator import resolve_webhook_task_id, validate_webhook_task_type from src.services.protocol_webhook_service import get_protocol_webhook_service # TODO: Missing module - these functions need to be implemented @@ -44,8 +44,15 @@ def discover_creative_formats_from_url(url): from src.admin.utils import echo_context, require_tenant_access from src.admin.utils.audit_decorator import log_admin_action +from src.core.database.repositories.media_buy import ApprovalTrigger from src.core.database.repositories.uow import AdminCreativeUoW -from src.core.tools.media_buy_create import execute_approved_media_buy, push_creative_to_existing_buy +from src.core.logging_config import log_safe +from src.core.tools.media_buy_create import push_creative_to_existing_buy +from src.core.workflow_finalization import ( + ApprovalExecutionStatus, + execute_and_finalize_media_buy_approval, + prepare_media_buy_approval_execution, +) # Note: CreativeFormat table was dropped in migration f2addf453200 # All format-related routes have been removed @@ -79,31 +86,6 @@ def _cleanup_completed_tasks(): logger.debug(f"Cleaned up completed AI review task: {task_id}") -def _compute_media_buy_status_from_flight_dates(media_buy) -> str: - """Compute status based on flight dates: 'active' if within window, else 'scheduled'.""" - now = datetime.now(UTC) - - start_time = None - if media_buy.start_time: - raw_start = media_buy.start_time - start_time = raw_start.replace(tzinfo=UTC) if raw_start.tzinfo is None else raw_start.astimezone(UTC) - elif media_buy.start_date: - start_time = datetime.combine(media_buy.start_date, datetime.min.time()).replace(tzinfo=UTC) - - end_time = None - if media_buy.end_time: - raw_end = media_buy.end_time - end_time = raw_end.replace(tzinfo=UTC) if raw_end.tzinfo is None else raw_end.astimezone(UTC) - elif media_buy.end_date: - end_time = datetime.combine(media_buy.end_date, datetime.max.time()).replace(tzinfo=UTC) - - # If start time passed and end time not passed, set to active - if start_time and end_time and now >= start_time and now <= end_time: - return "active" - - return "scheduled" - - async def _call_webhook_for_creative_status( creative_id, tenant_id: str, @@ -238,13 +220,17 @@ async def _call_webhook_for_creative_status( # step_tool_name is untrusted (workflow_steps DB column). Validate a # COPY for the SDK payload; keep the original label for metadata - # (salesagent-yi3s, salesagent-yk7o). + # used by delivery metadata. wire_task_type = validate_webhook_task_type(step_tool_name or "sync_creatives") + # Send the buyer-facing correlation id (the id they hold), falling back to the + # step id — same resolution the media-buy approve webhook / context_manager use. + correlation_task_id = resolve_webhook_task_id(step_request_data, step_step_id) + payload: Task | TaskStatusUpdateEvent | McpWebhookPayload if protocol == "a2a": payload = create_a2a_webhook_payload( - task_id=step_step_id, + task_id=correlation_task_id, status=GeneratedTaskStatus.completed, result=result_dict, context_id=step_context_id, @@ -252,7 +238,7 @@ async def _call_webhook_for_creative_status( else: # SDK 5.7: returns McpWebhookPayload directly payload = create_mcp_webhook_payload( - task_id=step_step_id, + task_id=correlation_task_id, status=GeneratedTaskStatus.completed, task_type=wire_task_type, result=result_dict, @@ -587,7 +573,9 @@ def approve_creative(tenant_id, creative_id, **kwargs): assignment_buy_ids = [a.media_buy_id for a in assignments] logger.info( - f"[CREATIVE APPROVAL] Creative {creative_id} approved, checking {len(assignments)} media buy assignments" + "[CREATIVE APPROVAL] Creative %s approved, checking %s media buy assignments", + log_safe(creative_id), + len(assignments), ) # Snapshot buy statuses here to avoid a second UoW after commit @@ -600,29 +588,51 @@ def approve_creative(tenant_id, creative_id, **kwargs): continue assignment_buy_statuses[media_buy_id] = media_buy.status - logger.info(f"[CREATIVE APPROVAL] Media buy {media_buy_id} status: {media_buy.status}") - - if media_buy.status in {"pending_creatives", "draft"}: - # Get all creative assignments for this media buy - all_assignments = uow.assignments.get_by_media_buy(media_buy_id) - - creative_ids = [a.creative_id for a in all_assignments] - all_creatives = uow.creatives.admin_get_by_ids(creative_ids) - - unapproved_creatives = [ - c.creative_id for c in all_creatives if c.status not in ["approved", "active"] - ] + logger.info( + "[CREATIVE APPROVAL] Media buy %s status: %s", + log_safe(media_buy_id), + log_safe(media_buy.status), + ) + # The eligible source states for THIS trigger are derived from the + # canonical set beside its declaration, not restated here. The bare + # ``{"pending_creatives", "draft"}`` literal that used to gate this was a + # hand-copy of that derivation with its reasoning nowhere — and the + # reasoning is the load-bearing part: a buy still awaiting a human + # decision must NOT be promoted by a creative approval. + preparation = prepare_media_buy_approval_execution( + media_buys=uow.media_buys, + assignments=uow.assignments, + creatives=uow.creatives, + media_buy_id=media_buy_id, + # Human approval was recorded when the buy entered + # pending_creatives. Creative unblocking must not replace + # that audit identity or timestamp with a system actor. + approved_by=None, + trigger=ApprovalTrigger.CREATIVE_UNBLOCK, + ) + if preparation.status is ApprovalExecutionStatus.READY: + media_buy_actions.append({"media_buy_id": media_buy_id}) + elif preparation.status is ApprovalExecutionStatus.WAITING_FOR_CREATIVES: logger.info( - f"[CREATIVE APPROVAL] Media buy {media_buy_id} has {len(unapproved_creatives)} unapproved creatives remaining" + "[CREATIVE APPROVAL] Media buy %s still waiting for %s creatives: %s", + log_safe(media_buy_id), + len(preparation.blocking_creative_ids), + log_safe(preparation.blocking_creative_ids), + ) + elif preparation.status is ApprovalExecutionStatus.CLAIM_REFUSED: + logger.info( + "[CREATIVE APPROVAL] Media buy %s execution was already claimed", + log_safe(media_buy_id), + ) + else: + # NOT_EXECUTABLE. Most often the buy is still awaiting a human + # decision, which this trigger deliberately may not promote. + logger.info( + "[CREATIVE APPROVAL] Media buy %s is not executable by a creative unblock (status %s)", + log_safe(media_buy_id), + log_safe(media_buy.status), ) - - if not unapproved_creatives: - media_buy_actions.append({"media_buy_id": media_buy_id}) - else: - logger.info( - f"[CREATIVE APPROVAL] Media buy {media_buy_id} still waiting for {len(unapproved_creatives)} creatives: {unapproved_creatives}" - ) # UoW auto-commits here @@ -639,26 +649,38 @@ def approve_creative(tenant_id, creative_id, **kwargs): # Execute adapter creation for unblocked media buys for action in media_buy_actions: logger.info( - f"[CREATIVE APPROVAL] All creatives approved for media buy {action['media_buy_id']}, executing adapter creation" + "[CREATIVE APPROVAL] All creatives approved for media buy %s, executing adapter creation", + log_safe(action["media_buy_id"]), + ) + + outcome = execute_and_finalize_media_buy_approval( + tenant_id=tenant_id, + media_buy_id=action["media_buy_id"], + step_id=None, + apply_flight_status=True, ) - success, error_msg = execute_approved_media_buy(action["media_buy_id"], tenant_id) - - if success: - # Update media buy status in a separate UoW - with AdminCreativeUoW(tenant_id) as uow2: - assert uow2.media_buys is not None - mb = uow2.media_buys.get_by_id(action["media_buy_id"]) - if mb: - new_status = _compute_media_buy_status_from_flight_dates(mb) - mb.status = new_status - mb.approved_at = datetime.now(UTC) - mb.approved_by = "system" - # auto-commits - - logger.info(f"[CREATIVE APPROVAL] Media buy {action['media_buy_id']} successfully created in adapter") + if outcome.status is ApprovalExecutionStatus.SUCCEEDED: + logger.info( + "[CREATIVE APPROVAL] Media buy %s successfully created in adapter", + log_safe(action["media_buy_id"]), + ) + elif outcome.status is ApprovalExecutionStatus.FAILED: + logger.error( + "[CREATIVE APPROVAL] Adapter creation failed for %s: %s", + log_safe(action["media_buy_id"]), + log_safe(outcome.error_message), + ) + elif outcome.status is ApprovalExecutionStatus.PENDING_RECONCILIATION: + logger.error( + "[CREATIVE APPROVAL] External media buy creation succeeded but activation remains pending for %s", + log_safe(action["media_buy_id"]), + ) else: - logger.error(f"[CREATIVE APPROVAL] Adapter creation failed for {action['media_buy_id']}: {error_msg}") + logger.warning( + "[CREATIVE APPROVAL] Adapter outcome could not finalize workflow for media buy %s", + log_safe(action["media_buy_id"]), + ) # Retroactive push for already-live buys (#1038): # Buys in pending_creatives/draft were handled above. For buys that are @@ -670,7 +692,11 @@ def approve_creative(tenant_id, creative_id, **kwargs): ] for buy_id in buys_to_push: - logger.info(f"[CREATIVE APPROVAL] Retroactive push: creative {creative_id} → live buy {buy_id}") + logger.info( + "[CREATIVE APPROVAL] Retroactive push: creative %s → live buy %s", + log_safe(creative_id), + log_safe(buy_id), + ) push_success, push_err = push_creative_to_existing_buy( creative_id=creative_id, media_buy_id=buy_id, @@ -678,7 +704,10 @@ def approve_creative(tenant_id, creative_id, **kwargs): ) if not push_success: logger.error( - f"[CREATIVE APPROVAL] Retroactive push failed for creative {creative_id} → buy {buy_id}: {push_err}" + "[CREATIVE APPROVAL] Retroactive push failed for creative %s → buy %s: %s", + log_safe(creative_id), + log_safe(buy_id), + log_safe(push_err), ) push_warnings.append(f"Creative push to buy {buy_id} failed — see server logs for details") diff --git a/src/admin/blueprints/operations.py b/src/admin/blueprints/operations.py index efc6a86747..786692c0cf 100644 --- a/src/admin/blueprints/operations.py +++ b/src/admin/blueprints/operations.py @@ -6,18 +6,29 @@ from adcp import Error, create_a2a_webhook_payload, create_mcp_webhook_payload from adcp.types import GeneratedTaskStatus as AdcpTaskStatus - -# FIXME(#1388): Package has a local subclass; import from src.core.schemas (Pattern #7/#4). -from adcp.types import Package from flask import Blueprint, request from sqlalchemy import select - -from src.admin.utils import echo_context, require_auth, require_tenant_access +from werkzeug.wrappers import Response + +from src.admin.utils import echo_context, require_auth, require_tenant_access, session_user_email +from src.admin.utils.approval import ( + APPROVED_MEDIA_BUY_EXECUTION_FAILURE_MESSAGE, + APPROVED_MEDIA_BUY_PENDING_RECONCILIATION_MESSAGE, + waiting_for_creatives_message, +) from src.core.database.models import PushNotificationConfig +from src.core.database.repositories.creative import CreativeAssignmentRepository, CreativeRepository from src.core.database.repositories.media_buy import MediaBuyRepository +from src.core.database.repositories.workflow import WorkflowRepository from src.core.exceptions import AdCPMediaBuyRejectedError -from src.core.schemas import CreateMediaBuyError, CreateMediaBuySuccess -from src.core.webhook_validator import validate_webhook_task_type +from src.core.logging_config import log_safe +from src.core.schemas import CreateMediaBuyError +from src.core.webhook_validator import resolve_webhook_task_id, validate_webhook_task_type +from src.core.workflow_finalization import ( + ApprovalExecutionStatus, + execute_and_finalize_media_buy_approval, + prepare_media_buy_approval_execution, +) from src.services.protocol_webhook_service import get_protocol_webhook_service logger = logging.getLogger(__name__) @@ -25,7 +36,7 @@ def _as_request_dict(value: dict[str, Any] | str | None) -> dict[str, Any]: """Narrow JSONType (dict|str|None) to a dict for .get() / echo_context.""" - return value if isinstance(value, dict) else {} + return dict(value) if isinstance(value, dict) else {} # Create blueprint @@ -109,7 +120,6 @@ def media_buy_detail(tenant_id, media_buy_id): CreativeAssignment, Principal, Product, - WorkflowStep, ) try: @@ -179,20 +189,14 @@ def media_buy_detail(tenant_id, media_buy_id): ctx_manager = ContextManager() workflow_steps = ctx_manager.get_object_lifecycle("media_buy", media_buy_id, tenant_id=tenant_id) - # Find if there's a pending approval step - pending_approval_step = None - for step in workflow_steps: - if step.get("status") in ["requires_approval", "pending_approval"]: - # Get the full workflow step for approval actions (tenant-scoped via Context join) - from src.core.database.models import Context as DBContext - - stmt = ( - select(WorkflowStep) - .join(DBContext) - .where(DBContext.tenant_id == tenant_id, WorkflowStep.step_id == step["step_id"]) - ) - pending_approval_step = db_session.scalars(stmt).first() - break + # Find the step awaiting a decision for this media buy, via the repository so the + # canonical APPROVABLE_STEP_STATUSES (incl. the legacy ``approval`` alias) is used — + # the same set the approve/reject route and the atomic claim/reject methods use. + # An inline {requires_approval, pending_approval} filter here would hide the approval + # UI for legacy ``approval`` steps that the POST route can in fact action. + pending_approval_step = WorkflowRepository(db_session, tenant_id).get_approvable_step_for_object( + "media_buy", media_buy_id + ) # Get computed readiness state (not just raw database status) from src.admin.services.media_buy_readiness_service import MediaBuyReadinessService @@ -302,7 +306,7 @@ def _media_buy_webhook_metadata(step_data: dict, tenant_id: str, media_buy_id: s The protocol webhook service reads task_type/tenant_id/principal_id/ media_buy_id from this dict for delivery logging and the audit trail (protocol_webhook_service.py) — populate all four. Shared by the approve - and reject branches (PR #1567 round-2 cleanup). + and reject branches. """ return { "task_type": step_data["tool_name"], @@ -312,6 +316,19 @@ def _media_buy_webhook_metadata(step_data: dict, tenant_id: str, media_buy_id: s } +def _refused_media_buy_redirect(tenant_id: str, media_buy_id: str, message: str) -> Response: + """Flash ``message`` and redirect back to the media-buy detail page. + + The approve and reject routes both refuse an atomic claim/reject the same way — flash an + error, then redirect to the detail page — differing only in wording. One home so the + redirect target can't drift between the two branches. + """ + from flask import flash, redirect, url_for + + flash(message, "error") + return redirect(url_for("operations.media_buy_detail", tenant_id=tenant_id, media_buy_id=media_buy_id)) + + @operations_bp.route("/media-buy//approve", methods=["POST"]) @require_tenant_access() def approve_media_buy(tenant_id, media_buy_id, **kwargs): @@ -322,36 +339,35 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): from sqlalchemy.orm import attributes from src.core.database.database_session import get_db_session - from src.core.database.models import Context as DBContext - from src.core.database.models import ObjectWorkflowMapping, WorkflowStep try: action = request.form.get("action") # "approve" or "reject" reason = request.form.get("reason", "") + requested_step_id = request.form.get("workflow_step_id") with get_db_session() as db_session: - # Find the pending approval workflow step for this media buy (tenant-scoped via Context join) - stmt = ( - select(WorkflowStep) - .join(ObjectWorkflowMapping, WorkflowStep.step_id == ObjectWorkflowMapping.step_id) - .join(DBContext) - .filter( - DBContext.tenant_id == tenant_id, - ObjectWorkflowMapping.object_type == "media_buy", - ObjectWorkflowMapping.object_id == media_buy_id, - WorkflowStep.status.in_(["requires_approval", "pending_approval"]), + # Find the workflow step awaiting a decision for this media buy, via the repository + # so the lookup uses the CANONICAL APPROVABLE_STEP_STATUSES (incl. the legacy + # ``approval`` alias GAM/Broadstreet emit) — the same source set the atomic + # claim_approval/reject_if_approvable methods guard on. An inline + # {requires_approval, pending_approval} filter here would drop legacy ``approval`` + # steps before they ever reached the claim/reject below. + step = ( + WorkflowRepository(db_session, tenant_id).get_approvable_step_for_object( + "media_buy", media_buy_id, step_id=requested_step_id ) + if requested_step_id + else None ) - step = db_session.scalars(stmt).first() if not step: - flash("No pending approval found for this media buy", "warning") + flash("The selected approval is missing or no longer pending for this media buy", "warning") return redirect(url_for("operations.media_buy_detail", tenant_id=tenant_id, media_buy_id=media_buy_id)) # Extract step data to dict to avoid detached instance errors after commit/nested sessions. # JSONType columns are typed as dict|str|None; narrow before echo_context / .get(). request_data = _as_request_dict(step.request_data) - step_data = { + step_data: dict[str, Any] = { "step_id": step.step_id, "context_id": step.context_id, "tool_name": step.tool_name, @@ -359,10 +375,7 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): } # Get user info for audit - from flask import session as flask_session - - user_info = flask_session.get("user", {}) - user_email = user_info.get("email", "system") if isinstance(user_info, dict) else str(user_info) + user_email = session_user_email(default="system") approve_repo = MediaBuyRepository(db_session, tenant_id) media_buy = approve_repo.get_by_id(media_buy_id) @@ -378,7 +391,19 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): } if action == "approve": - step.status = "approved" + # Atomic compare-and-set claim: requires_approval/pending_approval → approved + # via the source-state-guarded primitive. Because ``approved`` is non-terminal, + # a broad terminal-guard would let a second concurrent approver win an + # approved→approved no-op and ALSO run the irreversible adapter creation below + # (duplicate order). claim_approval admits exactly one approver; a loser (already + # approved, canceled, or otherwise not awaiting approval) returns None → refuse + # and DO NOT run the irreversible adapter creation. + if WorkflowRepository(db_session, tenant_id).claim_approval(step.step_id) is None: + return _refused_media_buy_redirect( + tenant_id, + media_buy_id, + "This step is no longer awaiting approval (already approved or finalized).", + ) step.updated_at = datetime.now(UTC) if not step.comments: @@ -392,91 +417,82 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): ) attributes.flag_modified(step, "comments") - if media_buy and media_buy.status == "pending_approval": - # Check if all creatives are approved before moving to scheduled - from src.core.database.models import Creative, CreativeAssignment - - stmt_assignments = select(CreativeAssignment).filter_by( - tenant_id=tenant_id, media_buy_id=media_buy_id - ) - assignments = db_session.scalars(stmt_assignments).all() - - all_creatives_approved = True - if assignments: - creative_ids = [a.creative_id for a in assignments] - stmt_creatives = select(Creative).filter( - Creative.tenant_id == tenant_id, Creative.creative_id.in_(creative_ids) + # No status pre-filter: prepare_media_buy_approval_execution owns the whole + # eligibility decision and reports NOT_EXECUTABLE. The literal that used to + # sit here recognised only ``pending_approval``, so approving a + # pending_creatives or draft buy fell through to the plain-workflow branch + # below — step terminalized, operator told "approved successfully", buy + # never executed and the creative gate never run. + preparation = prepare_media_buy_approval_execution( + media_buys=approve_repo, + assignments=CreativeAssignmentRepository(db_session, tenant_id), + creatives=CreativeRepository(db_session, tenant_id), + media_buy_id=media_buy_id, + approved_by=user_email, + ) + if preparation.status is not ApprovalExecutionStatus.NOT_EXECUTABLE: + # Commit the human decision and irreversible domain claim + # atomically. A crash after this commit is recoverable from + # ``activating``; no second request may dispatch the adapter. + if preparation.status is ApprovalExecutionStatus.WAITING_FOR_CREATIVES: + db_session.commit() + flash( + waiting_for_creatives_message(len(preparation.blocking_creative_ids)), + "info", + ) + return redirect( + url_for("operations.media_buy_detail", tenant_id=tenant_id, media_buy_id=media_buy_id) + ) + if preparation.status is ApprovalExecutionStatus.CLAIM_REFUSED: + db_session.rollback() + return _refused_media_buy_redirect( + tenant_id, + media_buy_id, + "This media buy is already executing or no longer pending approval.", ) - creatives = db_session.scalars(stmt_creatives).all() - - # Check if any creatives are not approved - for creative in creatives: - if creative.status != "approved": - all_creatives_approved = False - break - else: - # No creatives assigned yet - all_creatives_approved = False - - # Update status based on creative approval state - if all_creatives_approved: - if media_buy.start_time and media_buy.end_time: - # Compute flight window - if media_buy.start_time: - start_time = ( - media_buy.start_time.astimezone(UTC) - if media_buy.start_time.tzinfo - else media_buy.start_time.replace(tzinfo=UTC) - ) - - if media_buy.end_time: - end_time = ( - media_buy.end_time.astimezone(UTC) - if media_buy.end_time.tzinfo - else media_buy.end_time.replace(tzinfo=UTC) - ) - - now = datetime.now(UTC) - if now < start_time: - media_buy.status = "scheduled" - elif now > end_time: - media_buy.status = "completed" - else: - media_buy.status = "active" - else: - # No start or end time - set to active - media_buy.status = "active" - else: - # Keep it in a state that shows it needs creative approval - # Use "draft" which will be displayed as "needs_approval" or "needs_creatives" by readiness service - media_buy.status = "draft" - - media_buy.approved_at = datetime.now(UTC) - media_buy.approved_by = user_email db_session.commit() - # Execute adapter creation for approved media buy - # This creates the order/line items in GAM (or other adapter) - # Uses the same logic as auto-approved media buys - from src.core.tools.media_buy_create import execute_approved_media_buy - - logger.info(f"[APPROVAL] Executing adapter creation for approved media buy {media_buy_id}") - success, error_msg = execute_approved_media_buy(media_buy_id, tenant_id) - - if not success: - # Adapter creation failed - update status and show error - with get_db_session() as error_session: - error_repo = MediaBuyRepository(error_session, tenant_id) - error_buy = error_repo.update_status(media_buy_id, "failed") - if error_buy: - error_session.commit() + logger.info( + "[APPROVAL] Executing adapter creation for approved media buy %s", + log_safe(media_buy_id), + ) + outcome = execute_and_finalize_media_buy_approval( + tenant_id=tenant_id, + media_buy_id=media_buy_id, + step_id=step_data["step_id"], + context=echo_context(request_data), + ) + if outcome.status is ApprovalExecutionStatus.PENDING_RECONCILIATION: + logger.error( + "[APPROVAL] External media buy creation succeeded but activation remains pending for %s", + log_safe(media_buy_id), + ) + flash(APPROVED_MEDIA_BUY_PENDING_RECONCILIATION_MESSAGE, "warning") + return redirect( + url_for("operations.media_buy_detail", tenant_id=tenant_id, media_buy_id=media_buy_id) + ) - flash(f"Media buy approved but adapter creation failed: {error_msg}", "error") + if outcome.status is ApprovalExecutionStatus.FAILED: + logger.error( + "[APPROVAL] Adapter creation failed for %s: %s", + log_safe(media_buy_id), + log_safe(outcome.error_message), + ) + flash(APPROVED_MEDIA_BUY_EXECUTION_FAILURE_MESSAGE, "error") + return redirect( + url_for("operations.media_buy_detail", tenant_id=tenant_id, media_buy_id=media_buy_id) + ) + if outcome.status is ApprovalExecutionStatus.FINALIZATION_FAILED: + logger.error( + "[APPROVAL] Adapter succeeded but workflow step %s could not be finalized", + log_safe(step_data["step_id"]), + ) + flash("Media buy was created but its workflow result could not be finalized", "error") return redirect( url_for("operations.media_buy_detail", tenant_id=tenant_id, media_buy_id=media_buy_id) ) - logger.info(f"[APPROVAL] Adapter creation succeeded for {media_buy_id}") + logger.info("[APPROVAL] Adapter creation succeeded for %s", log_safe(media_buy_id)) # Send webhook notification to buyer webhook_config = None @@ -494,33 +510,21 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): webhook_config = db_session.scalars(stmt_webhook).first() if webhook_config and media_buy_data: - approve_repo = MediaBuyRepository(db_session, tenant_id) - all_packages = approve_repo.get_packages(media_buy_id) - - # Echo the buyer's request context (shared helper, also used by - # the creative approval webhook in blueprints/creatives.py). - approve_context = echo_context(request_data) - - # The buy IS committed at this point, so a confirmed Success - # (status/confirmed_at/revision from the subclass defaults) is - # semantically correct here — route through the sync_success() - # factory like every sibling construction site (PR #1567 round-2 cleanup). - create_media_buy_approved_result = CreateMediaBuySuccess.sync_success( - media_buy_id=media_buy_id, - packages=[Package(package_id=x.package_id) for x in all_packages], - context=approve_context, - ) + assert outcome.finalization is not None + assert outcome.finalization.result is not None + create_media_buy_approved_result = outcome.finalization.result metadata = _media_buy_webhook_metadata(step_data, tenant_id, media_buy_id, media_buy_data) # Determine protocol type from workflow step request_data protocol = step_data["request_data"].get( "protocol", "mcp" ) # Default to MCP for backward compatibility + correlation_task_id = resolve_webhook_task_id(step_data["request_data"], step_data["step_id"]) # Create appropriate webhook payload based on protocol if protocol == "a2a": create_media_buy_approved_payload = create_a2a_webhook_payload( - task_id=step_data["step_id"], + task_id=correlation_task_id, status=AdcpTaskStatus.completed, result=create_media_buy_approved_result, context_id=step_data["context_id"], @@ -528,9 +532,9 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): else: # tool_name is untrusted (workflow_steps DB column). # Validate a COPY for the SDK payload; metadata keeps - # the original label (salesagent-yi3s, salesagent-yk7o). + # the original label for delivery metadata. create_media_buy_approved_payload = create_mcp_webhook_payload( - task_id=step_data["step_id"], + task_id=correlation_task_id, task_type=validate_webhook_task_type(step_data.get("tool_name", "create_media_buy")), result=create_media_buy_approved_result, status=AdcpTaskStatus.completed, @@ -551,12 +555,34 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): flash("Media buy approved and order created successfully", "success") else: + if ( + WorkflowRepository(db_session, tenant_id).complete_claimed_approval(step_data["step_id"]) + is None + ): + db_session.rollback() + flash("Workflow result could not be finalized; please retry approval", "error") + return redirect( + url_for("operations.media_buy_detail", tenant_id=tenant_id, media_buy_id=media_buy_id) + ) db_session.commit() flash("Media buy approved successfully", "success") elif action == "reject": - step.status = "rejected" - step.error_message = reason or "Rejected by administrator" + # Atomic compare-and-set with the SAME source-state guard as approve (mirror): + # a step already approved (execution underway), canceled, or otherwise not + # awaiting a decision returns None → refuse the reject. This prevents rejecting + # an approved step and stranding a live ad-server order behind a rejected workflow. + if ( + WorkflowRepository(db_session, tenant_id).reject_if_approvable( + step.step_id, error_message=reason or "Rejected by administrator" + ) + is None + ): + return _refused_media_buy_redirect( + tenant_id, + media_buy_id, + "This step is no longer awaiting a decision (already approved or finalized).", + ) step.updated_at = datetime.now(UTC) if not step.comments: @@ -570,8 +596,13 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): ) attributes.flag_modified(step, "comments") - if media_buy and media_buy.status == "pending_approval": - media_buy.status = "rejected" + # Guarded in the UPDATE rather than assigned here: a raw write had no + # source-state guard (it could mark a buy rejected after execution was + # already claimed) and recognised only ``pending_approval``, so rejecting + # a pending_creatives or draft buy left it in its old state while its + # workflow step said rejected. + if media_buy: + approve_repo.reject_pending_execution(media_buy_id) db_session.commit() @@ -600,7 +631,7 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): # Route the code through the typed AdCPError cascade so the buyer sees # the same WIRE code the tool path emits for this event # (MEDIA_BUY_REJECTED is internal-only; wire_error_code translates it - # to POLICY_VIOLATION — never hand-pick codes here; PR #1567 round-2 item 1). + # to POLICY_VIOLATION — never hand-pick codes here). rejection = AdCPMediaBuyRejectedError(f"Rejected: {reason or 'No reason provided'}") create_media_buy_rejected_result = CreateMediaBuyError( errors=[Error(code=rejection.wire_error_code, message=rejection.message)] @@ -611,11 +642,12 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): protocol = step_data["request_data"].get( "protocol", "mcp" ) # Default to MCP for backward compatibility + correlation_task_id = resolve_webhook_task_id(step_data["request_data"], step_data["step_id"]) # Create appropriate webhook payload based on protocol if protocol == "a2a": create_media_buy_rejected_payload = create_a2a_webhook_payload( - task_id=step_data["step_id"], + task_id=correlation_task_id, status=AdcpTaskStatus.rejected, result=create_media_buy_rejected_result, context_id=step_data["context_id"], @@ -623,9 +655,9 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): else: # tool_name is untrusted (workflow_steps DB column). # Validate a COPY for the SDK payload; metadata keeps the - # original label (salesagent-yi3s, salesagent-yk7o). + # original label for delivery metadata. create_media_buy_rejected_payload = create_mcp_webhook_payload( - task_id=step_data["step_id"], + task_id=correlation_task_id, task_type=validate_webhook_task_type(step_data.get("tool_name", "create_media_buy")), result=create_media_buy_rejected_result, status=AdcpTaskStatus.rejected, @@ -647,6 +679,11 @@ def approve_media_buy(tenant_id, media_buy_id, **kwargs): flash("Media buy rejected", "info") + else: + # Neither approve nor reject (unknown/missing action) — a no-op would be + # indistinguishable from success to the operator. Flash and redirect. + flash(f"Unknown action: {action!r}", "error") + return redirect(url_for("operations.media_buy_detail", tenant_id=tenant_id, media_buy_id=media_buy_id)) except Exception as e: diff --git a/src/admin/blueprints/policy.py b/src/admin/blueprints/policy.py index 5ab20e02ae..2f0201a0f1 100644 --- a/src/admin/blueprints/policy.py +++ b/src/admin/blueprints/policy.py @@ -6,11 +6,12 @@ from flask import Blueprint, jsonify, redirect, render_template, request, session, url_for from sqlalchemy import select -from src.admin.utils import get_tenant_config_from_db, require_auth +from src.admin.utils import get_tenant_config_from_db, require_tenant_access, session_user_email from src.admin.utils.audit_decorator import log_admin_action from src.core.audit_logger import AuditLogger from src.core.database.database_session import get_db_session -from src.core.database.models import AuditLog, Context, Tenant, WorkflowStep +from src.core.database.models import AuditLog, Tenant, WorkflowStep +from src.core.database.repositories.workflow import WorkflowRepository logger = logging.getLogger(__name__) @@ -19,16 +20,13 @@ @policy_bp.route("/", methods=["GET"]) -@require_auth() +@require_tenant_access() def index(tenant_id): """View and manage policy settings for the tenant.""" - # Check access + # Tenant membership is enforced by require_tenant_access; viewers stay read-blocked. if session.get("role") == "viewer": return "Access denied", 403 - if session.get("role") == "tenant_admin" and session.get("tenant_id") != tenant_id: - return "Access denied", 403 - with get_db_session() as db_session: # Get tenant info tenant = db_session.scalars(select(Tenant).filter_by(tenant_id=tenant_id)).first() @@ -138,17 +136,14 @@ def index(tenant_id): @policy_bp.route("/update", methods=["POST"]) -@require_auth() +@require_tenant_access() @log_admin_action("update_policy") def update(tenant_id): """Update policy settings for the tenant.""" - # Check access - only admins can update policy + # Tenant membership is enforced by require_tenant_access; role gate stays for test-mode. if session.get("role") not in ["super_admin", "tenant_admin"]: return "Access denied", 403 - if session.get("role") == "tenant_admin" and session.get("tenant_id") != tenant_id: - return "Access denied", 403 - try: # Get current config config = get_tenant_config_from_db(tenant_id) @@ -208,24 +203,21 @@ def parse_textarea_lines(field_name): @policy_bp.route("/rules", methods=["GET", "POST"]) -@require_auth() +@require_tenant_access() def rules(tenant_id): """Redirect old policy rules URL to new comprehensive policy settings page.""" return redirect(url_for("policy.index", tenant_id=tenant_id)) @policy_bp.route("/review/", methods=["GET", "POST"]) -@require_auth() +@require_tenant_access() @log_admin_action("review_policy_task") def review_task(tenant_id, task_id): """Review and approve/reject a policy review task.""" - # Check access + # Tenant membership is enforced by require_tenant_access; viewers stay read-blocked. if session.get("role") == "viewer": return "Access denied", 403 - if session.get("role") == "tenant_admin" and session.get("tenant_id") != tenant_id: - return "Access denied", 403 - with get_db_session() as db_session: if request.method == "POST": # Handle approval/rejection @@ -233,33 +225,43 @@ def review_task(tenant_id, task_id): notes = request.form.get("notes", "") try: - # Get the workflow step (tenant-scoped via Context join) - stmt = ( - select(WorkflowStep) - .join(Context, WorkflowStep.context_id == Context.context_id) - .filter(Context.tenant_id == tenant_id, WorkflowStep.step_id == task_id) - ) - step = db_session.scalars(stmt).first() + # Read AND mutate through the same repository: the tenant-scoping join and the + # policy_review type guard live in one place (get_policy_review_step) for both the + # POST and GET legs, so the mutation route can't drift from a hand-rolled read. + repo = WorkflowRepository(db_session, tenant_id) + step = repo.get_policy_review_step(task_id) if not step: return "Task not found", 404 - # Update status based on action + # Atomic terminal-safe transition via the shared conditional-UPDATE + # primitive — a concurrent cancel/terminal decision makes it return + # None → the review is refused (409) rather than overwriting. if action == "approve": - step.status = "completed" - step.response_data = {"approved": True, "notes": notes} + transitioned = repo.transition_if_nonterminal( + task_id, status="completed", response_data={"approved": True, "notes": notes} + ) elif action == "reject": - step.status = "failed" - step.response_data = {"approved": False, "notes": notes} + transitioned = repo.transition_if_nonterminal( + task_id, status="failed", response_data={"approved": False, "notes": notes} + ) + else: + # An unknown/missing action is a bad request, not a finalized-task conflict. + return "Invalid action", 400 + + if transitioned is None: + return "Task was already finalized (e.g. canceled) and cannot be reviewed", 409 db_session.commit() - # Log the action - audit_logger = AuditLogger() - audit_logger.log( - tenant_id=tenant_id, + # Log the action (AuditLogger requires adapter_name; the method is + # log_operation, not a nonexistent .log — the prior call 500'd the route). + reviewer = session_user_email(default="system") + AuditLogger(adapter_name="AdminUI", tenant_id=tenant_id).log_operation( operation="policy_review", - principal_id=session.get("user"), + principal_name=reviewer, + principal_id=reviewer, + adapter_id="AdminUI", success=True, details={"task_id": task_id, "action": action, "notes": notes}, ) @@ -272,12 +274,7 @@ def review_task(tenant_id, task_id): # GET request - show review form try: - stmt = ( - select(WorkflowStep) - .join(Context, WorkflowStep.context_id == Context.context_id) - .filter(Context.tenant_id == tenant_id, WorkflowStep.step_id == task_id) - ) - step = db_session.scalars(stmt).first() + step = WorkflowRepository(db_session, tenant_id).get_policy_review_step(task_id) if not step: return "Task not found", 404 diff --git a/src/admin/blueprints/workflows.py b/src/admin/blueprints/workflows.py index c3fcb874c0..9cb42593dd 100644 --- a/src/admin/blueprints/workflows.py +++ b/src/admin/blueprints/workflows.py @@ -2,18 +2,31 @@ import json import logging -from datetime import UTC, datetime +from typing import Any -from flask import Blueprint, flash, jsonify, redirect, render_template, request, session, url_for +from flask import Blueprint, Response, flash, jsonify, redirect, render_template, request, url_for from sqlalchemy import select - -from src.admin.utils import require_tenant_access +from sqlalchemy.orm import Session + +from src.admin.utils import echo_context, require_tenant_access, session_user_email +from src.admin.utils.approval import ( + APPROVED_MEDIA_BUY_EXECUTION_FAILURE_MESSAGE, + APPROVED_MEDIA_BUY_PENDING_RECONCILIATION_MESSAGE, + waiting_for_creatives_message, +) from src.admin.utils.audit_decorator import log_admin_action from src.core.database.database_session import get_db_session from src.core.database.models import Context from src.core.database.models import Principal as ModelPrincipal from src.core.database.repositories import MediaBuyRepository -from src.core.database.repositories.workflow import WorkflowRepository +from src.core.database.repositories.creative import CreativeAssignmentRepository, CreativeRepository +from src.core.database.repositories.workflow import APPROVABLE_STEP_STATUSES, WorkflowRepository +from src.core.logging_config import log_safe +from src.core.workflow_finalization import ( + ApprovalExecutionStatus, + execute_and_finalize_media_buy_approval, + prepare_media_buy_approval_execution, +) logger = logging.getLogger(__name__) @@ -36,8 +49,10 @@ def list_workflows(tenant_id, **kwargs): workflow_repo = WorkflowRepository(db, tenant_id) all_steps = workflow_repo.get_all_steps() - # Separate pending approval steps for summary - pending_steps = [s for s in all_steps if s.status == "pending_approval"] + # Separate pending approval steps for summary. Uses the canonical approvable set, not an + # inline literal: a subset literal here undercounts (it would drop ``requires_approval`` + # and the legacy ``approval`` alias) and drifts the moment the set changes. + pending_steps = [s for s in all_steps if s.status in APPROVABLE_STEP_STATUSES] # Get media buys for context media_buy_repo = MediaBuyRepository(db, tenant_id) @@ -149,28 +164,155 @@ def review_workflow_step(tenant_id, workflow_id, step_id): ) +def _refused_decision_response( + workflow_repo: WorkflowRepository, step_id: str, awaiting_desc: str +) -> tuple[Response, int]: + """Map a refused approval/rejection compare-and-set to the right HTTP error. + + ``claim_approval`` / ``reject_if_approvable`` return None for EITHER a step that does not + exist (404) OR one that exists but is no longer in an approvable status (409 Conflict — + e.g. already approved by a concurrent request, or canceled). Distinguish via a tenant-scoped + fetch so a genuine concurrency conflict returns 409, not a misleading 404. + """ + existing = workflow_repo.get_by_step_id(step_id) + if existing is None: + return jsonify({"error": "Workflow step not found"}), 404 + return jsonify({"error": f"Workflow step is not {awaiting_desc} (status: {existing.status})"}), 409 + + +def _complete_plain_workflow_approval(db: Session, tenant_id: str, step_id: str) -> tuple[Response, int]: + """Complete a no-execution approval in the claim's original transaction.""" + completed = WorkflowRepository(db, tenant_id).complete_claimed_approval(step_id) + if completed is None: + db.rollback() + logger.error( + "[APPROVAL] Claimed workflow step %s could not be completed atomically", + log_safe(step_id), + ) + return jsonify({"success": False, "error": "Workflow result could not be finalized"}), 503 + db.commit() + flash("Workflow step approved successfully", "success") + return jsonify({"success": True}), 200 + + +def _approve_mapped_media_buy( + *, + db: Session, + tenant_id: str, + step_id: str, + media_buy_id: str, + request_data: dict[str, Any], + user_email: str, +) -> tuple[Response, int]: + """Handle the media-buy-specific portion of a claimed workflow approval.""" + media_buy_repo = MediaBuyRepository(db, tenant_id) + media_buy = media_buy_repo.get_by_id(media_buy_id) + logger.info( + "[APPROVAL] Media buy lookup: found=%s, status=%s", + media_buy is not None, + media_buy.status if media_buy else "N/A", + ) + # No status pre-filter here on purpose: prepare_media_buy_approval_execution owns the + # whole eligibility decision and reports NOT_EXECUTABLE below. The literal that used + # to sit here recognised only ``pending_approval``, so a pending_creatives or draft + # buy took the plain-workflow path — step terminalized, buy never executed, creative + # gate and execution claim both skipped. + preparation = prepare_media_buy_approval_execution( + media_buys=media_buy_repo, + assignments=CreativeAssignmentRepository(db, tenant_id), + creatives=CreativeRepository(db, tenant_id), + media_buy_id=media_buy_id, + approved_by=user_email, + ) + if preparation.status is ApprovalExecutionStatus.NOT_EXECUTABLE: + logger.warning( + "[APPROVAL] Media buy not executable: media_buy=%s, status=%s", + media_buy is not None, + media_buy.status if media_buy else "N/A", + ) + return _complete_plain_workflow_approval(db, tenant_id, step_id) + if preparation.status is ApprovalExecutionStatus.WAITING_FOR_CREATIVES: + blocking_count = len(preparation.blocking_creative_ids) + logger.warning( + "[APPROVAL] Cannot execute adapter creation yet - %s creatives not approved: %s", + blocking_count, + log_safe(preparation.blocking_creative_ids), + ) + flash(waiting_for_creatives_message(blocking_count), "info") + db.commit() + return jsonify({"success": True}), 200 + if preparation.status is ApprovalExecutionStatus.CLAIM_REFUSED: + db.rollback() + return jsonify({"success": False, "error": "Media buy is already executing or no longer pending"}), 409 + + db.commit() + outcome = execute_and_finalize_media_buy_approval( + tenant_id=tenant_id, + media_buy_id=media_buy_id, + step_id=step_id, + context=echo_context(request_data), + ) + if outcome.status is ApprovalExecutionStatus.PENDING_RECONCILIATION: + logger.error( + "[APPROVAL] External media buy creation succeeded but activation remains pending for %s", + log_safe(media_buy_id), + ) + flash(APPROVED_MEDIA_BUY_PENDING_RECONCILIATION_MESSAGE, "warning") + return ( + jsonify({"success": False, "error": APPROVED_MEDIA_BUY_PENDING_RECONCILIATION_MESSAGE, "pending": True}), + 503, + ) + if outcome.status is ApprovalExecutionStatus.FAILED: + logger.error( + "[APPROVAL] Adapter creation failed for %s: %s", + log_safe(media_buy_id), + log_safe(outcome.error_message), + ) + flash(APPROVED_MEDIA_BUY_EXECUTION_FAILURE_MESSAGE, "error") + return jsonify({"success": False, "error": APPROVED_MEDIA_BUY_EXECUTION_FAILURE_MESSAGE}), 500 + if outcome.status is ApprovalExecutionStatus.FINALIZATION_FAILED: + logger.error( + "[APPROVAL] Adapter outcome for workflow step %s could not be finalized", + log_safe(step_id), + ) + return jsonify({"success": False, "error": "Workflow result could not be finalized"}), 500 + + logger.info("[APPROVAL] Media buy %s successfully created in adapter", log_safe(media_buy_id)) + flash("Workflow step approved and media buy created successfully", "success") + return jsonify({"success": True}), 200 + + @workflows_bp.route("//workflows//steps//approve", methods=["POST"]) @require_tenant_access() @log_admin_action("approve_workflow_step") def approve_workflow_step(tenant_id, workflow_id, step_id): - """Approve a workflow step.""" + """Approve a workflow step. + + ``workflow_id`` is a cosmetic path segment only: WorkflowStep has no workflow_id + column and the value is never populated (an unwired stub — see the TODO at + mcp_context_wrapper). The step is a tenant-scoped primary key, so authorization is + complete at (tenant, step_id); there is nothing to scope against the URL's workflow. + Wiring a real workflow grouping (and validating the step against it) is separate work. + """ + del workflow_id # cosmetic; see docstring try: with get_db_session() as db: # Get and update the workflow step via repository (tenant-scoped) workflow_repo = WorkflowRepository(db, tenant_id) - user_info = session.get("user", {}) - user_email = user_info.get("email", "system") if isinstance(user_info, dict) else str(user_info) + user_email = session_user_email(default="system") - step = workflow_repo.update_status( - step_id, - status="approved", - ) + # Atomic compare-and-set: requires_approval/pending_approval → approved. Because + # ``approved`` is non-terminal, a broad terminal-guard would let a second concurrent + # approver win an approved→approved no-op and ALSO run execute_approved_media_buy + # below (duplicate adapter work). claim_approval admits exactly one approver; a + # loser gets None → 409 Conflict (not 404) and does NOT execute. + step = workflow_repo.claim_approval(step_id) if not step: - return jsonify({"error": "Workflow step not found"}), 404 + return _refused_decision_response(workflow_repo, step_id, "awaiting approval") - db.commit() + request_data = dict(step.request_data) if isinstance(step.request_data, dict) else {} # Check if this is a media buy creation workflow step mappings = workflow_repo.get_mappings_for_step(step_id) @@ -187,73 +329,15 @@ def approve_workflow_step(tenant_id, workflow_id, step_id): if mapping: media_buy_id = mapping.object_id logger.info(f"[APPROVAL] Workflow step {step_id} approved for media buy {media_buy_id}") - - # Get the media buy - media_buy_repo = MediaBuyRepository(db, tenant_id) - media_buy = media_buy_repo.get_by_id(media_buy_id) - - logger.info( - f"[APPROVAL] Media buy lookup: found={media_buy is not None}, status={media_buy.status if media_buy else 'N/A'}" + return _approve_mapped_media_buy( + db=db, + tenant_id=tenant_id, + step_id=step_id, + media_buy_id=media_buy_id, + request_data=request_data, + user_email=user_email, ) - - if media_buy and media_buy.status == "pending_approval": - # Check if all required creatives are approved before executing adapter creation - from src.core.database.models import Creative as CreativeModel - from src.core.database.models import CreativeAssignment - - stmt_assignments = select(CreativeAssignment).filter_by(media_buy_id=media_buy_id) - assignments = db.scalars(stmt_assignments).all() - - if assignments: - creative_ids = [a.creative_id for a in assignments] - stmt_creatives = select(CreativeModel).filter(CreativeModel.creative_id.in_(creative_ids)) - creatives = db.scalars(stmt_creatives).all() - - unapproved_creatives = [ - c.creative_id for c in creatives if c.status not in ["approved", "active"] - ] - - if unapproved_creatives: - logger.warning( - f"[APPROVAL] Cannot execute adapter creation yet - " - f"{len(unapproved_creatives)} creatives not approved: {unapproved_creatives}" - ) - flash( - f"Media buy approved! Waiting for {len(unapproved_creatives)} creative(s) to be approved before creating in GAM.", - "info", - ) - media_buy.status = "pending_creatives" - db.commit() - return jsonify({"success": True}), 200 - - # Execute adapter creation - from src.core.tools.media_buy_create import execute_approved_media_buy - - logger.info(f"[APPROVAL] Executing adapter creation for approved media buy {media_buy_id}") - success, error_msg = execute_approved_media_buy(media_buy_id, tenant_id) - - if not success: - logger.error(f"[APPROVAL] Adapter creation failed for {media_buy_id}: {error_msg}") - flash(f"Workflow approved but media buy creation failed: {error_msg}", "error") - return jsonify({"success": False, "error": error_msg}), 500 - - # Update media buy status - media_buy.status = "scheduled" - media_buy.approved_at = datetime.now(UTC) - media_buy.approved_by = user_email - db.commit() - - logger.info(f"[APPROVAL] Media buy {media_buy_id} successfully created in adapter") - flash("Workflow step approved and media buy created successfully", "success") - else: - logger.warning( - f"[APPROVAL] Media buy not executed: media_buy={media_buy is not None}, status={media_buy.status if media_buy else 'N/A'}" - ) - flash("Workflow step approved successfully", "success") - else: - flash("Workflow step approved successfully", "success") - - return jsonify({"success": True}), 200 + return _complete_plain_workflow_approval(db, tenant_id, step_id) except Exception as e: logger.error(f"Error approving workflow step {step_id}: {e}", exc_info=True) @@ -264,7 +348,12 @@ def approve_workflow_step(tenant_id, workflow_id, step_id): @require_tenant_access() @log_admin_action("reject_workflow_step") def reject_workflow_step(tenant_id, workflow_id, step_id): - """Reject a workflow step with a reason.""" + """Reject a workflow step with a reason. + + ``workflow_id`` is a cosmetic path segment only (see ``approve_workflow_step``): no + backing column, never populated; authorization is complete at (tenant, step_id). + """ + del workflow_id # cosmetic; see approve_workflow_step try: data = request.get_json() or {} reason = data.get("reason", "No reason provided") @@ -273,17 +362,16 @@ def reject_workflow_step(tenant_id, workflow_id, step_id): # Get and update the workflow step via repository (tenant-scoped) workflow_repo = WorkflowRepository(db, tenant_id) - user_info = session.get("user", {}) - user_email = user_info.get("email", "system") if isinstance(user_info, dict) else str(user_info) + user_email = session_user_email(default="system") - step = workflow_repo.update_status( - step_id, - status="rejected", - error_message=reason, - ) + # Atomic compare-and-set with the SAME source-state guard as approve: a step that + # has already been approved (execution underway) cannot be rejected — that would + # strand a live ad-server order behind a rejected workflow. A loser gets None → + # 409 Conflict (not 404). + step = workflow_repo.reject_if_approvable(step_id, error_message=reason) if not step: - return jsonify({"error": "Workflow step not found"}), 404 + return _refused_decision_response(workflow_repo, step_id, "awaiting a decision") db.commit() diff --git a/src/admin/utils/__init__.py b/src/admin/utils/__init__.py index 788f24b8d6..d1d3ba3857 100644 --- a/src/admin/utils/__init__.py +++ b/src/admin/utils/__init__.py @@ -16,6 +16,7 @@ parse_json_config, require_auth, require_tenant_access, + session_user_email, translate_custom_targeting, validate_gam_network_response, validate_gam_user_response, @@ -30,6 +31,7 @@ "is_tenant_admin", "require_auth", "require_tenant_access", + "session_user_email", # Utility functions "parse_json_config", "get_tenant_config_from_db", diff --git a/src/admin/utils/approval.py b/src/admin/utils/approval.py new file mode 100644 index 0000000000..f5d763fd74 --- /dev/null +++ b/src/admin/utils/approval.py @@ -0,0 +1,37 @@ +"""Shared buyer-safe messages for admin approval outcomes.""" + +APPROVED_MEDIA_BUY_EXECUTION_FAILURE_MESSAGE = ( + "Workflow approved, but media buy creation failed. Review server logs before retrying." +) + +APPROVED_MEDIA_BUY_PENDING_RECONCILIATION_MESSAGE = ( + "Media buy was created externally, but activation could not be finalized. " + "The workflow remains pending for safe reconciliation." +) + + +def waiting_for_creatives_message(blocking_count: int) -> str: + """The one operator-facing message for an approved media buy still blocked on creatives. + + Both admin approve routes reach this from the SAME + ``ApprovalExecutionStatus.WAITING_FOR_CREATIVES`` returned by + ``prepare_media_buy_approval_execution``, so the wording gets one home rather than one + per route. The text is adapter-agnostic on purpose: this fires for whichever ad server + the tenant is configured with, not only GAM. + + ``blocking_count == 0`` is a DIFFERENT operator situation, not a degenerate case of the + same one. ``_approval_creative_gate`` returns ``(False, ())`` when the buy has no + creative assignments at all — the gate is unsatisfied precisely BECAUSE nothing is + assigned — so a single count-interpolated sentence renders as "Waiting for 0 + creative(s) to be approved", which tells the operator to wait for an empty set. The + two cases need opposite actions: assign creatives, versus wait for the assigned ones + to clear review. + """ + if blocking_count == 0: + return ( + "Media buy approved! No creatives are assigned yet — assign and approve at least one " + "before it can be created in the ad server." + ) + return ( + f"Media buy approved! Waiting for {blocking_count} creative(s) to be approved before creating in the ad server." + ) diff --git a/src/admin/utils/audit_decorator.py b/src/admin/utils/audit_decorator.py index 19426e4dfe..790b83c9f3 100644 --- a/src/admin/utils/audit_decorator.py +++ b/src/admin/utils/audit_decorator.py @@ -15,7 +15,7 @@ from functools import wraps from typing import Any -from flask import g, request, session +from flask import g, request logger = logging.getLogger(__name__) @@ -166,11 +166,9 @@ def decorator(f: Callable[..., Any]) -> Callable[..., Any]: @wraps(f) def decorated_function(*args: Any, **kwargs: Any) -> Any: # Get user from session - user_info = session.get("user", {}) - if isinstance(user_info, dict): - user_email: str = user_info.get("email", "unknown") - else: - user_email = str(user_info) if user_info else "unknown" + from src.admin.utils.helpers import session_user_email + + user_email: str = session_user_email() # Get tenant_id from kwargs (most admin routes have this) tenant_id: str | None = kwargs.get("tenant_id") diff --git a/src/admin/utils/helpers.py b/src/admin/utils/helpers.py index 325c2ae646..f1a8ed620d 100644 --- a/src/admin/utils/helpers.py +++ b/src/admin/utils/helpers.py @@ -137,6 +137,23 @@ def get_tenant_config_from_db(tenant_id): return {} +def session_user_email(default: str = "unknown") -> str: + """The acting user's email from the Flask session, for audit attribution. + + ``session["user"]`` is a dict under OAuth login and a bare string under the test/password + path, so every caller needs the same isinstance split. Keeping it in one place stops an + audit row from recording a dict repr (``{'email': ...}``) instead of an email — a lossy + attribution that is invisible until someone reads the audit log. + + ``default`` is the caller's placeholder for an absent/blank session user (routes that log + an operator action use ``"system"``; generic audit decorators use ``"unknown"``). + """ + user_info = session.get("user") + if isinstance(user_info, dict): + return user_info.get("email") or default + return str(user_info) if user_info else default + + def is_super_admin(email): """Check if user is a super admin based on email or domain. @@ -585,7 +602,7 @@ def echo_context(request_data: dict) -> ContextObject | None: extra=allow passthrough and the dict was already validated at request time. Returns ``None`` when no context was stored (absent/None/non-dict), so ``exclude_none`` keeps the field off the wire. Shared by the media-buy - approve and creative approval webhook paths (PR #1567 round-3 DRY). + approve and creative approval webhook paths. """ context_data = request_data.get("context") if context_data and isinstance(context_data, dict): diff --git a/src/app.py b/src/app.py index 2b29c03ef4..732456979e 100644 --- a/src/app.py +++ b/src/app.py @@ -37,18 +37,25 @@ from src.core.exceptions import ( INVALID_REQUEST_SUGGESTION, VALIDATION_ERROR_SUGGESTION, + AdCPAuthenticationError, AdCPError, AdCPInvalidRequestError, AdCPValidationError, build_two_layer_error_envelope, build_validation_error_details, - normalize_to_adcp_error, + safe_adcp_error, + safe_validation_error_message, + validation_error_field, ) from src.core.http_utils import get_header_case_insensitive as _get_header_case_insensitive from src.core.lifecycle import run_all_shutdown_callbacks from src.core.main import mcp from src.core.resolved_identity import resolve_identity -from src.core.tool_error_logging import handle_tool_error, record_boundary_error +from src.core.tool_error_logging import ( + best_effort_boundary_identity, + handle_tool_error, + record_boundary_error, +) from src.landing import generate_tenant_landing_page from src.landing.landing_page import generate_fallback_landing_page from src.routes.api_v1 import router as api_v1_router @@ -130,9 +137,21 @@ async def app_lifespan(app: FastAPI): # --------------------------------------------------------------------------- -def _envelope_response(request: Request, exc: AdCPError) -> JSONResponse: +def _envelope_response(request: Request, exc: AdCPError, *, original: Exception) -> JSONResponse: """Build a JSONResponse carrying the two-layer envelope for ``exc``. + ``exc`` is the WIRE error — already sanitized, and the sole source of the response + body and HTTP status. ``original`` is the exception as raised, and exists only for the + privileged server log: ``record_boundary_error`` branches on ``isinstance(error, + AdCPError)`` to pick log severity, so handing it the sanitized twin made every REST + failure — including a genuine untyped crash — log at WARNING with no traceback and the + scrubbed message. MCP and A2A both pass the original, so REST was the one boundary that + dropped the diagnostic. Tenant-visible sinks are unaffected: ``record_boundary_error`` + re-derives ``safe_adcp_error`` internally for the activity feed and audit log. + + ``original`` is keyword-only and required so a new handler cannot silently regress to + the sanitized-twin behaviour by omitting it. + Single source of truth for the REST envelope-response shape — used by every exception handler so HTTP status, body envelope, wire codes, and observability (logger + activity feed + audit log) are constructed @@ -141,15 +160,21 @@ def _envelope_response(request: Request, exc: AdCPError) -> JSONResponse: Symmetric with the MCP and A2A boundaries: all three transports delegate to ``record_boundary_error`` so log severity, activity-feed publishing, and audit logging stay in lockstep. Identity is not resolved on - ``request.state`` at the exception-handler boundary, so we resolve it - best-effort here (auth token + tenant headers) to populate the - tenant-scoped sinks (activity feed, audit log) for REST errors the same - way MCP and A2A do. Identity resolution never raises into the error path — - a lookup miss degrades to anonymous and ``record_boundary_error`` falls - back to the WARNING log line carrying the error code, message, and path. + ``request.state`` at the exception-handler boundary, so we recover scope + best-effort here to populate the tenant-scoped sinks (activity feed, audit + log) for REST errors the same way MCP and A2A do. Authentication errors + remain unscoped: client-controlled routing headers do not attest tenant + ownership, and rejected credentials cannot supply a trusted principal. + Other errors may reuse a fully authenticated identity, but tenant routing + hints alone never scope a sink write. Scope lookup never raises into the + error path — a miss degrades to anonymous and + ``record_boundary_error`` falls back to the server log. """ - tenant_id, principal_id = _best_effort_rest_identity(request) - record_boundary_error("rest", request.url.path, exc, tenant_id=tenant_id, principal_id=principal_id) + if isinstance(exc, AdCPAuthenticationError): + tenant_id, principal_id = None, None + else: + tenant_id, principal_id = _best_effort_rest_identity(request) + record_boundary_error("rest", request.url.path, original, tenant_id=tenant_id, principal_id=principal_id) return JSONResponse( status_code=exc.status_code, content=build_two_layer_error_envelope(exc), @@ -161,17 +186,15 @@ def _best_effort_rest_identity(request: Request) -> tuple[str | None, str | None Used solely to scope the activity-feed and audit-log sinks in ``record_boundary_error`` — never to make an authorization decision. - ``require_valid_token=False`` so an invalid/expired token (which may be - the very error being handled) still yields a tenant from the host headers - instead of raising. Any failure degrades to ``(None, None)``; observability - must not shadow the buyer's original error. + ``require_valid_token=False`` avoids replacing the original response while + the shared helper requires a resolved principal before trusting tenant + scope. Any failure or anonymous identity degrades to ``(None, None)``; + observability must not shadow the buyer's original error. """ - try: - identity = resolve_identity(dict(request.headers), protocol="rest", require_valid_token=False) - return identity.tenant_id, identity.principal_id - except Exception: - logger.debug("REST boundary: best-effort identity resolution failed", exc_info=True) - return None, None + return best_effort_boundary_identity( + lambda: resolve_identity(dict(request.headers), protocol="rest", require_valid_token=False), + transport="rest", + ) @app.exception_handler(AdCPError) @@ -193,7 +216,7 @@ async def adcp_error_handler(request: Request, exc: AdCPError) -> JSONResponse: in ``_envelope_response`` so all three handlers leave a uniform breadcrumb. """ - return _envelope_response(request, exc) + return _envelope_response(request, safe_adcp_error(exc), original=exc) @app.exception_handler(ValueError) @@ -209,7 +232,7 @@ async def value_error_handler(request: Request, exc: ValueError) -> JSONResponse Does NOT catch FastAPI's ``RequestValidationError`` (separate class, not a ValueError subclass) — that has its own handler below. """ - return _envelope_response(request, normalize_to_adcp_error(exc)) + return _envelope_response(request, safe_adcp_error(exc), original=exc) @app.exception_handler(RequestValidationError) @@ -234,10 +257,15 @@ async def request_validation_error_handler(request: Request, exc: RequestValidat # location prefix); join the rest into the JSONPath-lite ``field`` the envelope # already uses (e.g. attribution_window.post_click.interval). Stripping at any # position would erase a body field literally named "query"/"body"/"path". - raw_loc = [str(p) for p in first.get("loc", ())] - loc = raw_loc[1:] if raw_loc and raw_loc[0] in ("body", "query", "path") else raw_loc - field = ".".join(loc) or None - message = first.get("msg") or "Request failed schema validation" + projected_errors = [] + for error in errors: + projected = dict(error) + raw_loc = list(error.get("loc", ())) + projected["loc"] = raw_loc[1:] if raw_loc and raw_loc[0] in ("body", "query", "path") else raw_loc + projected_errors.append(projected) + first = projected_errors[0] if projected_errors else {} + field = validation_error_field(first) + message = safe_validation_error_message(first) if first else "Request failed schema validation" # Code selection by failure semantics, grounded in the AdCP graded # error-compliance storyboard: a VALUE/enum/range violation on a # structurally-valid field is canonically VALIDATION_ERROR; a missing/ @@ -253,9 +281,10 @@ async def request_validation_error_handler(request: Request, exc: RequestValidat message, field=field, suggestion=suggestion, - details=build_validation_error_details(errors), + details=build_validation_error_details(projected_errors), + _wire_safe_message=True, ) - return _envelope_response(request, adcp_exc) + return _envelope_response(request, adcp_exc, original=exc) @app.exception_handler(PermissionError) @@ -268,7 +297,7 @@ async def permission_error_handler(request: Request, exc: PermissionError) -> JS error instead of the 403 authorization envelope every transport should emit for the same condition. """ - return _envelope_response(request, normalize_to_adcp_error(exc)) + return _envelope_response(request, safe_adcp_error(exc), original=exc) @app.exception_handler(ToolError) @@ -288,6 +317,17 @@ async def tool_error_handler(request: Request, exc: ToolError) -> JSONResponse: return handle_tool_error(exc) +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse: + """Keep unexpected REST failures on the same sanitized AdCP wire contract. + + No ``logger.exception`` here: passing ``original`` makes ``record_boundary_error`` + take its untyped branch (ERROR + ``exc_info=True``), so the boundary emits exactly one + traceback instead of two. + """ + return _envelope_response(request, safe_adcp_error(exc), original=exc) + + # --------------------------------------------------------------------------- # A2A Integration — add routes directly to the FastAPI app (not as sub-app) # so middleware and scope["state"] propagate correctly within the same ASGI app. diff --git a/src/core/auth_context.py b/src/core/auth_context.py index af7a653c76..64a6d2f371 100644 --- a/src/core/auth_context.py +++ b/src/core/auth_context.py @@ -97,18 +97,19 @@ def _resolve_auth_dep(auth_ctx: AuthContext = get_auth_context) -> "ResolvedIden def _require_auth_dep(auth_ctx: AuthContext = get_auth_context) -> "ResolvedIdentity": - """FastAPI dependency: resolve identity (auth-required, raises 401 if missing). + """FastAPI dependency: resolve identity (auth-required, raises 401 if rejected). - Returns ResolvedIdentity on success. Raises AdCPAuthRequiredError if - no token is present or the token is invalid. The error carries the shared - AUTH_REQUIRED suggestion so the REST 401 envelope tells the buyer how to - recover (parity with require_identity on the _impl path; AdCP POST-F3). + Returns ResolvedIdentity on success. AdCP 3.1.1 requires the REST wire to + distinguish absent credentials (AUTH_MISSING) from rejected credentials + (AUTH_INVALID). """ - from src.core.auth import AUTH_REQUIRED_SUGGESTION - from src.core.exceptions import AdCPAuthRequiredError + from src.core.exceptions import AdCPAuthInvalidError, classify_auth_credentials_error if not auth_ctx.auth_token: - raise AdCPAuthRequiredError("Authentication required", suggestion=AUTH_REQUIRED_SUGGESTION) + raise classify_auth_credentials_error( + auth_ctx.headers, + missing_message="Authentication required", + ) from src.core.resolved_identity import resolve_identity @@ -120,7 +121,7 @@ def _require_auth_dep(auth_ctx: AuthContext = get_auth_context) -> "ResolvedIden ) if not identity.principal_id: - raise AdCPAuthRequiredError("Authentication required", suggestion=AUTH_REQUIRED_SUGGESTION) + raise AdCPAuthInvalidError("Authentication credentials were rejected.") # Set tenant ContextVar at the REST transport boundary if identity.tenant: diff --git a/src/core/auth_policy.py b/src/core/auth_policy.py new file mode 100644 index 0000000000..22f4889871 --- /dev/null +++ b/src/core/auth_policy.py @@ -0,0 +1,46 @@ +"""Transport-neutral authentication policy for AdCP skills.""" + +from __future__ import annotations + +# The sole list of skills safe to invoke without a resolved principal. +# Every transport imports this exact immutable object so an auth-policy change +# cannot expose or reject a skill on only one boundary. +# +# Membership is unchanged from the per-transport lists this replaced — +# consolidation, not re-policy. +# +# Grounded against the pinned 3.1.1 spec PER ENTRY, because the four do not in +# fact share one justification: +# +# get_adcp_capabilities / get_products / list_creative_formats +# building/by-layer/L2/authentication.mdx "Public Operations (No +# Authentication Required)" names exactly these three — the only +# positively-stated public set in the spec. get_products appears in BOTH +# lists: public with a partial catalog, authenticated for full access. +# +# list_authorized_properties +# NOT spec-grounded, and deliberately kept anyway. The spec REMOVED this v2 +# task in v3: its sole mention across the 3.1.1 docs is the migration note +# in protocol/get_adcp_capabilities.mdx ("removed in v3"; its fields moved +# under media_buy.portfolio). It is here because both transports already +# treated it as public before this list existed, and revoking a surface +# buyers may still call belongs with the v2-compat sunset, not with an +# error-boundary change. +# +# list_accounts is EXCLUDED on positive spec text, not on absence from a list: +# accounts/tasks/list_accounts.mdx opens "Returns all accounts the AUTHENTICATED +# agent can operate on this vendor agent", and protocol/required-tasks.mdx marks +# it Conditional for account-id namespaces with "require_operator_auth: true". +# Per BR-RULE-055 (docs/test-obligations/business-rules.md). +# +# Absence from "Public Operations" is NOT itself an argument for exclusion: that +# list omits most operations — including list_authorized_properties above — so +# reading it as a closed prohibition would contradict this very set. +AUTH_OPTIONAL_SKILLS = frozenset( + { + "get_adcp_capabilities", + "get_products", + "list_creative_formats", + "list_authorized_properties", + } +) diff --git a/src/core/context_manager.py b/src/core/context_manager.py index 12f3b1c4c5..415a09aee1 100644 --- a/src/core/context_manager.py +++ b/src/core/context_manager.py @@ -20,8 +20,15 @@ from src.core.database.database_session import DatabaseManager from src.core.database.models import Context, ObjectWorkflowMapping, WorkflowStep from src.core.database.models import Context as DBContext -from src.core.exceptions import AdCPError, build_two_layer_error_envelope, normalize_to_adcp_error +from src.core.database.repositories.workflow import WorkflowRepository + +# NOT AdCPError/normalize_to_adcp_error (which #1697 imports here): this branch's +# audit_workflow_step_failure passes the ORIGINAL exception to safe_adcp_error +# instead — pre-wrapping with normalize_to_adcp_error is the webhook secret-leak +# this PR fixed, so those two names are unused here by design. +from src.core.exceptions import build_two_layer_error_envelope, safe_adcp_error from src.core.webhook_validator import ( + resolve_webhook_task_id, validate_webhook_task_type, webhook_url_for_log, ) @@ -299,73 +306,74 @@ def update_workflow_step( response_data = response_data.model_dump(mode="json") session = self.session try: - stmt = select(WorkflowStep).filter_by(step_id=step_id) - if tenant_id: - stmt = stmt.join(DBContext).where(DBContext.tenant_id == tenant_id) - - step = session.scalars(stmt).first() - if step: - old_status = step.status # Capture old status before changing - - if status: - step.status = status - if status in ["completed", "failed"] and not step.completed_at: - step.completed_at = datetime.now(UTC) - + # Resolve the tenant scope the repository primitive needs. Most callers + # pass it; when absent, the repository derives it from the step's context + # (data access stays in the repository — no raw query here). + scoped_tenant = tenant_id or WorkflowRepository.resolve_tenant_for_step(session, step_id) + if scoped_tenant is None: + return # step (or its context) not found + repo = WorkflowRepository(session, scoped_tenant) + + if status: + # ATOMIC terminal-safe status write via the SHARED conditional-UPDATE + # primitive (WHERE status NOT IN terminal). This closes the cancellation + # TOCTOU even when update_workflow_step runs concurrently with a buyer + # cancel — e.g. MockAdServer._schedule_async_completion() fires this from + # a delayed background thread. A refused transition (None) means the step + # was concurrently terminalized (canceled) or is gone: apply NOTHING and + # send NO webhook, so we never report a completion that lost the race. + completed_at = datetime.now(UTC) if status in ("completed", "failed") else None + step = repo.transition_if_nonterminal( + step_id, + status=status, + completed_at=completed_at, + response_data=response_data, + error_message=error_message, + ) + if step is None: + console.print( + f"[yellow]⚠️ Status change to '{status}' refused for step {step_id} " + f"(already terminal or not found) — no webhook[/yellow]" + ) + return + if transaction_details is not None: + step.transaction_details = transaction_details + self._apply_comment(step, add_comment) + session.commit() + # Webhook fires ONLY because the status transition actually applied. + self._send_push_notifications(step, status, session) + else: + # No status change → plain field update; no terminal concern, no webhook + # (webhooks fire on status changes only, matching prior behavior). + step = repo.get_by_step_id(step_id) + if step is None: + return if response_data is not None: step.response_data = response_data if error_message is not None: step.error_message = error_message if transaction_details is not None: step.transaction_details = transaction_details - - if add_comment: - # Ensure comments is a list - if not isinstance(step.comments, list): - step.comments = [] - # Create a new list to trigger SQLAlchemy change detection - new_comments = list(step.comments) - new_comments.append( - { - "user": add_comment.get("user", "system"), - "timestamp": datetime.now(UTC).isoformat(), - "text": add_comment.get("text", add_comment.get("comment", "")), - } - ) - step.comments = new_comments - - # DEBUG: Log the condition check values BEFORE commit - console.print("[magenta]🔍 PRE-COMMIT WEBHOOK DEBUG:[/magenta]") - console.print("[magenta] update_workflow_step called with:[/magenta]") - console.print(f"[magenta] step_id={step_id}[/magenta]") - console.print(f"[magenta] status parameter={status}[/magenta]") - console.print("[magenta] Database state BEFORE commit:[/magenta]") - console.print(f"[magenta] old_status={old_status}[/magenta]") - console.print(f"[magenta] new step.status={step.status}[/magenta]") - console.print("[magenta] Condition evaluation:[/magenta]") - console.print(f"[magenta] status parameter truthy? {bool(status)}[/magenta]") - console.print(f"[magenta] step object exists? {step is not None}[/magenta]") - console.print(f"[magenta] Will trigger webhook? {status and step}[/magenta]") - + self._apply_comment(step, add_comment) session.commit() - console.print(f"[green]✅ Updated workflow step {step_id} (committed to database)[/green]") - - # DEBUG: Log the condition check values AFTER commit - console.print("[yellow]🔍 POST-COMMIT WEBHOOK DEBUG:[/yellow]") - console.print(f"[yellow] status={status}[/yellow]") - console.print(f"[yellow] old_status={old_status}[/yellow]") - console.print(f"[yellow] step exists={step is not None}[/yellow]") - console.print(f"[yellow] Webhook trigger condition (status and step): {status and step}[/yellow]") - - # Send push notifications if status changed - if status and step: - console.print(f"[blue]🚀 WEBHOOK: Calling _send_push_notifications for step {step_id}[/blue]") - self._send_push_notifications(step, status, session) - else: - console.print(f"[yellow]⚠️ WEBHOOK SKIPPED: status={status}, step={step is not None}[/yellow]") finally: session.close() + @staticmethod + def _apply_comment(step: WorkflowStep, add_comment: dict[str, str] | None) -> None: + """Append a comment to a step's comments list (new list → change detection).""" + if not add_comment: + return + comments = list(step.comments) if isinstance(step.comments, list) else [] + comments.append( + { + "user": add_comment.get("user", "system"), + "timestamp": datetime.now(UTC).isoformat(), + "text": add_comment.get("text", add_comment.get("comment", "")), + } + ) + step.comments = comments + def audit_workflow_step_failure(self, step_id: str, exc: Exception) -> None: """Mark a workflow step failed with the spec two-layer envelope as ``response_data``. @@ -376,38 +384,23 @@ def audit_workflow_step_failure(self, step_id: str, exc: Exception) -> None: (``adcp_error`` + ``errors[]``) via ``build_two_layer_error_envelope`` so async and sync paths see the same wire shape. - Untyped exceptions are normalized to ``AdCPError`` via - ``normalize_to_adcp_error``. Wire-code enforcement ensures webhook - subscribers only see codes in ``WIRE_STANDARD_CODES``. + The shared ``safe_adcp_error`` policy (``src/core/exceptions.py``) does the whole job and + must receive the ORIGINAL exception: it scrubs the message for internal/infra errors (the + SERVICE_UNAVAILABLE family + terminal CONFIGURATION_ERROR) AND for every UNTYPED exception + (``ValueError``, ``PermissionError``, adapter ``RuntimeError``…), so a raw interpolated + ``str(exc)`` — e.g. a connection string — never reaches the buyer's webhook; it coerces any + non-standard code to ``SERVICE_UNAVAILABLE`` and passes only TYPED client-correctable + ``AdCPError`` subclasses through with their buyer-facing message intact. Do NOT pre-wrap + with ``normalize_to_adcp_error`` — that would map an untyped ``ValueError`` to a *trusted* + ``AdCPValidationError`` whose raw message (the secret) then passes through the scrub. This + is the async twin of the synchronous re-raise scrub; both share one definition. Wraps the ``update_workflow_step`` call in ``try/except`` so a DB hiccup during audit doesn't replace the original exception that the caller is about to re-raise. """ - from src.core.exceptions import WIRE_STANDARD_CODES - try: - source = normalize_to_adcp_error(exc) - - # Defensive wire-code enforcement: webhook subscribers must only - # see codes in ``WIRE_STANDARD_CODES``. If the wire code falls - # outside the standard set, override with SERVICE_UNAVAILABLE - # so async subscribers never receive an internal-only code. - # Structured fields (details/field/suggestion/context) carry - # forward so buyer agents and webhook subscribers retain - # machine-actionable correction context across the rewrite. - wire_code = source.wire_error_code - if wire_code not in WIRE_STANDARD_CODES: - source = AdCPError.synthesize( - source.message or str(source), - error_code="SERVICE_UNAVAILABLE", - recovery="terminal", - details=source.details, - field=source.field, - suggestion=source.suggestion, - context=source.context, - ) - + source = safe_adcp_error(exc) response_data = build_two_layer_error_envelope(source) error_message = source.message or str(source) @@ -796,7 +789,12 @@ def _send_push_notifications(self, step: WorkflowStep, new_status: str, session: from src.core.database.models import PushNotificationConfig # Get object mappings for this step - stmt = select(ObjectWorkflowMapping).filter_by(step_id=step.step_id) + # Deterministic order: ``mappings[0]`` below picks ONE mapping, so an + # unordered select makes which one arbitrary. Same order_by as the + # sibling ObjectWorkflowMapping select above. + stmt = ( + select(ObjectWorkflowMapping).filter_by(step_id=step.step_id).order_by(ObjectWorkflowMapping.created_at) + ) mappings = session.scalars(stmt).all() if not mappings: @@ -825,132 +823,148 @@ def _send_push_notifications(self, step: WorkflowStep, new_status: str, session: console.print(f"[cyan]🔍 Found {len(webhooks)} active webhook configs for principal {principal_id}[/cyan]") - # Send notifications for each mapping (media buy, creative, etc.) - for mapping in mappings: - console.print( - f"[cyan]📦 Processing mapping: {mapping.object_type} {mapping.object_id} action={mapping.action}[/cyan]" - ) + # ONE webhook per step status change. The payload depends only on + # (step, new_status): the delivery URL comes from the step's own + # ``request_data.push_notification_config``, so ``mappings`` and + # ``webhooks`` are opt-in gates, not per-item delivery targets. + # Looping over them multiplied identical sends (mappings x configs) + # — a buyer's auto-approved create_media_buy received duplicate + # ``completed`` webhooks. E2E pin: + # test_a2a_webhook_payload_types::test_completed_status_sends_task_payload. + if not webhooks: + console.print(f"[yellow]No active webhook configs for principal {principal_id}; skipping[/yellow]") + return + mapping = mappings[0] + console.print( + f"[cyan]📦 Processing mapping: {mapping.object_type} {mapping.object_id} action={mapping.action}[/cyan]" + ) + # build push notification config from step request data + from uuid import uuid4 - for _webhook_config in webhooks: - # build push notification config from step request data - from uuid import uuid4 - - cfg_dict = (step.request_data or {}).get("push_notification_config") or {} - url = cfg_dict.get("url") - if not url: - console.print("[red]No push notification URL present; skipping webhook[/red]") - continue - - authentication = cfg_dict.get("authentication") or {} - schemes = authentication.get("schemes") or [] - auth_type = schemes[0] if isinstance(schemes, list) and schemes else None - auth_token = authentication.get("credentials") - - # Derive principal/tenant from the step context if available - context_obj = getattr(step, "context", None) - derived_tenant_id = tenant_id or (getattr(context_obj, "tenant_id", None)) - derived_principal_id = getattr(context_obj, "principal_id", None) - - push_notification_config = PushNotificationConfig( - id=cfg_dict.get("id") or f"pnc_{uuid4().hex[:16]}", - tenant_id=derived_tenant_id, - principal_id=derived_principal_id, - url=url, - authentication_type=auth_type, - authentication_token=auth_token, - is_active=True, - ) + cfg_dict = (step.request_data or {}).get("push_notification_config") or {} + url = cfg_dict.get("url") + if not url: + console.print("[red]No push notification URL present; skipping webhook[/red]") + return - service = get_protocol_webhook_service() + authentication = cfg_dict.get("authentication") or {} + schemes = authentication.get("schemes") or [] + auth_type = schemes[0] if isinstance(schemes, list) and schemes else None + auth_token = authentication.get("credentials") + + # Derive principal/tenant from the step context if available + context_obj = getattr(step, "context", None) + derived_tenant_id = tenant_id or (getattr(context_obj, "tenant_id", None)) + derived_principal_id = getattr(context_obj, "principal_id", None) + + push_notification_config = PushNotificationConfig( + id=cfg_dict.get("id") or f"pnc_{uuid4().hex[:16]}", + tenant_id=derived_tenant_id, + principal_id=derived_principal_id, + url=url, + authentication_type=auth_type, + authentication_token=auth_token, + is_active=True, + ) - safe_webhook_url = webhook_url_for_log(push_notification_config.url) - console.print( - f"[cyan]📤 Sending webhook to {safe_webhook_url} for {mapping.object_type} {mapping.object_id}[/cyan]" - ) + service = get_protocol_webhook_service() - # Build webhook payload based on protocol type. - # task_type_str is the ORIGINAL action label — it keys the - # delivery-webhook guards + audit log and must NOT be rewritten - # by the SDK fallback (salesagent-yi3s). wire_task_type is the - # validated COPY passed to the SDK payload builder. - task_type_str = step.tool_name or mapping.action or "unknown" - protocol = (step.request_data or {}).get("protocol", "mcp") # Default to MCP - try: - status_enum = GeneratedTaskStatus(new_status) - except ValueError: - status_enum = GeneratedTaskStatus.unknown - - # SDK 5.7 validates task_type against TaskType enum; coerce a - # COPY for the payload while leaving task_type_str untouched. - wire_task_type = validate_webhook_task_type(task_type_str) - - payload: Task | TaskStatusUpdateEvent | McpWebhookPayload - if protocol == "a2a": - payload = create_a2a_webhook_payload( - task_id=step.step_id, - status=status_enum, - context_id=step.context_id, - result=step.response_data or {}, - ) - else: - # SDK 5.7: returns McpWebhookPayload directly - payload = create_mcp_webhook_payload( - task_id=step.step_id, - status=status_enum, - task_type=wire_task_type, - result=step.response_data, - ) + # Sanitized for every operator-facing line below (#1697): scheme://host/path, + # never credentials or query. + safe_webhook_url = webhook_url_for_log(push_notification_config.url) + console.print( + f"[cyan]📤 Sending webhook to {safe_webhook_url} for {mapping.object_type} {mapping.object_id}[/cyan]" + ) + + # Build webhook payload based on protocol type. + # task_type_str is the ORIGINAL action label — it keys the + # delivery-webhook guards + audit log and must NOT be rewritten + # by the SDK fallback. wire_task_type is the + # validated COPY passed to the SDK payload builder. + task_type_str = step.tool_name or mapping.action or "unknown" + protocol = (step.request_data or {}).get("protocol", "mcp") # Default to MCP + # Correlate to the id the BUYER holds. The A2A boundary persisted its + # outer transport ``task_*`` id on the step's request_data + # (external_task_id) at create time; the buyer polls / receives the + # webhook against THAT id, not the internal step_id. MCP/REST have no + # outer id, so they fall back to step_id (unchanged behavior). + correlation_task_id = resolve_webhook_task_id(step.request_data, step.step_id) + try: + status_enum = GeneratedTaskStatus(new_status) + except ValueError: + status_enum = GeneratedTaskStatus.unknown + + # SDK 5.7 validates task_type against TaskType enum; coerce a + # COPY for the payload while leaving task_type_str untouched. + wire_task_type = validate_webhook_task_type(task_type_str) + + payload: Task | TaskStatusUpdateEvent | McpWebhookPayload + if protocol == "a2a": + payload = create_a2a_webhook_payload( + task_id=correlation_task_id, + status=status_enum, + context_id=step.context_id, + result=step.response_data or {}, + ) + else: + # SDK 5.7: returns McpWebhookPayload directly + payload = create_mcp_webhook_payload( + task_id=correlation_task_id, + status=status_enum, + task_type=wire_task_type, + result=step.response_data, + ) - metadata: dict[str, Any] = { - "task_type": task_type_str, - "tenant_id": derived_tenant_id, - "principal_id": derived_principal_id, - } + metadata: dict[str, Any] = { + "task_type": task_type_str, + "tenant_id": derived_tenant_id, + "principal_id": derived_principal_id, + } + + try: + # If we're already in an event loop, schedule the send; otherwise run it directly + try: + loop = asyncio.get_running_loop() + task = loop.create_task( + service.send_notification( + push_notification_config=push_notification_config, + payload=payload, + metadata=metadata, + ) + ) - try: - # If we're already in an event loop, schedule the send; otherwise run it directly + def _log_task_result( + t: asyncio.Task, + raw_url: str = push_notification_config.url, + safe_url: str = safe_webhook_url, + ) -> None: + # Runs AFTER pin_task's discard (see pin_task + # docstring), so this log-and-swallow can't hold + # the strong ref past completion. + # Pass raw URL — _log_webhook_send_outcome owns sanitize. try: - loop = asyncio.get_running_loop() - task = loop.create_task( - service.send_notification( - push_notification_config=push_notification_config, - payload=payload, - metadata=metadata, - ) - ) - - def _log_task_result( - t: asyncio.Task, - raw_url: str = push_notification_config.url, - safe_url: str = safe_webhook_url, - ) -> None: - # Runs AFTER pin_task's discard (see pin_task - # docstring), so this log-and-swallow can't hold - # the strong ref past completion. - # Pass raw URL — _log_webhook_send_outcome owns sanitize. - try: - _log_webhook_send_outcome(raw_url, t.result()) - except Exception as e: - console.print(f"[red]❌ Webhook failed for {safe_url}: {str(e)}[/red]") - - # Strong-ref pin against asyncio's weak-ref task - # tracker; discard runs before _log_task_result. - pin_task(task, on_done=_log_task_result) - except RuntimeError: - # No running loop; safe to run synchronously - sent = asyncio.run( - service.send_notification( - push_notification_config=push_notification_config, - payload=payload, - metadata=metadata, - ) - ) - _log_webhook_send_outcome(push_notification_config.url, sent) - - except requests.exceptions.Timeout: - console.print(f"[red]❌ Webhook timeout for {safe_webhook_url}[/red]") - except requests.exceptions.RequestException as e: - console.print(f"[red]❌ Webhook failed for {safe_webhook_url}: {str(e)}[/red]") + _log_webhook_send_outcome(raw_url, t.result()) + except Exception as e: + console.print(f"[red]❌ Webhook failed for {safe_url}: {str(e)}[/red]") + + # Strong-ref pin against asyncio's weak-ref task + # tracker; discard runs before _log_task_result. + pin_task(task, on_done=_log_task_result) + except RuntimeError: + # No running loop; safe to run synchronously + sent = asyncio.run( + service.send_notification( + push_notification_config=push_notification_config, + payload=payload, + metadata=metadata, + ) + ) + _log_webhook_send_outcome(push_notification_config.url, sent) + + except requests.exceptions.Timeout: + console.print(f"[red]❌ Webhook timeout for {safe_webhook_url}[/red]") + except requests.exceptions.RequestException as e: + console.print(f"[red]❌ Webhook failed for {safe_webhook_url}: {str(e)}[/red]") except Exception as e: console.print(f"[red]Error sending push notifications: {e}[/red]") diff --git a/src/core/creative_agent_registry.py b/src/core/creative_agent_registry.py index 126ed52875..2f76251a47 100644 --- a/src/core/creative_agent_registry.py +++ b/src/core/creative_agent_registry.py @@ -362,6 +362,19 @@ def _get_tenant_agents(self, tenant_id: str | None) -> list[CreativeAgent]: agents.sort(key=lambda a: a.priority) return [a for a in agents if a.enabled] + def get_registered_agent_urls(self, tenant_id: str | None) -> frozenset[str]: + """Return canonical federation URLs accepted for format references. + + ``CREATIVE_AGENT_URL`` may redirect the standard agent to an in-network + test service, but buyers must continue to reference its public + federation identity. Tenant-specific agents retain their configured + public identities. + """ + agents = self._get_tenant_agents(tenant_id) + urls = {canonical_agent_url(PUBLIC_DEFAULT_AGENT_URL)} + urls.update(canonical_agent_url(agent.agent_url) for agent in agents if agent is not self.DEFAULT_AGENT) + return frozenset(urls) + async def _fetch_formats_from_agent( self, client: ADCPMultiAgentClient, diff --git a/src/core/database/repositories/__init__.py b/src/core/database/repositories/__init__.py index ca606ffda4..1e33227e26 100644 --- a/src/core/database/repositories/__init__.py +++ b/src/core/database/repositories/__init__.py @@ -26,6 +26,7 @@ from src.core.database.repositories.tenant_config import TenantConfigRepository from src.core.database.repositories.uow import ( AccountUoW, + ApprovalUoW, MediaBuyUoW, ProductUoW, PushNotificationConfigUoW, @@ -37,6 +38,7 @@ __all__ = [ "AccountRepository", "AccountUoW", + "ApprovalUoW", "AdapterConfigRepository", "TenantNotConfiguredError", "CurrencyLimitRepository", diff --git a/src/core/database/repositories/creative.py b/src/core/database/repositories/creative.py index f0db77cc3f..f2335776ed 100644 --- a/src/core/database/repositories/creative.py +++ b/src/core/database/repositories/creative.py @@ -465,8 +465,12 @@ def delete(self, assignment_id: str) -> bool: # Cross-model lookups (for assignment workflow) # ------------------------------------------------------------------ - def find_package_with_media_buy(self, package_id: str) -> tuple[MediaPackage, MediaBuy] | None: - """Find a package and its parent media buy within the tenant. + def find_package_with_media_buy( + self, + package_id: str, + principal_id: str, + ) -> tuple[MediaPackage, MediaBuy] | None: + """Find a principal-owned package and its parent buy within the tenant. Delegates to MediaBuyRepository — all MediaPackage queries are owned by that repository per the no-raw-MediaPackage-select guard. @@ -476,7 +480,7 @@ def find_package_with_media_buy(self, package_id: str) -> tuple[MediaPackage, Me from src.core.database.repositories.media_buy import MediaBuyRepository mb_repo = MediaBuyRepository(self._session, self._tenant_id) - return mb_repo.find_package_with_media_buy(package_id) + return mb_repo.find_package_with_media_buy(package_id, principal_id) def get_creative_by_id(self, creative_id: str, principal_id: str) -> Creative | None: """Get a creative by its full composite key (tenant + principal + creative_id). diff --git a/src/core/database/repositories/media_buy.py b/src/core/database/repositories/media_buy.py index db8284a722..2b1ca17053 100644 --- a/src/core/database/repositories/media_buy.py +++ b/src/core/database/repositories/media_buy.py @@ -14,9 +14,10 @@ import datetime from decimal import Decimal +from enum import StrEnum from typing import TYPE_CHECKING, Any -from sqlalchemy import select +from sqlalchemy import select, update from sqlalchemy.orm import Session, joinedload from src.core.database.models import MediaBuy, MediaPackage @@ -25,6 +26,47 @@ from adcp.types import ContextObject +# Every pre-execution state a media buy can be claimed for adapter execution FROM. +APPROVED_EXECUTION_SOURCE_STATUSES = ("pending_approval", "pending_creatives", "draft") + +# The subset still awaiting a human decision. +# +# This split is declared HERE, beside the set it narrows, because deriving it blindly +# would be wrong in a way that is easy to get backwards: a buy in ``pending_approval`` +# has not been approved by anyone yet, so a trigger that is not a human decision — the +# last blocking creative being approved, say — must NOT promote it. Letting it would make +# creative approval a substitute for human approval. +# +# It was previously a bare ``{"pending_creatives", "draft"}`` literal at the creative +# route, i.e. this derivation hand-copied at a call site with the reasoning nowhere. Read +# through ``execution_source_statuses_for`` rather than re-deriving. +HUMAN_DECISION_PENDING_STATUSES = ("pending_approval",) + + +class ApprovalTrigger(StrEnum): + """What is asking for a media buy to be executed. + + The trigger decides which source states are eligible, so the routes name what they + ARE rather than restating which statuses that implies. Four call sites each carried + their own status literal; their union was the canonical set, so no single one looked + wrong and nothing textual flagged the split. + """ + + HUMAN_DECISION = "human_decision" + """An administrator approved the workflow step. Every pre-execution state is eligible.""" + + CREATIVE_UNBLOCK = "creative_unblock" + """The last blocking creative was approved. States still awaiting a human decision are + NOT eligible — see ``HUMAN_DECISION_PENDING_STATUSES``.""" + + +def execution_source_statuses_for(trigger: ApprovalTrigger) -> tuple[str, ...]: + """Source states a media buy may be claimed for execution from, for this trigger.""" + if trigger is ApprovalTrigger.CREATIVE_UNBLOCK: + return tuple(s for s in APPROVED_EXECUTION_SOURCE_STATUSES if s not in HUMAN_DECISION_PENDING_STATUSES) + return APPROVED_EXECUTION_SOURCE_STATUSES + + class MediaBuyRepository: """Tenant-scoped data access for MediaBuy and MediaPackage. @@ -232,11 +274,17 @@ def get_packages_for_ids(self, media_buy_ids: list[str]) -> dict[str, list[Media result.setdefault(pkg.media_buy_id, []).append(pkg) return result - def find_package_with_media_buy(self, package_id: str) -> tuple[MediaPackage, MediaBuy] | None: - """Find a package and its parent media buy by package_id within the tenant. + def find_package_with_media_buy( + self, + package_id: str, + principal_id: str, + ) -> tuple[MediaPackage, MediaBuy] | None: + """Find a package and parent buy owned by the principal in this tenant. Useful when you only have a package_id and need to resolve the parent - media buy (e.g. during creative-to-package assignment). + media buy (e.g. during creative-to-package assignment). Principal + scoping prevents a same-tenant buyer from assigning its creative to + another buyer's package. Returns (MediaPackage, MediaBuy) tuple or None if not found. """ @@ -246,6 +294,7 @@ def find_package_with_media_buy(self, package_id: str) -> tuple[MediaPackage, Me .where( MediaPackage.package_id == package_id, MediaBuy.tenant_id == self._tenant_id, + MediaBuy.principal_id == principal_id, ) ).first() if result is None: @@ -453,6 +502,100 @@ def update_status( self._session.flush() return media_buy + def claim_approved_execution( + self, + media_buy_id: str, + *, + trigger: ApprovalTrigger = ApprovalTrigger.HUMAN_DECISION, + ) -> bool: + """Atomically claim one approved media buy for irreversible execution. + + The source-state guard lets exactly one concurrent admin request move + the buy into ``activating``. A loser must not call the external adapter. + ``activating`` is deliberately durable before dispatch, because an + exception after the request is sent cannot prove whether the ad server + created the order. + + ``trigger`` selects the eligible source states: a creative unblock may not + promote a buy still awaiting a human decision. The guard is enforced IN the + UPDATE, so a buy that changes state between the caller's read and this write + is refused rather than promoted. + """ + return self._transition_approved_execution( + media_buy_id, + source_statuses=execution_source_statuses_for(trigger), + target_status="activating", + ) + + def reject_pending_execution(self, media_buy_id: str) -> bool: + """Atomically reject a media buy that has not begun executing. + + Mirrors ``claim_approved_execution``: same source states, opposite decision. + Exists so the admin reject route stops assigning ``media_buy.status`` directly — + a raw write has no source guard, so it could mark a buy rejected after execution + had already been claimed, and it recognised only ``pending_approval``, leaving a + rejected ``pending_creatives`` or ``draft`` buy sitting in its old state while its + workflow step said rejected. + """ + return self._transition_approved_execution( + media_buy_id, + source_statuses=APPROVED_EXECUTION_SOURCE_STATUSES, + target_status="rejected", + ) + + def mark_approved_execution_unknown( + self, + media_buy_id: str, + *, + expected_updated_at: datetime.datetime | None = None, + ) -> bool: + """Persist an ambiguous post-dispatch outcome without overwriting success.""" + return self._transition_approved_execution( + media_buy_id, + source_statuses=("activating",), + target_status="activation_unknown", + expected_updated_at=expected_updated_at, + ) + + def renew_approved_execution_lease(self, media_buy_id: str) -> bool: + """Renew a live execution claim while its worker is still running.""" + return self._transition_approved_execution( + media_buy_id, + source_statuses=("activating",), + target_status="activating", + ) + + def complete_approved_execution(self, media_buy_id: str) -> bool: + """Mark execution active only while this worker still owns the claim.""" + return self._transition_approved_execution( + media_buy_id, + source_statuses=("activating",), + target_status="active", + ) + + def _transition_approved_execution( + self, + media_buy_id: str, + *, + source_statuses: tuple[str, ...], + target_status: str, + expected_updated_at: datetime.datetime | None = None, + ) -> bool: + """Shared tenant-scoped CAS for the approval execution lifecycle.""" + statement = update(MediaBuy).where( + MediaBuy.tenant_id == self._tenant_id, + MediaBuy.media_buy_id == media_buy_id, + MediaBuy.status.in_(source_statuses), + ) + if expected_updated_at is not None: + statement = statement.where(MediaBuy.updated_at == expected_updated_at) + updated_id = self._session.execute( + statement.values(status=target_status, updated_at=datetime.datetime.now(datetime.UTC)) + .returning(MediaBuy.media_buy_id) + .execution_options(synchronize_session="fetch") + ).scalar_one_or_none() + return updated_id is not None + def update_fields(self, media_buy_id: str, **kwargs: Any) -> MediaBuy | None: """Update arbitrary fields on a media buy within this tenant. diff --git a/src/core/database/repositories/uow.py b/src/core/database/repositories/uow.py index d66ee4856b..e2a385b7a5 100644 --- a/src/core/database/repositories/uow.py +++ b/src/core/database/repositories/uow.py @@ -14,7 +14,7 @@ # auto-commits when exiting the `with` block with WorkflowUoW(tenant_id) as uow: - steps = uow.workflows.list_by_tenant(status="pending") + steps = uow.workflows.list_by_tenant(principal_id=principal_id, status="pending") # auto-commits when exiting the `with` block with TenantConfigUoW(tenant_id) as uow: @@ -192,6 +192,28 @@ def _clear_repos(self) -> None: self.workflows = None +class ApprovalUoW(BaseUoW): + """Single transaction for workflow and media-buy approval orchestration.""" + + workflows: WorkflowRepository | None + media_buys: MediaBuyRepository | None + creatives: CreativeRepository | None + assignments: CreativeAssignmentRepository | None + + def _init_repos(self) -> None: + assert self._session is not None + self.workflows = WorkflowRepository(self._session, self._tenant_id) + self.media_buys = MediaBuyRepository(self._session, self._tenant_id) + self.creatives = CreativeRepository(self._session, self._tenant_id) + self.assignments = CreativeAssignmentRepository(self._session, self._tenant_id) + + def _clear_repos(self) -> None: + self.workflows = None + self.media_buys = None + self.creatives = None + self.assignments = None + + class TenantConfigUoW(BaseUoW): """Unit of Work for tenant configuration reads. diff --git a/src/core/database/repositories/workflow.py b/src/core/database/repositories/workflow.py index e6aa9daf17..b7a97044e5 100644 --- a/src/core/database/repositories/workflow.py +++ b/src/core/database/repositories/workflow.py @@ -16,15 +16,53 @@ from __future__ import annotations -from datetime import datetime +from datetime import UTC, datetime from typing import Any -from sqlalchemy import func, select +from sqlalchemy import ColumnExpressionArgument, Select, func, select, update from sqlalchemy.orm import Session from src.core.database.models import Context as DBContext from src.core.database.models import ObjectWorkflowMapping, Principal, WorkflowStep +# Workflow-step statuses that are final outcomes. Single source of truth for the +# repository's atomic terminal-transition guard and the A2A boundary's step→TaskState map. +TERMINAL_STEP_STATUSES = frozenset({"completed", "rejected", "failed", "canceled"}) + +# Statuses from which a buyer cancel is still safe — i.e. states where NO irreversible +# external (ad-server) work has begun. Deliberately EXCLUDES both ``approved`` and +# ``in_progress``: +# * ``approved`` — the admin-approve path commits it BEFORE execute_approved_media_buy, +# so cancelling it would leave a real order behind a canceled task. +# * ``in_progress`` — the create/update execution paths set it BEFORE running their +# adapter/business side-effects (media_buy_create.py, media_buy_update.py), so it marks +# that irreversible work is already underway; cancelling then would strand external +# state behind a canceled task. +# ``approval`` is the legacy adapter-emitted awaiting-decision alias of ``requires_approval`` +# (GAM/Broadstreet/base_workflow — see APPROVABLE_STEP_STATUSES below): a pre-side-effect state, +# so it is cancellable for the same reason ``requires_approval`` is. Until historic +# ``approval`` rows and their producers are normalized, it is carried in BOTH sets. +# Terminal statuses are (trivially) excluded too. A cancel is only accepted while the step is +# still purely pending human/forecasting action, before any side-effects have run. +CANCELLABLE_STEP_STATUSES = frozenset({"pending", "requires_approval", "pending_approval", "approval"}) + +# Statuses a step can be approved or rejected FROM — i.e. it is still awaiting a human +# decision and no irreversible execution has started. Approval/rejection is a compare-and-set +# from one of these to a decided status. Because ``approved`` is (deliberately) NON-terminal, +# a broad "not terminal" guard would let a SECOND concurrent approver win an ``approved → +# approved`` no-op and also run the irreversible adapter creation (duplicate order), and would +# let a reject run ``approved → rejected`` (stranding a live order behind a rejected workflow). +# Restricting the source states to this set makes exactly one decider win. +# +# ``approval`` is the LEGACY awaiting-decision status emitted by the adapter workflow managers +# (base_workflow.py default; GAM order-activation / manual-order / creative-approval steps; +# Broadstreet via the base manager). It is semantically identical to ``requires_approval`` — +# a step a publisher must approve/reject — so it MUST be approvable, otherwise those live human +# workflows can never be actioned. Normalizing every producer to the canonical +# ``requires_approval`` (including normalizing existing ``approval`` rows) is not +# complete yet; until then this set carries the legacy alias. +APPROVABLE_STEP_STATUSES = frozenset({"requires_approval", "pending_approval", "approval"}) + class WorkflowRepository: """Tenant-scoped data access for WorkflowStep and ObjectWorkflowMapping. @@ -60,15 +98,120 @@ def get_by_step_id(self, step_id: str) -> WorkflowStep | None: ) ).first() - def get_by_step_id_or_raise(self, step_id: str) -> WorkflowStep: - """Get a workflow step by ID or raise ``AdCPTaskNotFoundError``. + def get_policy_review_step(self, step_id: str) -> WorkflowStep | None: + """A tenant-scoped ``policy_review`` step by id, or None. + + The ``step_type`` guard is load-bearing, not cosmetic: the policy review route drives a + step terminal, so without it an arbitrary step id (e.g. a media-buy approval) could be + finalized here with a fabricated ``{"approved": true}`` artifact. Keeping both the + tenant-scoping join and the type predicate in the repository means the POST and GET legs + — and any future caller — share one definition instead of re-inlining the query. + """ + step = self.get_by_step_id(step_id) + if step is None or step.step_type != "policy_review": + return None + return step + + def get_approvable_step_for_object( + self, object_type: str, object_id: str, *, step_id: str | None = None + ) -> WorkflowStep | None: + """The workflow step awaiting a decision for a mapped business object (tenant-scoped). + + Joins ObjectWorkflowMapping and filters status to APPROVABLE_STEP_STATUSES — the + canonical awaiting-decision set (including the legacy ``approval`` alias emitted by the + adapter workflow producers). The admin media-buy detail approve/reject route uses this + so its prefilter matches the ``claim_approval`` / ``reject_if_approvable`` source-state + guard; an inline ``{requires_approval, pending_approval}`` filter previously dropped + legacy ``approval`` steps before they could reach the CAS. + + When ``step_id`` is supplied, the step must also be the exact decision rendered to the + administrator. This prevents a stale form from approving a different mapped workflow + when several approval operations exist for one media buy. Without ``step_id`` (the GET + page), the oldest mapped approval is selected deterministically. + """ + stmt = ( + select(WorkflowStep) + .join(ObjectWorkflowMapping, WorkflowStep.step_id == ObjectWorkflowMapping.step_id) + .join(DBContext, WorkflowStep.context_id == DBContext.context_id) + .where( + DBContext.tenant_id == self._tenant_id, + ObjectWorkflowMapping.object_type == object_type, + ObjectWorkflowMapping.object_id == object_id, + WorkflowStep.status.in_(APPROVABLE_STEP_STATUSES), + ) + ) + if step_id is not None: + stmt = stmt.where(WorkflowStep.step_id == step_id) + return self._session.scalars( + stmt.order_by(ObjectWorkflowMapping.created_at, WorkflowStep.created_at, WorkflowStep.step_id) + ).first() + + def _principal_scoped_steps(self, principal_id: str) -> Select[tuple[WorkflowStep]]: + """Base SELECT over the steps a single principal owns, within the tenant. + + One home for what "principal-scoped" means, so the buyer-facing reads cannot + drift apart: the tenant join every read carries, PLUS ``DBContext.principal_id``. + Step ids and transport task ids are bearer-ish identifiers, so a read that + authorizes only the tenant lets any same-tenant sibling principal who learns an + id read another principal's stored ``response_data`` or drive its workflow. + Callers pass ``principal_id`` as a required keyword so no call site can omit it. + """ + return ( + select(WorkflowStep) + .join(DBContext) + .where( + DBContext.tenant_id == self._tenant_id, + DBContext.principal_id == principal_id, + ) + ) + + def get_by_external_task_id(self, external_task_id: str, *, principal_id: str) -> WorkflowStep | None: + """Get the workflow step carrying a given transport outer task id. + + The A2A boundary persists its outer ``task_*`` id (the id returned to the + buyer) on the create step's ``request_data.external_task_id`` (see + ``_create_media_buy_impl``), so a durable ``tasks/get`` poll can resolve the + buyer's id → step → terminal status + stored ``response_data`` artifact, + surviving a server restart (the admin approval that terminalized the step runs + in a different process, so the in-memory task map is not enough). + + INVARIANT — one writer, one step: an ``external_task_id`` must be written by an + operation that produces AT MOST ONE workflow step, because this lookup returns a + single row for the key. It is deliberately unordered and ungated: with the + invariant held there is nothing to order or count. A caller that writes the same + id across N steps (``sync_creatives`` creates one step per creative) makes the + key ambiguous, and every reader — durable poll, cancel, webhook correlation — + silently acts on an arbitrary one of the N. Such an operation must address its + steps by ``step_id`` instead of being threaded through here. + + Scoped to BOTH the tenant and the owning ``principal_id`` — see + :meth:`_principal_scoped_steps` for why the tenant alone is not an + authorization boundary here. + """ + return self._session.scalars( + self._principal_scoped_steps(principal_id).where( + WorkflowStep.request_data["external_task_id"].as_string() == external_task_id, + ) + ).first() + + def get_by_step_id_or_raise(self, step_id: str, *, principal_id: str) -> WorkflowStep: + """Get a principal's own workflow step by ID or raise ``AdCPTaskNotFoundError``. Collapses the task fetch-and-raise guard shared by get_task/complete_task. No ``context`` parameter by design: those tools carry the FastMCP transport ``Context``, not an AdCP ``ContextObject``, so the task not-found envelope stays context-less rather than echoing a transport object into a repository. + + Principal-scoped (:meth:`_principal_scoped_steps`), unlike the plain + :meth:`get_by_step_id` used by admin and already-authorized internal callers: + these two callers ARE the buyer-facing MCP tools, where the principal is the + authorization boundary. A sibling principal's step is reported not-found — + the same signal an unknown id gets, so the response does not confirm the id + exists. """ - step = self.get_by_step_id(step_id) + step = self._session.scalars( + self._principal_scoped_steps(principal_id).where(WorkflowStep.step_id == step_id) + ).first() if step is None: from src.core.exceptions import AdCPTaskNotFoundError @@ -78,28 +221,26 @@ def get_by_step_id_or_raise(self, step_id: str) -> WorkflowStep: def list_by_tenant( self, *, + principal_id: str, status: str | None = None, object_type: str | None = None, object_id: str | None = None, offset: int = 0, limit: int = 20, ) -> list[WorkflowStep]: - """List workflow steps for the tenant, with optional filters. + """List the steps ``principal_id`` owns in this tenant, with optional filters. Args: + principal_id: Owning principal — required, see :meth:`_principal_scoped_steps`. + The buyer-facing ``list_tasks`` tool is the caller, so leaking a sibling + principal's steps here would expose their ids and summaries. status: Filter by step status (e.g., "pending", "requires_approval"). object_type: Filter by associated object type (e.g., "media_buy"). object_id: Filter by specific object ID (requires object_type). offset: Number of steps to skip. limit: Maximum number of steps to return. """ - stmt = ( - select(WorkflowStep) - .join(DBContext) - .where( - DBContext.tenant_id == self._tenant_id, - ) - ) + stmt = self._principal_scoped_steps(principal_id) if status: stmt = stmt.where(WorkflowStep.status == status) @@ -120,21 +261,18 @@ def list_by_tenant( def count_by_tenant( self, *, + principal_id: str, status: str | None = None, object_type: str | None = None, object_id: str | None = None, ) -> int: - """Count workflow steps matching the given filters. + """Count the steps ``principal_id`` owns in this tenant, matching the filters. - Uses the same filter logic as list_by_tenant but returns only the count. + Uses the same filter logic and the same principal scope as list_by_tenant + but returns only the count — the pagination total must describe the same + rows the page does, or it leaks the existence of a sibling's steps. """ - stmt = ( - select(WorkflowStep) - .join(DBContext) - .where( - DBContext.tenant_id == self._tenant_id, - ) - ) + stmt = self._principal_scoped_steps(principal_id) if status: stmt = stmt.where(WorkflowStep.status == status) @@ -170,6 +308,28 @@ def get_latest_mapping_for_object(self, object_type: str, object_id: str) -> Obj .order_by(ObjectWorkflowMapping.created_at.desc()) ).first() + def get_claimed_create_approval_step_for_media_buy(self, media_buy_id: str) -> WorkflowStep | None: + """Return the claimed create-approval step for a media buy. + + A media buy can have newer update mappings. Creative-unblock execution + must terminalize the original ``create_media_buy`` approval, not an + arbitrary latest mapping. + """ + return self._session.scalars( + select(WorkflowStep) + .join(ObjectWorkflowMapping, WorkflowStep.step_id == ObjectWorkflowMapping.step_id) + .join(DBContext, WorkflowStep.context_id == DBContext.context_id) + .where( + DBContext.tenant_id == self._tenant_id, + ObjectWorkflowMapping.object_type == "media_buy", + ObjectWorkflowMapping.object_id == media_buy_id, + ObjectWorkflowMapping.action.in_(("create", "approve")), + WorkflowStep.tool_name == "create_media_buy", + WorkflowStep.status == "approved", + ) + .order_by(ObjectWorkflowMapping.created_at.desc(), WorkflowStep.created_at.desc()) + ).first() + def get_step_by_id(self, step_id: str) -> WorkflowStep | None: """Alias of :meth:`get_by_step_id` (identical tenant-scoped lookup). @@ -275,34 +435,184 @@ def get_principal_name(self, principal_id: str) -> str | None: # WorkflowStep writes # ------------------------------------------------------------------ - def update_status( + @staticmethod + def resolve_tenant_for_step(session: Session, step_id: str) -> str | None: + """Resolve a step's tenant from its Context (repository owns this join). + + Lets callers that lack a tenant scope up front (e.g. ContextManager) build a + tenant-scoped repository without issuing a raw ``WorkflowStep``/``DBContext`` + query outside the repository layer. Returns None when the step (or its + context) does not exist. Read-only; does not commit. + """ + return session.scalar( + select(DBContext.tenant_id) + .join(WorkflowStep, WorkflowStep.context_id == DBContext.context_id) + .where(WorkflowStep.step_id == step_id) + ) + + def _atomic_transition( self, step_id: str, *, status: str, + status_guard: ColumnExpressionArgument[bool], completed_at: datetime | None = None, response_data: dict[str, Any] | None = None, error_message: str | None = None, ) -> WorkflowStep | None: - """Update the status of a workflow step. - - Returns the updated step, or None if not found. - Does NOT commit — the caller handles that. + """Shared atomic conditional transition: set ``status`` IFF ``status_guard`` holds. + + ONE conditional UPDATE (tenant-scoped ``WHERE step_id … AND ``) + with ``RETURNING`` — the single-statement re-evaluation against committed state + is what makes competing writers safe in either ordering. ``status_guard`` is a + SQLAlchemy predicate on ``WorkflowStep.status`` (e.g. NOT IN terminal, or IN + cancellable). Returns the re-loaded step, or None when no row matched (step + absent or its status failed the guard). Does NOT commit. """ - step = self.get_by_step_id(step_id) - if step is None: - return None - - step.status = status + values: dict[str, Any] = {"status": status} if completed_at is not None: - step.completed_at = completed_at + values["completed_at"] = completed_at if response_data is not None: - step.response_data = response_data + values["response_data"] = response_data if error_message is not None: - step.error_message = error_message + values["error_message"] = error_message elif status == "completed": - # Clear error message on successful completion - step.error_message = None + # Clear error message on successful completion. + values["error_message"] = None - self._session.flush() - return step + scoped_step_ids = ( + select(WorkflowStep.step_id) + .join(DBContext) + .where( + WorkflowStep.step_id == step_id, + DBContext.tenant_id == self._tenant_id, + ) + ) + # returning() makes the DML yield rows, so success is observable without + # the CursorResult.rowcount attribute (untyped on Session.execute's Result). + # synchronize_session="fetch" keeps any already-loaded ORM copy consistent + # so callers that further mutate the returned step see the new status. + updated = ( + self._session.execute( + update(WorkflowStep) + .where(WorkflowStep.step_id.in_(scoped_step_ids), status_guard) + .values(**values) + .returning(WorkflowStep.step_id) + .execution_options(synchronize_session="fetch") + ) + .scalars() + .first() + ) + if updated is None: + return None + return self.get_by_step_id(step_id) + + def transition_if_nonterminal( + self, + step_id: str, + *, + status: str, + completed_at: datetime | None = None, + response_data: dict[str, Any] | None = None, + error_message: str | None = None, + ) -> WorkflowStep | None: + """Atomically set a step's status IFF it is not already terminal. + + The SINGLE terminal-transition primitive shared by every competing writer + (admin approve/reject, background approval, manual complete): a terminal + workflow step (completed/rejected/failed/canceled) is IMMUTABLE. The FIRST + committed writer wins and no later writer — in either ordering — can + overwrite a committed terminal decision. + + Returns the updated step (re-loaded) or None when it does not exist OR is + already terminal (write refused). Does NOT commit. + """ + return self._atomic_transition( + step_id, + status=status, + status_guard=WorkflowStep.status.not_in(TERMINAL_STEP_STATUSES), + completed_at=completed_at, + response_data=response_data, + error_message=error_message, + ) + + def claim_approval(self, step_id: str) -> WorkflowStep | None: + """Atomically claim a step for approval: requires_approval/pending_approval → approved. + + A compare-and-set restricted to APPROVABLE_STEP_STATUSES. Because ``approved`` is + (deliberately) NON-terminal, the broad ``transition_if_nonterminal`` guard would let a + SECOND concurrent approver win an ``approved → approved`` no-op and also run + ``execute_approved_media_buy`` — duplicating irreversible adapter work. This narrower + source-state guard makes exactly ONE approver win; a later approver sees ``approved`` + (not in the source set) and gets None. Returns the updated step, or None when the step + is absent OR not in an approvable status (already approved/executing/terminal). Does + NOT commit. + """ + return self._atomic_transition( + step_id, + status="approved", + status_guard=WorkflowStep.status.in_(APPROVABLE_STEP_STATUSES), + ) + + def complete_claimed_approval(self, step_id: str) -> WorkflowStep | None: + """Atomically complete an approved step that needs no external execution. + + This is intentionally a second update in the caller's SAME transaction + as ``claim_approval``. Committing ``approved`` first and finalizing in + another UoW can strand an unreclaimable nonterminal step if that second + transaction fails. Restricting the source state to ``approved`` also + prevents this convenience path from completing an unclaimed decision. + """ + return self._atomic_transition( + step_id, + status="completed", + status_guard=WorkflowStep.status == "approved", + completed_at=datetime.now(UTC), + response_data={"approved": True}, + ) + + def reject_if_approvable( + self, + step_id: str, + *, + error_message: str | None = None, + response_data: dict[str, Any] | None = None, + ) -> WorkflowStep | None: + """Atomically reject a step awaiting a decision: requires_approval/pending_approval → rejected. + + Mirror of ``claim_approval`` with the SAME source-state guard, so a step that has + already been ``approved`` (irreversible execution underway) cannot be rejected — which + would otherwise strand a live ad-server order behind a rejected workflow. Returns the + updated step, or None when the step is absent OR not in an approvable status. Does NOT + commit. + """ + return self._atomic_transition( + step_id, + status="rejected", + status_guard=WorkflowStep.status.in_(APPROVABLE_STEP_STATUSES), + error_message=error_message, + response_data=response_data, + ) + + def cancel_if_cancellable(self, step_id: str, *, completed_at: datetime) -> bool: + """Atomically cancel a step IFF it is in a CANCELLABLE status. + + The buyer-facing cancel primitive (A2A ``tasks/cancel``). It refuses to cancel a step + once irreversible external work has begun — CANCELLABLE_STEP_STATUSES excludes both + ``approved`` (admin-approve commits it before order creation) and ``in_progress`` (the + create/update paths persist it before their adapter side-effects), as well as all + terminal states — so a cancel can never strand a real order behind a canceled task. The + atomicity is a single conditional UPDATE (``_atomic_transition``): the guard is + re-evaluated against committed state, so competing writers are safe in either ordering. + Returns True when canceled, False when the step is absent or not in a cancellable status. + Does NOT commit. + """ + return ( + self._atomic_transition( + step_id, + status="canceled", + status_guard=WorkflowStep.status.in_(CANCELLABLE_STEP_STATUSES), + completed_at=completed_at, + ) + is not None + ) diff --git a/src/core/exceptions.py b/src/core/exceptions.py index ecadf55826..1c9669855e 100644 --- a/src/core/exceptions.py +++ b/src/core/exceptions.py @@ -11,13 +11,16 @@ from __future__ import annotations import logging -from typing import TYPE_CHECKING, Any, ClassVar, Literal +import re +from collections.abc import Mapping +from types import UnionType +from typing import TYPE_CHECKING, Annotated, Any, ClassVar, Literal, Union, cast, get_args, get_origin from adcp.server.helpers import STANDARD_ERROR_CODES, adcp_error from pydantic import BaseModel, ValidationError if TYPE_CHECKING: - from collections.abc import Iterator, Mapping, Sequence + from collections.abc import Callable, Iterator, Sequence from adcp.types import ContextObject @@ -32,17 +35,14 @@ # WIRE_STANDARD_CODES. Codes in ERROR_CODE_MAPPING are translated at the # transport boundary; codes in INTERNAL_CODES never leave the server. -# Spec codes the SDK helper table has not caught up to. The pinned 3.1 enum -# (enums/error-code.json @ adcp 04f59d2d5) defines these as real wire codes; -# adcp 5.7's ``STANDARD_ERROR_CODES`` predates them, and the SDK is a -# cross-check, not the authority. CREATIVE_NOT_FOUND per the enum: correctable, -# and "Sellers MUST return this code uniformly for any creative_id not owned by -# the calling account" (#1430 review). CONFIGURATION_ERROR per the enum: -# terminal — "the buyer cannot resolve a seller-side deployment -# misconfiguration and MUST NOT auto-retry" (#1430 review). The remaining -# demoted spec code (BILLING_NOT_SUPPORTED) is tracked for the same treatment -# in #1602. +# Spec codes the SDK helper table has not caught up to. The tagged AdCP 3.1.1 +# enum is authoritative; the SDK is a cross-check, not the authority. +# AUTH_MISSING / AUTH_INVALID distinguish no credentials from rejected +# credentials. CREATIVE_NOT_FOUND is correctable; CONFIGURATION_ERROR and +# AUTH_INVALID are terminal. _SPEC_SUPPLEMENT_CODES: dict[str, dict[str, str]] = { + "AUTH_MISSING": {"recovery": "correctable", "message": "Authentication credentials are required"}, + "AUTH_INVALID": {"recovery": "terminal", "message": "Authentication credentials were rejected"}, "CREATIVE_NOT_FOUND": {"recovery": "correctable", "message": "Creative not found"}, "CONFIGURATION_ERROR": {"recovery": "terminal", "message": "Configuration error"}, } @@ -263,6 +263,7 @@ def __init__( suggestion: str | None = None, retry_after: int | None = None, context: ContextObject | dict[str, Any] | None = None, + _wire_safe_message: bool = False, ) -> None: # ``error_code`` and ``status_code`` kwargs are only used by the # sanctioned ``synthesize()`` classmethod for boundary fallback paths @@ -275,6 +276,7 @@ def __init__( self.suggestion = suggestion if suggestion is not None else type(self)._default_suggestion self.retry_after = retry_after self.context = context + self._wire_safe_message = _wire_safe_message self.error_code = error_code if error_code is not None else type(self)._default_error_code self.status_code = status_code if status_code is not None else type(self)._default_status_code self.recovery = recovery if recovery is not None else type(self)._default_recovery @@ -418,12 +420,28 @@ def to_adcp_error(self) -> dict[str, Any]: ) +# Canonical buyer-facing suggestions from error-code.json enumMetadata (AdCP 3.1.1): +# each code carries its own default hint, so a VALIDATION_ERROR must not borrow +# INVALID_REQUEST's text. Text is byte-identical to the pinned enum and graded +# by the pinned-fixture oracle in test_error_boundary_translation. +INVALID_REQUEST_SUGGESTION = "check request parameters and fix" +VALIDATION_ERROR_SUGGESTION = "review error details and fix field values" + + class AdCPValidationError(AdCPError): """Invalid parameters or request data (400).""" _default_status_code: ClassVar[int] = 400 _default_error_code: ClassVar[str] = "VALIDATION_ERROR" _default_recovery: ClassVar[RecoveryHint] = "correctable" + # The buyer gets a hint even when a raise site supplies none — same reason + # AdCPAuthenticationError carries one below. The scrub used to synthesize this + # text on the way out, so a site that opts its message in via + # ``_wire_safe_message`` (and thereby skips the scrub) would otherwise trade a + # restored ``message`` for a lost ``suggestion``; 23 of the opted-in sites pass + # no explicit suggestion. Byte-identical to what the scrub emitted, so the + # opt-in is a pure gain rather than a swap. Per-raise ``suggestion=`` overrides. + _default_suggestion: ClassVar[str | None] = VALIDATION_ERROR_SUGGESTION class AdCPInvalidRequestError(AdCPValidationError): @@ -436,27 +454,27 @@ class AdCPInvalidRequestError(AdCPValidationError): """ _default_error_code: ClassVar[str] = "INVALID_REQUEST" + # Its own code's enum text, NOT the inherited VALIDATION_ERROR one — the two + # codes carry different canonical hints and a subclass must not borrow. + _default_suggestion: ClassVar[str | None] = INVALID_REQUEST_SUGGESTION -AUTH_REQUIRED_SUGGESTION = "Provide valid credentials (x-adcp-auth token)." +AUTH_REQUIRED_CANONICAL_SUGGESTION = ( + "provide credentials when missing; do NOT auto-retry rejected credentials — escalate for rotation" +) +# Backward-compatible name used by legacy AUTH_REQUIRED raise sites. The value +# remains the pinned enumMetadata suggestion so those paths cannot advise an +# unsafe retry for rejected credentials. +AUTH_REQUIRED_SUGGESTION = AUTH_REQUIRED_CANONICAL_SUGGESTION class AdCPAuthenticationError(AdCPError): - """Missing or invalid authentication credentials (401). - - Emits the standard ``AUTH_REQUIRED`` wire code — the sole authentication - error code in the AdCP 3.1 error-code enum and adcp 5.7 - ``STANDARD_ERROR_CODES``. Its enum description explicitly covers both - "credentials missing" and "credentials presented but rejected", so it is - the canonical code for every authentication failure. - - Recovery is ``correctable`` per the pinned AdCP error-code enum - (``AUTH_REQUIRED.recovery == "correctable"``; released 3.1.0 agrees) — - not the ``terminal`` base default. The enum carries operationally distinct - sub-cases (missing credentials → retry; presented-but-rejected → escalate), - but its single canonical ``recovery`` classification is ``correctable``, - and the wire contract is graded against that enum (#1417, - superseding the earlier "storyboards grade only the code" judgment). + """Legacy missing-or-invalid authentication failure (401). + + Emits deprecated ``AUTH_REQUIRED`` only where lower-layer business helpers + lack the wire credential state needed to select AdCP 3.1.1 + ``AUTH_MISSING`` or ``AUTH_INVALID``. Its pinned recovery remains + ``correctable`` and its suggestion preserves both sub-cases. """ _default_status_code: ClassVar[int] = 401 @@ -476,6 +494,42 @@ class AdCPAuthRequiredError(AdCPAuthenticationError): """ +class AdCPAuthMissingError(AdCPAuthenticationError): + """No Authorization credentials were presented (401, ``AUTH_MISSING``).""" + + _default_error_code: ClassVar[str] = "AUTH_MISSING" + _default_recovery: ClassVar[RecoveryHint] = "correctable" + _default_suggestion: ClassVar[str | None] = "provide credentials via the auth header and retry" + + +class AdCPAuthInvalidError(AdCPAuthenticationError): + """Presented Authorization credentials were rejected (401, ``AUTH_INVALID``).""" + + _default_error_code: ClassVar[str] = "AUTH_INVALID" + _default_recovery: ClassVar[RecoveryHint] = "terminal" + _default_suggestion: ClassVar[str | None] = ( + "do NOT auto-retry — credentials were rejected; rotate keys, refresh OAuth tokens once if applicable, " + "otherwise escalate to a human" + ) + + +def classify_auth_credentials_error( + headers: Mapping[str, str], + *, + missing_message: str = "Authentication credentials are required.", + invalid_message: str = "Authentication credentials were rejected.", +) -> AdCPAuthMissingError | AdCPAuthInvalidError: + """Classify absent credentials separately from presented unusable credentials. + + AdCP 3.1.1 defines the split by presence of the standard Authorization + header. The seller may still accept legacy ``x-adcp-auth`` credentials, + but that extension does not change the standard wire classifier. + """ + if any(name.lower() == "authorization" for name in headers): + return AdCPAuthInvalidError(invalid_message) + return AdCPAuthMissingError(missing_message) + + class AdCPAuthorizationError(AdCPError): """Authenticated but not authorized for this resource (403). @@ -1026,13 +1080,6 @@ def build_two_layer_error_envelope(exc: AdCPError) -> dict[str, Any]: return envelope -# Canonical buyer-facing suggestions from error-code.json enumMetadata (AdCP 3.1.1): -# each code carries its own default hint, so a VALIDATION_ERROR must not borrow -# INVALID_REQUEST's text. -INVALID_REQUEST_SUGGESTION = "check request parameters and fix" -VALIDATION_ERROR_SUGGESTION = "review error details and fix field values" - - def first_validation_error_field(validation_error: ValidationError) -> str | None: """Return the bracket-notation path of the first Pydantic error, or ``None``. @@ -1045,24 +1092,216 @@ def first_validation_error_field(validation_error: ValidationError) -> str | Non errors = validation_error.errors() if not errors: return None + return validation_error_field(errors[0]) + + +def _loaded_pydantic_models() -> list[type[BaseModel]]: + """Return every currently loaded Pydantic model exactly once.""" + models: list[type[BaseModel]] = [] + pending = list(BaseModel.__subclasses__()) + seen: set[type[BaseModel]] = set() + while pending: + model = pending.pop() + if model in seen: + continue + seen.add(model) + models.append(model) + pending.extend(model.__subclasses__()) + return models + + +def _expanded_validation_annotations(annotations: Sequence[Any]) -> list[Any]: + """Flatten Annotated and union wrappers used while walking a schema path.""" + result: list[Any] = [] + pending = list(annotations) + while pending: + annotation = pending.pop() + origin = get_origin(annotation) + if origin is Annotated: + args = get_args(annotation) + if args: + pending.append(args[0]) + elif origin in (Union, UnionType): + pending.extend(get_args(annotation)) + else: + result.append(annotation) + return result + + +def _sequence_item_annotations(annotations: Sequence[Any]) -> list[Any]: + """Return element annotations for sequence branches at an integer path segment.""" + items: list[Any] = [] + for annotation in _expanded_validation_annotations(annotations): + if get_origin(annotation) in (list, tuple, set, frozenset): + args = get_args(annotation) + if args: + items.append(args[0]) + return items + + +def _validation_annotation_branches( + annotations: Sequence[Any], +) -> tuple[list[Any], list[type[BaseModel]]]: + """Split schema candidates into mapping-value and model branches.""" + mapping_values: list[Any] = [] + models: list[type[BaseModel]] = [] + for annotation in _expanded_validation_annotations(annotations): + origin = get_origin(annotation) + if origin in (dict, Mapping): + args = get_args(annotation) + mapping_values.append(args[1] if len(args) > 1 else Any) + elif isinstance(annotation, type) and issubclass(annotation, BaseModel): + models.append(annotation) + return mapping_values, models + + +def _safe_validation_location_segment( + segment: object, + annotations: Sequence[Any], +) -> tuple[str, list[Any]]: + """Project one string path segment and advance its trusted schema branches.""" + mapping_values, models = _validation_annotation_branches(annotations) + if not isinstance(segment, str) or not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", segment): + return ("nested_field" if mapping_values else "unrecognized_key"), mapping_values + + declared = [model.model_fields[segment].annotation for model in models if segment in model.model_fields] + if declared: + return segment, declared + + matching_models = [model for model in models if model.__name__ == segment] + if matching_models: + return segment, matching_models + if mapping_values: + return "nested_field", mapping_values + return "nested_field", [] + + +def safe_validation_error_location(error: Mapping[str, Any]) -> list[str | int]: + """Return a field location with client-controlled unknown keys redacted. + + Walk the loaded Pydantic schema graph so declared nested fields remain + actionable while arbitrary mapping keys are replaced. Pydantic represents + both with plain strings in ``loc``; character-shape checks alone are not a + trust boundary because credentials are often identifier-shaped. + """ + + annotations: list[Any] = _loaded_pydantic_models() + location: list[str | int] = [] + for segment in error.get("loc", ()): + if isinstance(segment, int): + location.append(segment) + item_annotations = _sequence_item_annotations(annotations) + if item_annotations: + annotations = item_annotations + continue + + safe_segment, annotations = _safe_validation_location_segment(segment, annotations) + location.append(safe_segment) + + if str(error.get("type", "")) in {"extra_forbidden", "unexpected_keyword_argument"} and location: + location[-1] = "unrecognized_field" + return location + + +def validation_error_field(error: Mapping[str, Any]) -> str | None: + """Render one buyer-safe validation location in bracket notation.""" parts: list[str] = [] - for loc in errors[0]["loc"]: + for loc in safe_validation_error_location(error): if isinstance(loc, int): parts.append(f"[{loc}]") elif parts: parts.append(f".{loc}") else: parts.append(str(loc)) - return "".join(parts) + return "".join(parts) or None + + +_STATIC_VALIDATION_MESSAGES = { + "enum": "Value is not one of the permitted options.", + "finite_number": "Expected a numeric value.", + "float_type": "Expected a numeric value.", + "integer_type": "Expected an integer value.", + "literal_error": "Value is not one of the permitted options.", +} + + +def _safe_length_error_message(error_type: str, context: object) -> str: + """Render trusted numeric length constraints without copying rejected input.""" + if not isinstance(context, Mapping): + return "Value does not satisfy the permitted length constraints." + + is_string = error_type.startswith("string_") + if "too_short" in error_type: + minimum = context.get("min_length") + if isinstance(minimum, int): + unit = "characters" if is_string else "items" + return f"Value must contain at least {minimum} {unit}." + + maximum = context.get("max_length") + if isinstance(maximum, int): + unit = "characters" if is_string else "items" + return f"Value must contain at most {maximum} {unit}." + return "Value does not satisfy the permitted length constraints." + + +def _safe_numeric_range_error_message(context: object) -> str: + """Retain only trusted numeric bounds from a Pydantic range error.""" + if isinstance(context, Mapping): + relation_by_key = { + "ge": "greater than or equal to", + "gt": "greater than", + "le": "less than or equal to", + "lt": "less than", + } + for key, relation in relation_by_key.items(): + bound = context.get(key) + if isinstance(bound, int | float) and not isinstance(bound, bool): + return f"Value must be {relation} {bound}." + return "Value is outside the permitted range." + + +def safe_validation_error_message(error: Mapping[str, Any]) -> str: + """Return static, actionable text for one Pydantic error. + + Pydantic's raw ``msg`` and ``input`` fields are untrusted request-derived + data: custom validators and extra-field failures can embed credentials, + connection strings, or other buyer secrets. Wire diagnostics retain the + structured field path and error category while using only static text. + """ + error_type = str(error.get("type", "")) + if "missing" in error_type: + return "Required field is missing." + if error_type in {"extra_forbidden", "unexpected_keyword_argument"}: + return "Extra field is not allowed by the AdCP request schema." + if "string_type" in error_type: + return "Expected a string value." + if "int_" in error_type or error_type == "integer_type": + return "Expected an integer value." + if "float_" in error_type: + return "Expected a numeric value." + if "bool_" in error_type: + return "Expected a boolean value." + if "pattern" in error_type: + return "Value does not match the required format." + if "too_short" in error_type or "too_long" in error_type: + return _safe_length_error_message(error_type, error.get("ctx")) + if error_type.startswith(("greater_than", "less_than")): + return _safe_numeric_range_error_message(error.get("ctx")) + return _STATIC_VALIDATION_MESSAGES.get(error_type, "Value does not satisfy the field constraints.") def build_validation_error_details(errors: Sequence[Mapping[str, Any]]) -> dict[str, Any]: - """Project Pydantic errors into the buyer-safe structured detail shape.""" + """Project Pydantic errors into the buyer-safe structured detail shape. + + Never copy Pydantic's raw ``msg``, ``input``, or ``ctx`` values. They can + contain rejected request data even when the exception has already been + translated to a typed ``AdCPValidationError``. + """ return { "validation_errors": [ { - "loc": list(error.get("loc", ())), - "msg": error.get("msg"), + "loc": safe_validation_error_location(error), + "msg": safe_validation_error_message(error), "type": error.get("type"), } for error in errors @@ -1070,28 +1309,408 @@ def build_validation_error_details(errors: Sequence[Mapping[str, Any]]) -> dict[ } +def _pydantic_validation_error_kwargs(exc: ValidationError) -> dict[str, Any]: + """Constructor kwargs for a structured ``AdCPValidationError``. + + The projector can populate message/field/details, but it cannot choose the semantic error + class or wire code; the registry's single ``adcp_class`` authority does that at runtime. + """ + errors = exc.errors() + return { + "message": safe_validation_error_message(errors[0]) if errors else "Request failed schema validation", + "field": first_validation_error_field(exc), + "suggestion": VALIDATION_ERROR_SUGGESTION, + "details": build_validation_error_details(errors), + } + + +def _message_only_kwargs(exc: Exception) -> dict[str, Any]: + """Constructor kwargs for built-ins whose semantic mapping needs only their message.""" + return {"message": str(exc)} + + +# Ordered registry of EVERY raw-exception → typed-AdCPError normalizer. The SINGLE SOURCE OF TRUTH +# for ``normalize_to_adcp_error`` AND the completeness guard +# (``test_sanitized_category_registry_covers_all_correctable_builtin_targets``), which pins every +# CLIENT-CORRECTABLE target code into ``_SANITIZED_BY_WIRE_CODE`` — so a future mapping (e.g. +# ``KeyError → AdCPNotFoundError``, or another SPECIAL normalizer) can't silently fall through to +# the misleading generic internal message when scrubbed. Each entry is +# ``(builtin_type, adcp_class, kwargs_projector)``. ``adcp_class`` is the ONE semantic authority: +# runtime always instantiates it, and the completeness guard reads it. The projector can only return +# constructor kwargs (message/field/details/etc.), so input-dependent projection cannot change the +# error class or wire code. ``ValidationError`` is listed FIRST because it IS-A ``ValueError`` and +# needs structured kwargs; first ``isinstance`` match wins. +_BuiltinNormalizer = tuple[type[Exception], type["AdCPError"], "Callable[[Any], dict[str, Any]]"] +_BUILTIN_NORMALIZATION: tuple[_BuiltinNormalizer, ...] = ( + (ValidationError, AdCPValidationError, _pydantic_validation_error_kwargs), + (ValueError, AdCPValidationError, _message_only_kwargs), + (PermissionError, AdCPAuthorizationError, _message_only_kwargs), +) + +# Projectors may populate buyer-facing presentation fields only. Semantic identity belongs solely +# to the registry's ``adcp_class``; allowing constructor overrides such as ``error_code`` or +# ``recovery`` would recreate a second authority behind the completeness guard's back. ``context`` +# is also forbidden because it bypasses the raw-exception scrubbing applied to messages/details. +_NORMALIZATION_PROJECTOR_KEYS = frozenset({"message", "field", "suggestion", "retry_after", "details"}) + + +def _representative_builtin(exc_type: type[Exception]) -> Exception: + """A minimal live instance of a registry builtin, for probing its projector.""" + if exc_type is ValidationError: + + class _Probe(BaseModel): + x: int + + probe_error: ValidationError | None = None + try: + _Probe(x="not-an-int") + except ValidationError as ve: + probe_error = ve + assert probe_error is not None, "probe model failed to raise ValidationError" + return probe_error + return exc_type("probe") + + +def _projector_key_violations(registry: tuple[_BuiltinNormalizer, ...]) -> list[str]: + """Forbidden-key findings per registry projector, probed with a representative builtin. + + Pure detector shared by the import-time gate below and its known-bad self-test, so the + gate's matcher cannot silently degrade. + """ + findings: list[str] = [] + for exc_type, _adcp_class, projector in registry: + forbidden = set(projector(_representative_builtin(exc_type))) - _NORMALIZATION_PROJECTOR_KEYS + if forbidden: + findings.append(f"{exc_type.__name__}: forbidden projector keys {sorted(forbidden)}") + return findings + + +def _build_normalized_error(adcp_class: type[AdCPError], projected_kwargs: dict[str, Any]) -> AdCPError: + """Instantiate the registry's fixed semantic class from presentation-only kwargs. + + The presentation-only contract is enforced at MODULE IMPORT (the gate below probes every + registry projector), never mid-request: this function runs inside boundary exception + handlers at all three transports, where a raise would shadow the original exception and + fail open with no envelope. + """ + return adcp_class(**projected_kwargs) + + +# Import-time gate: a projector emitting a semantic override (``error_code``/``recovery``/ +# ``context``/anything off the allowlist) fails the BUILD here, mirroring the +# ``_NON_STANDARD_TARGETS`` precedent above — never a live request inside a boundary handler. +_PROJECTOR_KEY_VIOLATIONS = _projector_key_violations(_BUILTIN_NORMALIZATION) +assert not _PROJECTOR_KEY_VIOLATIONS, f"normalization projector contract violated: {_PROJECTOR_KEY_VIOLATIONS}" + + def normalize_to_adcp_error(exc: Exception) -> AdCPError: """Normalize untyped exceptions to typed AdCPError subclasses. Single source of truth for the wrapping applied at all three transport boundaries (MCP, A2A, REST). Already-typed ``AdCPError`` passes through - unchanged. Pydantic ``ValidationError`` maps to a structured, sanitized - ``AdCPValidationError``; other ``ValueError`` instances map to the plain - validation error, ``PermissionError`` to ``AdCPAuthorizationError``, and - anything else wraps in base ``AdCPError`` (INTERNAL_ERROR). + unchanged. Every raw-exception normalizer lives in ``_BUILTIN_NORMALIZATION``: + Pydantic ``ValidationError`` → a structured, sanitized ``AdCPValidationError`` (field + + details); other ``ValueError`` → the plain validation error; ``PermissionError`` → + ``AdCPAuthorizationError``. Anything else wraps in base ``AdCPError`` (INTERNAL_ERROR). """ if isinstance(exc, AdCPError): return exc + for exc_type, adcp_class, kwargs_projector in _BUILTIN_NORMALIZATION: + if isinstance(exc, exc_type): + return _build_normalized_error(adcp_class, kwargs_projector(exc)) + return AdCPError(str(exc) or type(exc).__name__) + + +# Internal/infra wire codes whose raise-site message may embed adapter/DB internals (a +# connection string, a stack detail) and MUST be scrubbed before reaching ANY buyer-facing +# wire. The SERVICE_UNAVAILABLE family (base ``AdCPError``, ``AdCPAdapterError`` + subclasses, +# ``AdCPServiceUnavailableError``) plus terminal ``CONFIGURATION_ERROR`` (seller-side +# misconfiguration the buyer can't resolve — its decryption-failure raise sites can interpolate +# a secret) both belong here. Keyed on the wire code, NOT on ``recovery == "transient"`` (which +# would miss the terminal Config/base errors and false-positive on the safe-message RateLimit). +INTERNAL_WIRE_CODES: frozenset[str] = frozenset({"SERVICE_UNAVAILABLE", "CONFIGURATION_ERROR"}) + +_SANITIZED_INTERNAL_MESSAGE = "An internal error occurred while processing the request." +# We always emit a suggestion, though the spec does not require one (error.json requires only +# ``code`` + ``message``); transport-errors.mdx §Security Considerations constrains its CONTENT +# to generic correction guidance, which is what the scrub provides. The raise site's suggestion +# (which, like the message, may interpolate internals) is replaced with static guidance whose +# retry semantics MATCH the emitted ``recovery``. A single retry-later string is WRONG for a +# ``terminal`` internal error (CONFIGURATION_ERROR — per 3.1.1 the buyer MUST NOT auto-retry; +# the seller operator must resolve it): pairing ``recovery: terminal`` with "retry later" emits +# a self-contradictory envelope. Pick the suggestion by recovery so the two never disagree. +# +# Deliberate deviation from the pinned enum: unlike ``_SANITIZED_BY_WIRE_CODE`` below — +# whose suggestions ARE the codes' canonical ``enumMetadata`` text — this bucket is keyed on +# ``recovery``, not on the wire code, so its strings are ours rather than the spec's (the enum +# says "retry with exponential backoff" for SERVICE_UNAVAILABLE; we say "retry later ... contact +# the seller"). The semantics agree; the wording does not. That is the point: this bucket must +# also serve codes with no enum entry at all — an unmapped internal code is coerced to +# SERVICE_UNAVAILABLE by the caller — and keying on the emitted ``recovery`` is what guarantees +# the guidance can never contradict it. Per-code spec text would reintroduce exactly the +# contradiction this table was written to remove. Kept auditable by +# ``test_sanitized_suggestions_match_pinned_spec_enum``, which holds the client-correctable +# codes to the enum and so makes the two-axis split visible rather than accidental. +_SANITIZED_TRANSIENT_SUGGESTION = "Retry the request later; if the problem persists, contact the seller." +_SANITIZED_TERMINAL_SUGGESTION = ( + "This request cannot be retried; the seller must resolve the issue before it can succeed. Contact the seller." +) +_SANITIZED_CORRECTABLE_SUGGESTION = ( + "The request could not be completed as submitted; review and adjust the request before resubmitting." +) + +# Exhaustive over ``RecoveryHint`` — one static, internals-free suggestion per recovery class so +# the guidance can never contradict the emitted ``recovery``. A missing/extra key is caught by +# ``test_..._suggestion_table_covers_all_recovery_hints``. +_SUGGESTION_BY_RECOVERY: dict[RecoveryHint, str] = { + "transient": _SANITIZED_TRANSIENT_SUGGESTION, + "correctable": _SANITIZED_CORRECTABLE_SUGGESTION, + "terminal": _SANITIZED_TERMINAL_SUGGESTION, +} + + +def _sanitized_suggestion_for(recovery: RecoveryHint) -> str: + """Static, internals-free guidance matching ``recovery``: retry for ``transient``, adjust-and- + resubmit for ``correctable``, no-retry/escalation for ``terminal``. Keyed on the SAME + ``recovery`` the sanitized error carries so the suggestion can't contradict it.""" + return _SUGGESTION_BY_RECOVERY[recovery] + + +def _canonical_recovery_for(wire_code: str) -> RecoveryHint: + """The recovery the SDK table (plus the pinned-spec supplement) assigns a wire code + (SERVICE_UNAVAILABLE→transient, CONFIGURATION_ERROR→terminal, …). NOT the pinned spec enum + for every code — the SDK table diverges from it for several codes; both codes this scrub can + emit agree across the two sources, which is what the callers rely on. Internal/infra errors + emit the recovery their CODE mandates, NOT a possibly-inconsistent instance override — a + sanitized SERVICE_UNAVAILABLE tagged ``terminal``/``correctable`` (or a CONFIGURATION_ERROR + tagged ``transient``) is self-contradictory on the wire. Unknown codes are coerced to + SERVICE_UNAVAILABLE by the caller, so they resolve to ``transient`` here too.""" + meta = WIRE_STANDARD_CODES.get(wire_code) + return cast(RecoveryHint, meta["recovery"]) if meta else "transient" + + +# Category-specific sanitized (message, suggestion) for the client-correctable codes a scrub can +# emit, so the human text MATCHES the machine code — a VALIDATION_ERROR must not read "an internal +# error occurred", and an AUTH_REQUIRED must say "authenticate", not "adjust the request". The +# SUGGESTION leg is the code's canonical spec text (error-code.json enumMetadata, via the shared +# spec-derived constants above) — hand-written retry advice here once contradicted the enum, which +# says AUTH_REQUIRED must NOT advise auto-retry. Messages stay ours (the spec defines no canonical +# message); all strings are static and secret-free — ``str(exc)`` is never restored. Codes absent +# here (SERVICE_UNAVAILABLE, CONFIGURATION_ERROR) fall back to the generic internal message + a +# recovery-matched suggestion, which is correct for the internal/infra bucket. +_SANITIZED_BY_WIRE_CODE: dict[str, tuple[str, str]] = { + "INVALID_REQUEST": ( + "The request is malformed or contains unsupported fields; review it and resubmit.", + INVALID_REQUEST_SUGGESTION, + ), + # DELIBERATE mismatch, do not "fix" by rewording: VALIDATION_ERROR_SUGGESTION reads + # "review error details and fix field values", yet the scrub branch in + # ``safe_adcp_error`` withholds ``details`` — so the buyer is pointed at a payload + # this path does not send. The suggestion is nevertheless kept verbatim because it is + # the code's canonical ``enumMetadata`` text from the pinned error-code enum, and + # ``test_sanitized_suggestions_match_pinned_spec_enum`` holds it there. Diverging from + # the enum to describe OUR scrub would trade a spec violation for a wording nicety; + # the ``field`` that IS forwarded remains the actionable half. + "VALIDATION_ERROR": ( + "The request could not be validated; review the submitted fields and resubmit.", + VALIDATION_ERROR_SUGGESTION, + ), + "AUTH_REQUIRED": ( + "Authentication or authorization failed; provide valid credentials or the required permissions.", + AUTH_REQUIRED_CANONICAL_SUGGESTION, + ), +} + + +def _sanitized_text_for(wire_code: str, recovery: RecoveryHint) -> tuple[str, str]: + """The sanitized ``(message, suggestion)`` a scrub emits for ``wire_code``. + + ONE selection rule for BOTH scrub paths: the ``synthesize``-based ``_scrubbed_error`` + below, and the ``AdCPValidationError`` branch of ``safe_adcp_error``, which must return + ``type(exc)(...)`` to preserve the subclass and therefore cannot delegate to + ``_scrubbed_error``. Inlining the lookup at both sites let the same wire code yield + different buyer text depending on whether the exception was a raw built-in or a typed + validation error. Codes without a category entry fall back to the generic internal + message plus a ``recovery``-matched suggestion. + """ + return _SANITIZED_BY_WIRE_CODE.get(wire_code, (_SANITIZED_INTERNAL_MESSAGE, _sanitized_suggestion_for(recovery))) + + +def _scrubbed_error( + *, + error_code: str, + wire_code: str, + recovery: RecoveryHint, + status_code: int, + context: ContextObject | dict[str, Any] | None, + field: str | None = None, +) -> AdCPError: + """A wire-safe ``AdCPError`` carrying the given code/recovery but a SANITIZED, secret-free + message + suggestion selected by the BUYER-FACING ``wire_code`` (so the human text matches the + machine code), with ``details`` dropped. A caller may preserve a field path derived from a + structured validator; raw messages and input values are never retained. The single scrub + constructor for ``safe_adcp_error`` so message replacement can't drift between call sites. + Codes without a category entry fall back to the generic internal message + a + recovery-matched suggestion.""" + message, suggestion = _sanitized_text_for(wire_code, recovery) + return AdCPError.synthesize( + message, + error_code=error_code, + status_code=status_code, + recovery=recovery, + field=field, + suggestion=suggestion, + context=context, + ) + + +def synthesize_safe_adcp_error( + *, + error_code: str, + status_code: int, + context: ContextObject | dict[str, Any] | None = None, +) -> AdCPError: + """Build a synthetic wire error without trusting an untyped source message. + + Boundary fallbacks sometimes have a legacy machine code and HTTP status but + no typed ``AdCPError`` instance. They must preserve those semantics without + copying ``str(exc)`` into a buyer-visible envelope. The machine code is + authoritative for recovery; an untyped source's positional recovery hint + is never trusted because it can contradict the standardized code metadata. + """ + wire_code = to_wire_error_code(error_code) + resolved_recovery = _canonical_recovery_for(wire_code) + return _scrubbed_error( + error_code=wire_code, + wire_code=wire_code, + recovery=resolved_recovery, + status_code=status_code, + context=context, + ) + + +def safe_adcp_error(exc: Exception) -> AdCPError: + """Return a wire-safe ``AdCPError`` — THE sanitization policy for MCP, A2A, REST, and the + webhook push path via ``ContextManager.audit_workflow_step_failure``. + + Buyer-facing boundaries route exceptions through this helper so raw built-in messages cannot + expose secrets. The approval-service webhook still builds its own message and depends on + source-site scrubbing; route any NEW buyer-facing error surface through this policy instead of + adding a second one. + + Two ORTHOGONAL decisions, deliberately kept separate — conflating them is what leaked secrets + (a raw ``ValueError`` pre-normalized to a *trusted* ``AdCPValidationError`` whose raw message + then survived): + + 1. SEMANTIC code/recovery — from ``normalize_to_adcp_error`` (``ValueError`` → + VALIDATION_ERROR, ``PermissionError`` → AUTH_REQUIRED, native ``AdCPError`` unchanged). This + is the SAME mapping the synchronous MCP/REST/A2A boundaries apply, so a webhook audit and + the synchronous response for the SAME exception emit the SAME code — no divergence. + 2. MESSAGE trust — from the ORIGINAL exception's PROVENANCE. A raw built-in's ``str(exc)`` is + UNTRUSTED (it can embed a connection string / token / SQL) and is SCRUBBED even when its + semantic code is client-correctable; only an explicitly-raised typed ``AdCPError`` carries a + controlled buyer-facing message worth preserving. + + Concretely: + - INTERNAL/INFRA codes (``wire_error_code in INTERNAL_WIRE_CODES`` — SERVICE_UNAVAILABLE family + + terminal CONFIGURATION_ERROR): message scrubbed regardless of provenance (a typed + ``AdCPAdapterError(f"...: {e}")`` can interpolate a secret too); recovery normalized to the + wire code's canonical value; suggestion derived from that recovery. + - A wire code not in ``WIRE_STANDARD_CODES`` (internal-only): coerced to SERVICE_UNAVAILABLE and + scrubbed. + - A raw Pydantic ``ValidationError`` uses the dedicated safe projector: + static messages plus structured field paths/error types, never raw + ``msg``/``input``/``ctx`` values. + - Any other CLIENT-CORRECTABLE standard code (VALIDATION_ERROR, + ``*_NOT_FOUND``, AUTH_*, …): the message is kept ONLY when the ORIGINAL + exception is a typed ``AdCPError`` (trusted provenance); a RAW built-in + that merely *normalized* to such a code keeps the semantic code/recovery + but has its untrusted message scrubbed. + """ + # (1) SEMANTIC code/recovery — the mapping the synchronous boundaries also apply. + normalized = normalize_to_adcp_error(exc) + wire_code = normalized.wire_error_code + + if wire_code in INTERNAL_WIRE_CODES: + return _scrubbed_error( + error_code=normalized.error_code, + wire_code=wire_code, + recovery=_canonical_recovery_for(wire_code), + status_code=normalized.status_code, + context=normalized.context, + ) + if wire_code not in WIRE_STANDARD_CODES: + # An internal-only code the wire doesn't model: coerce to SERVICE_UNAVAILABLE + scrub. + return _scrubbed_error( + error_code="SERVICE_UNAVAILABLE", + wire_code="SERVICE_UNAVAILABLE", + recovery=_canonical_recovery_for("SERVICE_UNAVAILABLE"), + status_code=normalized.status_code, + context=normalized.context, + ) + # (2) MESSAGE trust — client-correctable standard code. if isinstance(exc, ValidationError): - errors = exc.errors() - return AdCPValidationError( - errors[0].get("msg") if errors else "Request failed schema validation", - field=first_validation_error_field(exc), - suggestion=VALIDATION_ERROR_SUGGESTION, - details=build_validation_error_details(errors), + # The normalization registry's Pydantic projector is intentionally + # buyer-safe and preserves all invalid field paths. Returning that + # projection retains actionable multi-field diagnostics without + # allowing raw validator messages or rejected values onto the wire. + return normalized + if isinstance(exc, AdCPValidationError) and not exc._wire_safe_message: + # Typed validation text is untrusted by default because business + # validators frequently interpolate exception/adapter internals a raise + # site never audited. ``_wire_safe_message=True`` opts in a message that + # is either static, OR built entirely from values traceable to THIS + # buyer's current request (never adapter/system internals or another + # principal's data). + # + # Grounding — this applies to ``message`` itself, not only ``details``: + # adcp/dist/schemas/3.1.1/core/error.json's own canonical worked example + # echoes the rejected value in BOTH places at once: + # {"code": "INVALID_PRICING_MODEL", + # "message": "Pricing option not found: po_prism_abandoner_cpm", + # "field": "pricing_option_id", + # "details": {"rejected_value": "po_prism_abandoner_cpm", ...}} + # ``details.rejected_value`` is documented there as "the offending value + # the buyer supplied, echoed for buyer-side diagnostic clarity" — the + # same value the example ALSO puts in ``message``. ``message`` itself has + # no documented wording constraint ("Human-readable error message" is the + # entire schema description); error-handling.mdx's only MUST-be-generic + # cases are cross-tenant resource enumeration and seller-internal + # secrets, neither of which this covers. + # + # ``details`` is withheld here DELIBERATELY, not by omission: it has the same + # provenance as ``message`` (the raise site builds both, from the same values, + # with the same absent audit), so one flag governs both channels — trusted + # together or scrubbed together. Do NOT "restore" it by forwarding + # ``normalized.details``: on this branch ``normalize_to_adcp_error`` returned + # ``exc`` itself, so that forwards the very raise-site payload this branch + # exists to withhold. ``field`` IS forwarded because it is a schema path, not + # raise-site prose. The sanctioned way to put ``details`` on the wire is to + # audit the site and opt in — then the untouched ``exc`` passes through below. + # Both halves pinned by test_wire_safe_opt_in_governs_details_and_message_together. + safe_message, safe_suggestion = _sanitized_text_for(wire_code, normalized.recovery) + return type(exc)( + safe_message, + suggestion=safe_suggestion, + retry_after=normalized.retry_after, + recovery=normalized.recovery, + context=normalized.context, + field=normalized.field, + _wire_safe_message=True, ) - if isinstance(exc, ValueError): - return AdCPValidationError(str(exc)) - if isinstance(exc, PermissionError): - return AdCPAuthorizationError(str(exc)) - return AdCPError(str(exc) or type(exc).__name__) + if isinstance(exc, AdCPError): + # Explicitly-raised typed error → controlled buyer-facing message, pass through unchanged. + return exc + # A RAW built-in that normalized to a client-correctable code: keep that SEMANTIC code/recovery + # (so webhook and synchronous responses agree) but SCRUB the untrusted ``str(exc)`` message — + # replaced with a static, category-appropriate message + suggestion (a VALIDATION_ERROR reads + # "review the submitted fields", an AUTH_REQUIRED reads "provide valid credentials"), never the + # generic "internal error" that contradicts the code. + return _scrubbed_error( + error_code=normalized.error_code, + wire_code=wire_code, + recovery=normalized.recovery, + status_code=normalized.status_code, + context=normalized.context, + field=normalized.field, + ) diff --git a/src/core/idempotency_canonical.py b/src/core/idempotency_canonical.py index 8a62569899..57aaa7a4c0 100644 --- a/src/core/idempotency_canonical.py +++ b/src/core/idempotency_canonical.py @@ -61,7 +61,9 @@ def canonical_payload_hash(payload: dict[str, Any]) -> str: except RecursionError as exc: # A pathologically nested payload must reject as a buyer error, not # crash the boundary with an unhandled RecursionError. - raise AdCPValidationError("request payload too deeply nested to canonicalize for idempotency") from exc + raise AdCPValidationError( + "request payload too deeply nested to canonicalize for idempotency", _wire_safe_message=True + ) from exc def canonical_request_hash(request: BaseModel) -> str: diff --git a/src/core/logging_config.py b/src/core/logging_config.py index d5e4eab892..8dcb8f56cf 100644 --- a/src/core/logging_config.py +++ b/src/core/logging_config.py @@ -229,11 +229,10 @@ def setup_oauth_logging() -> None: def log_safe(value: object) -> str: - """Neutralize CR/LF in request-provided values before logging. + """Neutralize CR/LF in untrusted values before logging. - Buyer-supplied ids (creative_id, package_id) flow into log lines; a - newline embedded in one would forge log entries (CodeQL py/log-injection). - Response payloads and exception messages are NOT sanitized — buyers - correlate on exact ids. + Buyer-supplied ids and downstream exception messages can flow into log + lines; a newline embedded in either would forge entries (CodeQL + py/log-injection). Structured-log redaction remains a separate concern. """ return str(value).replace("\r", "").replace("\n", "") diff --git a/src/core/mcp_auth_middleware.py b/src/core/mcp_auth_middleware.py index 070ecdaec7..7ea8e0cf17 100644 --- a/src/core/mcp_auth_middleware.py +++ b/src/core/mcp_auth_middleware.py @@ -1,6 +1,7 @@ """FastMCP middleware for centralized MCP identity resolution. -Resolves identity once per tool call and stores it on FastMCP context state. +Makes one authoritative authentication decision per tool call and stores the +resolved identity on FastMCP context state. Tool functions read the pre-resolved identity via ctx.get_state('identity') instead of calling resolve_identity_from_context() directly. """ @@ -11,21 +12,14 @@ from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.tools.tool import ToolResult -from src.core.transport_helpers import resolve_identity_from_context +from src.core.auth_policy import AUTH_OPTIONAL_SKILLS +from src.core.transport_helpers import extract_headers_from_context, resolve_identity_from_context logger = logging.getLogger(__name__) -# Discovery tools that work without authentication. -# All other tools require a valid auth token. -AUTH_OPTIONAL_TOOLS = frozenset( - { - "get_adcp_capabilities", - "get_products", - "list_accounts", - "list_creative_formats", - "list_authorized_properties", - } -) +# Compatibility alias for callers/tests that import the middleware policy. +# The transport-neutral object is the single source of truth. +AUTH_OPTIONAL_TOOLS = AUTH_OPTIONAL_SKILLS class MCPAuthMiddleware(Middleware): @@ -43,11 +37,58 @@ async def on_call_tool( ) -> ToolResult: tool_name = context.message.name require_auth = tool_name not in AUTH_OPTIONAL_TOOLS + headers = extract_headers_from_context(context.fastmcp_context) - identity = resolve_identity_from_context( - context.fastmcp_context, - require_valid_token=require_auth, - ) + try: + identity = resolve_identity_from_context( + context.fastmcp_context, + require_valid_token=require_auth, + ) + if require_auth and (identity is None or not identity.principal_id): + from src.core.exceptions import classify_auth_credentials_error + + raise classify_auth_credentials_error( + headers, + missing_message="Authentication required for tool invocation", + ) + except Exception as error: + # Identity resolution runs before the decorated tool body, so its + # failures cannot reach with_error_logging. Reuse both halves of the + # shared boundary path: record exactly once, then preserve the + # two-layer MCP wire contract. + from src.core.exceptions import ( + AdCPAuthenticationError, + AdCPAuthInvalidError, + AdCPAuthMissingError, + classify_auth_credentials_error, + ) + from src.core.tool_error_logging import ( + _translate_to_tool_error, + record_boundary_error, + ) + + wire_error = error + if ( + require_auth + and isinstance(error, AdCPAuthenticationError) + and not isinstance(error, (AdCPAuthMissingError, AdCPAuthInvalidError)) + ): + wire_error = classify_auth_credentials_error( + headers, + missing_message="Authentication required for tool invocation", + ) + + # A rejected request has no trusted principal. Client-controlled + # host/x-adcp-tenant headers are routing hints, not proof that the + # caller may write into that tenant's activity or audit sinks. + record_boundary_error( + "mcp", + tool_name, + error, + tenant_id=None, + principal_id=None, + ) + _translate_to_tool_error(wire_error) if context.fastmcp_context: await context.fastmcp_context.set_state("identity", identity, serializable=False) diff --git a/src/core/mcp_compat_middleware.py b/src/core/mcp_compat_middleware.py index d48285b020..27b89af3e1 100644 --- a/src/core/mcp_compat_middleware.py +++ b/src/core/mcp_compat_middleware.py @@ -16,9 +16,12 @@ from mcp.types import CallToolRequestParams from pydantic import ValidationError -from src.core.exceptions import normalize_to_adcp_error from src.core.request_compat import deep_strip_to_schema, normalize_request_params, strip_unknown_params -from src.core.tool_error_logging import _translate_to_tool_error, record_boundary_error +from src.core.tool_error_logging import ( + _translate_to_tool_error, + best_effort_boundary_identity, + record_boundary_error, +) logger = logging.getLogger(__name__) @@ -118,24 +121,21 @@ async def on_call_tool( raise exc = retry_exc - # Normalize once for the audit record, then pass the raw exception to - # _translate_to_tool_error so the emitted AdCPToolError keeps it as - # __cause__. The translator intentionally normalizes it a second time. - typed = normalize_to_adcp_error(exc) - tenant_id = None - principal_id = None + # Preserve raw exception provenance for both observability and wire + # translation. Each boundary derives the semantic VALIDATION_ERROR + # independently while scrubbing validator messages that may include + # rejected secret values. + identity = None if context.fastmcp_context is not None: try: identity = await context.fastmcp_context.get_state("identity") - if identity is not None: - tenant_id = identity.tenant_id - principal_id = identity.principal_id except Exception: logger.debug("Could not read MCP identity for validation error logging", exc_info=True) + tenant_id, principal_id = best_effort_boundary_identity(lambda: identity, transport="mcp") record_boundary_error( "mcp", tool_name, - typed, + exc, tenant_id=tenant_id, principal_id=principal_id, ) diff --git a/src/core/resolved_identity.py b/src/core/resolved_identity.py index bd04648b30..e1b9d4d54b 100644 --- a/src/core/resolved_identity.py +++ b/src/core/resolved_identity.py @@ -171,11 +171,15 @@ def resolve_identity( if principal_id is None: if require_valid_token: - from src.core.exceptions import AdCPAuthenticationError - - raise AdCPAuthenticationError( - f"Authentication token is invalid for tenant '{tenant_id or 'any'}'. " - f"The token may be expired, revoked, or associated with a different tenant.", + from src.core.exceptions import classify_auth_credentials_error + + raise classify_auth_credentials_error( + headers, + missing_message="Authentication credentials are required via the Authorization header.", + invalid_message=( + "Authentication credentials were rejected. " + "The token may be expired, revoked, or associated with a different tenant." + ), ) # For discovery endpoints, continue without auth elif not tenant_context and token_tenant: diff --git a/src/core/schema_helpers.py b/src/core/schema_helpers.py index bb96b6159e..830131f6b2 100644 --- a/src/core/schema_helpers.py +++ b/src/core/schema_helpers.py @@ -164,6 +164,7 @@ def to_brand_reference(brand: dict[str, Any] | BrandReference | str | None) -> B raise AdCPValidationError( "Invalid brand: domain is required", field="brand", + _wire_safe_message=True, ) allowed = BrandReference.model_fields.keys() ref_data = {key: value for key, value in brand.items() if key in allowed} diff --git a/src/core/schemas/_base.py b/src/core/schemas/_base.py index 88ec911157..1cc3e68225 100644 --- a/src/core/schemas/_base.py +++ b/src/core/schemas/_base.py @@ -1981,6 +1981,7 @@ def _validate_package_update_shape(cls, data: Any) -> Any: "package_id is required to identify the package being updated.", field=package_field_path("package_id"), suggestion="Include the package_id of the package you want to update.", + _wire_safe_message=True, ) present = sorted(f for f in cls._IMMUTABLE_PACKAGE_FIELDS if f in data) if present: @@ -2028,6 +2029,7 @@ def validate_idempotency_key_shape(key: str | None) -> None: "idempotency_key contains characters outside [A-Za-z0-9_.:-].", field="idempotency_key", suggestion="Use only letters, digits, and the characters _ . : -", + _wire_safe_message=True, ) diff --git a/src/core/security/url_validator.py b/src/core/security/url_validator.py index cf2f62e342..ec4a3ff731 100644 --- a/src/core/security/url_validator.py +++ b/src/core/security/url_validator.py @@ -118,6 +118,15 @@ def check_url_ssrf( hostname = parsed.hostname if not hostname: return False, "URL must have a valid hostname" + # The password operand is redundant TODAY, and is kept deliberately. Probed across + # every userinfo form urlparse accepts (`:pass@`, `user@`, `user:pass@`, bare, `@`, + # `:@`, `:p@host:port`), a non-None password always arrives with a non-None username — + # the EMPTY STRING, which is not None — so the first operand already covers it. + # Retained as belt-and-braces: this is a credential-leak boundary, the redundancy + # costs one comparison, and keeping it means the guard does not silently depend on + # that urlparse detail holding across versions. + if parsed.username is not None or parsed.password is not None: + return False, "URL must not contain embedded credentials" if hostname.lower() in BLOCKED_HOSTNAMES: return False, f"URL hostname '{hostname}' is blocked (internal/private)" diff --git a/src/core/tool_error_logging.py b/src/core/tool_error_logging.py index d125d1bf7b..354756a7a3 100644 --- a/src/core/tool_error_logging.py +++ b/src/core/tool_error_logging.py @@ -20,7 +20,8 @@ AdCPError, RecoveryHint, build_two_layer_error_envelope, - normalize_to_adcp_error, + safe_adcp_error, + synthesize_safe_adcp_error, ) from src.core.tool_context import ToolContext @@ -192,13 +193,16 @@ def record_boundary_error( ``"anonymous"`` for downstream sinks. Behavior: - 1. stdlib logger: WARNING for typed ``AdCPError`` (expected, - buyer-correctable error path), ERROR with ``exc_info=True`` for - untyped fallthrough so on-call sees the traceback. - 2. ``activity_feed.log_error`` (when ``tenant_id`` present) so the - operator UI surfaces the error in real time. + 1. privileged stdlib logger: the original message at WARNING for typed + ``AdCPError`` (expected, buyer-correctable error path), or ERROR with + ``exc_info=True`` for untyped fallthrough so on-call sees the + traceback. + 2. ``activity_feed.log_error`` (when ``tenant_id`` present) receives a + buyer-safe message so tenant dashboard/WebSocket consumers cannot + see adapter, credential, or connection details. 3. ``get_audit_logger(transport.upper(), tenant_id).log_operation`` - (when ``tenant_id`` present) for the persistent record. + receives the same safe message for the persistent record and any + downstream notification sink. All sinks are defensively wrapped — observability failures cannot replace the buyer's original error. Sink failures log at WARNING (not @@ -232,14 +236,23 @@ def record_boundary_error( # No tenant context — activity feed and audit log require tenant scoping. return + # Tenant-visible and persistent sinks are not privileged diagnostic + # channels. Typed internal errors can contain adapter responses, + # connection strings, or credentials even when their wire envelope is + # scrubbed later, so derive their fields from the same safe representation + # used at transport boundaries. The original remains in the server logger + # above for on-call diagnosis. + sink_error = safe_adcp_error(error) + sink_error_code, sink_error_message, _sink_recovery = extract_error_info(sink_error) + try: from src.services.activity_feed import activity_feed activity_feed.log_error( tenant_id=tenant_id, principal_name=principal_id or "anonymous", - error_message=f"{operation}: {error_message}", - error_code=error_code, + error_message=f"{operation}: {sink_error_message}", + error_code=sink_error_code, ) except Exception as e: logger.warning("Failed to log %s error to activity feed: %s", transport_upper, e) @@ -254,12 +267,40 @@ def record_boundary_error( principal_id=principal_id or "anonymous", adapter_id=f"{transport}_boundary", success=False, - error=error_message, + error=sink_error_message, ) except Exception as e: logger.warning("Failed to log %s error to audit log: %s", transport_upper, e) +def best_effort_boundary_identity( + resolver: Callable[[], Any], + *, + transport: str, +) -> tuple[str | None, str | None]: + """Resolve identity scope for observability without affecting the response. + + Non-authentication failures can reuse an already-permissive identity path to + enrich activity-feed and audit records. Authentication failures stay + unscoped because their client-controlled routing headers are not an + attestation of tenant ownership. Resolution failures degrade to an unscoped + server log and never replace the buyer's original error. + """ + try: + identity = resolver() + except Exception: + logger.debug("%s boundary: best-effort identity resolution failed", transport.upper(), exc_info=True) + return None, None + + principal_id = getattr(identity, "principal_id", None) + if not principal_id: + # Tenant routing hints are client-controlled. Without an authenticated + # principal they cannot authorize writes to tenant-visible activity or + # persistent audit sinks. + return None, None + return getattr(identity, "tenant_id", None), principal_id + + def _log_tool_error(tool_name: str, error: Exception, tenant_id: str | None, principal_id: str | None) -> None: """Backwards-compatible MCP wrapper for record_boundary_error. @@ -285,13 +326,9 @@ def _translate_to_tool_error(error: Exception) -> NoReturn: if isinstance(error, ToolError): # Includes AdCPToolError — already in wire shape. raise error - # Normalize untyped exceptions (ValueError, PermissionError) to typed - # AdCPError via the shared normalize_to_adcp_error() helper — same - # mapping the A2A and REST boundaries apply. The result is always an - # AdCPError; the wrap-vs-passthrough branches produce byte-identical - # AdCPToolError values, so the function unconditionally builds the - # envelope and chains the original exception for traceback fidelity. - typed = normalize_to_adcp_error(error) + # Normalize semantics and scrub untrusted raw-exception presentation + # fields through the shared buyer-facing policy used by every transport. + typed = safe_adcp_error(error) raise AdCPToolError(build_two_layer_error_envelope(typed), status_code=typed.status_code) from error @@ -449,11 +486,9 @@ def handle_tool_error(e: ToolError) -> JSONResponse: # so we copy to preserve the envelope-builder's immutability contract. return JSONResponse(status_code=e.status_code, content=dict(e.envelope)) - error_code, error_message, recovery = extract_error_info(e) - synthetic = AdCPError.synthesize( - error_message, + error_code, _error_message, _recovery = extract_error_info(e) + synthetic = synthesize_safe_adcp_error( error_code=error_code, status_code=_ERROR_CODE_TO_STATUS.get(error_code, 500), - recovery=recovery, ) return JSONResponse(status_code=synthetic.status_code, content=build_two_layer_error_envelope(synthetic)) diff --git a/src/core/tools/_media_buy_status.py b/src/core/tools/_media_buy_status.py index d5edf14bd1..44f32e22ce 100644 --- a/src/core/tools/_media_buy_status.py +++ b/src/core/tools/_media_buy_status.py @@ -96,6 +96,8 @@ "approved": "active", "ready": "active", "scheduled": "active", + "activating": "pending_start", + "activation_unknown": "pending_start", "pending_activation": "pending_start", "paused": "paused", "completed": "completed", diff --git a/src/core/tools/accounts.py b/src/core/tools/accounts.py index 659cc76d9e..8dc267fb8d 100644 --- a/src/core/tools/accounts.py +++ b/src/core/tools/accounts.py @@ -2,7 +2,7 @@ Handles account management per AdCP spec (UC-011): - Agent-scoped results (BR-RULE-054) -- Auth-optional list with empty fallback (BR-RULE-055) +- Authentication required for list and sync operations (BR-RULE-055) - Upsert by natural key (BR-RULE-056) - Atomic XOR response (BR-RULE-057) - Brand echo (BR-RULE-058) @@ -116,7 +116,9 @@ def _list_accounts_impl( ) -> ListAccountsResponse: """List accounts accessible to the authenticated agent. - Per BR-RULE-055: requires authentication, raises AUTH_REQUIRED if missing. + Per BR-RULE-055: requires authentication. This shared implementation raises + legacy AUTH_REQUIRED; transports may reject earlier with their canonical + missing/invalid credential code. Per BR-RULE-054: returns only accounts accessible to the agent. Args: @@ -437,6 +439,7 @@ def _extract_natural_key(entry: Any) -> tuple[str, str | None, str, bool | None] "Each account entry must include 'brand', 'operator', and 'billing'; " "the account-reference (settings-update) form is not supported by this seller.", recovery="correctable", + _wire_safe_message=True, ) brand_domain = brand.domain brand_id = None @@ -482,7 +485,9 @@ async def _sync_accounts_impl( # Validate non-empty accounts array if not req.accounts: - raise AdCPValidationError("accounts array must not be empty — at least one account is required.") + raise AdCPValidationError( + "accounts array must not be empty — at least one account is required.", _wire_safe_message=True + ) dry_run = bool(req.dry_run) delete_missing = bool(req.delete_missing) diff --git a/src/core/tools/creative_formats.py b/src/core/tools/creative_formats.py index 60fbc5c8f3..1e1139ea61 100644 --- a/src/core/tools/creative_formats.py +++ b/src/core/tools/creative_formats.py @@ -208,9 +208,11 @@ def _list_creative_formats_impl( except AdCPError: raise except Exception as e: + # Raw exception logged server-side only — registry init failures may carry + # infra detail (hosts, connection strings). Keep str(e) off the wire. logger.error(f"Failed to create creative agent registry: {e}", exc_info=True) raise AdCPServiceUnavailableError( - f"Creative agent registry initialization failed: {e}", + "Failed to initialize creative agent registry.", context=req.context, ) from e diff --git a/src/core/tools/creatives/_assignments.py b/src/core/tools/creatives/_assignments.py index ff4bda423a..3dc1fa88e1 100644 --- a/src/core/tools/creatives/_assignments.py +++ b/src/core/tools/creatives/_assignments.py @@ -117,7 +117,7 @@ def _process_assignments( for package_id in package_ids: # Find which media buy this package belongs to - pkg_result = assignment_repo.find_package_with_media_buy(package_id) + pkg_result = assignment_repo.find_package_with_media_buy(package_id, principal_id) media_buy_id = None actual_package_id = None diff --git a/src/core/tools/creatives/_processing.py b/src/core/tools/creatives/_processing.py index 4003b70876..be49e09966 100644 --- a/src/core/tools/creatives/_processing.py +++ b/src/core/tools/creatives/_processing.py @@ -20,17 +20,34 @@ from src.core.exceptions import AdCPConfigurationError from src.core.helpers import _extract_format_info, _validate_creative_assets -from src.core.schemas import CreativeStatusEnum, SyncCreativeResult +from src.core.schemas import CreativeStatusEnum, SyncCreativeResult, format_id_identity from src.core.validation_helpers import run_async_in_sync_context from ._assets import _build_creative_data, _extract_message_from_assets, _extract_url_from_assets if TYPE_CHECKING: from src.core.database.repositories.creative import CreativeRepository + from src.core.schemas import Format, LibraryFormatId logger = logging.getLogger(__name__) +def _find_matching_format(all_formats: list[Format], creative_format: LibraryFormatId) -> Format | None: + """Find a format by its canonical AdCP federation identity. + + ``all_formats`` is the registry's pre-fetched ``list[Format]`` (registry.list_all_formats + return type). ``creative_format`` accepts ``LibraryFormatId`` rather than the narrower + local ``FormatId`` subclass because callers pass either — e.g. ``CreativeAsset.format_id`` + is typed ``FormatReferenceStructuredObject`` (an ``adcp.types`` alias for the same + ``LibraryFormatId`` base ``Format.format_id`` also extends). + """ + target_identity = format_id_identity(creative_format) + return next( + (fmt for fmt in all_formats if format_id_identity(fmt.format_id) == target_identity), + None, + ) + + def _failed_sync_result( creative_id: str, error_msg: str, *, recovery: str | None = None, code: str = "SERVICE_UNAVAILABLE" ) -> SyncCreativeResult: @@ -69,7 +86,7 @@ def _update_existing_creative( tenant: dict[str, Any], webhook_url: str | None, context: dict[str, Any] | BaseModel | None, - all_formats: list[Any], + all_formats: list[Format], registry: Any, principal_id: str, ) -> tuple[SyncCreativeResult, bool]: @@ -189,12 +206,7 @@ def _update_existing_creative( # Use pre-fetched formats (fetched outside transaction at function start) # This avoids async HTTP calls inside savepoint - # Find matching format - format_obj = None - for fmt in all_formats: - if fmt.format_id == creative_format: - format_obj = fmt - break + format_obj = _find_matching_format(all_formats, creative_format) if format_obj and format_obj.agent_url: # Check if format is generative (has output_format_ids) @@ -474,7 +486,7 @@ def _create_new_creative( tenant: dict[str, Any], webhook_url: str | None, context: dict[str, Any] | BaseModel | None, - all_formats: list[Any], + all_formats: list[Format], registry: Any, principal_id: str, ) -> tuple[SyncCreativeResult, bool]: @@ -507,12 +519,7 @@ def _create_new_creative( # Use pre-fetched formats (fetched outside transaction at function start) # This avoids async HTTP calls inside savepoint - # Find matching format - format_obj = None - for fmt in all_formats: - if fmt.format_id == creative_format: - format_obj = fmt - break + format_obj = _find_matching_format(all_formats, creative_format) if format_obj and format_obj.agent_url: # Check if format is generative (has output_format_ids) diff --git a/src/core/tools/creatives/_validation.py b/src/core/tools/creatives/_validation.py index 85d9e95d00..7a032dfc93 100644 --- a/src/core/tools/creatives/_validation.py +++ b/src/core/tools/creatives/_validation.py @@ -92,10 +92,10 @@ def _validate_creative_input( # Additional business logic validation if not creative.name or str(creative.name).strip() == "": - raise AdCPValidationError("Creative name cannot be empty", field="name") + raise AdCPValidationError("Creative name cannot be empty", field="name", _wire_safe_message=True) if not creative.format_id: - raise AdCPValidationError("Creative format is required", field="format_id") + raise AdCPValidationError("Creative format is required", field="format_id", _wire_safe_message=True) # Use validated format (auto-upgraded from string if needed) format_value = validated_creative.format diff --git a/src/core/tools/creatives/_workflow.py b/src/core/tools/creatives/_workflow.py index 1c46ce9a72..063d4e0478 100644 --- a/src/core/tools/creatives/_workflow.py +++ b/src/core/tools/creatives/_workflow.py @@ -15,6 +15,45 @@ logger = logging.getLogger(__name__) +def _sync_step_request_data( + *, + creative_info: dict[str, Any], + format_value: Any, + status: str, + approval_mode: str, + push_notification_config: PushNotificationConfig | dict | None, + context: ContextObject | dict | None, + identity: ResolvedIdentity | None, +) -> dict[str, Any]: + """Build the ``request_data`` persisted on a creative-approval workflow step. + + One home for what a creative-approval step carries, because the readers are spread + out: the webhook payload builder reads ``protocol`` and ``context``, and the admin UI + reads the creative fields. Optional keys are OMITTED rather than written as None — + the readers test presence. + + No outer transport task id is written here. A sync creates one step per creative, so + a single ``external_task_id`` would name N rows ambiguously; ``resolve_webhook_task_id`` + therefore falls back to ``step_id`` for creative approvals. + """ + request_data: dict[str, Any] = { + "creative_id": creative_info["creative_id"], + "format": format_value, + "name": creative_info["name"], + "status": status, + "approval_mode": approval_mode, + } + # Engine's _pydantic_json_serializer handles Pydantic models in JSONB automatically. + if push_notification_config: + request_data["push_notification_config"] = push_notification_config + # Echoed back in the webhook. + if context: + request_data["context"] = context + # Drives webhook payload construction. + request_data["protocol"] = identity.protocol if identity else "mcp" + return request_data + + def _create_sync_workflow_steps( creatives_needing_approval: list[dict[str, Any]], principal_id: str, @@ -28,6 +67,10 @@ def _create_sync_workflow_steps( Creates a persistent async context and one workflow step per creative, plus ``ObjectWorkflowMapping`` records linking each creative to its step. + + ONE STEP PER CREATIVE is why no transport-level outer task id is carried in here: + a single id stamped across N steps resolves to an arbitrary one of them. Each step + is addressed by its own ``step_id``. """ from src.core.context_manager import get_context_manager @@ -71,24 +114,15 @@ def _create_sync_workflow_steps( if isinstance(format_value, BaseModel): format_value = format_value.model_dump(mode="json") - request_data_for_workflow = { - "creative_id": creative_info["creative_id"], - "format": format_value, - "name": creative_info["name"], - "status": status, - "approval_mode": approval_mode, - } - # Store push_notification_config if provided for async notification - # Engine's _pydantic_json_serializer handles Pydantic models in JSONB automatically - if push_notification_config: - request_data_for_workflow["push_notification_config"] = push_notification_config - - # Store context if provided (for echoing back in webhook) - if context: - request_data_for_workflow["context"] = context - - # Store protocol type for webhook payload creation - request_data_for_workflow["protocol"] = identity.protocol if identity else "mcp" + request_data_for_workflow = _sync_step_request_data( + creative_info=creative_info, + format_value=format_value, + status=status, + approval_mode=approval_mode, + push_notification_config=push_notification_config, + context=context, + identity=identity, + ) step = ctx_manager.create_workflow_step( context_id=persistent_ctx.context_id, diff --git a/src/core/tools/creatives/listing.py b/src/core/tools/creatives/listing.py index 3d52fe3154..5ca028d427 100644 --- a/src/core/tools/creatives/listing.py +++ b/src/core/tools/creatives/listing.py @@ -105,18 +105,20 @@ def _build_list_creatives_request( created_after_dt = datetime.fromisoformat(created_after.replace("Z", "+00:00")) except ValueError: raise AdCPValidationError( - f"Invalid created_after date format: {created_after}", + "Invalid created_after date format.", field="created_after", suggestion="Provide 'created_after' as an ISO 8601 datetime (e.g. 2026-01-01T00:00:00Z) and resend.", + _wire_safe_message=True, ) if created_before: try: created_before_dt = datetime.fromisoformat(created_before.replace("Z", "+00:00")) except ValueError: raise AdCPValidationError( - f"Invalid created_before date format: {created_before}", + "Invalid created_before date format.", field="created_before", suggestion="Provide 'created_before' as an ISO 8601 datetime (e.g. 2026-01-01T00:00:00Z) and resend.", + _wire_safe_message=True, ) # Validate sort_order is valid Literal diff --git a/src/core/tools/media_buy_create.py b/src/core/tools/media_buy_create.py index 1d185826b3..0c37acadee 100644 --- a/src/core/tools/media_buy_create.py +++ b/src/core/tools/media_buy_create.py @@ -11,11 +11,13 @@ import logging import random import secrets +import threading import time import uuid -from collections.abc import Sequence +from collections.abc import Callable, Sequence from datetime import UTC, datetime from decimal import Decimal +from functools import wraps from typing import TYPE_CHECKING, Annotated, Any, Literal, NoReturn, TypedDict, cast from urllib.parse import urlparse @@ -42,7 +44,6 @@ from src.core.database.repositories.idempotency_attempt import DEFAULT_REPLAY_TTL from src.core.exceptions import ( AdCPAdapterError, - AdCPAuthorizationError, AdCPBudgetExceededError, AdCPBudgetTooLowError, AdCPCapabilityNotSupportedError, @@ -142,6 +143,7 @@ def validate_agent_url(url: str | None) -> bool: Principal, Product, Targeting, + canonical_agent_url, ) from src.core.schemas import ( url as make_url, @@ -158,7 +160,11 @@ def validate_agent_url(url: str | None) -> bool: # Import get_product_catalog from main (after refactor) from src.core.validation_helpers import adcp_validation_boundary, format_validation_error, package_field_path -from src.core.webhook_validator import reject_unsafe_webhook_registration_url, webhook_url_for_log +from src.core.webhook_validator import ( + WebhookURLValidator, + reject_unsafe_webhook_registration_url, + webhook_url_for_log, +) from src.services.activity_feed import activity_feed from src.services.gam_product_config_service import GAMProductConfigService from src.services.targeting_capabilities import ( @@ -178,6 +184,118 @@ def validate_agent_url(url: str | None) -> bool: # See: adcp 2.12.0 changelog +def validate_push_notification_config_url( + push_notification_config: dict[str, Any] | PushNotificationConfig | None, +) -> str | None: + """Validate a protocol callback before any durable media-buy writes. + + Shared by create and update: both accept the same buyer-supplied callback, so + both must reject an unsafe one synchronously. Accepting it and never delivering + is indistinguishable to the buyer from a callback that simply never fired. + + Accepts the typed model (update carries ``req.push_notification_config``) as + well as the dict create's wrappers serialize to. + """ + if not push_notification_config: + return None + url = ( + push_notification_config.get("url") + if isinstance(push_notification_config, dict) + else push_notification_config.url + ) + if not url: + return None + # The typed model declares ``url`` as a Pydantic ``AnyUrl``, NOT a str, so the two + # entry points hand this different types for the same field: create's wrappers + # deserialize JSON into a dict of plain strings, while update passes + # ``req.push_notification_config`` through as the model. Coerce BEFORE the type + # guard — guarding ``isinstance(url, str)`` first is right for the dict path and + # rejects EVERY typed config on the update path, safe or not. A rejection-only test + # cannot catch that: it passes just as well when everything is rejected. + if not isinstance(url, str | dict | list): + url = str(url) + if not isinstance(url, str): + raise AdCPValidationError( + "Push notification URL must resolve to a public HTTPS endpoint", + field="push_notification_config.url", + _wire_safe_message=True, + ) + is_safe, _validation_error = WebhookURLValidator.validate_protocol_webhook_url(url) + if not is_safe: + raise AdCPValidationError( + "Push notification URL must resolve to a public HTTPS endpoint", + field="push_notification_config.url", + _wire_safe_message=True, + ) + return url + + +def _format_agent_compatibility_url(agent_url: object | None) -> str | None: + """Canonicalize an agent identity while accepting supported endpoint aliases. + + FormatId identity remains path-sensitive everywhere else. Create-media-buy + historically accepts an agent's base URL and its ``/mcp`` endpoint as the + same configured agent, so both registration and product compatibility must + apply that policy through this single helper. + """ + if agent_url is None: + return None + return canonical_agent_url(agent_url).removesuffix("/mcp") + + +def _format_reference_compatibility_key(agent_url: object | None, format_id: str) -> tuple[str | None, str]: + """Return the create-media-buy compatibility key for a format reference.""" + return (_format_agent_compatibility_url(agent_url), format_id) + + +def _validate_registered_format_agent( + agent_url: object, + registered_urls: frozenset[str], + *, + field: str, +) -> None: + """Apply the seller's creative-agent allowlist without disclosing its contents.""" + registered_compatibility_urls = { + compatibility_url + for registered_url in registered_urls + if (compatibility_url := _format_agent_compatibility_url(registered_url)) is not None + } + if _format_agent_compatibility_url(agent_url) not in registered_compatibility_urls: + raise AdCPInvalidRequestError( + "Creative agent is not registered.", + field=field, + suggestion="Use list_creative_formats to select a format from a registered creative agent.", + _wire_safe_message=True, + ) + + +def _validate_registered_format_agents(req: CreateMediaBuyRequest, tenant_id: str) -> None: + """Reject format references whose federation agent is not registered. + + AdCP 3.1.1 defines ``FormatId`` identity as ``(agent_url, id)``. Whether a + seller accepts a non-default agent is implementation policy (ungraded), so + this boundary emits a static INVALID_REQUEST rather than exposing registry + contents or the rejected URL. + """ + from src.core.creative_agent_registry import CreativeAgentRegistry + + format_references = [ + (package_index, format_index, format_id) + for package_index, package in enumerate(req.packages or []) + for format_index, format_id in enumerate(package.format_ids or []) + ] + if not format_references: + return + + registered_urls = CreativeAgentRegistry().get_registered_agent_urls(tenant_id) + for package_index, format_index, format_id in format_references: + _validate_registered_format_agent( + format_id.agent_url, + registered_urls, + field=f"packages[{package_index}].format_ids[{format_index}].agent_url", + ) + + def _get_creative_ids(package: AdcpPackageRequest | PackageRequest | Package | MediaPackage) -> list[str] | None: """Safely get creative_ids from a package (backward compatibility). @@ -723,7 +841,105 @@ def _build_adapter_asset_from_creative( return asset, None -def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, str | None]: +def _persist_approved_execution_outcome( + execute: Callable[..., tuple[bool | None, str | None]], +) -> Callable[..., tuple[bool | None, str | None]]: + """Persist failure evidence used by workflow reconciliation.""" + + @wraps(execute) + def wrapped( + media_buy_id: str, + tenant_id: str, + *, + execution_claimed: bool = False, + ) -> tuple[bool | None, str | None]: + success, error_message = execute( + media_buy_id, + tenant_id, + execution_claimed=execution_claimed, + ) + if success is False: + from src.core.database.repositories import MediaBuyUoW + + with MediaBuyUoW(tenant_id) as uow: + assert uow.media_buys is not None + uow.media_buys.update_status(media_buy_id, "failed") + return success, error_message + + return wrapped + + +def _mark_approved_execution_unknown( + media_buy_id: str, + tenant_id: str, + error_message: str, +) -> tuple[None, str]: + """Persist a post-dispatch ambiguity without hiding the original outcome.""" + from src.core.database.repositories import MediaBuyUoW + + try: + with MediaBuyUoW(tenant_id) as uow: + assert uow.media_buys is not None + uow.media_buys.mark_approved_execution_unknown(media_buy_id) + except Exception: + # The durable pre-dispatch ``activating`` claim still prevents a + # duplicate order. Its lease-based reconciler handles a failed marker + # write once the in-flight window expires. + logger.exception( + "[APPROVAL] Could not persist ambiguous execution marker for %s", + log_safe(media_buy_id), + ) + return None, error_message + + +class _ApprovalExecutionLease: + """Renew a durable execution claim until the owning worker exits.""" + + _RENEW_INTERVAL_SECONDS = 30.0 + + def __init__(self, media_buy_id: str, tenant_id: str) -> None: + self._media_buy_id = media_buy_id + self._tenant_id = tenant_id + self._stop = threading.Event() + self._thread = threading.Thread( + target=self._run, + name=f"approval-lease-{media_buy_id}", + daemon=True, + ) + + def start(self) -> None: + self._thread.start() + + def close(self) -> None: + self._stop.set() + self._thread.join(timeout=1.0) + + def _renew_once(self) -> bool: + from src.core.database.repositories import MediaBuyUoW + + with MediaBuyUoW(self._tenant_id) as uow: + assert uow.media_buys is not None + return uow.media_buys.renew_approved_execution_lease(self._media_buy_id) + + def _run(self) -> None: + while not self._stop.wait(self._RENEW_INTERVAL_SECONDS): + try: + if not self._renew_once(): + return + except Exception: + logger.exception( + "[APPROVAL] Could not renew execution lease for %s", + log_safe(self._media_buy_id), + ) + + +@_persist_approved_execution_outcome +def execute_approved_media_buy( + media_buy_id: str, + tenant_id: str, + *, + execution_claimed: bool = False, +) -> tuple[bool | None, str | None]: """Execute adapter creation for a manually approved media buy. This function is called after a media buy has been manually approved @@ -739,7 +955,10 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, tenant_id: The tenant ID for context Returns: - Tuple of (success: bool, error_message: str | None) + Tuple of (success, error_message). ``True`` means the external and + local activation completed, ``False`` means external creation was + rejected, and ``None`` means external creation succeeded but later + activation/finalization remains pending. """ from sqlalchemy import select @@ -752,7 +971,23 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, from src.core.config_loader import set_current_tenant from src.core.database.models import Tenant + adapter_invoked = False + external_creation_succeeded = False + execution_lease: _ApprovalExecutionLease | None = None try: + # Claim before dispatching any irreversible adapter work. This is a + # tenant-scoped compare-and-set, so concurrent approval entry points + # cannot both create the same external order. + if not execution_claimed: + with MediaBuyUoW(tenant_id) as claim_uow: + assert claim_uow.media_buys is not None + claimed = claim_uow.media_buys.claim_approved_execution(media_buy_id) + if not claimed: + return None, "Approved media buy execution is already claimed or no longer pending" + + execution_lease = _ApprovalExecutionLease(media_buy_id, tenant_id) + execution_lease.start() + # Load tenant and set context — single UoW for all reads with MediaBuyUoW(tenant_id) as uow: # FIXME(salesagent-9f2): raw session usages below should migrate to repository methods @@ -1039,6 +1274,7 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, _validate_creatives_before_adapter_call(packages, tenant_id, buy_principal_id, session=session) # Execute adapter creation (outside session to avoid conflicts) + adapter_invoked = True response = _execute_adapter_media_buy_creation( request, packages, @@ -1058,25 +1294,27 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, logger.error(f"[APPROVAL] Adapter creation failed for {media_buy_id}: {error_msg}") return False, error_msg + external_creation_succeeded = True logger.info(f"[APPROVAL] Adapter creation succeeded for {media_buy_id}: {response.media_buy_id}") - # Persist adapter IDs to package_config. + # The durable ``activating`` claim was persisted before adapter + # dispatch. Persist adapter IDs now that creation is confirmed. # platform_order_id is per-buy — always write to all packages so retroactive creative # push works regardless of whether the adapter also provides per-package line-item IDs. # platform_line_item_id is per-package and only present when the adapter maps them. platform_line_item_ids = getattr(response, "_platform_line_item_ids", {}) - if response.media_buy_id: - with MediaBuyUoW(tenant_id) as uow_plids: - assert uow_plids.media_buys is not None + with MediaBuyUoW(tenant_id) as uow_external: + assert uow_external.media_buys is not None + if response.media_buy_id: _persist_adapter_package_ids( - uow_plids.media_buys, + uow_external.media_buys, media_buy_id=media_buy_id, platform_order_id=str(response.media_buy_id), platform_line_item_ids=platform_line_item_ids or None, log_label="APPROVAL", ) - else: - logger.info("[APPROVAL] Adapter returned no media_buy_id — skipping ID persistence") + else: + logger.info("[APPROVAL] Adapter returned no media_buy_id — skipping ID persistence") # Upload and associate inline creatives if any exist # This handles inline creatives that were uploaded during initial media buy creation @@ -1157,7 +1395,7 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, + "\n\nAll creatives must have dimensions (width/height) and a content URL." ) logger.error(f"[APPROVAL] {error_msg}") - return False, error_msg + return _mark_approved_execution_unknown(media_buy_id, tenant_id, error_msg) if assets: logger.info(f"[APPROVAL] Uploading {len(assets)} creatives to adapter") @@ -1201,10 +1439,12 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, else: logger.warning("[APPROVAL] Adapter does not support creative upload, skipping") except Exception as creative_error: - # Creative upload failed - this is critical for GAM orders + # The external order already exists. Keep the approval + # pending for reconciliation instead of recording a + # false adapter failure. error_msg = f"Failed to upload creatives to adapter: {str(creative_error)}" logger.error(f"[APPROVAL] {error_msg}", exc_info=True) - return False, error_msg + return _mark_approved_execution_unknown(media_buy_id, tenant_id, error_msg) else: logger.info(f"[APPROVAL] No creative assignments found for {media_buy_id}, skipping creative upload") @@ -1220,28 +1460,39 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, if approval_success: logger.info(f"[APPROVAL] Successfully approved GAM order {response.media_buy_id}") else: - # GAM approval failed - return failure so status can be updated + # External creation already succeeded; approval remains + # incomplete but must not be terminalized as a rejection. error_msg = ( f"Failed to approve order {response.media_buy_id}, " f"it will remain in DRAFT status. This may be due to missing creatives or " f"GAM still processing inventory forecasts." ) logger.warning(f"[APPROVAL] {error_msg}") - return False, error_msg + return _mark_approved_execution_unknown(media_buy_id, tenant_id, error_msg) else: logger.info("[APPROVAL] Adapter does not support order approval, skipping") except Exception as approval_error: - # Approval exception - return failure + # External creation already succeeded; approval outcome remains + # incomplete and requires reconciliation. error_msg = f"Failed to approve order {response.media_buy_id}: {str(approval_error)}" logger.error(f"[APPROVAL] {error_msg}", exc_info=True) - return False, error_msg + return _mark_approved_execution_unknown(media_buy_id, tenant_id, error_msg) # Update media buy status to 'active' after successful adapter execution # (UC-002:437 — "updates the media buy status to active") with MediaBuyUoW(tenant_id) as uow3: assert uow3.media_buys is not None - uow3.media_buys.update_status(media_buy_id, "active") - logger.info(f"[APPROVAL] Updated media buy {media_buy_id} status to 'active'") + completed = uow3.media_buys.complete_approved_execution(media_buy_id) + if not completed: + return _mark_approved_execution_unknown( + media_buy_id, + tenant_id, + "Approved media buy execution lost its durable claim before completion", + ) + logger.info( + "[APPROVAL] Updated media buy %s status to 'active'", + log_safe(media_buy_id), + ) return True, None @@ -1249,9 +1500,20 @@ def execute_approved_media_buy(media_buy_id: str, tenant_id: str) -> tuple[bool, import traceback error_traceback = traceback.format_exc() - error_msg = f"Adapter creation failed: {str(e)}" + if external_creation_succeeded: + outcome = "Post-creation finalization failed" + elif adapter_invoked: + outcome = "Adapter creation outcome is unknown" + else: + outcome = "Adapter creation failed" + error_msg = f"{outcome}: {str(e)}" logger.error(f"[APPROVAL] {error_msg}\n{error_traceback}") + if adapter_invoked: + return _mark_approved_execution_unknown(media_buy_id, tenant_id, error_msg) return False, error_msg + finally: + if execution_lease is not None: + execution_lease.close() def push_creative_to_existing_buy( @@ -1290,7 +1552,7 @@ def push_creative_to_existing_buy( creative = uow.creatives.admin_get_by_id(creative_id) if not creative: return False, f"Creative {creative_id} not found" - if creative.status not in {"approved", "active"}: + if creative.status != "approved": return False, f"Creative {creative_id} is not approved (status={creative.status})" if (creative.data or {}).get("platform_creative_id"): @@ -1555,13 +1817,7 @@ async def _validate_and_convert_format_ids( registry = CreativeAgentRegistry() validated_format_ids = [] - # Get registered agents for this tenant - registered_agents = registry._get_tenant_agents(tenant_id) - # Normalize agent URLs for consistent comparison (strips /mcp, /a2a, /.well-known/*, trailing slashes) - # This ensures all URL variations match: "https://example.com/mcp/" -> "https://example.com" - from src.core.validation import normalize_agent_url - - registered_agent_urls = {normalize_agent_url(agent.agent_url) for agent in registered_agents} + registered_agent_urls = registry.get_registered_agent_urls(tenant_id) for idx, fmt_id in enumerate(format_ids): # STRICT ENFORCEMENT: Reject plain strings @@ -1577,8 +1833,12 @@ async def _validate_and_convert_format_ids( try: validated_fmt = FormatId.model_validate(fmt_id, from_attributes=True) except (ValueError, ValidationError) as e: + # Raw validation error logged server-side; the client message states the + # required structure without interpolating str(e). + logger.warning("Package %s, format_ids[%s]: invalid format_id structure: %s", package_idx + 1, idx, e) raise AdCPValidationError( - f"Package {package_idx + 1}, format_ids[{idx}]: Invalid format_id structure: {e}", + f"Package {package_idx + 1}, format_ids[{idx}]: Invalid format_id structure. " + f"Per AdCP spec, each format_id must be a FormatId object with {{agent_url, id}}.", ) from e agent_url = str(validated_fmt.agent_url).rstrip("/") format_id = validated_fmt.id @@ -1589,15 +1849,12 @@ async def _validate_and_convert_format_ids( f"Both agent_url and id are required. Got: agent_url={agent_url!r}, id={format_id!r}", ) - # VALIDATION: Check agent is registered - # Normalize incoming agent_url for comparison (strips /mcp, /a2a, /.well-known/*, trailing slashes) - normalized_agent_url = normalize_agent_url(agent_url) - if normalized_agent_url not in registered_agent_urls: - raise AdCPAuthorizationError( - f"Package {package_idx + 1}, format_ids[{idx}]: Creative agent not registered: {agent_url}. " - f"Registered agents: {', '.join(sorted(registered_agent_urls))}. " - f"Contact your administrator to register this creative agent.", - ) + # VALIDATION: Check the public federation identity is registered. + _validate_registered_format_agent( + agent_url, + registered_agent_urls, + field=f"packages[{package_idx}].format_ids[{idx}].agent_url", + ) # VALIDATION: Verify format exists on agent try: @@ -1611,11 +1868,13 @@ async def _validate_and_convert_format_ids( except AdCPError: raise except Exception as e: + # Raw fetch error logged server-side only (may carry upstream/network + # detail); the client message drops "Error: {e}" to avoid leaking it. logger.exception(f"Error fetching format {format_id} from {agent_url}: {e}") raise AdCPAdapterError( f"Package {package_idx + 1}, format_ids[{idx}]: Failed to verify format on agent. " - f"agent_url={agent_url}, format_id={format_id!r}. Error: {e}", - ) + f"agent_url={agent_url}, format_id={format_id!r}.", + ) from e # Format validated - add to results validated_format_ids.append({"agent_url": str(agent_url), "id": format_id}) @@ -2010,6 +2269,7 @@ async def _create_media_buy_impl( identity: ResolvedIdentity | None = None, context_id: str | None = None, raw_wire_payload: dict[str, Any] | None = None, + external_task_id: str | None = None, ) -> CreateMediaBuyResult: """Create a media buy with the specified parameters. @@ -2112,6 +2372,12 @@ async def _create_media_buy_impl( # Miss or unusable cached envelope — proceed as a fresh execution; the # MediaBuy backstop resolves any resulting duplicate to the degraded path. + # Reject an unsafe callback before ContextManager persists a context or + # workflow step. The delivery service validates again at connection time to + # cover DNS rebinding between registration and delivery. + push_notification_url = validate_push_notification_config_url(push_notification_config) + _validate_registered_format_agents(req, tenant["tenant_id"]) + # Context management and workflow step creation - create workflow step FIRST # Skip for dry_run mode (no side effects, no database writes) ctx_manager = get_context_manager() @@ -2135,6 +2401,12 @@ async def _create_media_buy_impl( workflow_metadata: dict[str, Any] = {"protocol": identity.protocol} if push_notification_config: workflow_metadata["push_notification_config"] = push_notification_config + # Persist the transport's outer async task id (opaque here — set only by the + # A2A boundary from the Task returned to the buyer) so the completion webhook + # and tasks/get can correlate to the id the BUYER holds, not the internal + # step_id. Durable so a poll survives a server restart. + if external_task_id: + workflow_metadata["external_task_id"] = external_task_id step = ctx_manager.create_workflow_step( context_id=persistent_ctx.context_id, @@ -2152,7 +2424,10 @@ async def _create_media_buy_impl( if push_notification_config: from src.core.database.repositories import PushNotificationConfigUoW - url = push_notification_config.get("url") + # The value our protocol-callback gate above already validated, so what + # gets persisted is exactly what was checked (#1697 dropped the former + # unconditional log here — the guarded one below keeps blank URLs silent). + url = push_notification_url authentication = push_notification_config.get("authentication", {}) # Match the pre-gate: whitespace-only URL must not reach upsert. @@ -2229,11 +2504,11 @@ async def _create_media_buy_impl( computed_start_time = computed_start_time.replace(tzinfo=UTC) if computed_start_time < now: - error_msg = f"Invalid start time: {req.start_time}. Start time cannot be in the past." raise AdCPInvalidRequestError( - error_msg, + "Start time cannot be in the past.", suggestion="Use a future datetime or 'asap' for immediate start.", field="start_time", + _wire_safe_message=True, ) # Validate end_time @@ -2247,11 +2522,11 @@ async def _create_media_buy_impl( computed_end_time = computed_end_time.replace(tzinfo=UTC) if computed_end_time <= computed_start_time: - error_msg = f"Invalid time range: end time ({req.end_time}) must be after start time ({req.start_time})." raise AdCPInvalidRequestError( - error_msg, + "End time must be after start time.", suggestion="Set end_time to a datetime after start_time.", field="end_time", + _wire_safe_message=True, ) # Assign computed times to local variables for use throughout the function @@ -2292,6 +2567,12 @@ async def _create_media_buy_impl( raise AdCPValidationError( error_msg, suggestion="Each package must reference a distinct product_id; remove the duplicate package or change its product_id.", + # Deliberate deviation: echoes product_ids the buyer submitted in this same + # request, not a static string. Sanctioned by adcp/dist/schemas/3.1.1/core/ + # error.json's details.rejected_value convention (echoing the buyer's own + # rejected value back "for buyer-side diagnostic clarity") — see the fuller + # citation in safe_adcp_error's AdCPValidationError branch. + _wire_safe_message=True, ) # 4. Currency-specific budget validation @@ -2643,6 +2924,13 @@ def unwrap_po(po: Any) -> Any: error_msg, suggestion="Check targeting constraints.", field="targeting_overlay", + # Deliberate deviation: all three violation sources interpolate only + # static text, fixed known dimension names, or the buyer's own + # submitted field names/values from this same request — never a + # secret or internal detail. Sanctioned by adcp/dist/schemas/3.1.1/ + # core/error.json's details.rejected_value convention; see the fuller + # citation in safe_adcp_error's AdCPValidationError branch. + _wire_safe_message=True, ) except (AdCPError, ValueError, PermissionError) as e: @@ -3276,15 +3564,12 @@ def unwrap_po(po: Any) -> Any: product_format_keys: set[tuple[str | None, str]] = set() if pkg_product.format_ids: for fmt in pkg_product.format_ids: - agent_url = fmt.agent_url - normalized_url = str(agent_url).rstrip("/") if agent_url else None - product_format_keys.add((normalized_url, fmt.id)) + product_format_keys.add(_format_reference_compatibility_key(fmt.agent_url, fmt.id)) # Build set of requested format keys for comparison requested_format_keys: set[tuple[str | None, str]] = set() for fmt in matching_package.format_ids: - normalized_url = str(fmt.agent_url).rstrip("/") if fmt.agent_url else None - requested_format_keys.add((normalized_url, fmt.id)) + requested_format_keys.add(_format_reference_compatibility_key(fmt.agent_url, fmt.id)) def format_display(url: str | None, fid: str) -> str: """Format a (url, id) pair for display, handling trailing slashes.""" @@ -3295,34 +3580,10 @@ def format_display(url: str | None, fid: str) -> str: clean_url = str(url).rstrip("/") return f"{clean_url}/{fid}" - def _has_supported_key(url: str | None, fid: str, keys: set = product_format_keys) -> bool: - """Check if (url, fid) is supported, allowing an '/mcp' URL variant. - - This does not mutate any of the underlying key sets; it only checks - for the presence of either the exact key or an alternative where - '/mcp' is appended to the end of the URL path. - - Args: - url: The format URL to check - fid: The format ID to check - keys: The set of supported (url, fid) tuples (bound at function definition) - """ - # Exact match first - if (url, fid) in keys: - return True - - # If URL provided, also try with '/mcp' appended (idempotent if already present) - if url: - # Convert to string in case it's an AnyUrl object - base = str(url).rstrip("/") - mcp_url = base if base.endswith("/mcp") else f"{base}/mcp" - if (mcp_url, fid) in keys: - return True - - return False - unsupported_formats = [ - format_display(url, fid) for url, fid in requested_format_keys if not _has_supported_key(url, fid) + format_display(url, fid) + for url, fid in requested_format_keys + if (url, fid) not in product_format_keys ] if unsupported_formats: @@ -3350,11 +3611,9 @@ def _has_supported_key(url: str | None, fid: str, keys: set = product_format_key product_format_dimensions = {} if pkg_product.format_ids: for fmt in pkg_product.format_ids: - agent_url = fmt.agent_url fmt_id = fmt.id - normalized_url = str(agent_url).rstrip("/") if agent_url else None if fmt_id: - product_format_dimensions[(normalized_url, fmt_id)] = ( + product_format_dimensions[_format_reference_compatibility_key(fmt.agent_url, fmt_id)] = ( fmt.width, fmt.height, fmt.duration_ms, @@ -3362,7 +3621,7 @@ def _has_supported_key(url: str | None, fid: str, keys: set = product_format_key # Process request format_ids, merging dimensions from product if missing for req_fmt in matching_package.format_ids: - normalized_url = str(req_fmt.agent_url).rstrip("/") if req_fmt.agent_url else None + compatibility_key = _format_reference_compatibility_key(req_fmt.agent_url, req_fmt.id) # Check if request format has dimensions if req_fmt.width is not None and req_fmt.height is not None: # Request has dimensions, convert to our FormatId type @@ -3377,7 +3636,7 @@ def _has_supported_key(url: str | None, fid: str, keys: set = product_format_key ) else: # Try to get dimensions from product's format_ids - product_dims = product_format_dimensions.get((normalized_url, req_fmt.id)) + product_dims = product_format_dimensions.get(compatibility_key) if product_dims and (product_dims[0] is not None or product_dims[1] is not None): # Merge dimensions from product format_ids_to_use.append( @@ -3506,6 +3765,7 @@ def _has_supported_key(url: str | None, fid: str, keys: set = product_format_key raise AdCPValidationError( "start_time and end_time are required but were not properly set", context=req.context, + _wire_safe_message=True, ) # PRE-VALIDATE: Check all creatives have required fields BEFORE calling adapter @@ -4317,7 +4577,10 @@ def _has_supported_key(url: str | None, fid: str, keys: set = product_format_key # Audit logging failure is non-critical, but we should log it logger.warning(f"Failed to log failed media buy creation to audit: {audit_error}") - raise AdCPAdapterError(f"Failed to create media buy: {str(e)}") + # Raw exception already logged/audited above; keep str(e) off the client + # message (may carry adapter/DB internals). A2A boundary also scrubs this + # SERVICE_UNAVAILABLE-class message; MCP/REST rely on this source scrub. + raise AdCPAdapterError("Failed to create media buy.") from e def _build_create_media_buy_request( @@ -4511,6 +4774,7 @@ async def create_media_buy_raw( ctx: Context | ToolContext | None = None, identity: ResolvedIdentity | None = None, raw_wire_payload: dict[str, Any] | None = None, + external_task_id: str | None = None, ): """Create a new media buy with specified parameters (raw function for A2A server use). @@ -4583,6 +4847,7 @@ async def create_media_buy_raw( identity=identity, context_id=_ctx_id, raw_wire_payload=raw_wire_payload, + external_task_id=external_task_id, ) diff --git a/src/core/tools/media_buy_delivery.py b/src/core/tools/media_buy_delivery.py index bbb469e6ec..dee6175475 100644 --- a/src/core/tools/media_buy_delivery.py +++ b/src/core/tools/media_buy_delivery.py @@ -52,6 +52,7 @@ def _validate_attribution_window(attribution_window: "AttributionWindow | None") "(the window spans the full campaign flight)", field="attribution_window", suggestion="interval must be 1 when unit is 'campaign'", + _wire_safe_message=True, ) @@ -217,6 +218,7 @@ def _get_media_buy_delivery_impl( field="start_date", suggestion="Set start_date to a date before end_date and resend.", context=req.context, + _wire_safe_message=True, ) else: # Default to last 30 days diff --git a/src/core/tools/media_buy_list.py b/src/core/tools/media_buy_list.py index 56db2769cc..d59f26dc5f 100644 --- a/src/core/tools/media_buy_list.py +++ b/src/core/tools/media_buy_list.py @@ -63,6 +63,7 @@ class _PackageData: from src.core.database.repositories import MediaBuyUoW from src.core.database.repositories.creative import CreativeRepository from src.core.exceptions import ( + AdCPAuthRequiredError, AdCPCapabilityNotSupportedError, AdCPValidationError, ) @@ -114,7 +115,10 @@ def _get_media_buys_impl( media_buys=[], errors=[ Error( # structural-guard: advisory: get_media_buys degrades to empty list + error, not a raise - code="AUTH_REQUIRED", message="Principal ID not found in context" + # Code taken from the typed class that owns it, not restated as a + # literal: the raise path and this degraded path must never drift. + code=AdCPAuthRequiredError._default_error_code, + message="Principal ID not found in context", ) ], ) @@ -125,7 +129,8 @@ def _get_media_buys_impl( media_buys=[], errors=[ Error( # structural-guard: advisory: get_media_buys degrades to empty list + error, not a raise - code="AUTH_REQUIRED", message=f"Principal {principal_id} not found" + code=AdCPAuthRequiredError._default_error_code, + message=f"Principal {principal_id} not found", ) ], ) @@ -451,18 +456,29 @@ def _resolve_status_filter( else: raw = [status_filter] - try: - return {MediaBuyStatus(s) for s in raw} - except ValueError as e: - # An unknown status string is a bad request, not a server fault — surface - # a clean VALIDATION_ERROR instead of letting the ValueError escape as a - # 500. (A dedicated STATUS_FILTER_INVALID_VALUE code is a separate, - # unimplemented gap; see the xfailed boundary-status-filter rows.) + # An unknown status string is a bad request, not a server fault — surface a + # clean VALIDATION_ERROR instead of letting a ValueError escape as a 500. + # (A dedicated STATUS_FILTER_INVALID_VALUE code is a separate, unimplemented + # gap; see the xfailed boundary-status-filter rows.) The message echoes the + # buyer's OWN invalid value (safe — it is client-provided input, not a server + # internal) so the error is correctable, but never interpolates a raw caught + # exception (str(e)), which could carry internals. + resolved: set[MediaBuyStatus] = set() + invalid: list[str] = [] + for s in raw: + try: + resolved.add(MediaBuyStatus(s)) + except ValueError: + invalid.append(s) + if invalid: + logger.warning("Invalid status_filter value(s): %s", invalid) + invalid_str = ", ".join(repr(v) for v in invalid) raise AdCPValidationError( - f"Invalid status_filter value: {e}", + f"Invalid status_filter value: {invalid_str} is not a valid MediaBuyStatus.", field="status_filter", suggestion="status_filter values must be valid media-buy statuses", - ) from e + ) + return resolved # Persisted MediaBuy.status -> AdCP MediaBuyStatus wire enum, DERIVED from the diff --git a/src/core/tools/media_buy_update.py b/src/core/tools/media_buy_update.py index 5aaa6899f1..2f745fe655 100644 --- a/src/core/tools/media_buy_update.py +++ b/src/core/tools/media_buy_update.py @@ -94,6 +94,7 @@ validate_max_daily_package_spend, validate_min_package_budget, ) +from src.core.tools.media_buy_create import validate_push_notification_config_url from src.core.transport_helpers import resolve_identity_from_context from src.core.utils import utc_flight_start from src.core.validation_helpers import adcp_validation_boundary, package_field_path @@ -343,10 +344,29 @@ def _verify_principal( ) +def _update_workflow_metadata(identity: ResolvedIdentity, external_task_id: str | None) -> dict[str, Any]: + """Metadata merged into the update step's ``request_data``. + + ``external_task_id`` is the buyer's outer A2A ``task_*`` id. An update that returns + ``status="submitted"`` hands the buyer that id, so it has to reach the durable step: + ``resolve_webhook_task_id`` reads exactly this key to decide whether the completion + webhook carries the buyer's id or falls back to the internal ``step_id``, and + ``tasks/cancel`` refuses outright when no durable step carries it. + + Omitted rather than written as None on MCP/REST, which have no outer task id — the + reader tests presence. + """ + metadata: dict[str, Any] = {"protocol": identity.protocol} + if external_task_id: + metadata["external_task_id"] = external_task_id + return metadata + + def _update_media_buy_impl( req: UpdateMediaBuyRequest, identity: ResolvedIdentity | None = None, context_id: str | None = None, + external_task_id: str | None = None, ) -> UpdateMediaBuyResult | UpdateMediaBuySubmitted: """Shared implementation for update_media_buy (used by both MCP and A2A). @@ -397,7 +417,7 @@ def _update_media_buy_impl( media_buy_id_to_use = req.media_buy_id if not media_buy_id_to_use: - raise AdCPValidationError("media_buy_id is required") + raise AdCPValidationError("media_buy_id is required", _wire_safe_message=True) # Verify principal owns this media buy _verify_principal(media_buy_id_to_use, identity, uow.media_buys, context=req.context) @@ -436,6 +456,12 @@ def _update_media_buy_impl( # Extract testing context early (needed for dry_run check) testing_ctx = identity.testing_context if identity.testing_context else AdCPTestContext() + # Reject an unsafe callback before ContextManager persists a context or + # workflow step — the same fence create applies, through the same validator. + # Without it an update accepts a private callback, reports success, and + # silently never delivers. + validate_push_notification_config_url(req.push_notification_config) + # Create or get persistent context and workflow step # (ctx_manager + step were hoisted before the try block so the # AdCPError / Exception handlers can mark the step as failed) @@ -466,7 +492,7 @@ def _update_media_buy_impl( status="in_progress", tool_name="update_media_buy", request_data=req, - request_metadata={"protocol": identity.protocol}, + request_metadata=_update_workflow_metadata(identity, external_task_id), ) principal = resolve_principal_or_raise(principal_id, tenant_id=identity.tenant_id, context=req.context) @@ -797,6 +823,7 @@ def _update_media_buy_impl( "package_id is required when updating package budget", field=package_field_path("package_id"), context=req.context, + _wire_safe_message=True, ) # Extract budget amount - handle both float and Budget object budget_amount: float @@ -878,6 +905,7 @@ def _update_media_buy_impl( "package_id is required when updating creative_ids", field=package_field_path("package_id"), context=req.context, + _wire_safe_message=True, ) # Resolve media_buy_id @@ -979,6 +1007,7 @@ def _update_media_buy_impl( "package_id is required when uploading creatives", field=package_field_path("package_id"), context=req.context, + _wire_safe_message=True, ) # Sync creatives (upload/update) @@ -1028,6 +1057,7 @@ def _update_media_buy_impl( "package_id is required when updating creative_assignments", field=package_field_path("package_id"), context=req.context, + _wire_safe_message=True, ) # Resolve media_buy_id @@ -1209,6 +1239,7 @@ def _update_media_buy_impl( "package_id is required when updating targeting_overlay", field=package_field_path("package_id"), context=req.context, + _wire_safe_message=True, ) from sqlalchemy.orm import attributes @@ -1513,6 +1544,7 @@ def _build_update_request( "start_time, end_time, packages, budget, push_notification_config, " "reporting_webhook, context, or ext." ), + _wire_safe_message=True, ) return req @@ -1618,6 +1650,7 @@ def update_media_buy_raw( idempotency_key: str | None = None, # AdCP idempotency key for retry safety ctx: Context | ToolContext | None = None, identity: ResolvedIdentity | None = None, + external_task_id: str | None = None, ): """Update an existing media buy (raw function for A2A server use). @@ -1670,4 +1703,4 @@ def update_media_buy_raw( identity = resolve_identity_from_context(ctx, require_valid_token=True) # A2A/REST callers pass identity directly without a FastMCP Context, so there # is no workflow context_id to forward — _impl creates one if needed. - return _update_media_buy_impl(req=req, identity=identity, context_id=None) + return _update_media_buy_impl(req=req, identity=identity, context_id=None, external_task_id=external_task_id) diff --git a/src/core/tools/products.py b/src/core/tools/products.py index 5fe0adfc65..e7496e0cee 100644 --- a/src/core/tools/products.py +++ b/src/core/tools/products.py @@ -169,7 +169,7 @@ async def _get_products_impl( # Require at least one search criterion (brief, brand, or filters) if not req.brief and not req.brand and not req.filters: - raise AdCPValidationError("At least one of 'brief', 'brand', or 'filters' is required") + raise AdCPValidationError("At least one of 'brief', 'brand', or 'filters' is required", _wire_safe_message=True) # Extract identity fields identity = require_identity(identity, context=req.context) @@ -818,9 +818,13 @@ async def get_products( ) except ValueError as e: # Helper raises ValueError for semantic (non-Pydantic) input problems. + # Log the raw exception server-side; do not interpolate str(e) into the + # wire message (boundary sanitization — the raw text may carry internals). + logger.warning("Invalid get_products request: %s", e) raise AdCPValidationError( - f"Invalid get_products request: {e}", + "Invalid get_products request.", suggestion="Correct the get_products request per the AdCP specification and resend.", + _wire_safe_message=True, ) from e # Read identity pre-resolved by MCPAuthMiddleware diff --git a/src/core/tools/properties.py b/src/core/tools/properties.py index c584af1cb2..586dd7ff42 100644 --- a/src/core/tools/properties.py +++ b/src/core/tools/properties.py @@ -187,9 +187,12 @@ def _list_authorized_properties_impl( error=str(e), ) + # Raw exception already logged/audited above; keep str(e) off the client + # message (it may carry adapter/DB internals). The A2A boundary also scrubs + # this SERVICE_UNAVAILABLE-class message, but MCP/REST rely on this source scrub. raise AdCPAdapterError( - f"Failed to list authorized properties: {str(e)}", - ) + "Failed to list authorized properties.", + ) from e async def list_authorized_properties( diff --git a/src/core/tools/signals.py b/src/core/tools/signals.py index d63085fa19..7da25643b1 100644 --- a/src/core/tools/signals.py +++ b/src/core/tools/signals.py @@ -310,8 +310,9 @@ async def _activate_signal_impl( except AdCPError: raise except Exception as e: + # Raw exception logged server-side only; keep str(e) off the client message. logger.error("Error activating signal %s: %s", signal_agent_segment_id, e) - raise AdCPAdapterError(str(e), context=context) from e + raise AdCPAdapterError("Failed to activate signal.", context=context) from e async def activate_signal( diff --git a/src/core/tools/task_management.py b/src/core/tools/task_management.py index 51b4e7f837..7bd5d4c31e 100644 --- a/src/core/tools/task_management.py +++ b/src/core/tools/task_management.py @@ -16,6 +16,7 @@ from src.core.audit_logger import get_audit_logger from src.core.auth import require_identity, require_principal_id, require_tenant from src.core.database.repositories.uow import WorkflowUoW +from src.core.database.repositories.workflow import TERMINAL_STEP_STATUSES from src.core.exceptions import ( AdCPConflictError, AdCPValidationError, @@ -37,7 +38,10 @@ async def list_tasks( """List workflow tasks with filtering options. Args: - status: Filter by task status ("pending", "in_progress", "completed", "failed", "requires_approval") + status: Filter by workflow-step status. Terminal: "completed", "rejected", "failed", + "canceled" (``TERMINAL_STEP_STATUSES``). Non-terminal: "pending", "in_progress", + "approved", "requires_approval", "pending_approval", and the legacy + awaiting-decision alias "approval". object_type: Filter by object type ("media_buy", "creative", "product") object_id: Filter by specific object ID limit: Maximum number of tasks to return (default: 20) @@ -53,18 +57,23 @@ async def list_tasks( identity = require_identity(identity) tenant = require_tenant(identity) - require_principal_id(identity) # F-03: an authenticated (non-anonymous) principal is required + principal_id = require_principal_id(identity) # F-03: an authenticated (non-anonymous) principal is required with WorkflowUoW(tenant["tenant_id"]) as uow: assert uow.workflows is not None + # Principal-scoped, not tenant-scoped: this is a buyer-facing tool, so a sibling + # principal in the same tenant must not see another buyer's task ids or summaries. + # The count carries the same scope as the page, or the total leaks their existence. total = uow.workflows.count_by_tenant( + principal_id=principal_id, status=status, object_type=object_type, object_id=object_id, ) tasks = uow.workflows.list_by_tenant( + principal_id=principal_id, status=status, object_type=object_type, object_id=object_id, @@ -139,12 +148,14 @@ async def get_task( identity = require_identity(identity) tenant = require_tenant(identity) - require_principal_id(identity) # F-03: an authenticated (non-anonymous) principal is required + principal_id = require_principal_id(identity) # F-03: an authenticated (non-anonymous) principal is required with WorkflowUoW(tenant["tenant_id"]) as uow: assert uow.workflows is not None - task = uow.workflows.get_by_step_id_or_raise(task_id) + # Principal-scoped: a sibling principal who learns this task id must not read its + # stored response_data. Reported not-found, like any unknown id. + task = uow.workflows.get_by_step_id_or_raise(task_id, principal_id=principal_id) mappings = uow.workflows.get_mappings_for_step(task_id) @@ -215,22 +226,36 @@ async def complete_task( with WorkflowUoW(tenant["tenant_id"]) as uow: assert uow.workflows is not None - task = uow.workflows.get_by_step_id_or_raise(task_id) - - if task.status not in ["pending", "in_progress", "requires_approval"]: + # Principal-scoped: a sibling principal must not be able to complete or fail + # another buyer's task. This fetch gates reachability for the transition below — + # a step this principal does not own raises not-found before any write is + # attempted, so ``transition_if_nonterminal`` needs no separate scope. + task = uow.workflows.get_by_step_id_or_raise(task_id, principal_id=principal_id) + + # Derived from the canonical terminal set, not a hand-listed positive set: a + # literal enumerating the states that MAY complete silently omits every + # non-terminal state it forgot — it omitted ``pending_approval`` and the legacy + # ``approval`` alias, so this pre-check refused steps the authoritative guard + # (``transition_if_nonterminal``, which keys on ``TERMINAL_STEP_STATUSES``) accepts. + if task.status in TERMINAL_STEP_STATUSES: raise AdCPConflictError(f"Task {task_id} is already {task.status} and cannot be completed") completed_time = datetime.now(UTC) + # transition_if_nonterminal returns None if the step was concurrently + # terminalized (e.g. a buyer cancel committed between the read above and this + # write). The pre-check is a non-atomic hint; this None check is the + # authoritative guard. A lost transition must NOT be reported as a successful + # completion. if status == "completed": - uow.workflows.update_status( + updated = uow.workflows.transition_if_nonterminal( task_id, status=status, completed_at=completed_time, response_data=response_data or {"manually_completed": True, "completed_by": principal_id}, ) else: - uow.workflows.update_status( + updated = uow.workflows.transition_if_nonterminal( task_id, status=status, completed_at=completed_time, @@ -238,6 +263,11 @@ async def complete_task( response_data=response_data, ) + if updated is None: + raise AdCPConflictError( + f"Task {task_id} was concurrently finalized (e.g. canceled) and cannot be marked {status}" + ) + audit_logger = get_audit_logger("task_management", tenant["tenant_id"]) audit_logger.log_operation( operation="complete_task", diff --git a/src/core/transport_helpers.py b/src/core/transport_helpers.py index bcb86658eb..195e414a9b 100644 --- a/src/core/transport_helpers.py +++ b/src/core/transport_helpers.py @@ -34,6 +34,23 @@ def _make_lazy_tenant(tenant_id: str) -> LazyTenantContext: return LazyTenantContext(tenant_id) +def extract_headers_from_context(ctx: Context | ToolContext | None) -> dict[str, str]: + """Read request headers once from FastMCP dependencies or context fallback.""" + headers = None + try: + headers = get_http_headers(include_all=True) + except Exception: + logger.debug("get_http_headers() unavailable, trying fallback", exc_info=True) + + if not headers and ctx is not None: + if hasattr(ctx, "meta") and ctx.meta and "headers" in ctx.meta: + headers = ctx.meta["headers"] + elif hasattr(ctx, "headers"): + headers = ctx.headers + + return dict(headers or {}) + + def resolve_identity_from_context( ctx: Context | ToolContext | None, require_valid_token: bool = True, @@ -66,18 +83,7 @@ def resolve_identity_from_context( ) # Handle FastMCP Context — extract headers and resolve - headers = None - try: - headers = get_http_headers(include_all=True) - except Exception: - logger.debug("get_http_headers() unavailable, trying fallback", exc_info=True) - - # Fallback to context.meta if available - if not headers and ctx is not None: - if hasattr(ctx, "meta") and ctx.meta and "headers" in ctx.meta: - headers = ctx.meta["headers"] - elif hasattr(ctx, "headers"): - headers = ctx.headers + headers = extract_headers_from_context(ctx) if not headers: if ctx is None: diff --git a/src/core/validation_helpers.py b/src/core/validation_helpers.py index 615f97d7da..7b1c7729a4 100644 --- a/src/core/validation_helpers.py +++ b/src/core/validation_helpers.py @@ -14,8 +14,11 @@ from pydantic import ValidationError from src.core.exceptions import ( + VALIDATION_ERROR_SUGGESTION, AdCPValidationError, build_validation_error_details, + safe_validation_error_location, + safe_validation_error_message, ) from src.core.exceptions import ( first_validation_error_field as first_validation_error_field, @@ -56,6 +59,7 @@ def adcp_validation_boundary(context: str = "parameters", field: str | None = No field=field if field is not None else first_validation_error_field(e), suggestion=suggest_validation_fix(e), details=build_validation_error_details(errors), + _wire_safe_message=True, ) from e @@ -180,38 +184,8 @@ def format_validation_error(validation_error: ValidationError, context: str = "r """ error_details = [] for error in validation_error.errors(): - field_path = ".".join(str(loc) for loc in error["loc"]) - error_type = error["type"] - msg = error["msg"] - input_val = error.get("input") - - # Add helpful context for common validation errors - if "string_type" in error_type and isinstance(input_val, dict): - error_details.append( - f" • {field_path}: Expected string, got object. " - f"AdCP spec requires this field to be a simple string, not a structured object." - ) - elif "string_type" in error_type: - error_details.append( - f" • {field_path}: Expected string, got {type(input_val).__name__}. Please provide a string value." - ) - elif "missing" in error_type: - error_details.append(f" • {field_path}: Required field is missing") - elif "extra_forbidden" in error_type: - # For extra_forbidden, show the actual value to help debug what was passed - if input_val is not None: - # Format the input value more verbosely for debugging - try: - input_repr = json.dumps(input_val, indent=2, default=str) - except (TypeError, ValueError): - input_repr = repr(input_val) - error_details.append( - f" • {field_path}: Extra field not allowed by AdCP spec.\n Received value: {input_repr}" - ) - else: - error_details.append(f" • {field_path}: Extra field not allowed by AdCP spec") - else: - error_details.append(f" • {field_path}: {msg}") + field_path = ".".join(str(loc) for loc in safe_validation_error_location(error)) + error_details.append(f" • {field_path}: {safe_validation_error_message(error)}") error_msg = ( f"Invalid {context}: The following fields do not match the AdCP specification:\n\n" @@ -223,31 +197,13 @@ def format_validation_error(validation_error: ValidationError, context: str = "r def suggest_validation_fix(validation_error: ValidationError) -> str: - """Derive a single buyer-facing correction hint from a Pydantic ValidationError. - - Produces the actionable ``suggestion`` companion to - ``format_validation_error``'s diagnostic message, so request-validation - rejections carry a non-empty wire ``suggestion`` (AdCP POST-F3: the buyer - must learn how to fix the request). The hint names the offending field(s) - and the corrective action, keyed off the Pydantic error ``type``: - - * ``missing`` → provide the required field - * ``string_pattern_mismatch`` / ``string_too_short`` / ``string_too_long`` → fix the value to satisfy the constraint - * ``extra_forbidden`` → remove the unrecognized field - * anything else → correct the field per the AdCP spec + """Return AdCP 3.1.1's canonical validation recovery guidance. + + Field-specific diagnostics remain available in ``message``, ``field``, and + ``details.validation_errors``. The top-level suggestion is protocol + metadata and must not drift by Pydantic error type or transport. """ - errors = validation_error.errors() - if not errors: - return "Correct the request to match the AdCP specification and resend." - - first = errors[0] - field_path = ".".join(str(loc) for loc in first.get("loc", ())) or "request" - error_type = first.get("type", "") - - if "missing" in error_type: - return f"Provide the required '{field_path}' field and resend the request." - if "extra_forbidden" in error_type: - return f"Remove the unrecognized '{field_path}' field; it is not part of the AdCP request schema." - if error_type.startswith("string_pattern_mismatch") or "too_short" in error_type or "too_long" in error_type: - return f"Provide a valid '{field_path}' value that satisfies the AdCP field constraints and resend." - return f"Correct the '{field_path}' field to match the AdCP specification and resend." + # Keep the parameter in the shared boundary API: callers provide the + # ValidationError whose safe diagnostics are projected separately. + del validation_error + return VALIDATION_ERROR_SUGGESTION diff --git a/src/core/webhook_validator.py b/src/core/webhook_validator.py index 624007885f..7302f195be 100644 --- a/src/core/webhook_validator.py +++ b/src/core/webhook_validator.py @@ -75,6 +75,20 @@ def validate_webhook_task_type(task_type: str, fallback: str = WEBHOOK_TASK_TYPE return task_type +def resolve_webhook_task_id(request_data: dict[str, Any] | str | None, step_id: str) -> str: + """Return the buyer-visible task id, falling back for legacy workflow rows. + + A2A persists its outer task id in ``request_data.external_task_id``. MCP/REST + workflows and older A2A rows do not have that field and continue to use the + internal workflow step id. + """ + if isinstance(request_data, dict): + external_task_id = request_data.get("external_task_id") + if isinstance(external_task_id, str) and external_task_id: + return external_task_id + return step_id + + def webhook_ssrf_suggestion() -> str: """Buyer-facing suggestion for registration/outbound SSRF rejections.""" if _strict_mode(): @@ -161,6 +175,40 @@ def _require_https() -> bool: """Production requires HTTPS; ADCP_TESTING keeps HTTP for capture servers.""" return _strict_mode() + @staticmethod + def _require_https_for_webhook() -> bool: + """The ONE scheme rule for AdCP protocol callbacks — registration and delivery. + + Require HTTPS unless the deployment says EXPLICITLY that it is development. + Both gates read this, so they cannot disagree about the scheme the way they + previously did (registration on ``_require_https()``, delivery on + ``ENVIRONMENT == "development"``), which on the default stack let an ``http://`` + reporting webhook register with a success response and then silently never + deliver. + + Spec: ``dist/docs/3.1.1/building/by-layer/L1/security.mdx`` requires sellers to + "Reject non-HTTPS URLs in production" (§Counterparty-supplied URLs, item 1) and + to enforce "URL parsing, HTTPS, hostname normalization, and reserved-range + rejection **at write time**" — i.e. at registration, not only at delivery. + + FAIL-SAFE ON UNKNOWN, which is the part two earlier versions of this rule got + wrong in opposite directions. Keying on ``is_production()`` (a literal ``== + "production"`` compare) made ``prod``, ``staging``, ``test`` and unset all + permissive — an ops team writing ``ENVIRONMENT=prod`` would have shipped plaintext + delivery of callbacks carrying Bearer credentials. Keying on ``_strict_mode()`` + lets ``ADCP_TESTING`` downgrade a production deployment. Only an explicit + ``development`` relaxes this; anything unrecognised is strict, so a typo fails + closed rather than open. + + The permissive case must therefore be DECLARED: ``docker-compose.e2e.yml`` sets + ``ENVIRONMENT: development`` already, and ``docker-compose.yml`` now does too. A + deployment that declares nothing gets HTTPS enforcement at BOTH gates, so the + buyer receives an explicit registration rejection instead of a silent delivery + failure. This is the SCHEME axis of the same single-definition fix + ``_matches_development_test_host`` applies to the HOST axis. + """ + return os.getenv("ENVIRONMENT", "").strip().lower() != "development" + @classmethod def validate_webhook_url(cls, url: str) -> tuple[bool, str]: """ @@ -174,6 +222,44 @@ def validate_webhook_url(cls, url: str) -> tuple[bool, str]: """ return check_url_ssrf(url, require_https=cls._require_https()) + @staticmethod + def _matches_development_test_host(url: str) -> bool: + """True iff ``url`` is the ONE development-only callback host the seam admits. + + One TERM of the host rule, not the whole rule. Read on its own this helper + only guarantees the two gates agree about the development test host; the + ``ADCP_TESTING`` loopback allowance is a second term, applied at registration + and NOT at protocol delivery. So the symmetry this docstring used to claim — + "a host admissible for delivery is admissible to register and vice versa" — + does not hold: measured across ENVIRONMENT x ADCP_TESTING x URL, 5 of 32 + combinations register a callback that delivery then refuses. The direction is + fail-safe (silent non-delivery, not an SSRF hole) and closing it is tracked + separately, because the two gates are not interchangeable: this same protocol + validator is also the buyer-supplied callback gate in create_media_buy, so + relaxing it to match registration would widen an SSRF boundary. + + Inert outside ``ENVIRONMENT=development``: in production this returns False + before reading anything else, so no seam can widen the production gate. + Admits exactly one hostname (the configured value), http/https only, and + never a URL carrying credentials — arbitrary private hosts stay blocked. + """ + if os.getenv("ENVIRONMENT", "").lower() != "development": + return False + test_host = os.getenv("ADCP_WEBHOOK_TEST_HOST") + if not test_host: + return False + parsed = urlparse(url) + try: + _port = parsed.port # malformed port raises here, not at comparison time + except ValueError: + return False + return ( + parsed.hostname == test_host + and parsed.scheme in {"http", "https"} + and parsed.username is None + and parsed.password is None + ) + @classmethod def validate_webhook_url_registration(cls, url: str) -> tuple[bool, str]: """Registration-time SSRF gate (no DNS required). @@ -183,21 +269,73 @@ def validate_webhook_url_registration(cls, url: str) -> tuple[bool, str]: (``validate_outbound_webhook_url``). When ``ADCP_TESTING=true``, localhost/loopback are allowed for capture servers. Production requires HTTPS. + + Also honors the development-only test-host seam (see + ``_matches_development_test_host``): the E2E stack's callback host must be + admissible at REGISTRATION as well as at delivery, or the capture server + can never be registered in the first place. Both E2E modes pair the + emitted callback host with this allowed host — ``tests`` in-network + (docker-compose.e2e.yml), ``host.docker.internal`` standalone + (tests/e2e/conftest.py). """ allow_localhost = _adcp_testing() is_valid, error = check_url_ssrf( url, resolve_dns=False, - require_https=cls._require_https(), + require_https=cls._require_https_for_webhook(), ) - return cls._maybe_allow_localhost(is_valid, error, allow_localhost=allow_localhost) + is_valid, error = cls._maybe_allow_localhost(is_valid, error, allow_localhost=allow_localhost) + if is_valid: + return True, "" + if cls._matches_development_test_host(url): + return True, "" + return False, error @classmethod def validate_outbound_webhook_url(cls, url: str) -> tuple[bool, str]: - """Send-time SSRF gate (full DNS), with localhost allowance under ADCP_TESTING.""" - if _adcp_testing(): - return cls.validate_for_testing(url, allow_localhost=True) - return cls.validate_webhook_url(url) + """Send-time SSRF gate (full DNS) for APPLICATION webhook delivery. + + Reads the same ``_require_https_for_webhook`` policy as the AdCP callback gates, + so the scheme decision is one rule across all three delivery sinks. It previously + short-circuited to ``validate_for_testing`` whenever ``ADCP_TESTING`` was set, + which dropped HTTPS enforcement entirely — including in production. That left the + "a testing flag must not downgrade a deployment" property holding at 1 of the 3 + sinks that can deliver the SAME buyer URL with the SAME Bearer token + (``protocol_webhook_service`` had it; ``webhook_delivery_service`` and + ``order_approval_service``, both reaching here via + ``reject_unsafe_outbound_webhook_url``, did not). + + ``ADCP_TESTING`` still governs the HOST axis — capture servers on localhost — + because that is what the flag is for. It no longer governs the SCHEME axis, which + is a deployment property. + """ + is_valid, error = check_url_ssrf(url, require_https=cls._require_https_for_webhook()) + return cls._maybe_allow_localhost(is_valid, error, allow_localhost=_adcp_testing()) + + @classmethod + def validate_protocol_webhook_url(cls, url: str) -> tuple[bool, str]: + """Validate a protocol callback, with one explicit in-network test seam. + + Production callbacks require HTTPS because notification payloads and + legacy Bearer credentials must not cross a plaintext connection. The + E2E Docker stack may use HTTP only for its exact runner hostname while + in development mode so delivery can be exercised without a public + relay. Arbitrary private hosts are never enabled by this seam. + + The HTTPS decision is ``_require_https_for_webhook()``, shared with the + registration gate so the two cannot disagree about the SCHEME — exactly as + ``_matches_development_test_host`` stops them disagreeing about the HOST. + See that helper for the divergence this closed and for why the rule keys on + ``is_production()`` rather than on ``_strict_mode()``. + """ + is_valid, error = check_url_ssrf(url, require_https=cls._require_https_for_webhook()) + if is_valid: + return True, "" + # Same seam definition the registration gate uses — one source of truth, so + # a host admissible for delivery is admissible to register and vice versa. + if cls._matches_development_test_host(url): + return True, "" + return False, error @classmethod def validate_for_testing(cls, url: str, allow_localhost: bool = False) -> tuple[bool, str]: diff --git a/src/core/workflow_finalization.py b/src/core/workflow_finalization.py new file mode 100644 index 0000000000..c3b243c40f --- /dev/null +++ b/src/core/workflow_finalization.py @@ -0,0 +1,682 @@ +"""Shared terminalization for manually approved media-buy workflow steps.""" + +from __future__ import annotations + +import logging +import time +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, date, datetime, timedelta +from enum import StrEnum +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, cast + +from adcp.types import ContextObject +from sqlalchemy.exc import SQLAlchemyError + +from src.core.database.repositories import ApprovalUoW, WorkflowUoW +from src.core.database.repositories.creative import CreativeAssignmentRepository, CreativeRepository +from src.core.database.repositories.media_buy import ( + ApprovalTrigger, + MediaBuyRepository, + execution_source_statuses_for, +) +from src.core.exceptions import build_two_layer_error_envelope, safe_adcp_error +from src.core.schemas import CreateMediaBuySuccess, Package +from src.core.tools._media_buy_status import resolve_canonical_status + +if TYPE_CHECKING: + from src.core.database.models import WorkflowStep + +logger = logging.getLogger(__name__) + +_FINALIZATION_ATTEMPTS = 3 +_FINALIZATION_RETRY_DELAY_SECONDS = 0.05 +_APPROVAL_EXECUTION_LEASE = timedelta(minutes=15) +_RECOVERABLE_SUCCESS_STATUSES = frozenset({"active", "scheduled", "completed"}) + + +def _retryable_database_error(exc: BaseException) -> bool: + return isinstance(exc, SQLAlchemyError) or ( + isinstance(exc, RuntimeError) and str(exc).startswith("Database is unhealthy") + ) + + +@dataclass(frozen=True) +class ApprovalFinalization: + """Outcome of the terminal compare-and-set after external execution.""" + + applied: bool + result: CreateMediaBuySuccess | None = None + + +class ApprovalExecutionStatus(StrEnum): + """Business states returned by the shared approval execution flow.""" + + READY = "ready" + WAITING_FOR_CREATIVES = "waiting_for_creatives" + NOT_EXECUTABLE = "not_executable" + CLAIM_REFUSED = "claim_refused" + PENDING_RECONCILIATION = "pending_reconciliation" + FAILED = "failed" + SUCCEEDED = "succeeded" + FINALIZATION_FAILED = "finalization_failed" + + +@dataclass(frozen=True) +class ApprovalExecutionOutcome: + """Typed outcome rendered independently by each admin transport.""" + + status: ApprovalExecutionStatus + finalization: ApprovalFinalization | None = None + error_message: str | None = None + blocking_creative_ids: tuple[str, ...] = () + + +@dataclass(frozen=True) +class _ApprovalTerminalPayload: + """Values persisted by one terminal workflow transition.""" + + status: str + response_data: dict[str, Any] + stored_error: str | None + result: CreateMediaBuySuccess | None + + +@dataclass(frozen=True) +class _ApprovalRecoveryState: + """Durable domain state used to reconcile a claimed approval.""" + + step_id: str + request_data: dict[str, Any] + media_buy_status: str + media_buy_updated_at: datetime | None + + +def _run_database_operation_with_retries[T]( + operation: Callable[[], T], + *, + retry_message: str, + exhausted_message: str, +) -> T | None: + """Retry one database-only operation without repeating external work. + + ``exhausted_message`` is REQUIRED and the exhausting exception is logged with it. + Exhaustion returns the same ``None`` a legitimate "nothing to reconcile" returns, so + without a message at every call site the two outcomes are indistinguishable in the + log — and this module exists to recover approvals after a database outage, which + makes its own outage path the one that must not be silent. + """ + for attempt in range(1, _FINALIZATION_ATTEMPTS + 1): + try: + return operation() + except (SQLAlchemyError, RuntimeError) as exc: + if not _retryable_database_error(exc): + raise + if attempt == _FINALIZATION_ATTEMPTS: + logger.error(exhausted_message, exc_info=exc) + return None + logger.warning(retry_message, attempt) + time.sleep(_FINALIZATION_RETRY_DELAY_SECONDS * (2 ** (attempt - 1))) + return None + + +def _approval_terminal_payload( + *, + uow: ApprovalUoW, + media_buy_id: str | None, + succeeded: bool, + error_message: str | None, + context: ContextObject | dict[str, Any] | None, +) -> _ApprovalTerminalPayload: + """Build the safe response and result stored by a terminal transition.""" + assert uow.media_buys is not None + if succeeded: + result: CreateMediaBuySuccess | None = None + response_data: dict[str, Any] = {"approved": True} + if media_buy_id is not None: + packages = uow.media_buys.get_packages(media_buy_id) + result = CreateMediaBuySuccess.sync_success( + media_buy_id=media_buy_id, + packages=[Package(package_id=package.package_id) for package in packages], + context=context, + ) + response_data = result.model_dump(mode="json", exclude_none=True) + return _ApprovalTerminalPayload("completed", response_data, None, result) + + source = safe_adcp_error(RuntimeError(error_message or "Approved media buy execution failed")) + return _ApprovalTerminalPayload( + "failed", + build_two_layer_error_envelope(source), + source.message, + None, + ) + + +def _unknown_execution_claim_result( + *, + uow: ApprovalUoW, + step_id: str, + media_buy_id: str | None, + mark_media_buy_unknown: bool, + media_buy_expected_updated_at: datetime | None, +) -> ApprovalFinalization | None: + """Claim an expired execution lease before publishing a failed task.""" + if not mark_media_buy_unknown or media_buy_id is None: + return None + assert uow.media_buys is not None + assert uow.workflows is not None + if uow.media_buys.mark_approved_execution_unknown( + media_buy_id, + expected_updated_at=media_buy_expected_updated_at, + ): + return None + + existing = uow.workflows.get_by_step_id(step_id) + already_failed = existing is not None and existing.status == "failed" and existing.response_data + return ApprovalFinalization(applied=bool(already_failed)) + + +def _existing_terminal_finalization( + *, + existing: WorkflowStep | None, + expected_status: str, + succeeded: bool, + media_buy_id: str | None, +) -> ApprovalFinalization: + """Return an idempotent result for a transition another worker completed.""" + if existing is not None and existing.status == expected_status and existing.response_data: + if not succeeded or media_buy_id is None: + return ApprovalFinalization(applied=True) + stored_result = CreateMediaBuySuccess.model_validate(existing.response_data) + if stored_result.media_buy_id == media_buy_id: + return ApprovalFinalization(applied=True, result=stored_result) + return ApprovalFinalization(applied=False) + if existing is not None and existing.status == "approved": + raise SQLAlchemyError("approval finalization did not reach a terminal state") + return ApprovalFinalization(applied=False) + + +def _finalize_media_buy_approval_once( + *, + tenant_id: str, + step_id: str, + media_buy_id: str | None, + succeeded: bool, + error_message: str | None, + context: ContextObject | dict[str, Any] | None, + mark_media_buy_failed: bool, + mark_media_buy_unknown: bool, + media_buy_expected_updated_at: datetime | None, + apply_flight_status: bool, +) -> ApprovalFinalization: + """Attempt one atomic terminal transition in a fresh UoW.""" + with ApprovalUoW(tenant_id) as uow: + assert uow.workflows is not None + assert uow.media_buys is not None + claim_result = _unknown_execution_claim_result( + uow=uow, + step_id=step_id, + media_buy_id=media_buy_id, + mark_media_buy_unknown=mark_media_buy_unknown, + media_buy_expected_updated_at=media_buy_expected_updated_at, + ) + if claim_result is not None: + return claim_result + + payload = _approval_terminal_payload( + uow=uow, + media_buy_id=media_buy_id, + succeeded=succeeded, + error_message=error_message, + context=context, + ) + transitioned = uow.workflows.transition_if_nonterminal( + step_id, + status=payload.status, + completed_at=datetime.now(UTC), + response_data=payload.response_data, + error_message=payload.stored_error, + ) + if transitioned is None: + return _existing_terminal_finalization( + existing=uow.workflows.get_by_step_id(step_id), + expected_status=payload.status, + succeeded=succeeded, + media_buy_id=media_buy_id, + ) + if not succeeded and media_buy_id is not None and mark_media_buy_failed and not mark_media_buy_unknown: + uow.media_buys.update_status(media_buy_id, "failed") + elif succeeded and media_buy_id is not None and apply_flight_status: + media_buy = uow.media_buys.get_by_id(media_buy_id) + if media_buy is None: + raise SQLAlchemyError("approved media buy disappeared during terminal finalization") + uow.media_buys.update_status( + media_buy_id, + media_buy_status_from_flight_dates( + start_time=media_buy.start_time, + end_time=media_buy.end_time, + start_date=cast(date, media_buy.start_date), + end_date=cast(date, media_buy.end_date), + ), + ) + return ApprovalFinalization(applied=True, result=payload.result) + + +def finalize_media_buy_approval_step( + *, + tenant_id: str, + step_id: str, + media_buy_id: str | None, + succeeded: bool, + error_message: str | None = None, + context: ContextObject | dict[str, Any] | None = None, + mark_media_buy_failed: bool = True, + mark_media_buy_unknown: bool = False, + media_buy_expected_updated_at: datetime | None = None, + apply_flight_status: bool = False, +) -> ApprovalFinalization: + """Persist the durable terminal outcome of an approval execution. + + The adapter runs in its own UoW, so this helper accepts scalars only and + opens a fresh session. Workflow status, failure-domain status, and stored + response are committed together. The repository's terminal guard ensures + an already-finalized decision is never overwritten. + """ + + def operation() -> ApprovalFinalization: + return _finalize_media_buy_approval_once( + tenant_id=tenant_id, + step_id=step_id, + media_buy_id=media_buy_id, + succeeded=succeeded, + error_message=error_message, + context=context, + mark_media_buy_failed=mark_media_buy_failed, + mark_media_buy_unknown=mark_media_buy_unknown, + media_buy_expected_updated_at=media_buy_expected_updated_at, + apply_flight_status=apply_flight_status, + ) + + result = _run_database_operation_with_retries( + operation, + retry_message=( + "Approval finalization transaction failed; retrying without re-running the adapter (attempt %s)" + ), + exhausted_message=( + "Approval finalization remains pending after bounded retries; " + "tasks/get reconciliation will retry from persisted domain state" + ), + ) + return result or ApprovalFinalization(applied=False) + + +def _claimed_approval_step( + tenant_id: str, + media_buy_id: str, +) -> tuple[str, dict[str, Any]] | None: + """Load the claimed approval needed for creative-unblock finalization.""" + with WorkflowUoW(tenant_id) as uow: + assert uow.workflows is not None + step = uow.workflows.get_claimed_create_approval_step_for_media_buy(media_buy_id) + if step is None: + return None + request_data = dict(step.request_data) if isinstance(step.request_data, dict) else {} + return step.step_id, request_data + + +def finalize_latest_media_buy_approval_step( + *, + tenant_id: str, + media_buy_id: str, + succeeded: bool, + error_message: str | None = None, + apply_flight_status: bool = False, +) -> ApprovalFinalization: + """Finalize the latest claimed approval mapped to a creative-unblocked buy.""" + claimed = _run_database_operation_with_retries( + lambda: _claimed_approval_step(tenant_id, media_buy_id), + retry_message="Claimed approval lookup failed; retrying (attempt %s)", + exhausted_message=( + f"Claimed approval lookup exhausted retries for media buy {media_buy_id} " + f"(tenant {tenant_id}); the approval was NOT finalized and needs reconciliation" + ), + ) + if claimed is None: + return ApprovalFinalization(applied=False) + step_id, request_data = claimed + + return finalize_media_buy_approval_step( + tenant_id=tenant_id, + step_id=step_id, + media_buy_id=media_buy_id, + succeeded=succeeded, + error_message=error_message, + context=request_data.get("context") if isinstance(request_data.get("context"), dict) else None, + apply_flight_status=apply_flight_status, + ) + + +def media_buy_status_from_flight_dates( + *, + start_time: datetime | None, + end_time: datetime | None, + start_date: date | None, + end_date: date | None, + now: datetime | None = None, +) -> str: + """Resolve an approved buy's persisted flight status through the canonical lifecycle resolver.""" + current = now or datetime.now(UTC) + current_utc = current.replace(tzinfo=UTC) if current.tzinfo is None else current.astimezone(UTC) + + def normalize_utc(value: datetime | None) -> datetime | None: + if value is None: + return None + return value.replace(tzinfo=UTC) if value.tzinfo is None else value.astimezone(UTC) + + flight = SimpleNamespace( + status="active", + start_time=normalize_utc(start_time), + end_time=normalize_utc(end_time), + start_date=start_date, + end_date=end_date, + is_paused=False, + ) + canonical = resolve_canonical_status(flight, current_utc.date()) + # Persistence calls this pre-flight state ``scheduled``; the wire lifecycle + # resolver calls the same state ``pending_start``. + return "scheduled" if canonical == "pending_start" else canonical + + +def _approval_creative_gate( + *, + assignments: CreativeAssignmentRepository, + creatives: CreativeRepository, + media_buy_id: str, + principal_id: str, +) -> tuple[bool, tuple[str, ...]]: + """Return whether the buy has at least one assignment and all are approved. + + ``approved`` is the only creative status that satisfies the gate. The other + members of the creative status enum — ``processing``, ``pending_review``, + ``suspended``, ``rejected``, ``archived`` — all block. + """ + creative_ids = list( + dict.fromkeys(assignment.creative_id for assignment in assignments.get_by_media_buy(media_buy_id)) + ) + if not creative_ids: + return False, () + creative_rows = creatives.get_by_ids(creative_ids, principal_id) + status_by_id = {creative.creative_id: creative.status for creative in creative_rows} + blocking_ids = tuple(creative_id for creative_id in creative_ids if status_by_id.get(creative_id) != "approved") + return not blocking_ids, blocking_ids + + +def prepare_media_buy_approval_execution( + *, + media_buys: MediaBuyRepository, + assignments: CreativeAssignmentRepository, + creatives: CreativeRepository, + media_buy_id: str, + approved_by: str | None, + trigger: ApprovalTrigger = ApprovalTrigger.HUMAN_DECISION, +) -> ApprovalExecutionOutcome: + """Decide, and if it applies claim, one adapter execution for a media buy. + + The caller owns the surrounding UoW so the human workflow decision and + the irreversible domain claim can commit together. + + This owns the WHOLE eligibility decision — callers must not pre-filter on + ``media_buy.status``. Four of them used to, each with its own literal, and their + union happened to be the canonical set, so no single site read as wrong: an + approve route that recognised only ``pending_approval`` sent a ``pending_creatives`` + or ``draft`` buy down the plain-workflow path instead, terminalizing the step and + reporting success while the buy was never executed and the creative gate never ran. + + The two refusals are distinct and callers render them differently: + + * ``NOT_EXECUTABLE`` — there is no media buy, or it is not in a state this trigger + may execute from. Nothing was claimed and nothing is wrong; the caller finishes + the workflow step on its own. + * ``CLAIM_REFUSED`` — it WAS a candidate and the atomic claim lost, so another + request is already executing. The caller must not proceed. + """ + source_statuses = execution_source_statuses_for(trigger) + media_buy = media_buys.get_by_id(media_buy_id) + if media_buy is None or media_buy.status not in source_statuses: + return ApprovalExecutionOutcome(ApprovalExecutionStatus.NOT_EXECUTABLE) + + gate_satisfied, blocking_ids = _approval_creative_gate( + assignments=assignments, + creatives=creatives, + media_buy_id=media_buy_id, + principal_id=media_buy.principal_id, + ) + approval_fields: dict[str, Any] = {} + if approved_by is not None: + approval_fields = {"approved_at": datetime.now(UTC), "approved_by": approved_by} + if not gate_satisfied: + media_buys.update_status( + media_buy_id, + "pending_creatives", + **approval_fields, + ) + return ApprovalExecutionOutcome( + ApprovalExecutionStatus.WAITING_FOR_CREATIVES, + blocking_creative_ids=blocking_ids, + ) + + if not media_buys.claim_approved_execution(media_buy_id, trigger=trigger): + return ApprovalExecutionOutcome(ApprovalExecutionStatus.CLAIM_REFUSED) + if approval_fields: + media_buys.update_fields(media_buy_id, **approval_fields) + return ApprovalExecutionOutcome(ApprovalExecutionStatus.READY) + + +def _finalize_approval_execution( + *, + tenant_id: str, + media_buy_id: str, + step_id: str | None, + succeeded: bool, + error_message: str | None, + context: ContextObject | dict[str, Any] | None, + apply_flight_status: bool, +) -> ApprovalFinalization: + """Finalize either an explicitly identified or creative-unblocked step.""" + if step_id is None: + return finalize_latest_media_buy_approval_step( + tenant_id=tenant_id, + media_buy_id=media_buy_id, + succeeded=succeeded, + error_message=error_message, + apply_flight_status=apply_flight_status, + ) + return finalize_media_buy_approval_step( + tenant_id=tenant_id, + step_id=step_id, + media_buy_id=media_buy_id, + succeeded=succeeded, + error_message=error_message, + context=context, + apply_flight_status=apply_flight_status, + ) + + +def apply_media_buy_execution_outcome( + *, + tenant_id: str, + media_buy_id: str, + step_id: str | None, + success: bool | None, + error_message: str | None, + context: ContextObject | dict[str, Any] | None = None, + apply_flight_status: bool = False, +) -> ApprovalExecutionOutcome: + """Own the adapter tri-state to durable workflow-finalization mapping.""" + if success is None: + return ApprovalExecutionOutcome( + ApprovalExecutionStatus.PENDING_RECONCILIATION, + error_message=error_message, + ) + + finalization = _finalize_approval_execution( + tenant_id=tenant_id, + media_buy_id=media_buy_id, + step_id=step_id, + succeeded=success, + error_message=error_message, + context=context, + apply_flight_status=apply_flight_status, + ) + if not finalization.applied or (success and finalization.result is None): + return ApprovalExecutionOutcome( + ApprovalExecutionStatus.FINALIZATION_FAILED, + finalization=finalization, + error_message=error_message, + ) + return ApprovalExecutionOutcome( + ApprovalExecutionStatus.SUCCEEDED if success else ApprovalExecutionStatus.FAILED, + finalization=finalization, + error_message=error_message, + ) + + +def execute_and_finalize_media_buy_approval( + *, + tenant_id: str, + media_buy_id: str, + step_id: str | None, + context: ContextObject | dict[str, Any] | None = None, + apply_flight_status: bool = False, +) -> ApprovalExecutionOutcome: + """Execute one claimed media buy and publish its durable tri-state result.""" + from src.core.tools.media_buy_create import execute_approved_media_buy + + success, error_message = execute_approved_media_buy( + media_buy_id, + tenant_id, + execution_claimed=True, + ) + return apply_media_buy_execution_outcome( + tenant_id=tenant_id, + media_buy_id=media_buy_id, + step_id=step_id, + success=success, + error_message=error_message, + context=context, + apply_flight_status=apply_flight_status, + ) + + +def _approval_recovery_state( + tenant_id: str, + media_buy_id: str, +) -> _ApprovalRecoveryState | None: + """Load workflow and domain rows together for durable reconciliation.""" + with ApprovalUoW(tenant_id) as uow: + assert uow.workflows is not None + assert uow.media_buys is not None + step = uow.workflows.get_claimed_create_approval_step_for_media_buy(media_buy_id) + media_buy = uow.media_buys.get_by_id(media_buy_id) + if step is None or media_buy is None: + return None + request_data = dict(step.request_data) if isinstance(step.request_data, dict) else {} + return _ApprovalRecoveryState( + step_id=step.step_id, + request_data=request_data, + media_buy_status=media_buy.status, + media_buy_updated_at=getattr(media_buy, "updated_at", None), + ) + + +def _approval_context(request_data: dict[str, Any]) -> dict[str, Any] | None: + context = request_data.get("context") + return context if isinstance(context, dict) else None + + +def _utc_datetime(value: datetime | None) -> datetime | None: + if value is not None and value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + + +def _reconcile_approval_state( + *, + tenant_id: str, + media_buy_id: str, + state: _ApprovalRecoveryState, +) -> ApprovalFinalization: + """Project one persisted media-buy state into a terminal workflow result.""" + if state.media_buy_status in _RECOVERABLE_SUCCESS_STATUSES: + return finalize_media_buy_approval_step( + tenant_id=tenant_id, + step_id=state.step_id, + media_buy_id=media_buy_id, + succeeded=True, + context=_approval_context(state.request_data), + apply_flight_status=True, + ) + if state.media_buy_status == "failed": + return finalize_media_buy_approval_step( + tenant_id=tenant_id, + step_id=state.step_id, + media_buy_id=media_buy_id, + succeeded=False, + error_message="Approved media buy execution failed", + ) + if state.media_buy_status == "activation_unknown": + return finalize_media_buy_approval_step( + tenant_id=tenant_id, + step_id=state.step_id, + media_buy_id=media_buy_id, + succeeded=False, + error_message="Approved media buy execution requires operator reconciliation", + mark_media_buy_failed=False, + ) + if state.media_buy_status != "activating": + return ApprovalFinalization(applied=False) + + observed_updated_at = _utc_datetime(state.media_buy_updated_at) + stale_before = datetime.now(UTC) - _APPROVAL_EXECUTION_LEASE + if observed_updated_at is not None and observed_updated_at > stale_before: + return ApprovalFinalization(applied=False) + return finalize_media_buy_approval_step( + tenant_id=tenant_id, + step_id=state.step_id, + media_buy_id=media_buy_id, + succeeded=False, + error_message="Approved media buy execution requires operator reconciliation", + mark_media_buy_failed=False, + mark_media_buy_unknown=True, + media_buy_expected_updated_at=observed_updated_at, + ) + + +def reconcile_claimed_media_buy_approval_step( + *, + tenant_id: str, + media_buy_id: str, +) -> ApprovalFinalization: + """Recover a claimed approval from persisted domain state only. + + ``execute_approved_media_buy`` durably marks a successful buy active and + its public wrapper marks a failed buy failed. A later tasks/get request can + therefore finish the workflow after a database outage without invoking the + external adapter again. + """ + state = _run_database_operation_with_retries( + lambda: _approval_recovery_state(tenant_id, media_buy_id), + retry_message="Approval reconciliation lookup failed; retrying (attempt %s)", + exhausted_message=( + f"Approval reconciliation lookup exhausted retries for media buy {media_buy_id} " + f"(tenant {tenant_id}); the approval remains unreconciled" + ), + ) + if state is None: + return ApprovalFinalization(applied=False) + return _reconcile_approval_state( + tenant_id=tenant_id, + media_buy_id=media_buy_id, + state=state, + ) diff --git a/src/routes/api_v1.py b/src/routes/api_v1.py index 35fe828806..4538203111 100644 --- a/src/routes/api_v1.py +++ b/src/routes/api_v1.py @@ -9,6 +9,7 @@ import json import logging +from types import MappingProxyType from typing import TYPE_CHECKING, Any if TYPE_CHECKING: @@ -22,6 +23,7 @@ from fastapi import APIRouter, Depends, Request from src.core.auth_context import require_auth, resolve_auth +from src.core.auth_policy import AUTH_OPTIONAL_SKILLS from src.core.schema_helpers import ( coerce_creative_filters, to_account_reference, @@ -49,6 +51,10 @@ router = APIRouter(prefix="/api/v1", tags=["api-v1"]) +# REST imports the same transport-neutral policy as A2A and MCP. Route +# signatures index this immutable map instead of maintaining another allowlist. +REST_AUTH_OPTIONAL_DEPENDENCIES = MappingProxyType(dict.fromkeys(AUTH_OPTIONAL_SKILLS, resolve_auth)) + # Note: ToolError handling lives entirely in the global ``@app.exception_handler`` # in src/app.py — REST routes never catch ToolError or import the MCP-boundary @@ -221,7 +227,10 @@ class SyncAccountsBody(SalesAgentBaseModel): @router.post("/products") -async def get_products(body: GetProductsBody, identity: ResolvedIdentity | None = resolve_auth): +async def get_products( + body: GetProductsBody, + identity: ResolvedIdentity | None = REST_AUTH_OPTIONAL_DEPENDENCIES["get_products"], +): """Get available products matching the brief (auth-optional discovery skill). ``ToolError`` propagates to the global handler in ``src.app`` for envelope @@ -239,14 +248,19 @@ async def get_products(body: GetProductsBody, identity: ResolvedIdentity | None @router.get("/capabilities") -async def get_capabilities(identity: ResolvedIdentity | None = resolve_auth): +async def get_capabilities( + identity: ResolvedIdentity | None = REST_AUTH_OPTIONAL_DEPENDENCIES["get_adcp_capabilities"], +): """Get AdCP capabilities (auth-optional discovery skill).""" response = await capabilities_module.get_adcp_capabilities_raw(identity=identity) return response.model_dump(mode="json") @router.post("/creative-formats") -async def list_creative_formats(body: ListCreativeFormatsBody, identity: ResolvedIdentity | None = resolve_auth): +async def list_creative_formats( + body: ListCreativeFormatsBody, + identity: ResolvedIdentity | None = REST_AUTH_OPTIONAL_DEPENDENCIES["list_creative_formats"], +): """List available creative formats (auth-optional discovery skill).""" from src.core.schemas import ListCreativeFormatsRequest @@ -260,7 +274,8 @@ async def list_creative_formats(body: ListCreativeFormatsBody, identity: Resolve @router.post("/authorized-properties") async def list_authorized_properties( - body: ListAuthorizedPropertiesBody, identity: ResolvedIdentity | None = resolve_auth + body: ListAuthorizedPropertiesBody, + identity: ResolvedIdentity | None = REST_AUTH_OPTIONAL_DEPENDENCIES["list_authorized_properties"], ): """List authorized properties (auth-optional discovery skill).""" from src.core.schemas import ListAuthorizedPropertiesRequest diff --git a/src/services/background_approval_service.py b/src/services/background_approval_service.py index d850a3ef2c..412edfa532 100644 --- a/src/services/background_approval_service.py +++ b/src/services/background_approval_service.py @@ -192,6 +192,27 @@ def _update_approval_progress(tenant_id: str, workflow_step_id: str, progress_da logger.warning(f"Failed to update approval progress: {e}") +def _log_refused_transition(workflow_step_id: str, status: str) -> None: + """Record that ``transition_if_nonterminal`` refused to finalize a step. + + The primitive returns None when the step is ALREADY terminal — a buyer cancel, an + admin rejection or another finalizer committed first — and a terminal step is + immutable, so refusing is the correct outcome of that race, not an error. It must + still be reported honestly: this polling thread is the only observer of its own + writes, so the log line below is the sole operator-visible record of what happened, + and the unconditional "Marked ... as completed" it replaces named a status the row + does not carry. + + Shared by both finalizers so the refusal is worded once (CLAUDE.md DRY invariant). + """ + logger.warning( + "Workflow step %s was NOT marked as %s: it is already terminal (concurrently " + "canceled/rejected/finalized). Leaving the committed outcome in place.", + workflow_step_id, + status, + ) + + def _mark_approval_complete( tenant_id: str, workflow_step_id: str, order_id: str, attempts: int, elapsed_seconds: float ) -> None: @@ -199,7 +220,7 @@ def _mark_approval_complete( try: with WorkflowUoW(tenant_id) as uow: assert uow.workflows is not None - step = uow.workflows.update_status( + step = uow.workflows.transition_if_nonterminal( workflow_step_id, status="completed", completed_at=datetime.now(UTC), @@ -209,7 +230,9 @@ def _mark_approval_complete( "message": f"Order approved successfully after {attempts} attempts ({int(elapsed_seconds)}s)", }, ) - if step: + if step is None: + _log_refused_transition(workflow_step_id, "completed") + else: step.transaction_details = { "approval_status": "approved", "gam_order_status": "APPROVED", @@ -217,7 +240,7 @@ def _mark_approval_complete( "elapsed_seconds": int(elapsed_seconds), "completed_at": datetime.now(UTC).isoformat(), } - logger.info(f"Marked workflow step {workflow_step_id} as completed") + logger.info("Marked workflow step %s as completed", workflow_step_id) except Exception as e: logger.error(f"Failed to mark approval complete: {e}") @@ -227,15 +250,17 @@ def _mark_approval_failed(tenant_id: str, workflow_step_id: str, error_message: try: with WorkflowUoW(tenant_id) as uow: assert uow.workflows is not None - step = uow.workflows.update_status( + step = uow.workflows.transition_if_nonterminal( workflow_step_id, status="failed", error_message=error_message, response_data={"status": "failed", "error": error_message}, ) - if step: + if step is None: + _log_refused_transition(workflow_step_id, "failed") + else: step.transaction_details = {"approval_status": "failed", "failure_reason": error_message} - logger.info(f"Marked workflow step {workflow_step_id} as failed") + logger.info("Marked workflow step %s as failed", workflow_step_id) except Exception as e: logger.error(f"Failed to mark approval failed: {e}") diff --git a/src/services/protocol_webhook_service.py b/src/services/protocol_webhook_service.py index 28446bbce9..7f26b1d7d8 100644 --- a/src/services/protocol_webhook_service.py +++ b/src/services/protocol_webhook_service.py @@ -19,7 +19,6 @@ from collections.abc import Mapping from datetime import UTC, datetime from typing import Any, cast -from urllib.parse import urlparse, urlunparse from uuid import uuid4 import requests @@ -33,10 +32,7 @@ from src.core.database.models import PushNotificationConfig from src.core.database.repositories.delivery import DeliveryRepository from src.core.lifecycle import register_shutdown -from src.core.webhook_validator import ( - reject_unsafe_outbound_webhook_url, - webhook_url_for_log, -) +from src.core.webhook_validator import WebhookURLValidator, webhook_url_for_log logger = logging.getLogger(__name__) @@ -107,25 +103,6 @@ def _to_wire_dict(payload: Any) -> dict[str, Any]: ) -def _normalize_localhost_for_docker(url: str) -> str: - """Replace localhost host with host.docker.internal while preserving userinfo and port.""" - try: - parsed = urlparse(url) - if parsed.hostname and parsed.hostname.lower() == "localhost": - userinfo = "" - if parsed.username: - userinfo = parsed.username - if parsed.password: - userinfo += f":{parsed.password}" - userinfo += "@" - port = f":{parsed.port}" if parsed.port else "" - new_netloc = f"{userinfo}host.docker.internal{port}" - return urlunparse(parsed._replace(netloc=new_netloc)) - except Exception: - logger.debug("Docker URL rewrite failed, using original URL", exc_info=True) - return url - - class ProtocolWebhookService: """ Service for sending protocol-level push notifications to clients. @@ -164,25 +141,22 @@ async def send_notification( ) return False - # SSRF gate on the configured URL *before* docker localhost rewrite. - # Under ADCP_TESTING, localhost/loopback is allowed for capture servers; - # production uses the full DNS-backed check (HTTPS required). - rejected, _error_msg = reject_unsafe_outbound_webhook_url( - push_notification_config.url, - log=logger, - kind="Protocol", - ) - if rejected: - return False - - url = _normalize_localhost_for_docker(push_notification_config.url) + # No docker-localhost rewrite here: this branch replaced that server-side + # dev convenience with the client-side ADCP_WEBHOOK_HOST/ADCP_WEBHOOK_TEST_HOST + # pair set in tests/e2e/conftest.py, so the capture server is registered at its + # reachable host directly. The SSRF gate lives per-attempt in the retry loop + # below (validate_protocol_webhook_url) rather than once here: it re-checks on + # every retry (covering DNS rebinding between attempts) and it is the + # protocol-callback validator, which carries the ADCP_WEBHOOK_TEST_HOST seam + # that #1697's generic validate_outbound_webhook_url does not. + url = push_notification_config.url # Prepare headers headers = {"Content-Type": "application/json", "User-Agent": "AdCP-Sales-Agent/1.0"} # Log sanitized config (exclude sensitive authentication_token) safe_config = { - "url": push_notification_config.url if hasattr(push_notification_config, "url") else None, + "url_configured": bool(push_notification_config.url), "authentication_type": ( push_notification_config.authentication_type if hasattr(push_notification_config, "authentication_type") @@ -217,6 +191,51 @@ async def send_notification( url=url, payload=payload_dict, headers=headers, metadata=metadata ) + def _record_refused_unsafe_url( + self, + *, + reason: str, + log_id: str, + tenant_id: str | None, + principal_id: str | None, + media_buy_id: str | None, + url: str, + task_type: str | None, + sequence_number: int, + notification_type: str | None, + attempt_count: int, + audit_logger: Any = None, + ) -> None: + """Record an SSRF refusal the way every other failure exit in the send loop does. + + A refusal is indistinguishable, from the buyer's side, from a webhook that + silently stopped arriving, so it needs the same delivery-log row and audit line an + HTTP failure gets — not just a server-side warning. It was the one failure exit + that wrote neither, and it discarded the validator's reason, which is precisely + the operator-facing detail that explains why delivery stopped. + + ``reason`` names the rejected host/scheme class and is operator-facing only: it + goes to the log, the delivery row and the audit trail, never onto the wire. + """ + logger.warning("Refusing protocol webhook delivery to an unsafe URL: %s", reason) + if task_type in ("delivery_report", "media_buy_delivery") and media_buy_id and tenant_id and principal_id: + self._write_delivery_log( + log_id=log_id, + tenant_id=tenant_id, + principal_id=principal_id, + media_buy_id=media_buy_id, + webhook_url=url, + task_type=task_type, + status="failed", + sequence_number=sequence_number, + notification_type=notification_type, + attempt_count=attempt_count, + error_message=f"Refused unsafe webhook URL: {reason}", + completed_at=datetime.now(UTC), + ) + if audit_logger: + audit_logger.log_warning(f"{task_type} webhook refused: unsafe URL ({reason})") + @staticmethod def _write_delivery_log( *, @@ -304,6 +323,22 @@ async def _send_with_retry_and_logging( audit_logger.log_info(f"Sending {task_type} webhook for task {task_id} (sequence #{sequence_number})") for attempt in range(max_attempts): + is_safe, validation_error = WebhookURLValidator.validate_protocol_webhook_url(url) + if not is_safe: + self._record_refused_unsafe_url( + reason=validation_error, + log_id=log_id, + tenant_id=tenant_id, + principal_id=principal_id, + media_buy_id=media_buy_id, + url=url, + task_type=task_type, + sequence_number=sequence_number, + notification_type=notification_type, + attempt_count=attempt + 1, + audit_logger=audit_logger, + ) + return False try: safe_url = webhook_url_for_log(url) logger.info( @@ -320,6 +355,11 @@ def _post() -> requests.Response: return self._session.post(url, json=payload, headers=headers, timeout=10.0, allow_redirects=False) response = await asyncio.to_thread(_post) + if 300 <= response.status_code < 400: + raise requests.HTTPError( + "Redirect responses are not accepted for protocol webhooks", + response=response, + ) response.raise_for_status() # Calculate response time @@ -365,9 +405,14 @@ def _post() -> requests.Response: response_time_ms = int((time.time() - start_time) * 1000) error_message = f"HTTP {status_code}: {str(e)}" - # Don't retry on 4xx errors (client errors - permanent failures) - if status_code and 400 <= status_code < 500: - logger.error(f"Webhook failed for task {task_id} with client error {status_code} - not retrying") + # Don't retry redirects or 4xx errors. Redirects are never + # followed because the Location target has not been validated. + if status_code and 300 <= status_code < 500: + logger.error( + "Webhook failed for task %s with non-retryable HTTP status %s", + task_id, + status_code, + ) # Write to webhook_delivery_log (failed) if ( diff --git a/templates/media_buy_detail.html b/templates/media_buy_detail.html index 8d8daef5d8..684c7b4f81 100644 --- a/templates/media_buy_detail.html +++ b/templates/media_buy_detail.html @@ -21,6 +21,7 @@

Media Buy Details

+ @@ -384,6 +385,7 @@

Reject Media Buy

+