You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Secondary "pause leaves workflow step in in_progress" bug is fixed (src/core/tools/media_buy_update.py:551; regression test tests/unit/test_update_media_buy_behavioral.py:546).
Original proposed fix (populate affected_packages on the success shape) is superseded by the pinned spec's dedicated submitted arm.
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.
get_task(task_id) returns the pending state; the buyer can poll.
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".
On completion, the buyer is notified (webhook carrying the spec-canonical envelope) and the workflow step is finalized completed.
On rejection, the buyer is notified; no changes applied.
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.
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.
Child of #1531. Verified at HEAD a55db82 (pin adcp==5.7.0 / AdCP 3.1.0-beta.3).
Impact
With
manual_approval_required=true, everyupdate_media_buy(budget, dates, pause/resume, creative assignments, cancellation) is accepted, reported successful, and silently discarded.manual_approval_operationsdefaults 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):
src/core/tools/media_buy_update.py:376-410) creates a workflow step (requires_approval), stores the request (request_obj=req), creates anObjectWorkflowMapping(action="update"), and returnsUpdateMediaBuySuccess(affected_packages=[]).Approval side (broken):
media_buy.status="pending_approval"(the update path only ever writespending_creatives).object_type=="media_buy"only, ignoremapping.action, and gate execution onstatus=="pending_approval"(src/admin/blueprints/workflows.py:199,src/admin/blueprints/operations.py:368). For an already-live buy that condition is never true.execute_approved_media_buy— the create path. No code reads the stored request back to replay an update.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):
UpdateMediaBuySuccesscarries nostatusand notask_id/workflow_step_id(src/core/schemas/_base.py:435,:450-453), so A2A reportscompleted, notsubmitted, and the buyer has nothing to pollget_taskwith.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
in_progress" bug is fixed (src/core/tools/media_buy_update.py:551; regression testtests/unit/test_update_media_buy_behavioral.py:546).affected_packageson the success shape) is superseded by the pinned spec's dedicated submitted arm.Contract (spec-grounded, full end-to-end)
Spec:
tests/fixtures/adcp_schemas_pinned/media-buy/update-media-buy-response.json→UpdateMediaBuySubmitted(required: [status, task_id]; description: "operator re-approval for mid-flight changes"). Obligations:docs/test-obligations/UC-003-update-media-buy.mdALT-MANUAL-APPROVAL-REQUIRED-01..05 (BR-RULE-017/020).UpdateMediaBuySubmittedwithstatus="submitted",task_id(= workflow step id),implementation_date: null. No empty synchronous success.get_task(task_id)returns the pending state; the buyer can poll.completed.Implementation constraints (landmines)
mapping.action. Do NOT route updates throughexecute_approved_media_buy— it runs the adapter create path +approve_order+ forcesstatus="active"(src/core/tools/media_buy_create.py:918/476/500) → duplicate ad-server order and clobberedpaused.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._update_media_buy_implhas 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.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_implreturns success, adapter not called now, step created). None drive the approval flow. Required additions:completed.UpdateMediaBuySubmittedwith atask_idthatget_taskresolves.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.