Summary
In createAdcpServerFromPlatform, the updateMediaBuy handler is wrapped in projectSync only, whereas getProducts, createMediaBuy, syncCreatives, and getSignals are wrapped in routeIfHandoff. Because of this, an adopter's update_media_buy handler can't return a ctx.handoffToTask(...) marker to go async. The framework never creates or owns the task, so it isn't registered with a framework-computed owner_scope, which means it can't be polled via tasks/get / get_task_status.
This is inconsistent with the spec. update_media_buy is a valid async task type (it's in enums/task-type.json and in SPEC_WEBHOOK_TASK_TYPES in runtime/protocol-for-tool.mjs), so async is spec-legal for it, but the runtime doesn't wire it up.
Version
@adcp/sdk@12.0.4 (spec 3.1.2). Present throughout the 12.x line.
Where (dist paths / symbols)
dist/lib/server/decisioning/runtime/from-platform.mjs:
routeIfHandoff is used by getProducts (~1858, 1936), createMediaBuy (~2007), syncCreatives (~2095, 2295), and getSignals (~2464).
- The
updateMediaBuy handler (~2033–2081) returns projectSync(...) (~2054) with no routeIfHandoff branch.
routeIfHandoff (defined ~1224) gates on isTaskHandoff(result); projectSync (~1173) has no handoff detection at all.
Impact
Adopters whose update_media_buy is genuinely slow (for example, an ad-server activation sync that runs past the buyer's transport timeout) have no framework path to return a Submitted task. Working around it means manually writing to the taskRegistry and hand-computing owner_scope to match what tasks/get re-derives (taskOwnerScopeFor → session:${ctx.sessionKey} when a session key is present). If the manual owner_scope doesn't match exactly, taskBelongsToCaller fails and every poll returns REFERENCE_NOT_FOUND even though the task completed. It's a silent, easy-to-hit footgun, and it forces adopters to duplicate internal SDK logic they shouldn't need to know about.
Steps to reproduce
- Adopter
update_media_buy handler returns ctx.handoffToTask(fn).
- The marker is ignored or mis-validated (it goes through
projectSync, not routeIfHandoff).
- Work around it with a manual
taskRegistry.create({...}) that omits ownerScope, so the row falls back to account:<id>.
- Poll
tasks/get with the returned task_id and get REFERENCE_NOT_FOUND, because the poll expects session:<id>.
Expected
updateMediaBuy should be wrapped in routeIfHandoff like the other async-capable tools, so that a handler returning ctx.handoffToTask(...) is framework-owned. Task creation, owner_scope (taskOwnerScopeFor), polling, and webhook emission would all be handled by the runtime, with no adopter-side owner_scope duplication.
Proposed fix
Wrap the updateMediaBuy dispatch result in routeIfHandoff, mirroring the createMediaBuy closure (~2007):
return routeIfHandoff(
taskRegistry,
{
tool: "update_media_buy",
accountId,
ownerScope: taskOwnerScopeFor(ctx, reqCtx.account.id),
// …
},
result,
project
);
update_media_buy is already in SPEC_WEBHOOK_TASK_TYPES, so sync-completion and async webhooks will emit consistently.
Workaround (for other adopters hitting this)
Register the task manually on the same taskRegistry the server was constructed with, and set owner_scope to session:${ctx.sessionKey} (that is, whatever your configured resolveSessionKey returns) so tasks/get scope-matches. Note that sessionKey isn't exposed on the handler's reqCtx, so you'll need to derive it the same way your resolveSessionKey does.
Summary
In
createAdcpServerFromPlatform, theupdateMediaBuyhandler is wrapped inprojectSynconly, whereasgetProducts,createMediaBuy,syncCreatives, andgetSignalsare wrapped inrouteIfHandoff. Because of this, an adopter'supdate_media_buyhandler can't return actx.handoffToTask(...)marker to go async. The framework never creates or owns the task, so it isn't registered with a framework-computedowner_scope, which means it can't be polled viatasks/get/get_task_status.This is inconsistent with the spec.
update_media_buyis a valid async task type (it's inenums/task-type.jsonand inSPEC_WEBHOOK_TASK_TYPESinruntime/protocol-for-tool.mjs), so async is spec-legal for it, but the runtime doesn't wire it up.Version
@adcp/sdk@12.0.4(spec 3.1.2). Present throughout the 12.x line.Where (dist paths / symbols)
dist/lib/server/decisioning/runtime/from-platform.mjs:routeIfHandoffis used bygetProducts(~1858, 1936),createMediaBuy(~2007),syncCreatives(~2095, 2295), andgetSignals(~2464).updateMediaBuyhandler (~2033–2081) returnsprojectSync(...)(~2054) with norouteIfHandoffbranch.routeIfHandoff(defined ~1224) gates onisTaskHandoff(result);projectSync(~1173) has no handoff detection at all.Impact
Adopters whose
update_media_buyis genuinely slow (for example, an ad-server activation sync that runs past the buyer's transport timeout) have no framework path to return a Submitted task. Working around it means manually writing to thetaskRegistryand hand-computingowner_scopeto match whattasks/getre-derives (taskOwnerScopeFor→session:${ctx.sessionKey}when a session key is present). If the manualowner_scopedoesn't match exactly,taskBelongsToCallerfails and every poll returnsREFERENCE_NOT_FOUNDeven though the task completed. It's a silent, easy-to-hit footgun, and it forces adopters to duplicate internal SDK logic they shouldn't need to know about.Steps to reproduce
update_media_buyhandler returnsctx.handoffToTask(fn).projectSync, notrouteIfHandoff).taskRegistry.create({...})that omitsownerScope, so the row falls back toaccount:<id>.tasks/getwith the returnedtask_idand getREFERENCE_NOT_FOUND, because the poll expectssession:<id>.Expected
updateMediaBuyshould be wrapped inrouteIfHandofflike the other async-capable tools, so that a handler returningctx.handoffToTask(...)is framework-owned. Task creation,owner_scope(taskOwnerScopeFor), polling, and webhook emission would all be handled by the runtime, with no adopter-sideowner_scopeduplication.Proposed fix
Wrap the
updateMediaBuydispatch result inrouteIfHandoff, mirroring thecreateMediaBuyclosure (~2007):update_media_buyis already inSPEC_WEBHOOK_TASK_TYPES, so sync-completion and async webhooks will emit consistently.Workaround (for other adopters hitting this)
Register the task manually on the same
taskRegistrythe server was constructed with, and setowner_scopetosession:${ctx.sessionKey}(that is, whatever your configuredresolveSessionKeyreturns) sotasks/getscope-matches. Note thatsessionKeyisn't exposed on the handler'sreqCtx, so you'll need to derive it the same way yourresolveSessionKeydoes.