Skip to content

Bug: manual-approval update_media_buy is accepted but never applied, and returns a non-spec envelope #1041

Description

@estofko-scope3

Child of #1531. Verified at HEAD a55db82 (pin adcp==5.7.0 / AdCP 3.1.0-beta.3).

Impact

With manual_approval_required=true, every update_media_buy (budget, dates, pause/resume, creative assignments, cancellation) is accepted, reported successful, and silently discarded. manual_approval_operations defaults to ["create_media_buy", "update_media_buy", "add_creative_assets"] (src/adapters/base.py:231), so update-gating is on by default for any tenant that enables manual approval — not an opt-in edge case.

What actually happens (traced)

Request side (works):

  • The manual-approval branch (src/core/tools/media_buy_update.py:376-410) creates a workflow step (requires_approval), stores the request (request_obj=req), creates an ObjectWorkflowMapping(action="update"), and returns UpdateMediaBuySuccess(affected_packages=[]).

Approval side (broken):

  • The branch never sets media_buy.status="pending_approval" (the update path only ever writes pending_creatives).
  • Both admin approval handlers match the mapping by object_type=="media_buy" only, ignore mapping.action, and gate execution on status=="pending_approval" (src/admin/blueprints/workflows.py:199, src/admin/blueprints/operations.py:368). For an already-live buy that condition is never true.
  • The only executor available is execute_approved_media_buy — the create path. No code reads the stored request back to replay an update.
  • The step is set status="approved" and committed before the check (workflows.py:165-173); the else-branch flashes "Workflow step approved successfully" and no-ops (workflows.py:248-252). The operator is told it worked, the step records "approved", nothing is applied.

Buyer side (broken):

  • UpdateMediaBuySuccess carries no status and no task_id/workflow_step_id (src/core/schemas/_base.py:435, :450-453), so A2A reports completed, not submitted, and the buyer has nothing to poll get_task with.
  • Approval uses workflow_repo.update_status (src/core/database/repositories/workflow.py:278), a pure DB write with no webhook — no completion notification is delivered.

Record corrections

Contract (spec-grounded, full end-to-end)

Spec: tests/fixtures/adcp_schemas_pinned/media-buy/update-media-buy-response.jsonUpdateMediaBuySubmitted (required: [status, task_id]; description: "operator re-approval for mid-flight changes"). Obligations: docs/test-obligations/UC-003-update-media-buy.md ALT-MANUAL-APPROVAL-REQUIRED-01..05 (BR-RULE-017/020).

  1. On a gated update, persist the proposed state and return UpdateMediaBuySubmitted with status="submitted", task_id (= workflow step id), implementation_date: null. No empty synchronous success.
  2. get_task(task_id) returns the pending state; the buyer can poll.
  3. On operator approval, the stored update is applied via the adapter (the same dispatch seam as the auto path) — obligation -03, "adapter is called at approval time, not request time".
  4. On completion, the buyer is notified (webhook carrying the spec-canonical envelope) and the workflow step is finalized completed.
  5. On rejection, the buyer is notified; no changes applied.
  6. Applies to all mutation types, including campaign-level pause/resume ("pause may require manual approval → submitted").

Implementation constraints (landmines)

  • Branch on mapping.action. Do NOT route updates through execute_approved_media_buy — it runs the adapter create path + approve_order + forces status="active" (src/core/tools/media_buy_create.py:918/476/500) → duplicate ad-server order and clobbered paused.
  • Do not reuse create's status="pending_approval" as the update pending marker — the create approval handler keys off it and would fire the create executor. Use a distinct pending representation for updates.
  • Replay must bypass the approval gate. _update_media_buy_impl has no bypass parameter (src/core/tools/media_buy_update.py:13); re-invoking it re-hits the gate → infinite defer. Factor the post-gate apply logic into a separately-callable path.
  • Concurrency decision required. Today multiple pending updates accumulate as separate steps/mappings (the gate does not dedupe/replace); revision (optimistic concurrency) is unimplemented. Decide overwrite-vs-queue and stale-state handling; coordinate with idempotency (feat: Add idempotency_key support on update_media_buy and sync_creatives #1075 / idempotency_key required on update_media_buy: schema requires it, impl leaves it optional — how to proceed? #1470).

Tests (existing coverage is request-only)

tests/unit/test_update_media_buy_behavioral.py::TestUC003ManualApproval -03/-04/-05 pass at HEAD but assert only the request half (_update_media_buy_impl returns success, adapter not called now, step created). None drive the approval flow. Required additions:

  • Drive the admin approve endpoint → assert adapter update called, DB reflects the change, completion webhook fired, step completed.
  • Assert the response is UpdateMediaBuySubmitted with a task_id that get_task resolves.
  • A rejection test: no changes applied + buyer notified.

Scope boundary

Owns the manual-approval update path end-to-end (server apply + update-response envelope + task_id). Auto-path adapter dispatch is #1089/#1529/#1261; create-response and delivery envelopes remain #1305. All under #1531.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status
    Ready for Dev

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions