Skip to content

feat(server): DecisioningPlatform v6.0 preview surface#1005

Open
bokelley wants to merge 54 commits intomainfrom
bokelley/decisioning-platform-v1-scaffold
Open

feat(server): DecisioningPlatform v6.0 preview surface#1005
bokelley wants to merge 54 commits intomainfrom
bokelley/decisioning-platform-v1-scaffold

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

@bokelley bokelley commented Apr 25, 2026

Summary

Preview / 6.0 surface for the next generation of @adcp/client server adopters. Adopters implement a single DecisioningPlatform class organized by specialism; the framework owns wire-mapping, account resolution, idempotency, error envelopes, async task lifecycle, and status-change projection. No more per-tool handlers, no ctx.store plumbing, no governance threading by hand.

import {
  AdcpError,
  createAdcpServerFromPlatform,
  publishStatusChange,
  type DecisioningPlatform,
  type SalesPlatform,
} from '@adcp/client/server/decisioning';

class GamPlatform implements DecisioningPlatform<GamConfig, GamMeta> {
  capabilities = {
    specialisms: ['sales-non-guaranteed'] as const,
    creative_agents: [],
    channels: ['display', 'video'] as const,
    pricingModels: ['cpm'] as const,
    config: { /* ... */ },
  };

  accounts = {
    resolve: async (ref) => ({ id, name, status: 'active', metadata, authInfo }),
  };

  sales: SalesPlatform = {
    getProducts: async (req) => ({ products: [...] }),
    createMediaBuy: async (req) => {
      if (req.total_budget.amount < this.config.floor) {
        throw new AdcpError('BUDGET_TOO_LOW', { recovery: 'correctable', message: '...' });
      }
      const order = await this.gam.createOrder(req);
      return { media_buy_id: order.id, status: 'pending_creatives', confirmed_at: new Date().toISOString() };
    },
    // ... updateMediaBuy, syncCreatives, getMediaBuyDelivery
  };
}

const platform = new GamPlatform();
const server = createAdcpServerFromPlatform(platform, { name: 'gam', version: '1.0.0' });
serve(() => server, { publicUrl: 'https://gam.example.com' });

The framework's createAdcpServerFromPlatform<P> constrains P so every claimed specialism's interface must be implemented (compile-time). Drop a method or claim a specialism without an implementation → fail compile.

What's wired

Platform interfaces (4):

  • SalesPlatform — 9 sales-* specialisms (non-guaranteed, guaranteed, broadcast-tv, social, catalog-driven, proposal-mode, …)
  • CreativeTemplatePlatform / CreativeGenerativePlatform — stateless transform / brief-to-creative
  • AudiencePlatform — audience-sync
  • SignalsPlatform — signal-marketplace, signal-owned

v2.1 dual-method async shape: for each spec-HITL tool (createMediaBuy, syncCreatives), adopter implements EXACTLY ONE of { xxx, xxxTask }. Framework dispatches sync OR HITL based on which is defined; validatePlatform() enforces exactly-one at construction. Dual-method only on tools whose wire response unions actually have a Submitted arm — every other tool is sync-only.

Wire-shape return values (Path A): platform methods return wire success-arm shapes directly (CreateMediaBuySuccess, SyncCreativesSuccess['creatives'], ActivateSignalSuccess). No intermediate types, no translation drift. Framework wraps with the response envelope; throw AdcpError for structured rejection (45 spec error codes). Zero as never casts in adopter code.

Status-change channel (publishStatusChange): module-level event bus for spec-native lifecycle channels (8 resource types: media_buy, creative, audience, signal, proposal, plan, rights_grant, delivery_report). Adopters call from webhook handlers, crons, in-process workers without holding a server reference.

Multi-tenant (createTenantRegistry): per-tenant config + 3 health states (healthy/unverified/disabled). JWKS validator against {agentUrl}/.well-known/brand.json. Composes with the existing serve() host-routing surface. Includes admin Express router for ops visibility.

Helpers:

  • getAsset / requireAsset — typed accessors for creative_manifest.assets[asset_id] (preserves discriminator narrowing)
  • resolveStartTime — handles the 'asap' | string | undefined union with platform-specific lead time (programmatic = now; broadcast = now + approval window)
  • toWireAccount — strips metadata + authInfo when projecting Account<TMeta> to wire shape

Public type re-exports: wire request/response types + asset-instance discriminated union shipped from @adcp/client/types. Adopters never reach into tools.generated.ts.

Skills:

  • skills/build-decisioning-creative-template/SKILL.md — adapter-first, ~14 KB
  • skills/build-decisioning-signal-marketplace/SKILL.md — adapter-first, ~7 KB
  • Both gated by typecheck:skill-examples CI step (extracts every fenced block, compiles against published dist/)

Worked sample platforms (5):

  • MockSyncSeller / MockHitlSeller — paired sync vs HITL demonstrations
  • BroadcastTvSeller — sales-broadcast-tv with HITL *Task everywhere
  • ProgrammaticSeller — sales-non-guaranteed sync + post-commit publishStatusChange
  • LiveRampAudienceProvider — audience-sync with multi-stage status changes (matching → matched → activating → active)
  • Multi-tenant deploymentTenantRegistry wiring two distinct sellers behind separate hosts

Validation

Five rounds of "be Emma" adopter simulation (fresh agent reads skill + SDK, builds an adapter from scratch, scores friction):

Round Specialism Adopter Verdict
4 creative-template AudioStack TTS 4.5/5
6a sales-guaranteed PremiereTV broadcaster 4.5/5
6c signal-marketplace DataMatrix data broker 4.5/5

Three primary types at consistent parity. Direct quote from round 6c: "Equivalent to creative-template and sales-guaranteed. Same shape, same error idiom, same publishStatusChange pattern." Zero as any / as never / as unknown casts in any of the produced adapters. Each adapter shipped in ~150 LOC of business logic.

Upstream spec issues filed

Surfaced during the simulation rounds. SDK can't ship cleanly until these land:

What's deferred

  • Governance specialisms (5: campaign-governance, creative-review, content-standards, property-lists, collection-lists) — blocked on spec issue #3329; the platform-interface design depends on the taxonomy resolving
  • creative-ad-server specialism — clean (Flashtalking-shaped); ship reactively when first adopter lands
  • brand-rights specialism — same; ship reactively
  • MCP Resources subscription wiring for publishStatusChange — parked until AdCP 3.1 picks up the resource-subscription model. Today's bus is in-memory; adopters who need wire projection run their own webhook infrastructure
  • Forward-compat 3.0 ↔ 3.1 wire shapes — pinned to 3.0 GA; framework adapts when 3.1 lands
  • tasks/get wire path for HITL polling — task registry has the data; wire-level integration ships in v6.0-rc.1

Migration path for adopters

  • 5.x → 6.0: re-implement the existing handler bag (createAdcpServer({ mediaBuy, creative, accounts })) as a DecisioningPlatform class. Most handler bodies port one-to-one — replace return adcpError(...) with throw new AdcpError(...), return wire success-arm shapes directly, drop the ctx.store plumbing.
  • 5.x continues to work unchanged during the v6.0 preview. Both surfaces ship side-by-side.

Test plan

  • npm run typecheck passes (zero errors)
  • npm test passes — 6049+ tests, no failures introduced
  • npm run typecheck:skill-examples passes — every fenced block in skills/ compiles against dist/
  • npm run lint clean — zero errors (warnings only)
  • CI workflow updated to run typecheck:skill-examples on every PR
  • Five adopter simulations confirm consistent 4.5/5 friction across the three primary specialism types

Files touched

40+ commits over the iteration. Highlights:

  • src/lib/server/decisioning/ — new subdirectory; all platform interfaces, helpers, runtime
  • src/lib/types/index.ts — public re-exports for adopter-facing wire types
  • src/lib/types/asset-instances.ts — public re-exports for asset-type discriminated unions
  • examples/decisioning-platform-*.ts — five worked examples
  • skills/build-decisioning-*/ — two adapter-first skills
  • scripts/generate-types.ts — strips JSON Schema if/then/else before jsts (eliminates intersection-noise types)
  • .github/workflows/ci.yml — adds typecheck:skill-examples step

🤖 Generated with Claude Code

Type surface for the v6.0 framework refactor: adopters describe their
decisioning system once via per-specialism interfaces; framework owns
wire mapping, account resolution, async tasks, status normalization,
and lifecycle state.

Lands in src/lib/server/decisioning/ as preview only — types compile
but are not re-exported from any public subpath. Wiring follows in a
later PR with the framework refactor.

Shape:
  - DecisioningPlatform<TConfig, TMeta>  — top-level adopter interface
  - DecisioningCapabilities<TConfig>     — single source of truth (also
                                            answers get_adcp_capabilities)
  - AccountStore<TMeta> + Account<TMeta> — auth-principal → account
  - AsyncOutcome<T> + ok/submitted/rejected — universal async pattern
  - StatusMappers                        — native → AdCP-typed status
  - SalesPlatform                        — 5 methods, 1:1 to wire tools
  - CreativeTemplatePlatform             — stateless transform
  - CreativeGenerativePlatform           — brief-to-creative
  - AudiencePlatform                     — cross-cutting audience-sync
  - RequiredPlatformsFor<S>              — compile-time capability gate

Companion docs:
  - docs/proposals/decisioning-platform-v1.md
    Locked design after round-2 expert review (prompt-eng, dx, ad-tech-
    protocol, ad-tech-product). Architecture diagram, AsyncOutcome
    rationale, capability config, lifecycle primitives, single-cut 6.0
    migration plan.

  - docs/proposals/decisioning-platform-training-agent-migration.md
    Worked migration sketch (Innovid training-agent → DecisioningPlatform).
    9 of 10 documented FRAMEWORK_MIGRATION blockers dissolve;
    ~5x line reduction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/lib/server/decisioning/decisioning.type-checks.ts Fixed
bokelley and others added 2 commits April 25, 2026 17:17
…ngPlatform v1.0

Validates the v1.0 interface against three real codebases beyond the
training-agent reference:

  - GAM (boring enterprise SSP, two parallel state machines, async
    reports + approvals): interface fits with two JSDoc clarifications
    and one open-question tag. Biggest find: AsyncOutcome's TaskUpdate
    union assumes monotonic submitted→completed/failed, but GAM's
    PENDING_APPROVAL → DRAFT bounce-back is a real workflow. Recommend
    Option B (failed + recovery: 'correctable') for v1.0; document.

  - Scope3 agentic-adapters (13 adapters, peer interface
    PlatformAdapter shipped independently): convergence is the
    validation. Their TargetingCapabilities shape and accountResolution
    flag are pre-6.0 must-fixes. Their AccountNotFoundError pattern is
    cleaner than constructing rejected({code: 'ACCOUNT_NOT_FOUND'}).

  - Prebid salesagent (Python multi-tenant, FastMCP, 6 adapters
    including GAM/Kevel/Triton/Xandr): interface fits, ~50% of the
    abstract BaseAdapter boilerplate dissolves into framework code.
    HITL via submitted({taskHandle}) replaces three runtime conventions
    (manual_approval_required + workflow_step_id + task_management.py)
    with one type-level decision.

Convergent must-fixes (cited by ≥2 of the three external sketches):

  1. Fill in TargetingCapabilities — Scope3 + Prebid shipped the same
     shape independently
  2. Fill in ReportingCapabilities.availableDimensions enum
  3. Add accountResolution: 'explicit' | 'implicit' on AccountStore
     (LinkedIn's pre-sync requirement)
  4. AccountNotFoundError throw-class pattern OR documented null-semantics
  5. supportedBillings / requireOperatorAuth (operator-billed retail
     media)
  6. JSDoc: bounce-back semantics, StatusMappers/rollup boundary,
     action-based update local dispatch idiom, framework dry_run
     interception
  7. Confirm PricingModel enum covers all 9 AdCP values

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n sketches

Three migration sketches against real adapter codebases (training-agent,
GAM, Scope3 agentic-adapters, Prebid salesagent) surfaced convergent
gaps in the v1.0 scaffold. Apply must-fixes pre-review.

TargetingCapabilities (capabilities.ts):
  Replaced the placeholder shape with the per-geo-system shape that
  Scope3 (TS, 13 adapters) and Prebid (Python, 6 adapters) independently
  shipped. Two peer codebases converged on this — strong signal.
  Includes geo_metros (nielsen_dma, eurostat_nuts2, uk_itl1/2),
  geo_postal_areas (us_zip, gb_outward, ca_fsa, de_plz, fr_code_postal,
  au_postcode, ch_plz, at_plz), geo_proximity (radius / travel_time /
  geometry + transport_modes), age_restriction.verification_methods,
  keyword_targets / negative_keywords match-type flags.

ReportingCapabilities.availableDimensions (capabilities.ts):
  Added typed enum: 'geo' | 'device_type' | 'device_platform' |
  'audience' | 'placement' | 'creative' | 'keyword' | 'catalog_item'.

AccountStore.resolution (account.ts):
  Added 'explicit' | 'implicit' field. LinkedIn requires sync_accounts
  before transacting (implicit); GAM/Snap/Meta accept inline account_id
  (explicit). Defaults to 'explicit' when omitted.

AccountNotFoundError (account.ts):
  Throw-class for adopters who prefer throw-based not-found signaling
  over null returns. Framework catches and emits the same fixed
  ACCOUNT_NOT_FOUND envelope. Documented narrow-use semantics: never
  use for upstream-API outages or schema-validation failures (those
  propagate as generic throws → SERVICE_UNAVAILABLE).

supportedBillings + requireOperatorAuth (capabilities.ts):
  Added for operator-billed retail-media platforms (Criteo, Amazon).

JSDoc clarifications:
  - TaskUpdate monotonic; bounce-back becomes failed +
    recovery: 'correctable' (async-outcome.ts)
  - StatusMappers is wire-status decoder; rollup is platform code
    (status-mappers.ts)
  - updateMediaBuy patches dispatch locally to action verbs in adapter
    code; don't ask framework for an action-based surface (sales.ts)
  - Framework owns dry_run interception, context echo, idempotency,
    auth, schema validation, task envelopes (platform.ts)

Type-level tests (decisioning.type-checks.ts):
  Added @ts-expect-error coverage for AccountStore.resolution invalid
  values, supportedBillings invalid values, TargetingCapabilities
  unknown geo metros, plus AccountNotFoundError throw pattern.

PricingModel enum already covered all 9 AdCP values; no change needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/lib/server/decisioning/decisioning.type-checks.ts Fixed
bokelley and others added 8 commits April 25, 2026 17:44
Companion to decisioning-platform-v1.md. Locks how the framework
projects a single DecisioningPlatform onto both MCP and A2A
transports without the adopter writing protocol-specific code.

Core shape: serve(platform, opts) is the only entry point. Reads
capabilities.specialisms[], derives the tool registry from
RequiredPlatformsFor<S> × the wire-tool catalog, mounts MCP + A2A
on the same Express app, serves agent-card.json + tools/list from
the same source.

Wire projection table: AsyncOutcome.kind → MCP tools/call response
+ A2A Task.state. Both protocols project from one normative table;
adopters never see it.

notify() push: framework resolves which transport(s) the task
envelope was issued on and projects accordingly. MCP egresses to
push_notification_config.url; A2A records in Task store + optional
buyer-registered webhook.

Per-tool override hook (mapMcpResponse / mapA2aArtifact) for the
5% case. Default projection covers 95%.

Phases:
  - rc.1: registry derivation + dispatch wiring + projection table +
          AgentCard auto-derivation + dry_run interception +
          getCapabilitiesFor(account) per-tenant override
  - rc.2: per-tool overrides + AdcpStructuredError wire alignment
  - v6.1: streaming (message/stream), input-required lifecycle

Open questions: single taskId namespace across transports (lean yes),
webhook idempotency dedup keys, AgentCard caching, MCP _meta version
field.

Companion to PR #1005. Implementation lands in follow-up PR with
the framework refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four parallel expert reviews (protocol, product, DX, code-reviewer)
surfaced four P0/P1 categories of fix beyond the migration-sketch
must-fixes already applied. Batch fix in one commit:

RequestContext wired (DX P0):
  Every specialism method signature flips from (req, account) to
  (req, ctx: Ctx). Adopters access ctx.account, ctx.state.workflowSteps(),
  ctx.resolve.creativeFormat(). Closes the gap where context.ts
  defined RequestContext but methods only received Account, and the
  training-agent migration sketch promised ctx.state.* without
  the type wiring.

RequiredPlatformsFor refactored (DX P0):
  Nested conditionals replace the union of (S extends X ? {} : never).
  Missing-specialism error now reads "Property 'sales' is missing in
  type 'P'" instead of "does not satisfy constraint 'never'."
  Unknown specialisms fall through to Record<string, never> for
  forward-compat with v1.1+.

ErrorCode + AdcpStructuredError aligned to spec (Protocol P0):
  ErrorCode expanded from 30 → 45 codes to match
  schemas/cache/3.0.0/enums/error-code.json. Added INVALID_STATE,
  MEDIA_BUY_NOT_FOUND, NOT_CANCELLABLE, PACKAGE_NOT_FOUND,
  CREATIVE_NOT_FOUND, SIGNAL_NOT_FOUND, SESSION_NOT_FOUND,
  PLAN_NOT_FOUND, REFERENCE_NOT_FOUND, SESSION_TERMINATED,
  PRODUCT_EXPIRED, PROPOSAL_NOT_COMMITTED, IO_REQUIRED,
  REQUOTE_REQUIRED, CAMPAIGN_SUSPENDED, GOVERNANCE_UNAVAILABLE,
  CREATIVE_DEADLINE_EXCEEDED. ErrorCode now exported from index.ts.

  AdcpStructuredError adds field?, suggestion?, retry_after? to match
  schemas/cache/3.0.0/core/error.json. Framework auto-fills
  retry_after for RATE_LIMITED / SERVICE_UNAVAILABLE when the
  platform omits it.

Multi-tenant + settlement (Product P1):
  - DecisioningPlatform.getCapabilitiesFor?(account) — per-tenant
    capability override. Multi-tenant SaaS adopters (Prebid-style)
    scope capabilities per resolved Account.
  - Account.billing?: { invoicedTo: 'agent' | 'operator' | BrandReference }
    — operator-billed settlement boundary. Comply storyboards use
    this to assert the right party is billed.
  - AccountStore.resolution adds 'derived' mode for single-tenant
    agents with no account_id on the wire.

DX helpers + JSDoc tightening:
  - unimplemented<T>() — stub-shape rejection (UNSUPPORTED_FEATURE +
    terminal). Adopters use this while standing up methods incrementally.
  - identityStatusMappers — for platforms whose native statuses
    match AdCP's already.
  - AccountNotFoundError JSDoc tightened: "throwable only from
    AccountStore.resolve()". Documents null as canonical, throw as
    optional. Also clarifies platform.ts "framework owns X" claims
    are forward-looking design intent, not current behavior.

Type-level tests cover all new shape:
  + AdcpStructuredError with field/suggestion/retry_after
  + AdcpStructuredError retry_after for RATE_LIMITED
  + The 5 new ErrorCode entries compile
  + RequiredPlatformsFor produces legible "missing field" error
  + AccountStore.resolution 'derived' mode
  + AccountStore.resolution invalid value (extended union)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two adopter teams (Prebid salesagent + Scope3 agentic-adapters) gave
direct feedback on the v1.0 design. Verdict: architecture is right;
four substantive items to land before merge.

partialResult on AsyncOutcomeSubmitted (Prebid):
  Preserves the "buy created in PENDING_APPROVAL, buyer should see it
  immediately" pattern that Prebid expresses today via
  workflow_step_id + paused MediaBuy. Framework projects partial_result
  to MCP structuredContent and A2A artifact data alongside adcp_task_id;
  terminal value flows through taskHandle.notify({ kind: 'completed' }).

aggregateRejected helper (Prebid):
  Multi-error pre-flight rejection (Prebid's
  validate_media_buy_request → list[str] pattern). Adopters extract a
  preflight() method and dispatch from each entry method, preserving
  DRY without forcing the framework to introspect platform-side checks.
  First error is canonical; rest land in details.errors.

dry_run subsumed by sandbox (user direction + Prebid feedback):
  Removed framework-interception language for dry_run. AdCP 3.0
  expresses validate-without-writing via AccountReference.sandbox: true
  on every tool. Framework resolves the buyer's sandbox account through
  accounts.resolve(); platform routes reads/writes to its sandbox
  backend. Tool-specific dry_run flags on sync_catalogs/sync_creatives
  remain wire fields the platform receives and honors locally.
  platform.ts JSDoc + mcp-a2a-unified-serving.md updated.

Python port plan (Prebid):
  New doc decisioning-platform-python-port.md covers the cross-language
  port: AsyncOutcome as Pydantic discriminated union; Protocol-based
  per-specialism interfaces; __init_subclass__ + validate_platform()
  runtime check at server boot replaces compile-time
  RequiredPlatformsFor<S>; Pydantic generics ergonomics weaker
  (default to dict[str, Any] for TMeta with documented upgrade path).
  mypy --strict is the supported development experience.

Adopter-questions doc (Scope3):
  New doc decisioning-platform-adopter-questions.md answers four
  Scope3 questions and three Prebid questions:
    - Per-call context schemas (TikTok advertiser_id, Google
      login_customer_id, Flashtalking library_id) → v1.1 explicit
      with getRequestContextSchema?<TTool>(tool); v1.0 path is
      Account.metadata after resolve.
    - Migration coexistence → rip-and-replace per-server, not in-process.
      Framework ships v6.0 as one semver bump.
    - comply_test_controller fit → framework-owned by default in v6.
      stateStore-first reads on every state-read tool when comply
      controller is enabled.
    - Wiring PR phasing → alpha.1 (this PR, types-only) → alpha.2
      (first runtime, early-adopter spike opportunity) → rc.1 (full
      wire mapping) → rc.2 (comply storyboard parity) → 6.0 GA.

Type-level tests added: partialResult on submitted; unimplemented
helper; aggregateRejected helper.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First runtime that accepts a DecisioningPlatform. Thin adapter over
the existing createAdcpServer(): framework primitives (idempotency,
RFC 9421 signing, governance, schema validation, state store,
MCP/A2A wire mapping, sandbox boundary) apply unchanged. New code
is the translation shim, not a forked runtime.

Files (src/lib/server/decisioning/runtime/):
  - from-platform.ts: createAdcpServerFromPlatform(platform, opts)
    entry. Validates platform; translates accounts.resolve() and the
    specialism methods into the existing handler-style config;
    delegates to createAdcpServer().
  - validate-platform.ts: runtime gate that mirrors the compile-time
    RequiredPlatformsFor<S>. Throws PlatformConfigError on
    "claimed X; missing Y" diagnostics. Substitute for the TS gate
    when platform is constructed from JS or relaxed-tsconfig contexts.
  - to-context.ts: buildRequestContext(handlerCtx) translates the
    framework's HandlerContext into the v6 RequestContext shape. v1.0
    wires ctx.account; state.* and resolve.* are stubbed and arrive
    incrementally.

Wired surface (this commit):
  - accounts.resolve() with AccountNotFoundError catch
  - SalesPlatform.getProducts dispatch end-to-end through MCP

Smoke tests (test/server-decisioning-from-platform.test.js):
  - Builds AdcpServer from a DecisioningPlatform stub
  - Dispatches get_products through platform.sales.getProducts
  - Returns ACCOUNT_NOT_FOUND envelope on AccountNotFoundError throw
  - validatePlatform throws on missing required specialism interface
  - validatePlatform passes on unknown future specialisms (forward-compat)

Reserved for upcoming commits:
  - projectAsyncOutcome helper exported (sync + rejected wired;
    submitted to follow with task-envelope wiring)
  - Remaining SalesPlatform methods: createMediaBuy, updateMediaBuy,
    syncCreatives, getMediaBuyDelivery
  - CreativeTemplatePlatform / CreativeGenerativePlatform
  - AudiencePlatform.syncAudiences
  - getCapabilitiesFor(account) per-tenant override
  - "Framework always calls accounts.resolve(authPrincipal)" behavior
    so requests without explicit account_id work for derived /
    implicit resolution modes

Status: Preview / 6.0. Not exported from public ./server subpath;
reach in via @adcp/client/server/decisioning/runtime for spike
experimentation only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expand createAdcpServerFromPlatform to dispatch the entire v1.0
specialism surface through the existing handler-style framework.

SalesPlatform — all 5 tools wired:
  - getProducts (sync only at the wire spec; sync arm passes through)
  - createMediaBuy (sync + submitted + rejected projection; submitted
    surfaces as { status: 'submitted', task_id, message? })
  - updateMediaBuy (sync + rejected; submitted → INVALID_STATE because
    AdCP wire has no UpdateMediaBuySubmitted arm — buyer polls
    get_media_buys for resolution; platform's task_id surfaces in
    details for operator log correlation)
  - syncCreatives (sync + submitted + rejected; supports manual review
    workflows that take 4-72h)
  - getMediaBuyDelivery (sync + rejected; submitted → INVALID_STATE
    transient; async report jobs surface via getMediaBuys polling)

CreativeTemplatePlatform + CreativeGenerativePlatform:
  - buildCreative (sync + rejected; submitted → INVALID_STATE because
    BuildCreativeResponse has no Submitted arm)
  - previewCreative (template-only; UNSUPPORTED_FEATURE on generative)
  - syncCreatives (full three-arm projection)

AudiencePlatform:
  - syncAudiences (sync + rejected; submitted → INVALID_STATE — async
    match-rate computation surfaces via getAudienceStatus poll)

AccountStore:
  - upsert via sync_accounts handler (sync + rejected; submitted →
    INVALID_STATE — AdCP wire SyncAccountsResponse has no Submitted arm)
  - list via list_accounts handler

Tests: 13/13 pass. Coverage:
  - sync arm projection on all 5 sales tools
  - submitted arm projection on createMediaBuy + syncCreatives
  - rejected arm projection (with full AdcpStructuredError fields)
  - INVALID_STATE projection for non-submitted-arm tools
  - creative + audience dispatch reaches platform method
  - validator forward-compat on unknown specialisms

Pattern observed: AdCP wire spec is asymmetric on async support.
Five tools have Submitted arms (create_media_buy, sync_creatives,
sync_event_sources, sync_catalogs, sync_plans). The rest don't.
Adopters whose async paths need wire propagation should track work
on submitted-arm tools; INVALID_STATE is the framework's signal
that the platform's intent isn't expressible at the wire layer.

Status: Preview / 6.0. Reserved for upcoming commits:
  - taskHandle.notify wired to the framework's task store + webhook
    emitter so platforms can actually push completion updates
  - partialResult projection onto submitted envelopes (Prebid's
    "buy created in PENDING_APPROVAL, buyer sees it now" pattern)
  - getCapabilitiesFor(account) per-tenant override runtime
  - Framework always calls accounts.resolve(authPrincipal) so
    'derived' and 'implicit' resolution modes work

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add ctx.startTask() to RequestContext + an in-memory task registry
the runtime owns and threads through to every dispatched tool. The
handle's notify(update) now persists lifecycle into the registry;
server.getTaskState(taskId) reads it back.

Why this matters: prior commit's submitted projection emitted
{ status, task_id } but adopters had no way to actually deliver
completion updates back through the framework. Now they do.

Files:
  src/lib/server/decisioning/runtime/task-registry.ts  (new)
    - TaskRecord shape: taskId, tool, accountId, status, statusMessage,
      partialResult, result, error, createdAt, updatedAt
    - TaskRegistry interface: startTask + getTask
    - createInMemoryTaskRegistry(): in-process Map<taskId, record>
    - Terminal lock-out: notify after completed/failed is a no-op

  src/lib/server/decisioning/context.ts
    - RequestContext.startTask<TResult>(opts?: { partialResult? }): TaskHandle<TResult>
    - JSDoc clarifies adopters can also construct their own TaskHandle
      if their backend produces stable IDs already

  src/lib/server/decisioning/runtime/from-platform.ts
    - Constructs registry per server instance (override via opts.taskRegistry)
    - Threads { tool, taskRegistry } into buildRequestContext on every dispatch
    - Returns DecisioningAdcpServer extending AdcpServer with getTaskState()

  src/lib/server/decisioning/runtime/to-context.ts
    - buildRequestContext takes BuildRequestContextOpts and wires
      ctx.startTask to the registry, scoped to the calling tool + account

Tests (17/17 pass):
  - ctx.startTask returns framework-issued taskId; record lands in registry
    with partialResult populated
  - notify({ kind: 'completed', result }) writes terminal record
  - notify({ kind: 'failed', error }) writes terminal error + statusMessage
  - terminal-state lock-out: subsequent notify calls are no-ops

Reserved for upcoming commits:
  - tasks/get wire handler so buyers poll the registry over MCP / A2A
  - webhook emitter integration on notify push (RFC 9421 signed callbacks
    to push_notification_config.url)
  - getCapabilitiesFor(account) per-tenant runtime
  - Framework always calls accounts.resolve(authPrincipal) for 'derived'
    and 'implicit' resolution modes

Status: Preview / 6.0. Not exported from public ./server subpath.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Round-5 refactor based on parallel JS + DX expert review. The
AsyncOutcome-as-default shape was idiosyncratic in the TS server
ecosystem (tRPC / Express / GraphQL all use plain async + throw);
the ceremony tax on every sync happy path was real; agent-generated
code mixed return shapes when both `ok(buy)` and `buy` typecheck.

New adopter shape:

  sales: SalesPlatform = {
    createMediaBuy: async (req, ctx) => {
      if (req.total_budget.amount < this.floor) {
        throw new AdcpError('BUDGET_TOO_LOW', {
          recovery: 'correctable',
          message: `Floor is $${this.floor} CPM`,
          field: 'total_budget.amount',
          suggestion: `Raise to at least ${this.floor * 1000}`,
        });
      }
      return await this.gam.createOrder(req);
    }
  }

Shape changes:

- SalesPlatform / CreativeTemplatePlatform / CreativeGenerativePlatform
  / AudiencePlatform / AccountStore.upsert: methods return Promise<T>
  directly (no AsyncOutcome wrapper).
- AdcpError class added (extends Error). Adopters throw it for
  structured rejection. Framework projects code/recovery/field/
  suggestion/retry_after/details to the wire envelope.
- Generic thrown Error → SERVICE_UNAVAILABLE (existing framework path).
- AsyncOutcome / ok / submitted / rejected stay as @internal projection
  vocabulary for the runtime layer; not exported from index.ts to
  adopter code.
- unimplemented() / aggregateRejected() removed from adopter surface.
  Adopters write `throw new AdcpError('UNSUPPORTED_FEATURE', ...)` for
  stubs; multi-error pre-flight uses `details.errors`.
- AccountNotFoundError unchanged — narrow throw-class for
  AccountStore.resolve.
- ctx.startTask() unchanged for out-of-process tasks. ctx.runAsync()
  (in-process timeout-race) lands in the next commit.

projectPlatformCall helper in runtime/from-platform.ts: try/await
the platform call, catch AdcpError instances and project to adcpError
envelope with full structured fields, propagate generic Errors to the
framework's existing SERVICE_UNAVAILABLE mapping.

Test refactor: 13/13 pass. New coverage for AdcpError throw projection
(structured fields end-to-end), generic Error → SERVICE_UNAVAILABLE
fall-through, ctx.startTask out-of-process lifecycle, validatePlatform
forward-compat. Removed obsolete AsyncOutcome construction tests.

Migration sketch updated (training-agent) to use the new shape.

Status: Preview / 6.0. Reserved for upcoming commits:
  - ctx.runAsync(opts, fn) — in-process timeout race + auto-defer
    with unref'd timer, settled-promise pattern, AbortController
    propagation, maxAutoAwait hard cap (per JS expert review)
  - Slow-pending-start warning (>30s sync return in pending_start)
  - NODE_ENV=production gate on in-memory task store
  - tasks/get wire handler (MCP + A2A)
  - webhook emitter on notify push

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adopters write `await ctx.runAsync(opts, asyncFn)` to opt into the
auto-defer pattern: framework races asyncFn against a configurable
timeout. If asyncFn resolves first, returns the value normally
(sync wire arm). If timeout wins, throws TaskDeferredError which
the runtime catches and projects to the submitted wire envelope
with task_id + message + partial_result; meanwhile asyncFn keeps
running in the background and notifies the registry on resolve/throw.

```ts
createMediaBuy: async (req, ctx) => {
  if (this.requiresApproval(req)) {
    return await ctx.runAsync(
      { message: 'Awaiting approval', partialResult: pendingBuy },
      async () => this.waitForOperatorApproval(req)
    );
  }
  return await this.platform.create(req);
};
```

Implementation:
  - Settled-promise pattern: work.then(value => 'ok', error => 'err')
    so Promise.race losers don't leak unhandled rejections
  - unref'd timeout timer; force-resolved when settled wins (Node
    test runner detects pending Promise.race losers as
    'cancelledByParent' otherwise)
  - Background completion registered with task registry; tests +
    forthcoming tasks/get path call server.awaitTask(taskId) for
    deterministic settlement
  - AdcpError thrown from asyncFn → projects to structured rejection
    on the registry record; generic Error → SERVICE_UNAVAILABLE
  - TaskDeferredError is internal sentinel; adopters never see it

New surface:
  - RequestContext.runAsync(opts, fn) for in-process auto-defer
  - DecisioningAdcpServer.awaitTask(taskId) for settlement-await
  - TaskDeferredError exported (mostly internal)
  - TaskRegistry._registerBackground / awaitTask

Tests (17/17 pass):
  - fast work resolves before timeout → sync arm
  - slow work exceeds timeout → submitted envelope with partial_result
  - background AdcpError throw → registry records 'failed' with structured fields
  - background generic Error → registry records 'failed' with SERVICE_UNAVAILABLE
  - terminal-state lock-out unchanged

Reserved for next commits:
  - Hard cap on background await (maxAutoAwaitMs) with AbortSignal
    cancellation. Currently the framework awaits the in-flight promise
    indefinitely; long-running work should use ctx.startTask explicitly.
  - Slow-pending-start warning (sync-returned buy in pending_start
    after >30s)
  - NODE_ENV=production gate on in-memory task store

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Comment thread src/lib/server/decisioning/runtime/to-context.ts Fixed
bokelley and others added 16 commits April 25, 2026 20:35
Worked example demonstrating four real-world async patterns adopters
hit when implementing a sales platform:

  1. In-process trafficker approval (ctx.runAsync). createMediaBuy
     races approval wait against the framework's auto-defer timeout.
     Fast path → sync wire arm. Slow path → submitted envelope with
     partial_result + auto-completion via the registry.
  2. Out-of-process completion (ctx.startTask). Adopter persists the
     framework-issued taskId; webhook handler later calls notify
     directly on the stored handle.
  3. Per-creative review (partial-batch). syncCreatives returns a mix
     of approved + pending_review rows; wire shape carries per-row
     status, no auto-defer needed.
  4. Multi-error pre-flight rejection. Single throw of AdcpError
     carrying details.errors with all validation failures.

examples/decisioning-platform-mock-seller.ts:
  - Class-based MockSeller implements DecisioningPlatform with full
    type safety. Demonstrates the canonical adopter shape:
    plain async methods returning Promise<T>, throw AdcpError for
    rejection, ctx.runAsync for in-process async opt-in.
  - Documents the four patterns inline with JSDoc.

test/server-decisioning-mock-seller.test.js:
  - 5/5 integration tests pass end-to-end through createAdcpServerFromPlatform.
  - Pattern 1: under-threshold sync, above-threshold submitted+complete
  - Pattern 3: mixed approved+pending_review rows in one response
  - Pattern 4: multi-error throw projects to envelope with details.errors
  - updateMediaBuy: throw AdcpError for not-found

DX takeaways from building this:
  - Adopter API feels native — no more `ok()`/`rejected()` ceremony
  - throw AdcpError reads like normal TS error handling
  - ctx.runAsync's "race-and-defer" is invisible until it actually
    defers, then control flow goes via throw — natural short-circuit
  - Per-creative status is the right shape for partial-batch review;
    no need for the wrapper to guess sync-vs-submitted

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Draft skill walking adopters through the new DecisioningPlatform shape.
Focused — ~250 lines vs the legacy build-seller-agent's 1397.

Covers:
  - Three rules (Promise<T>, throw AdcpError, ctx.runAsync for async opt-in)
  - Four async patterns with inline code
    1. Sync happy path
    2. Structured rejection — throw AdcpError + multi-error via details.errors
    3. In-process async — ctx.runAsync auto-defer
    4. Out-of-process async — ctx.startTask (webhook handlers)
  - Per-creative review (partial-batch)
  - Buyer-driven approval as separate methods (don't smush into createMediaBuy)
  - Error code vocabulary cheat-sheet (45 codes, three recovery classes)
  - Account resolution + AccountNotFoundError narrow-throw semantics
  - References: MockSeller worked example, design doc, MCP+A2A serving,
    migration sketches

Marks v6.0 as PREVIEW so adopters know this is still in alpha — the
legacy `build-seller-agent` skill remains the production path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adopters consuming the v6.0 DecisioningPlatform shape now import from
@adcp/client/server/decisioning. The subpath re-exports types,
AdcpError class, AccountNotFoundError, runtime functions
(createAdcpServerFromPlatform, validatePlatform, createInMemoryTaskRegistry),
and the canonical types for all four specialism interfaces.

Marked PREVIEW: subject to change before v6.0 GA. Legacy ./server
remains the production path until 6.0 ships.

The MockSeller example and the build-decisioning-platform skill now
use the clean import path:

  import { createAdcpServerFromPlatform, AdcpError, ... } from '@adcp/client/server/decisioning';

Tests still pass: 22/22 across decisioning suites.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Production hardening (DX expert caveat #2). The in-memory task
registry loses task state on process restart — fine for tests + dev,
NOT fine for production. Gate via allowlist:

  - NODE_ENV=test or NODE_ENV=development → default in-memory works
  - Anything else (including unset; production may unset NODE_ENV) →
    refuse construction with diagnostic
  - Override: pass `taskRegistry` explicitly (the canonical production
    path: durable Postgres-backed registry, landing in v6.0-rc.1)
  - Emergency hatch: ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1 explicitly
    accepts the in-flight-task-loss risk

Pattern follows feedback_node_env_allowlist.md: never compare
=== 'production' (production may unset NODE_ENV); always allowlist
the safe modes.

Tests (28/28 pass):
  - NODE_ENV=test → uses default (existing path)
  - NODE_ENV=development → uses default
  - NODE_ENV=production without ack → throws with diagnostic
  - NODE_ENV unset without ack → throws (treats as production)
  - NODE_ENV=production WITH ack env → allows (documented data-loss tradeoff)
  - explicit taskRegistry → no NODE_ENV check needed

Both decisioning test files set process.env.NODE_ENV='test' at the
top so node:test runs work without environment setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When createMediaBuy returns sync (no ctx.runAsync) AND elapsed >30s
AND the result carries status: 'pending_start', log a warning. This
catches adopters who silently regress buyer UX by awaiting a long
operator approval inside createMediaBuy without surfacing a partial
result via ctx.runAsync.

The warning suggests the right pattern:

  > [adcp] create_media_buy resolved with status: 'pending_start'
  > after Nms (>30000ms). Consider wrapping in
  > ctx.runAsync({ partialResult }, ...) to give the buyer immediate
  > visibility while approval completes.

Pattern: log-driven discovery for the case adopters cannot otherwise
find. Type-system can't distinguish "I'm fast" from "I'm secretly
awaiting hours" — the elapsed-time signal is the cheap detector.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Append "Round-5/6 refactor: throw-AdcpError adopter shape" section
to docs/proposals/decisioning-platform-v1.md so the proposal matches
the implementation.

Covers:
  - The new canonical adopter shape (plain Promise<T> + throw AdcpError +
    ctx.runAsync) with worked example
  - Three rules (return Promise<T>; throw AdcpError; await ctx.runAsync
    for in-process async opt-in)
  - What stays internal (AsyncOutcome / ok / submitted / rejected as
    framework projection vocabulary)
  - What's added: AdcpError class, ctx.runAsync, ctx.startTask,
    server.getTaskState, server.awaitTask
  - Production hardening: NODE_ENV gate on default in-memory task
    registry; slow-pending-start warning
  - Worked example + skill: MockSeller demonstrates four async
    patterns; skills/build-decisioning-platform/SKILL.md is the
    focused walkthrough
  - Migration: training-agent sketch updated; other migration sketches
    preserve old code style (mechanical substitutions documented)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Captures the architectural pivot from v1.0 alpha's ctx.runAsync
conditional pattern to a per-tool sync OR task split, plus
status-change webhooks as the channel for ongoing resource lifecycle.

Why a v2:
  - ctx.runAsync conflates HITL (no resource id until human acts) with
    slow-but-acknowledged (resource id sync, state changes async)
  - Buyer-side mental model becomes inconsistent: same tool, sometimes
    sync, sometimes submitted
  - partial_result on submitted envelope is non-spec — bug introduced
    in v1 alpha

Two clean shapes per tool:
  A. HITL: adopter implements xxxTask(taskId, req, ctx) — framework
     creates task before calling, adopter does the work, return value
     becomes the task's terminal result. Buyer sees submitted envelope
     immediately.
  B. Slow + status webhook: adopter implements xxx(req, ctx) sync,
     returns resource_id + initial status. Ongoing state changes flow
     via server.emitStatusChange(...) to subscribers of the resource's
     status-change webhook channel.

Categorization of all spec-async-eligible tools to one of the two
shapes (or both options where the workflow varies per seller).

New SDK surface:
  - DecisioningPlatform method pairs: xxx OR xxxTask (compile-time
    exactly-one enforcement via RequiredPlatformsFor<S>)
  - server.emitStatusChange(...) for slow-with-webhook tools
  - Task-creation fingerprint dedup (sha256 of account_id + tool +
    canonical body) prevents buyer-retry storms on slow HITL tools
  - Drops ctx.runAsync, ctx.startTask, partial_result

Upstream proposals (parallel):
  - Status-change webhook channels for 7 lifecycle resource types
  - Extending async-response pattern to get_media_buy_delivery,
    sync_audiences, activate_signal, acquire_rights (or rolling those
    into the status-webhook shape)

Open questions and migration plan documented inline.

Status: design proposal. Implementation gates on adopter sign-off.
v1.0 alpha (ctx.runAsync) remains in PR #1005 as the comparison point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Address review comment on v2 HITL split proposal: the implementor
should not need to know the difference between AdCP 3.0 (no
status-change webhooks) and 3.1 (proposed webhooks added) — or
between the two upstream proposals (status-change webhooks vs
extending the async-response pattern to sync_audiences et al).

The adopter writes one shape:

  syncAudiences: async (audiences, ctx) => {
    // Persist + kick off background match + return initial status
  }

  // Elsewhere when match completes:
  await server.emitStatusChange({ resource_type: 'audience', ... })

Framework adapts the wire projection by spec version:
  - 3.0: sync response + getAudienceStatus polling fallback
  - 3.1 with status webhooks: sync response + signed push to subscribers
  - 3.1 alternative (submitted-arm extension): converts sync response
    to submitted envelope; flips task to completed via tasks/get +
    push_notification_config

Routing read from ADCP_VERSION at construction; framework prefers
webhook over polling when multiple delivery channels are available.
Adopter never branches on spec version.

This applies to all "slow with eventual ack" tools (build_creative,
sync_catalogs, activate_signal, getMediaBuyDelivery, acquire_rights
when not HITL). HITL Shape A (xxxTask) doesn't have the forward-compat
concern since task_id + tasks/get is already in spec since 3.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reframe the upstream spec proposal around extending MCP Resources
rather than inventing 7 new webhook channels. Status changes flow
through the existing MCP Resources subscription model:

  - URI scheme: adcp://{account_id}/{resource_type}/{resource_id}
  - 8 resource types: media_buy, creative, audience, signal,
    proposal, plan, rights_grant, delivery_report
  - resources/list, resources/read, resources/subscribe,
    notifications/resources/updated — all native MCP

A2A backport: AdCP defines the same surface as DataPart-typed
message contracts. Status changes pushed via buyer's
push_notification_config.url with same content envelope as the MCP
notifications/resources/updated message body. Adopter parser is one
path on both transports.

Client compat:
  - Subscription-capable (Claude Code partial, A2A buyers): push
  - Polling-only (ChatGPT, others): resources/read on demand
  - 3.0 clients (no resources): fall back to tasks/get continuation

The framework's server.emitStatusChange(...) hides which client mode
each buyer is on. Subscribers get pushes; polling clients see new
state on next read.

Three benefits over inventing webhook channels:
  - Smaller upstream proposal (extend MCP Resources, not 7 new types)
  - Native subscription mechanism — clients adopting MCP resources
    get AdCP status changes for free
  - AdCP contributes A2A resource-subscription backport — useful
    well beyond ad tech

Issue 2 narrows: if Issue 1 (resource subscriptions) lands cleanly,
the only tool that genuinely needs an async-response extension is
acquire_rights (HITL by nature). Everything else uses resource
subscriptions.

Open question added: MCP client adoption gap. None of Claude Code /
ChatGPT / Cursor / Cline fully implement resources/subscribe today.
Polling fallback on resources/read is the path; subscription is the
optimization.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…+ event bus

Address concept-level feedback from the Prebid salesagent team on
the v2 design:

Multi-tenant routing (real architectural concern):
  Salesagent serves many tenants where the same tool is HITL for
  one (broadcast-TV) and sync for another (programmatic). The
  per-platform-class shape model breaks. Add optional getSpecialism()
  selector that returns per-tenant specialism impl after
  accounts.resolve(). Adopter holds multiple shapes, framework
  dispatches per-tenant. Buyer-predictability holds per-tenant
  (queryable via get_adcp_capabilities for that account).

Python port — sum-type alternative:
  TS's 14 declared methods (7 tools × 2 variants) is heavier in
  Python where the compile-time gate doesn't translate anyway.
  Salesagent counter-proposal: single _impl method returning sum
  type SyncResult[T] | TaskAccepted[T]. One signature, runtime
  dispatch. Recommended for Python port; TS keeps two-method shape
  because RequiredPlatformsFor<S> compile-time gate IS valuable
  there. Different ports, different ergonomics.

Event bus instead of server.emitStatusChange(...):
  Adopter holding a server reference invites circular imports +
  testing complications in Python/Flask multi-tenant deployments.
  Framework-provided event bus (`adcp.events.publish(StatusChange(...))`)
  decouples adopter from server instance. Adopting across both ports.

Other concerns folded in:
  - Resource URI privacy: account_id segment as tenant-private
    identifier; SDK logging masks; spec the contract
  - 3.0 fallback retention bound: spec it (recommend 7d post-terminal)
    or risk slow leak under subscribe-but-never-poll buyers
  - xxxTask naming: TS port keeps wire alignment; Python port using
    sum-type dodges naming entirely (discriminator is return type)
  - HITL-sometimes tools (acquire_rights, update_media_buy):
    declare HITL always, complete instantly when no approval needed,
    OR use per-tenant routing. Document the trade-off.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Append "v2.1 — Consolidated decisions" section that supersedes the
earlier v2 sections where they conflict. Locks the spec for
implementation.

Decisions locked:

  1. Method shape — dual-method (TS) for compile-time clarity vs
     footgun; sum-type (Python) since the TS gate doesn't translate
  2. Multi-tenant — TenantRegistry + host/path routing in serve();
     drop getSpecialism. Each tenant fully isolated (own agentUrl,
     signingKey, platform shape). Dynamic add/remove at runtime.
  3. Per-tenant signing-key validation against published JWKS.
     Three health states: healthy / unverified / disabled. Mismatch
     deterministic → disabled (refuse to dispatch); unreach transient
     → keep serving with cached JWKS. NOT fatal at startup; per-tenant
     isolation.
  4. AAO/adagents.json walked back — buyer-side concern, not seller
  5. Status-change subscriptions via MCP Resources extension + A2A
     backport. Drop ext.status_change. 3.0 fallback uses spec-native
     channels (tasks/get for HITL, per-resource reads for slow-ack)
  6. publishStatusChange via event bus (no adopter-held server ref)
  7. Admin API for tenant operations on separate port (operator-only)
  8. Per-call context schemas mostly resolve to AccountReference
     enumeration via list_accounts; framework hook deferred to v1.1+

Implementation phases revised: alpha.2 (SDK refactor) → alpha.3
(sample builds) → rc.1 (wire integration) → rc.2 (conformance) → GA.
1-2 weeks per phase given the deeper refactor.

What did not survive consolidation (explicitly listed for clarity):
  - getSpecialism, single-method-with-flag shape, AAO fetcher,
    ext.status_change, 7 webhook channel types, partial_result

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Each spec-HITL tool exposes a method-pair: adopter implements EXACTLY
ONE per pair. validatePlatform() enforces exactly-one at construction;
the runtime dispatches sync OR HITL based on whichever is defined.

  - Sync: framework awaits, projects return value to wire success arm
  - HITL: framework allocates taskId BEFORE invoking, returns submitted
    envelope, runs *Task(taskId, ...) in background. Method's return
    value becomes terminal `result`; thrown AdcpError becomes terminal
    `error`.

Strips the prior ctx.runAsync timeout-race + ctx.startTask lifecycle
primitives — buyers can't reason about a method that switches between
sync and submitted at runtime, and adopters can't reason about which
path they're in mid-method. Dual-method makes the wire shape predictable
from the type signature alone.

Worked examples: MockSyncSeller (sync createMediaBuy, auto-approve) and
MockHitlSeller (HITL createMediaBuyTask, trafficker review). Tests cover
both variants plus syncCreatives mixed approved/pending and multi-error
preflight rejection via AdcpError.details.errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…amples

Adds the status-change event bus that adopters use for spec-native
lifecycle channels (8 resource types: media_buy, creative, audience,
signal, proposal, plan, rights_grant, delivery_report). Module-level
publishStatusChange() so adopters can call from webhook handlers, crons,
in-process workers without holding a server reference. Wire-level MCP
Resources subscription handlers + A2A backport land in a follow-up.

Two realistic worked examples exercise the v2.1 dual-method shape end
to end:

  - BroadcastTvSeller: HITL via *Task variants for get_products,
    create_media_buy, sync_creatives. Buyer sees submitted envelope
    on each call; trafficker review + S&P clear in background. Post-
    acceptance lifecycle changes (accepted → active) flow through
    publishStatusChange. POLICY_VIOLATION rejection on regulated
    categories (political/cannabis/gambling).

  - ProgrammaticSeller: sync createMediaBuy + syncCreatives. Buyer
    gets media_buy_id immediately; pending_creatives → active
    transitions fire via publishStatusChange after creative review
    clears. Mixed approved/pending_review rows in one syncCreatives
    response.

Each example has integration tests covering happy path, post-commit
status-change channel, error rejection, and per-creative review flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Multi-tenant deployment helper for the v6.0 runtime. Composes with the
existing serve() host-routing surface — tenants are registered by
`agentUrl`, the registry maps host → DecisioningAdcpServer per request.

Per-tenant health states (never startup-fatal):

  - `healthy`     — JWKS validated, accepting traffic.
  - `unverified`  — initial validation pending or transiently unreachable.
                    Tenant accepts traffic with graceful degradation.
  - `disabled`    — JWKS deterministically rejected (key not in published
                    keys). Tenant returns null on resolveByHost; admin
                    must `recheck()` after fix to re-enable.

JWKS validator fetches `{agentUrl}/.well-known/brand.json` and matches
the tenant's signing key by `kid` first, falling back to JWK structural
equality on RSA(n,e) / EC(crv,x,y) / OKP(crv,x). Network errors and 5xx
classify as `transient`; 4xx / parse failure / key-not-in-JWKS classify
as `permanent`. Tests can swap in a fake validator.

One bad tenant is isolated — others keep serving. recheck() is the
admin-driven retry path; deduped against in-flight validation.

Sample: examples/decisioning-platform-multi-tenant.ts wires a
BroadcastTvSeller and ProgrammaticSeller behind separate hosts under
one registry, with per-tenant signingKey + serverOptions. Factory
plugs into serve() as the createAgent callback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LiveRampAudienceProvider sample for the audience-sync specialism. Demos
the canonical pattern for long-tail activation pipelines:

  - Sync syncAudiences returns per-audience rows with current state
    (matching is a valid sync outcome — buyer's audience_id is now
    known and they can subscribe for updates).
  - Match pipeline + activation pipeline run in background; each stage
    fires publishStatusChange({ resource_type: 'audience', ... }) so
    subscribed buyers see matched → activating → active without
    polling.
  - AdcpError('REFERENCE_NOT_FOUND') on missing audience reads;
    rejected-too-small returns failed/rejected sync row (no status
    change channel since the audience never enters the pipeline).

Also drops the stale `ctx.runAsync` reference from AudiencePlatform's
docstring — the v2.1 shape uses sync ack + publishStatusChange instead.

Tenant admin router: 4-endpoint Express-compatible router for ops
visibility into the TenantRegistry. GET /tenants, GET /tenants/:id,
POST /tenants/:id/recheck (force JWKS revalidate after fix), DELETE
/tenants/:id (idempotent unregister). Bare handlers also exported for
non-Express frameworks. RouterLike interface so we don't pull in an
Express runtime dependency in the SDK.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tion from public types

Round-6c be-Emma test confirmed signals reached 4.5/5 parity with
creative-template + sales-guaranteed. One remaining DX gap: adopters
needed Deployment, ActivationKey, and VendorPricingOption to type
their activation pipelines but had to derive them via indexed lookups
(\`type Deployment = ActivateSignalSuccess['deployments'][number]\`)
rather than importing directly.

Now re-exported from \`@adcp/client/types\` alongside the existing
SignalID / Destination / SignalCatalogType.

Round-6c verdict: 4.5/5 — same shape, same error idiom, same
publishStatusChange pattern as creative-template and sales-guaranteed.
Three primary specialism types now at parity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley changed the title feat(server): DecisioningPlatform v1.0 scaffold (preview / 6.0) feat(server): DecisioningPlatform v6.0 preview surface Apr 26, 2026
@bokelley
Copy link
Copy Markdown
Contributor Author

Reviewed the v2 HITL-split design from a Python-port perspective (would the salesagent server adopt this shape?). Concept-level feedback below.

What's right

  • The two-shape distinction is real. "No media_buy_id yet because a human hasn't acted" and "I gave you an ID, lifecycle is async" are genuinely different contracts, and v1 runAsync collapses them into a coin flip. Buyers can't cache, can't reason, can't write correct retry logic. Splitting fixes that.
  • emitStatusChange / publishStatusChange as the single forward-compat seam. Adopter writes one line; framework picks the wire projection (3.0 polling, 3.1 webhook, 3.1 async-arm). This is the most leverage in the doc.
  • Dropping partial_result. Off-spec drift is a category of bug, not an "ergonomic feature." Right call.
  • MCP Resources + A2A backport instead of inventing 7 webhook channel types. Smaller upstream ask, native subscription primitive, single envelope across transports. The A2A backport is also a real positive externality.
  • Fingerprint dedup at task creation. Orthogonal to wire-level idempotency keys, prevents duplicate proposal generation under buyer retries. Salesagent doesn't have this today and we've hit it.

Where the Python translation gets harder

  1. Compile-time enforcement story shrinks. TS's RequiredPlatformsFor<'sales-broadcast-tv'> = SalesPlatformHitl gives a clean error if a broadcast adopter implements sync createMediaBuy. Python can approximate with Protocol + @runtime_checkable + a validate_platform() call, but you lose the design-time signal — basically the proposal's pragmatic-runtime-only branch from day one. Worth being honest about that in any Python pitch.

  2. Method-name doubling is heavier in Python. 7 tools × 2 variants = 14 protocol methods. An alternative idiomatic Python shape: one _impl returning a sum type — SyncResult[T] | TaskAccepted (discriminated dataclass + match). Loses "exactly one shape per tool" but gains a single signature, which matches our existing _impl pattern better. Trade-off worth at least naming.

  3. server.publishStatusChange(...) from anywhere implies adopter holds a server reference. In Python/Flask this becomes a thread-local or registry singleton, both of which invite circular imports and complicate testing. An event-bus shape (adcp.events.publish(StatusChange(...))) decouples adopter code from the server instance — same wire result, looser coupling. Glad to see v6 went module-level here; that's the right call.

  4. Same tool, different shapes per tenant. Salesagent has one deployment serving many tenants — broadcast-TV tenants want HITL, programmatic tenants want sync, on the same create_media_buy tool. The TS design assumes per-platform-class choice, which works for a single-adopter SDK but doesn't map onto a multi-tenant server. We currently signal HITL via exception (ManualApprovalRequired-style) — one code path, dynamic decision. The proposal's two-method approach would force per-tenant strategy dispatch inside the framework. Workable, but the per-specialism compile-time enforcement loses most of its value here.

Concerns / questions

  • Resource URI scheme exposes account_id. Fine internally; worth a one-liner about whether these URIs are considered tenant-scoped private identifiers (logging, error messages, client-side caches).
  • 3.0 fallback projects status changes to tasks/get continuation. Implies the framework retains every status transition on the task registry until the buyer polls or some retention bound. Spec the retention or you'll grow a slow leak.
  • Naming xxxTask. In our codebase "task" is overloaded — there's a deprecated tasks table and the active workflow_steps system. For a Python port xxxHitl or xxxRequiresApproval is clearer for the seller-side audience even if xxxTask is correct relative to the wire. Project-specific concern, not a blocker.
  • Per-tool sync-OR-task is a feature, but acquire_rights and update_media_buy show the seam. Some tools are HITL-sometimes/sync-other-times legitimately (legal review only for new contracts). The buyer-predictability argument is good, but for these the answer is "always declare HITL and complete instantly when no approval is required" — worth making that explicit so adopters don't reach for runtime branching.

Bottom line

If we ported this pattern to Python, I'd take wholesale: the publishStatusChange seam, the spec-version-aware projection, the fingerprint dedup, and the MCP Resources subscription model. I'd be more skeptical of the two-methods-per-tool shape — the type-system payoff is much smaller in Python, and our multi-tenant deployment makes the per-platform-class assumption awkward. A sum-type return + event-bus emission feels more idiomatic and gets ~80% of the predictability win without the protocol surface bloat.

@bokelley
Copy link
Copy Markdown
Contributor Author

Adopter evaluation from agentic-adapters (13-platform downstream)

Walked through the prototype to gauge how close we are to migrating our 13 adapters off createServerFromAdapter onto v6. Surfacing both design-level and implementation-detail observations so you have one place to triage.

TL;DR

The prototype is structurally complete and runnable, but migration isn't close yet for our adapter set. Crucial structural win: from-platform.ts translates DecisioningPlatform → existing AdcpServerConfig and delegates to createAdcpServer. That means we don't have to wait for v6 to be "done" — we could migrate adapter-by-adapter the moment the gaps below close. The HITL split is genuinely better than the v1 AsyncOutcome<T> shape. A handful of decisions are worth pushing back on before they bake in.

Big design observations

1. The shape fits ~10 of our 13 cleanly. Three are awkward.

  • Sales-social (Snap, Meta, TikTok, Pinterest, LinkedIn, Reddit, Spotify) → SalesPlatform + AudiencePlatform composition.
  • Sales-catalog-driven / retail-media (Amazon, Citrusad, Criteo) → SalesPlatform with supportedBillings: ['operator'].
  • Sales-non-guaranteed (Google), Universal Ads.
  • CitrusAd / Criteo / Snap conversion-tracking + sync-catalogs + financials — no v6 home. Tools like syncEventSources, logEvent, syncCatalogs, getAccountFinancials are routed today via EventTrackingHandlers/SignalsHandlers/etc. by createServerFromAdapter, but the v6 platform interface doesn't expose them.

This is a surface gap, not a design flaw — EventTrackingPlatform / CatalogPlatform / FinancialsPlatform specialisms presumably land later. Until they do, we'd run a hybrid (some tools via v6, some via direct customTools) or wait. We can file an upstream issue with concrete wire-aligned proposals if helpful.

2. *Task dual-method is a real win over AsyncOutcome<T>.

Each method either returns a value or throws — no ok() / submitted() ceremony. Framework allocates taskId before calling the adopter and passes it in, so adopters can persist it to their own backend before kicking off slow work. That fixes a real ergonomics bug in v1 where adopters could only learn the task ID after returning.

validatePlatform() exactly-one enforcement is correct: claiming both shapes is a bug.

3. publishStatusChange as a module-level singleton — mild concern.

let activeBus: StatusChangeBus = createInMemoryStatusChangeBus();
export function publishStatusChange<TPayload>(opts) { activeBus.publish(opts); }

Convenient (adopters call from anywhere — webhook handler, cron, no server reference) but module-level singletons are hostile to multi-tenant test isolation. If we run two createAdcpServerFromPlatform instances in the same process for tests (and we do, all over), they share one bus. setStatusChangeBus() returning the previous bus for restoration is the documented escape hatch, but tests forgetting to restore will silently bleed events between suites.

Suggestion: also expose via server.statusChange.publish(...) so the function is a convenience over an instance method, not the only path. Or scope by account_id + ambient context — already viable since events carry account_id.

4. Sandbox-as-account is a meaningful improvement.

The platform.ts doc:

when AccountReference.sandbox === true, framework resolves the buyer's sandbox account via accounts.resolve(). There is no separate "dry-run" mode.

Collapses our messy "sandbox vs. dry-run vs. preview" dimension into one clean axis at the account boundary. Our current adapters thread sandbox through context awkwardly. v6 makes it just a different Account, and the platform routes by reading account.metadata. Strictly better.

5. RequiredPlatformsFor<S> compile-time gate is worth the complexity.

The nested-conditional encoding (vs. union of S extends X ? {} : never) for legible error messages is a thoughtful detail. Today our PlatformAdapter discovers missing methods at first request — claiming audience-sync without an audiences field would compile-fail under v6 instead of failing at startup. Strict win.

6. In-memory task registry blocks production today.

if (!safe && !ack) {
  throw new Error('createAdcpServerFromPlatform: in-memory task registry refused outside test/development...');
}

Allowlist-by-NODE_ENV pattern (✅ took our pgPool feedback). But the durable Postgres-backed registry is explicitly "landing in v6.0-rc.1", not shipped. Most of our adapters now have HITL paths (creative review, audience matching, media-buy approval on Meta/LinkedIn), so the in-memory registry isn't viable for prod. We'd need the env-var ack OR wait for the Postgres backend.

This is the single biggest shipping blocker for us.

Small implementation observations

  1. from-platform.ts:107 uses <P extends DecisioningPlatform<any, any>> — the comment explains why (Record<string, unknown> default doesn't accept adopter interfaces without an index signature). Real friction we'd hit immediately. The any, any is the right escape hatch but loses metadata-type narrowing in handler code. Likely fine in practice.

  2. from-platform.ts:280 extracts media_buy_id via cast(params as { media_buy_id?: string }).media_buy_id. Type discipline got loose at the projection seam. If MediaBuyHandlers.updateMediaBuy's param shape doesn't already declare media_buy_id, fixable upstream.

  3. to-context.ts:36-44 resolvers all throw "not yet wired in v6.0 alpha". Same with state.* readers (findByObject: () => []). None of our 13 adapters use these today, so tolerable for migration — but worth a comment somewhere louder than the file header that touching ctx.resolve.* will crash.

  4. buildAccountHandlers rejects when upsert/list undefined with UNSUPPORTED_FEATURE — matches our PR comply_test_controller: read-path interception for platform-proxy sellers #1002 feedback for graceful degradation. ✅

  5. AdcpError field-projection has copy-on-undefined pattern...(err.field !== undefined && { field: err.field }). Six lines per error projection. Bikeshed-level — single object-spread + Object.fromEntries(Object.entries(...).filter(...)) is cleaner.

  6. AccountNotFoundError instanceof at from-platform.ts:121 accepts both null return AND throw. Matches the documented contract in account.ts. ✅

  7. creative.ts documents file-an-issue paths — "file an issue with adcp spec to add a Submitted arm to BuildCreativeResponse." Good — the SDK isn't pretending to model what the wire spec doesn't allow. We had to learn this the hard way for update_media_buy.

  8. SyncAudiencesRow's extracted-from-wire patterntype Audience = NonNullable<SyncAudiencesRequest['audiences']>[number]. We do this exact dance inside our adapters today. Centralizing it is small but real ergonomic improvement.

  9. capabilities.tstargeting? is optional with "framework infers reasonable defaults" — doc claim doesn't quite match the type (no defaults visible in validatePlatform or from-platform). If "framework infers" means "empty object on the wire," fine. If it means "framework guesses based on specialism," worth verifying before relying on.

  10. config: TConfig is required but configSchema? is optional. So adopters can declare config without runtime validation. We'd use configSchema everywhere — strictly better than our current "validate at first request" pattern.

  11. No obvious home for OAuth providers. SnapOAuthProvider and our stdio + http OAuth wiring don't have an obvious home in DecisioningPlatform. Either it stays on the surrounding createAdcpServerFromPlatform opts (likely) or there's a missing surface (auth?: AuthProvider). Worth confirming the intended path.

  12. buildRequestContext throws "handler context missing resolved account" — opaque error. Under what condition could this fire post-validatePlatform? Worth a clearer error or assertion.

Migration spike feasibility — not yet, but soon

To migrate snap (our most-validated adapter) end-to-end:

Blockers:

  • a) Durable task registry (in-memory blocks prod; v6.0-rc.1 territory)
  • b) EventTrackingPlatform / CatalogPlatform / FinancialsPlatform specialisms — snap uses syncEventSources, logEvent, syncCatalogs, getAccountFinancials
  • c) OAuth provider wiring path confirmation
  • d) getProducts signature mismatch — our (ctx, brief, contextId, brand, sourceChain) vs. v6 (req, ctx) with args inside req. Trivial churn.

Friction (not blockers):

  • All our PlatformAdapter methods return Result<T, PlatformError> (neverthrow). v6 wants Promise<T> + throw new AdcpError(...). Either route works — a thin wrapper at the specialism boundary preserves internal Result discipline while emitting AdcpError outward.
  • We'd need an AccountStore impl. Snap is ~30 lines; TikTok with advertiserIds more involved but still a single resolver.

Recommended path (our side): wait until v6.0-rc.1 (durable task registry + non-sales specialisms) before attempting migration. We'll likely file follow-up issues for the missing specialisms with concrete proposals like we did for TargetingCapabilities.

The bones are good. Thanks for taking the v1 feedback on board — the dual-method shape, exactly-one validation, and the NODE_ENV allowlist pattern all landed cleanly.

@bokelley
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough Python-port read — useful grounding for the design choices. Addressing the four concerns directly:

Resource URI scheme / account_id exposure. These URIs are tenant-scoped private identifiers — they're only returned to the authenticated buyer that owns the account, not broadcast. That said, "worth a one-liner" is right: this should be documented explicitly in the resource URI scheme section rather than implied. I'll note it as a docs gap for the rc.1 pass.

Task registry retention. Valid concern. The tasks/get wire path is explicitly deferred to v6.0-rc.1 (see "What's deferred"), which means the in-memory task registry today has no eviction policy — intentionally limited scope for the preview. The retention bound needs to be spec'd before rc.1 ships; an LRU with a TTL (e.g. 24h or until buyer acks completion) is the obvious shape. I'll flag this as a required design decision for rc.1 so it doesn't slip.

Naming xxxTask. Noted. Project-specific, not a TS rename target. The xxxTask suffix is correct relative to the AdCP wire spec's TaskAccepted arm, so the SDK keeps it — but the Python-port naming guidance (xxxHitl / xxxRequiresApproval) is worth capturing in a cross-language porting note if that pitch moves forward.

acquire_rights / update_media_buy HITL-sometimes pattern. Agreed the recommended pattern should be explicit: always declare HITL (xxxTask), resolve immediately when no approval gate triggers. That gives buyers predictable polling semantics without the adopter having to conditionally branch on a runtime decision. The skill and sample platforms should call this out — I'll add a callout box to the BroadcastTvSeller example that covers it.

On the broader Python sum-type question: the "two methods, type-system enforces one" shape does earn significantly less in Python (as you lay out), and the multi-tenant deployment pattern makes per-platform-class choice awkward. If a Python SDK materializes, a SyncResult[T] | TaskAccepted return + adcp.events.publish(...) is worth prototyping — the wire semantics are identical, just a different adopter surface.

Nothing here blocks merge, but items 1 and 2 (URI scoping docs, retention design) are tracked as rc.1 prerequisites.


Generated by Claude Code

@bokelley
Copy link
Copy Markdown
Contributor Author

Acknowledged. The four concerns are now cleanly resolved or deferred with tracking:

  • URI scoping — docs gap noted for rc.1.
  • Task registry retention — LRU+TTL design flagged as a hard rc.1 prerequisite; in-memory-only is intentional for the preview window.
  • xxxTask naming — kept as spec-aligned; Python porting note captured for any future cross-language pitch.
  • HITL-sometimes pattern — "always declare HITL, resolve immediately when no gate triggers" will land as a callout in the BroadcastTvSeller sample.

Nothing blocking merge here; items 1 and 2 are the right rc.1 gates to hold.


Triaged by Claude Code


Generated by Claude Code

@bokelley
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough walk-through — this is exactly the signal the preview surface needs before rc.1.

On the surface gaps (EventTracking, Catalog, Financials): fully expected. The PR's "What's deferred" section covers this — EventTrackingPlatform/CatalogPlatform/FinancialsPlatform land as their specialism interfaces stabilize. The hybrid path you described (some tools via v6, some via customTools direct wiring) is the intended bridge posture. If concrete wire-aligned proposals for those tool groups would accelerate the specialism design, filing them on adcontextprotocol/adcp would be useful upstream signal.

On publishStatusChange singleton isolation — this is worth addressing before rc.1. The restoration escape hatch (setStatusChangeBus() + restore previous) is documented, but tests-that-forget-to-restore is a real failure mode in suites using beforeEach/afterEach patterns. Two options:

  1. Instance method alongside (server.statusChange.publish(...)) — makes the scoped reference first-class without removing the module-level convenience. A test can hold a specific server's bus; production code that doesn't hold a server reference still calls the module-level function.
  2. createAdcpServerFromPlatform returns { server, bus } — explicit bus reference on the return value; module-level function delegates to whatever was last registered (existing behavior). Slightly different ergonomics but avoids adding statusChange to the server type.

Option 1 is additive (non-breaking) and keeps the adopter API surface flat. Given the module-level function was optimized for webhook/cron use cases where the server reference isn't in scope, both paths coexisting seems right. Happy to track this as a follow-up issue or fold it here if you want to address it before opening to broader feedback.

On dual-method, sandbox-as-account, RequiredPlatformsFor<S>: noted and appreciated — the compile-time gate generating legible errors instead of opaque never-unions was a deliberate callout in the design, good to hear it lands well in practice.

The 10-of-13 coverage across your adapter set at this stage is a strong signal. The three awkward adapters all map to deferred platform interfaces — that's the expected shape for a preview cut.


Generated by Claude Code

@bokelley
Copy link
Copy Markdown
Contributor Author

Recommendation: fold Option 1 here.

The API surface is still fluid as a preview cut, and server.statusChange.publish(...) alongside the module-level function is strictly additive — no existing callers break, no signature changes. Establishing both paths from the initial preview means early adopters build correct test-isolation habits from day one rather than learning a new pattern at rc.1.

Option 2 (returning { server, bus } from createAdcpServerFromPlatform) is a harder ergonomic break for adopters who pattern-match on createAdcpServer's single-return shape and would require updating all worked samples. Option 1 avoids that churn entirely.

The implementation footprint is small: expose a statusChange: { publish: <overloads> } accessor on the server instance that delegates to the same internal bus. Tests hold the specific server's reference; webhook/cron code that doesn't have a server in scope keeps calling the module-level function unchanged.

If you want to land a minimal surface first and keep the PR scope tight, tracking as a follow-up issue is fine too — but given the test-isolation risk you flagged and the small diff, folding now seems like the right call before this opens to broader feedback.


Generated by Claude Code

bokelley and others added 2 commits April 26, 2026 18:53
… + lists

Ships four platform interfaces — the user-flagged "production now,
not experimental" specialisms. Closes the v6.0 surface gap for adopters
that need creative-ad-server (Innovid, Flashtalking, GAM-creative) and
governance-shaped agents.

**CreativeAdServerPlatform** (creative-ad-server):
  Third creative archetype alongside template + generative. Stateful
  library + per-creative pricing + tag generation. Six methods:
  buildCreative (library lookup OR inline build), previewCreative,
  syncCreatives (sync OR HITL), listCreatives, getCreativeDelivery.
  Wired through the framework's existing CreativeHandlers; from-platform
  dispatches listCreatives + getCreativeDelivery only when the platform
  is the ad-server variant (other archetypes return UNSUPPORTED_FEATURE).

**CampaignGovernancePlatform** (governance-spend-authority +
governance-delivery-monitor):
  Today's two specialisms describe one role — runtime governance
  decisioning. Single platform interface covers both. Four methods:
  checkGovernance (the runtime decision), syncPlans, reportPlanOutcome,
  getPlanAuditLogs. When adcp#3329 lands and the spec consolidates to
  `campaign-governance`, the type renames without shape change.

**PropertyListsPlatform** (property-lists):
  Standard list CRUD + token-issuance semantics. Five methods on the
  spec's create/update/get/list/delete pattern. createPropertyList
  returns a fetch_token; deletePropertyList revokes and signals
  cache invalidation.

**CollectionListsPlatform** (collection-lists):
  Same shape as PropertyListsPlatform but for program-level brand
  safety lists (IMDb / Gracenote / EIDR ids). **Interface only** —
  framework AdcpToolMap doesn't yet have create_collection_list etc.
  entries, so wire-level dispatch lands in a follow-up framework PR.
  Adopters can implement the interface today; tools register when
  framework lands.

All four wired:
  - DecisioningPlatform fields (campaignGovernance, propertyLists,
    collectionLists; creative? union extended with CreativeAdServerPlatform)
  - RequiredPlatformsFor compile-time gate
  - validatePlatform runtime gate (SPECIALISM_REQUIREMENTS)
  - Public type re-exports
  - Dispatch in from-platform.ts (collection-lists deferred)

Forward-compat test updated — was using \`governance-spend-authority\`
as the "unknown future specialism" example; now uses \`brand-rights\`
which remains genuinely unwired pending real adopter signal.

Coverage matrix: 9 sales-* + 2 creative + 1 creative-ad-server +
1 audience-sync + 2 signals + 2 campaign-governance + 1 property-lists
+ 1 collection-lists = 19 specialisms wired. Deferred: brand-rights,
content-standards, creative-review (new spec proposal).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… isolation

Add `statusChange: StatusChangeBus` to `DecisioningAdcpServer`. Each server
instance now owns a dedicated in-memory bus; `server.statusChange.publish(...)`
routes to that server's bus exclusively so tests spinning up multiple servers
don't cross-contaminate via the module-level `activeBus`. The module-level
`publishStatusChange(...)` is unchanged — webhook/cron adopters that don't hold
a server reference keep calling it as before.

Three isolation tests added to server-decisioning-from-platform.test.js.

https://claude.ai/code/session_01D8Q332BadyEX8g3njbiJAS
@bokelley
Copy link
Copy Markdown
Contributor Author

Folded. Commit 5e3206a adds statusChange: StatusChangeBus to DecisioningAdcpServer.

Each createAdcpServerFromPlatform call now creates its own createInMemoryStatusChangeBus() instance and exposes it via server.statusChange. Tests can hold a server reference and call server.statusChange.publish(...) to push events scoped to that server only — no setStatusChangeBus / restore dance needed for isolation. The module-level publishStatusChange(...) is untouched.

Three tests added to server-decisioning-from-platform.test.js covering: accessor presence, no-leakage into module bus, and two-server cross-contamination. All 24 tests pass.


Generated by Claude Code

@bokelley
Copy link
Copy Markdown
Contributor Author

Noted — the three-test coverage (accessor presence, no-leakage into module bus, two-server cross-contamination) is exactly the right isolation matrix for this pattern. The module-level publishStatusChange staying untouched keeps the webhook/cron ergonomics intact.


Triaged by Claude Code


Generated by Claude Code

@bokelley
Copy link
Copy Markdown
Contributor Author

Quick follow-up on 5e3206a1 — clean, targeted fix on Big Design #3. The per-server server.statusChange bus + preserved module-level publishStatusChange is exactly the split I'd hoped for, and the three isolation tests prove non-contamination. Thanks for the fast turnaround.

One latent concern, since the wiring is fresh in your head: the per-server bus and module-level activeBus are now fully independent. When MCP Resources subscription projection lands (currently punted to a later commit), the framework will need to subscribe to both so:

  • Tests using server.statusChange.publish(...) reach buyer-facing subscriptions
  • Production adopters calling module-level publishStatusChange(...) from webhooks/cron also reach those same subscriptions

If that wiring instead only subscribes to the module-level activeBus, server.statusChange.publish(...) silently becomes test-only sugar — which would be a foot-gun for any adopter who reaches for the per-server API in production code (e.g., to scope to a specific tenant deployment).

Right now neither bus reaches anything observable, so this is latent, not real. Just flagging now before the projection commit solidifies a single-subscribe pattern.

The rest of my prior eval stands unchanged — blockers (durable task registry, missing event-tracking/catalog/financials specialisms, OAuth surface confirmation) are still the gating items for our migration.

@bokelley
Copy link
Copy Markdown
Contributor Author

Understood — tracked for the projection commit.

The dual-subscribe requirement is clear: when MCP Resources subscription wiring lands, the subscriber must fan-in from both the per-server server.statusChange bus and the module-level activeBus, so neither call site becomes silently inert in production. The current isolation tests for server.statusChange are the right shape; the projection layer just needs to attach to both sources rather than anchoring to activeBus alone.

No changes to the current diff warranted — the buses are dormant until projection exists. Will note this constraint explicitly in the projection commit's design doc / PR body so the single-subscribe footgun is called out before any reviewer sees the diff.

The blocker list (durable task registry, missing event-tracking/catalog/financials specialisms, OAuth surface confirmation) remains unchanged.


Generated by Claude Code

bokelley and others added 2 commits April 26, 2026 23:24
…ispatch, list-helpers

Lands the six remaining fixes from round-7 Emma sims + PR review feedback. Fix
#1 (`server.statusChange` per-server bus) shipped separately in 5e3206a.

- StatusChangeResourceType extends with `'property_list'` + `'collection_list'`
  for list-changed events (cache invalidation, fetch_token revoke).

- TMeta threads through every specialism interface: SalesPlatform<TMeta>,
  CreativeTemplatePlatform<TMeta>, CreativeGenerativePlatform<TMeta>,
  CreativeAdServerPlatform<TMeta>, AudiencePlatform<TMeta>,
  SignalsPlatform<TMeta>, CampaignGovernancePlatform<TMeta>,
  PropertyListsPlatform<TMeta>, CollectionListsPlatform<TMeta>. Adopters get
  typed `ctx.account.metadata` access without casting. RequiredPlatformsFor<S>
  accepts an optional TMeta; default `any` keeps the constraint permissive when
  callers don't pass it. RequestContext<TAccount> generic constraint relaxed so
  metadata interfaces without index signatures (the common case — `interface
  MyMeta { brand_id: string }`) don't need to extend Record<string, unknown>.

- Collection-list dispatch wired through the framework: AdcpToolMap entries
  for `create/update/get/list/delete_collection_list`, matching
  GovernanceHandlers fields, GOVERNANCE_TOOLS extension,
  buildGovernanceHandlers wires `platform.collectionLists` onto the runtime.

- buildListCreativesResponse({ request, creatives, pagination, totalMatching? })
  helper. Builds the heavyweight ListCreativesResponse (query_summary
  + pagination wrapper) from a row array — adopters were re-deriving these
  fields per call.

- Public @adcp/client/types re-exports for the full property-list + collection-
  list CRUD surface (Create/Update/Get/List/Delete{Property,Collection}List
  Request/Response). Governance adopters no longer reach into generated files.

- skills/build-decisioning-platform/SKILL.md gains a "HITL-sometimes" section.
  Guidance: declare `*Task` even when most calls resolve immediately, so the
  buyer experience is uniform. Avoids the `createMediaBuy` (sync) vs
  `createMediaBuyTask` (HITL) split-on-request anti-pattern.

Tests: 6109 pass / 0 fail. Skill examples + storyboards unchanged. Two skill
files (`build-decisioning-creative-template`, `build-decisioning-signal-
marketplace`) updated to match the now-parameterized specialism interfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tract, louder stub errors

Three small follow-ups to downstream-reviewer concerns from the agentic-adapters team's
13-platform walk-through:

- Type discipline at the projection seam: drop the `(params as { foo?: T }).foo`
  casts in `from-platform.ts` for `media_buy_id`, `creatives` (sales + creative),
  `audiences`, and `accounts`. Wire request schemas already declare these
  properties; the casts were defensive copies that signaled looseness where the
  framework's `DomainHandler` typing already provides the shape. Reviewer item #2.

- Projection wiring contract pinned to the type definition: `DecisioningAdcpServer.statusChange`
  JSDoc now explicitly states the rc.1 projection commit MUST fan-in from BOTH
  the per-server bus AND the module-level `activeBus`. Anchoring the subscriber
  to only one source silently makes the other call site inert in production. Closes
  the latent foot-gun the reviewer flagged in their `5e3206a1` follow-up.

- Stub errors louder: `to-context.ts` resolvers (`propertyList` / `collectionList` /
  `creativeFormat`) now throw with a uniform "not yet wired in v6.0 alpha — landing
  in rc.1" diagnostic and the `buildRequestContext` no-account error explains it's a
  framework invariant violation. Reviewer item #3 + #12.

No behavior change for adopters whose code already paths through the typed wire
shapes; the tightening eliminates surface adopters could mistake for "the SDK is
unsure of its own types here."

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@EmmaLouise2018 EmmaLouise2018 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — feat(server): DecisioningPlatform v6.0 preview surface

Verdict: solid bones, premature ship. At least one security bug, two correctness bugs, and several adopter-blocking gaps need to land before merge.


🔴 Must fix before merge

  1. JWKS validator security holesrc/lib/server/decisioning/tenant-registry.ts:188-209. isMatchingKey returns true on kid equality alone without comparing the public-key material (n/e, x/y, crv/x). The corresponding test (test/server-decisioning-tenant-registry.test.js:271-283) actually asserts this broken behavior — published JWK has n: 'aaa', claimed key has n: 'bbb', validator returns ok: true. An attacker controlling a tenant's signingKey.publicJwk (or one whose kid collides) bypasses verification.

  2. validatePlatform enforces "not both" instead of "exactly one"runtime/validate-platform.ts:96-112. If an adopter declares sales but defines neither createMediaBuy nor createMediaBuyTask, validation passes and dispatch crashes at runtime via non-null assertions in from-platform.ts:309/343. No test covers the "neither defined" path.

  3. Spec-alignment claim wrong: 4 missing Submitted armsruntime/validate-platform.ts:60-71 and specialisms/sales.ts:1-15 claim only create_media_buy and sync_creatives have Submitted arms. The cached 3.0 GA schemas in schemas/cache/3.0.0/ ship submitted arms for six tools: create-media-buy, update-media-buy, get-products, build-creative, sync-creatives, sync-catalogs. The PR has no updateMediaBuyTask, no getProductsTask, no buildCreativeTask, no syncCatalogsTask. Either the schema cache is stale (run npm run sync-schemas) or the dual-method coverage is wrong. The PR cites adcp#3311 as missing, but the GA schema already has it.

  4. server.statusChange per-server bus is wired to nothingfrom-platform.ts:146, 168-173. The bus is created and exposed, but createAdcpServer is never given a subscription hook, and the module-level activeBus is independent. Adopters following the JSDoc believe events project to MCP Resources subscribers; they don't. Both buses are inert until a future commit. Either gate the field as a no-op stub with a TODO, unify the buses, or wire the projector before merging.

  5. Examples import via relative pathexamples/decisioning-platform-{mock-seller,broadcast-tv,...}.ts:32+ import from '../src/lib/server/decisioning', while the SKILL tells adopters to use '@adcp/client/server/decisioning'. The from-platform.ts:41-44 JSDoc also says "not yet exported" while package.json exports does ship the subpath. Three sources, three different stories — copy-paste from the example breaks.


🟠 Should fix

  1. AdCPSpecialism enum is 20 values; validate-platform.ts:18-49 only wires 14. Declaring specialisms: ['signed-requests', 'content-standards', 'brand-rights', 'governance-aware-seller'] passes validation while implementing nothing — silent capability inflation.

  2. Retail-media adopters are blocked. sales-catalog-driven and sales-retail-media are mapped to SalesPlatform, but the interface has zero methods for sync_catalogs, sync_event_sources, log_event, provide_performance_feedback. The README defers these to EventTrackingPlatform/CatalogPlatform/FinancialsPlatform "in v6.0-rc.1." Walmart/Criteo/Amazon adopters cannot ship on v6.0-alpha — needs to be louder than a deferred-list bullet.

  3. In-memory task registry NODE_ENV gate is a real prod blocker. from-platform.ts:192-206 throws unless NODE_ENV is test/development or ADCP_DECISIONING_ALLOW_INMEMORY_TASKS=1. Sales-broadcast-tv adopters are structurally forced into HITL *Task methods, which structurally need the registry. The skill never mentions it. Either ship a SQLite default or banner this as dev-only.

  4. No docs/migration-5.x-to-6.x.md. PR body promises "handler bodies port one-to-one"; the SKILL's "Reference" section sends adopters to docs/proposals/decisioning-platform-v1.md — i.e., the design proposal as docs. A v5.x adopter has no on-ramp.

  5. listAccounts skips projectSyncfrom-platform.ts:622. AdcpError throws bubble as generic and map to SERVICE_UNAVAILABLE instead of structured envelope.

  6. Race in _registerBackgroundruntime/task-registry.ts:125-135. If taskFn resolves synchronously, the composed.then cleanup runs before _registerBackground registers, and the entry never deletes. Memory leak only.

  7. <P extends DecisioningPlatform<any, any>> defeats RequiredPlatformsForfrom-platform.ts:139, 285, 358, 439, 457, 480, 600. Use <P extends DecisioningPlatform<TMeta, TStatus>, TMeta = unknown, TStatus = unknown> instead.

  8. Compile-time XOR not used for dual-method. Runtime check at validate-platform.ts:80-117 is correct, but TS's discriminated unions could make "both defined" unrepresentable. Big footgun for LLM-driven adopters.

  9. Recommended "always declare HITL, resolve immediately" pattern (SKILL.md:194-217) forces every buyer to poll tasks/get for sync calls — and tasks/get is itself deferred to v6.0-rc.1. The skill recommends a pattern that has no wire path yet.

  10. tenant-registry.ts:299-308 resolveByHost is O(N) parsing new URL per lookup. Build a Map<host, tenantId> at register-time.

  11. runValidation doesn't catch validator throwstenant-registry.ts:236-268. Tenants stuck unverified forever.

  12. Module-level publishStatusChange cross-test contaminationstatus-changes.ts:148. Concurrent test files clobber activeBus.

  13. AccountNotFoundError 3-way semanticsaccount.ts:114-167. Throwing it from a specialism method silently maps to SERVICE_UNAVAILABLE instead of ACCOUNT_NOT_FOUND. Make the constructor @internal so adopters can't misuse it outside accounts.resolve().

  14. ErrorCode union hand-maintainedasync-outcome.ts:24-26 has the codegen TODO. Will drift from schemas/cache/<version>/enums/error-code.json.

  15. typesVersions missing server/decisioningpackage.json:99-101. Adopters on moduleResolution: 'node' won't get autocomplete.


🟡 Design concerns (worth a follow-up, not merge-blocking)

  • Dual-method per-tool shape forces upfront sync-vs-HITL choice. Real DSPs/SSPs are HITL-sometimes (legal review for new contracts only). The "always declare HITL, resolve fast" workaround taxes every buyer with polling. A throw RequiresReviewError from a sync method that the framework converts to submitted is the more honest pattern — sync ergonomics with async correctness.
  • Sandbox-as-account loses real semantics. Buyers care about test budgets not billed, test creatives not trafficked. TTD/DV360 expose sandbox as a mode flag on a real account. Document that platforms can resolve sandbox to the same account with metadata.sandbox: true.
  • Hardcoded specialism lists in validate-platform.ts:18-49 and sales.ts:67-123 will drift. Drive from generated schema.
  • Sales specialism collapse oversimplifies broadcast and proposal-mode. A single SalesPlatform shape works for programmatic specialisms but flattens broadcast-TV's multi-stage IO/proposal lifecycle. Workable via publishStatusChange, but every broadcast adopter will hit awkwardness.
  • Status-change taxonomy missing verification_result / outcome_report for measurement vendors when measurement-verification graduates from preview. delivery_report as a status channel is overengineered — consider renaming to delivery_report_published.
  • AccountStore.resolution: 'explicit' | 'implicit' | 'derived' is real, not academic — maps cleanly to GAM/Snap (explicit), LinkedIn (implicit), self-hosted (derived). Keep.

Notes

  • Generated-types diff (core.generated.ts, tools.generated.ts) reads as a clean codegen run from vlatestv3.0.0. No hand-edit smell.
  • Architecture itself is right: single class, framework owns wire mapping, throw AdcpError, RequiredPlatformsFor compile-time gate. The shape is cleaner than v5.x's handler bag — the shipping wrapper just isn't ready.
  • Realistically only ~30% of AdCP-curious sellers can ship on this surface today (programmatic SSPs, signals, audiences, template/generative creative). RMNs, broadcast/proposal-mode, governance-past-spend-authority, brand-rights, OAuth-auth, and creative-ad-server adopters all have to wait for rc.1. The README's "v6.0 preview surface organized by specialism" framing should call that out per-specialism in the JSDoc, not just the deferred-list bullet.
  • The PR thread's "5 rounds of be-Emma at 4.5/5" + the simulated agentic-adapters / Python-port adopter comments all originate from the same author. Real-world adopter validation (someone from a downstream codebase touching this end-to-end) before opening to broader feedback would be high-value.

Three concrete things to land before merge: (a) fix #1 + #2 + #3, (b) reconcile the import-path divergence in #5, (c) decide whether HITL is alpha-shippable or scope this PR to the 100%-sync specialisms and ship *Task methods in rc.1.

bokelley and others added 2 commits April 27, 2026 06:25
…form doesn't model the tool

Both downstream adopter teams (agentic-adapters, training-agent) flagged the same
migration blocker: their codebases dispatch tools the v6 platform interface
doesn't yet model — getMediaBuys, listCreativeFormats, providePerformanceFeedback,
reportUsage, sync_event_sources, log_event, syncCatalogs, getAccountFinancials,
content-standards CRUD, creative-review, brand-rights — and the previous
`createAdcpServerFromPlatform` opts shape `Omit`-ed those handler categories,
silently dropping any adopter passthroughs.

This commit opens the seam:

- Drop `'mediaBuy' | 'creative' | 'accounts' | 'eventTracking'` from the
  `Omit` so adopters can pass raw handler-style entries on opts alongside
  the platform interface.
- Add `mergeHandlers(custom, platform)` — platform-derived handlers WIN
  per-key; adopter handlers fill any gaps. Returns `undefined` only when
  neither side has handlers (so the framework can omit the domain from
  `tools/list`).
- Apply to all 6 dispatch categories. Closes the silent-overwrite bug for
  `signals` and `governance` (already passable but were being clobbered by
  platform-derived assignment order).

The contract: adopter migrates sales / audiences / signals / governance
to the v6 platform shape today, and keeps custom handlers wired for tools
whose specialism interfaces are deferred to v1.1+ / rc.1
(event-tracking trio, catalog, financials, content-standards CRUD,
creative-review, brand-rights).

Four tests pin the contract:
- getMediaBuys (un-wired by SalesPlatform) dispatches through opts.mediaBuy
- log_event (no v6 specialism) dispatches through opts.eventTracking
- list_content_standards (deferred specialism) dispatches through opts.governance
- platform-derived getProducts WINS over opts.mediaBuy.getProducts (opts is
  the gap-filler, not an override; modify your platform method to change
  platform-derived behavior)

Tests: 6128 pass / 0 fail. No type changes to public specialism interfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ocs + OAuth wiring docs

Three follow-ups to make agentic-adapters and training-agent unblocked on the v6
preview surface today instead of waiting for rc.1.

**1) Durable Postgres-backed `TaskRegistry`.**

- `createPostgresTaskRegistry({ pool, tableName? })` + `getDecisioningTaskRegistryMigration()`.
  Vendor-prefixed table `adcp_decisioning_tasks` (avoids collisions with the
  MCP-level `adcp_mcp_tasks`). Indexes on `(account_id)` and
  `(status, created_at)`. Terminal-state idempotency enforced via SQL
  `WHERE status = 'submitted'` so concurrent webhook deliveries can't race
  to overwrite each other.

- Cross-instance reads: process A allocates the task, process B reads the
  lifecycle for `tasks/get` — pinned by an integration test in
  `test/server-decisioning-postgres-task-registry.test.js` (10 tests, all
  pass against local pg).

- `_registerBackground` / `awaitTask` stay process-local — promises don't
  serialize. Production HITL flows that span processes complete via
  webhook → an explicit `complete()` / `fail()` from the webhook handler;
  documented at the top of `postgres-task-registry.ts`.

**2) `TaskRegistry` interface async.**

- `create`, `complete`, `fail` now return `Promise<T>` (already true for
  `getTask`). Storage-backed impls couldn't satisfy the previous sync
  contract reliably; the in-memory impl resolves immediately so the
  framework-side cost is one microtask per dispatch.

- `DecisioningAdcpServer.getTaskState` is now `Promise<TaskRecord | null>`.
  Small breaking change to the preview surface; tests updated.

- `dispatchHitl` becomes `async` and awaits each registry call. Wrapper
  handlers were already async so no signature changes propagate outward.

**3) `accounts.resolve()` mandatory + sandbox posture docs.**

SKILL.md gains two sections:

- "`accounts.resolve()` is mandatory — even for 'no tenant' agents". Single-
  tenant agents that historically skipped resolution (training-agent's
  posture today) declare `resolution: 'derived'` and return a synthetic
  singleton. Without it, the framework's tenant-scoped invariants
  (idempotency, status-change `account_id`, workflow steps,
  `getCapabilitiesFor`) all break — explicit migration callout.

- "Sandbox: `AccountReference.sandbox === true`". No separate dry-run mode;
  the resolver routes to a sandbox account; platform reads/writes go
  through a sandbox backend per `account.metadata`.

**4) OAuth provider wiring docs.**

SKILL.md § "OAuth provider wiring" — verifiers live on `serve({ authenticate })`,
not on `DecisioningPlatform`. Worked example showing how a custom
`SnapOAuthProvider`-style verifier translates into framework `authInfo`
that the platform's `accounts.resolve(ref, { authInfo })` reads to map to
its tenant model.

Tests: 6061 pass / 0 fail (suite size now 1631 with the new pg test file).
Skill examples typecheck clean (one new skip-marker for the OAuth example
which references undefined identifiers).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley and others added 10 commits April 27, 2026 07:29
agentic-adapters and training-agent both flagged that the canonical
SalesPlatform shipped with 3/9 wire-spec sales tools missing from its
interface. Adopters were filling them via the merge seam — workaround, not
the answer. Lands the six remaining canonical methods as optional fields on
the platform interface so they're first-class on the v6 surface ahead of
rc.1.

**SalesPlatform** — four new optional methods:
- `getMediaBuys(req, ctx)` — list owned media buys with filter/pagination
- `providePerformanceFeedback(req, ctx)` — accept buyer-side performance signals
- `listCreativeFormats(req, ctx)` — discovery for self-hosted format catalogs
- `listCreatives(req, ctx)` — read library (also lives on CreativeAdServerPlatform
  for the standalone-creative-agent shape)

**AccountStore** — two new optional methods:
- `reportUsage(req)` — operator-billed usage rows for billing reconciliation
- `getAccountFinancials(req)` — spend / credit / payment status

Dispatch wired in `from-platform.ts`: presence-checked, falls through to the
merge seam when the platform doesn't implement (so adopters who don't migrate
these specific tools today aren't broken).

**Known limitation, lights up in rc.1:** `provide_performance_feedback` and
`list_creative_formats` wire requests don't carry an `account` field, so the
framework's current `hasAccount` check skips `resolveAccount` and the
RequestContext arrives without a tenant. Two tests pinned with TODO(rc.1)
markers; the rc.1 framework refactor adds `resolveAccount(undefined,
{ authInfo, toolName })` for these tools so platform methods always see a
tenant-scoped ctx. Interface + dispatch are in place.

`getMediaBuys`, `reportUsage`, `getAccountFinancials` light up today —
their wire requests carry `account`. Three tests pin those dispatches.

Tests: 6084 pass / 0 fail (suite size 1632, +6 from this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two adopter-blocking rc.1 items shipped together:

**1) Auth-derived account resolution.** Tools whose wire request doesn't
carry an `account` field (`provide_performance_feedback`,
`list_creative_formats`, the forthcoming `tasks/get` polling path) skip the
framework's `resolveAccount` today, leaving handlers without `ctx.account`
and breaking every v6 dispatch path through them.

- New `AdcpServerConfig.resolveAccountFromAuth?` opt — framework calls it
  with the auth principal for these tools so single-tenant agents
  (`resolution: 'derived'`) return their singleton and principal-keyed
  agents look up via `authInfo`.
- `AccountStore.resolve` signature widened to accept `AccountReference |
  undefined`. The `'derived'` mode returns the singleton; `'implicit'`
  resolves from auth; `'explicit'` either throws `AccountNotFoundError` or
  returns null.
- `createAdcpServerFromPlatform` wires it automatically — platform
  resolvers see the same surface for both account-bearing and
  account-less tools.

Tests pin `provide_performance_feedback` + `list_creative_formats`
end-to-end on a `'derived'` agent.

**2) HITL push-notification webhooks on terminal task state.** Adopters
running HITL today must poll `server.getTaskState` for completion. v6 now
honors the buyer's `push_notification_config: { url, token? }` from the
HITL request body and emits a signed RFC 9421 webhook to the URL on
`completed` / `failed` carrying the wire task payload:

    { task: { task_id, status: 'completed', result } }
    { task: { task_id, status: 'failed', error: { code, recovery, message } } }

When the buyer supplies a `token`, it's echoed as `validation_token` in the
payload so the receiver can authenticate the webhook origin.

- Webhook delivery uses `ctx.emitWebhook` (framework-provided when
  `webhooks` is wired on `serve()`) by default.
- New `taskWebhookEmitter?` opt on `createAdcpServerFromPlatform` lets
  adopters inject an explicit emitter — useful for tests (no signing key
  required), dedicated task delivery channels, or different retry policies
  vs. the host's other webhooks.
- Failures inside webhook delivery don't fail the task — registry already
  records terminal state. Logged via console for operator triage.

Three tests pin: completed-emit-with-token, failed-emit-with-error,
no-emit-when-config-missing.

Tests: 6138 pass / 0 fail (suite size 1648, +6 from this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…m guard

Three medium-priority items from the rc.1 expert red-team that addressed
real adopter-blocking gaps:

**M1 — `ContentStandardsPlatform` specialism.** Adds the `content-standards`
specialism (in AdCP 3.0 GA enum) with 6 required + 2 optional methods:
list/get/create/update/calibrate/validate + optional getMediaBuyArtifacts /
getCreativeFeatures. Wired through GovernanceHandlers in the framework.
Closes the specialism gap training-agent (Innovid) flagged. `creative-review`
is not a separate AdCP specialism — `CreativeAdServerPlatform.syncCreativesTask`
HITL covers manual creative-review workflows.

**M2 — `SalesPlatform` retail-media tools.** Three new optional methods on
SalesPlatform: `syncCatalogs`, `logEvent`, `syncEventSources`. Routed
through EventTrackingHandlers in the framework to match the wire-spec
category. Unblocks `sales-catalog-driven` adopters — Amazon retail-media,
Criteo, Citrusad, Walmart Connect, Shopify ad surfaces — without forcing
the merge-seam workaround agentic-adapters flagged.

**M3 — Merge-seam collision warning.** Closes the silent-migration-regression
trap: an adopter writes `opts.mediaBuy.getMediaBuys` via the merge seam
because v6.0 SalesPlatform doesn't model it; v6.x adds the method to
SalesPlatform; adopter's platform implements it; adopter's opts override
is silently shadowed by `mergeHandlers` returning `{ ...custom, ...platform }`.
No deprecation signal, custom logic stops running.

Fix: `mergeHandlers` accepts `{ mode, logger }` and detects collisions.
Three modes:
- `'warn'` (default) — log at construction; migration signal without
  breaking running deployments
- `'strict'` — throw `PlatformConfigError`; recommended for CI / new
  deployments
- `'silent'` — opt-out for adopters who deliberately use the seam as an
  override (e.g., wrapping platform behavior with logging)

Three tests pin warn / strict / silent.

Tests: 110 decisioning pass / 0 fail (suite size 33, +6 from this commit).
Skill examples typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a fourth mode to `mergeSeam` — `'log-once'` — that logs the first
time each `(domain, sortedColliders)` collision is seen in the process and
stays silent for subsequent constructions matching the same shape. Right
default for:

- Multi-tenant hosts where N tenants share a process and would otherwise
  produce N copies of the same warning at startup
- Hot-reload dev (file watcher reconstructs the server every change)

Module-level `Set<string>` keyed on `${domain}|${sortedColliders}` —
sorted so declaration-order doesn't fragment dedupe. Exposes
`_resetMergeSeamDedupe()` (`@internal`) for test reuse.

`'warn'` (default) keeps the every-construction behavior — right for
single-tenant agents where you want every restart to surface the
migration signal. Adopters with N tenants flip to `'log-once'`.

One test pinned: first construction warns; second construction with the
same collision shape stays silent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Generic instrumentation surface so adopters can wire DataDog / Prometheus /
OpenTelemetry / structured logging without baking a specific SDK into the
framework. Same shape as the existing webhook emitter's `onAttempt` /
`onAttemptResult` callbacks; same shape as the Postgres TaskRegistry
peer-dep pattern.

**Hooks (`DecisioningObservabilityHooks`):**
- `onAccountResolve({ tool, durationMs, resolved, fromAuth })` — wraps every
  `resolve()` call (both ref-based and auth-derived paths)
- `onTaskCreate({ tool, taskId, accountId })` — fires when `dispatchHitl`
  allocates a task in the registry
- `onTaskTransition({ taskId, tool, accountId, status, durationMs, errorCode? })`
  — fires when a task hits terminal state (completed | failed); errorCode
  populated on failed
- `onWebhookEmit({ taskId, tool, status, url, success, durationMs, errors? })`
  — fires after each push-notification delivery attempt, success or all-retries
- `onStatusChangePublish({ accountId, resourceType, resourceId })` — wraps
  the per-server bus; fires after every successful publish

**Throw-safe:** every callback wrapped in try/catch with framework-logger
warning on throw. Adopter telemetry mistakes never break dispatch.

**Coming in v6.1:** per-tool dispatch-latency hooks (`onDispatchStart` /
`onDispatchEnd`) — requires wrapping every handler entry point. Lands when
the per-handler instrumentation pass goes through.

**Follow-up:** `@adcp/client/telemetry/otel` peer-dep adapter that returns a
pre-wired `DecisioningObservabilityHooks` with AdCP-aligned span / metric
names. Adopters using DataDog / Prometheus implement directly.

Tests: 118 decisioning pass / 0 fail (suite size 34, +7 from this commit).
Pinning: each hook fires, hook-throw is caught + logged, dispatch unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…count-scoped getTaskState

Three security blockers from the rc.1 expert red-team. All buyer-controllable
input now validated before reaching the wire / shared registry surface.

**B5 — SSRF guard on push_notification_config.url.** The buyer-supplied
URL goes through `validatePushNotificationUrl()` before `dispatchHitl`
forwards it to `ctx.emitWebhook`. Rejected:

- Non-`https://` schemes (allowed in NODE_ENV=test/development OR via
  `ADCP_DECISIONING_ALLOW_HTTP_WEBHOOKS=1` — same allowlist pattern as
  the in-memory task registry)
- Bare `localhost` / `0` hostnames
- RFC 1918 private ranges: 10/8, 172.16/12, 192.168/16
- Loopback: 127/8, IPv6 ::1
- Link-local: 169.254/16 (covers AWS / GCP / Azure metadata services),
  fe80::/10
- CGNAT: 100.64/10
- IPv6 unique-local fc00::/7
- Multicast / reserved: 224+
- Malformed URLs

On rejection the framework logs at warn level and skips webhook delivery —
does NOT fail the task. Buyer can resend with a valid URL.

**B6 — Token shape validation.** push_notification_config.token rejects
values >255 chars or containing control characters. Rejected tokens are
NOT round-tripped in the webhook payload (`validation_token` field omitted).
Same warn-and-continue pattern.

**B7 — Account-scoped getTaskState.** `DecisioningAdcpServer.getTaskState`
now accepts an optional `expectedAccountId` second arg. When supplied,
returns `null` if the task's `accountId` doesn't match — closes the
cross-tenant probe vector where any caller with a known `task_id` could
read any tenant's task lifecycle (including `result` / `error` payloads).

Adopters wrapping `getTaskState` as a `tasks/get` wire handler MUST pass
`ctx.account.id` to scope reads. Single-arg form (no expectedAccountId)
remains for ops / test harnesses that hold no buyer account in scope.

Tests: 136 decisioning pass / 0 fail (suite size 36, +18 from this commit).
Pinning: every IP/scheme/host class rejection, well-formed accept path,
token length + control-char rejection, cross-tenant probe → null,
unscoped read still works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…um, dual-method, ordering

Closes 6 expert-flagged blockers in a single commit. All deterministic
correctness + spec-shape fixes that were tractable as a unit.

**B1 webhook payload spec-flat shape.** Emit the
`mcp-webhook-payload.json`-conformant envelope: top-level
`idempotency_key` (UUIDv4), `task_id`, `task_type`, `status`, `timestamp`,
`protocol`, `message` on failed, `result` carrying the success-arm body
for completed or `{ errors: [structuredError] }` for failed. Replaces the
previous `{ task: { ... } }` nested shape that wouldn't validate at
spec receivers.

**B2 task-status enum 9/9.** TaskStatus now matches enums/task-status.json
— submitted / working / input-required / completed / canceled / failed /
rejected / auth-required / unknown. Postgres CHECK constraint widened.
Framework writes only 3 today; the other 6 reserved for the v6.1
`taskRegistry.transition()` adopter-driven API.

**B4 AccountStore.resolve(ref, ctx?) two-arg signature.** Closes the
SKILL/runtime drift the security review flagged. Adopters fronting Snap
/ Meta / retail-media APIs translate `ctx.authInfo` (transport-level
auth) into their tenant model. ResolveContext.authInfo matches the
framework's HandlerContext['authInfo'] shape.

**B10 buildRequestContext null-account tolerance.** No longer throws
when `handlerCtx.account` is missing. Auth-derived resolvers may
legitimately return null for tools whose wire request lacks an
`account` field. Adopters either declare `resolution: 'derived'` or
read ctx.account defensively.

**B11 at-least-one for dual-method pairs.** dispatchHitl call sites
check both sync + task variants before dereferencing —
UNSUPPORTED_FEATURE instead of `TypeError: not a function` when
adopter wires a creative platform without either syncCreatives /
syncCreativesTask.

**B12 Postgres complete() failure ordering.** Webhook delivery gated
on registry write succeeding. Three failure surfaces handled distinctly:
- taskFn throws → record fail → emit failed webhook
- taskFn succeeds, registry write fails → log error, skip webhook
  (buyer's webhook view stays consistent with getTaskState reads)
- both fail → log error, skip webhook

Tests: 136 decisioning pass / 0 fail. Existing webhook tests updated to
assert on the spec-flat envelope (top-level task_id/status/result).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…startTask

Closes B8 from the rc.1 expert red-team. The DX reviewer flagged that the
canonical SKILL.md taught APIs that were removed — agents generating code
from the doc produced TS errors on the first try.

**Removed (no longer in the runtime):**
- `ctx.runAsync(opts, fn)` — was the v1 in-process auto-defer pattern
- `ctx.startTask({ partialResult })` — was the v1 out-of-process task handle
- "Four async patterns" / "Three rules" rule #3 — referenced ctx.runAsync

**Replaced with:**
- "Three async patterns" — sync happy path / structured rejection /
  HITL via `*Task` variant
- "Three rules" rule #3 now: "For HITL: implement xxxTask(taskId, req,
  ctx) instead of xxx(req, ctx). Pick exactly one per pair;
  validatePlatform() rejects defining both."
- Worked HITL example showing `createMediaBuyTask(taskId, req, ctx)`
  with operator wait + AdcpError on denial
- Buyer's two terminal-state paths: webhook push (with SSRF guard
  reference) + programmatic `getTaskState(taskId, accountId)`

**New sections added:**
- "Production task storage" — `createPostgresTaskRegistry({ pool })` +
  `getDecisioningTaskRegistryMigration()` migration helper, cross-instance
  reads, terminal-state idempotency, custom-backend implementation
  pointer
- "Observability hooks" — DecisioningObservabilityHooks worked example
  wiring metrics for the 5 hooks (onAccountResolve, onTaskCreate,
  onTaskTransition, onWebhookEmit, onStatusChangePublish), throw-safety
  callout, peer-dep adapter teaser

**Generic-typing fix:** the minimal example's `sales: SalesPlatform` is
now `sales: SalesPlatform<MyMeta>` — D3 from the DX reviewer. Adopters
who omit the generic param lose `ctx.account.metadata` typing; the
canonical example now models it correctly.

Skill examples: 112 blocks total, 32 compilable, 0 new errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-gap doc

**B9 — buyer-side polling path for HITL task lifecycle.**

The framework auto-registers a `tasks_get` custom tool when adopters call
`createAdcpServerFromPlatform`. Buyers call it with
`{ task_id, account? }` and receive the spec-flat lifecycle shape:

  - `task_id`, `task_type`, `status`, `timestamp`, `protocol`
  - `result` (success-arm body for completed)
  - `result.errors[]` (structured-error array for failed)
  - `message` (human-readable summary, when set)

Tenant-scoped: `'explicit'` adopters pass `account` and the framework
verifies the resolved account owns the task. Mismatch returns
`REFERENCE_NOT_FOUND` (same shape as not-found — no principal-enumeration
via task_id probing). `'derived'` / `'implicit'` adopters get scoping for
free via the auth-derived resolver.

Snake-case `tasks_get` (MCP tool names disallow `/`) approximates the
spec's `tasks/get` method. Native MCP `tasks/get` integration via the
SDK's experimental `registerToolTask` lands in v6.1 — that registers HITL
tools as MCP task tools and the SDK handles the protocol-level
`tasks/get` natively.

Four tests pin: completed lifecycle, failed with structured error,
cross-tenant probe rejection, unknown task_id.

**B13 deferred — codegen gap.**

The protocol reviewer flagged that `get_products`, `update_media_buy`,
and `build_creative` have spec-defined Submitted arms in
`core/async-response-data.json`, yet the SDK's specialism interfaces only
expose sync variants. The right fix is `*Task` methods on the platform
interfaces, but the TS codegen reads `*-response.json` (success-body
shape) without walking the response-data union — so
`GetProductsResponse` / `UpdateMediaBuyResponse` / `BuildCreativeResponse`
are single interfaces, not unions including Submitted.

Adding `*Task` methods today type-errors at the framework dispatch layer
(handler return type doesn't include SubmittedEnvelope). JSDoc on each
interface now documents the codegen gap and points adopters to
`publishStatusChange` for long-running flows. Closes when codegen models
the full response union — separate generator-side fix tracked as v6.1.

Tests: 140 decisioning pass / 0 fail (suite size 37, +4 from this commit).
Skill examples: 0 new errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…SSRF tightening

- tasks_get handler threads MCP RequestHandlerExtra.authInfo into both
  accounts.resolve(ref, ctx) sites — closes the bypass where a 'explicit'-
  mode resolver naively returned tenant B's account by id without
  authorizing against the principal. Defense-in-depth: when caller's
  account doesn't resolve at all but the task IS owned, refuse to leak
  (REFERENCE_NOT_FOUND, no principal-enumeration).
- SSRF guard strips IPv6 brackets before range checks (Node's
  URL.hostname keeps brackets — `[::1]` was bypassing the unbracketed
  prefix matchers). IPv4-mapped IPv6 dotted + hex forms recursively
  re-validate. Alternate IPv4 forms (integer/hex/octal) already
  canonicalized to dotted-decimal by Node's WHATWG parser before our
  checks see them — defense-in-depth regex rejectors removed as dead
  code, replaced with explanatory comment.
- validatePushNotificationToken rejects empty strings.
- Webhook payload field renamed validation_token → token (matches
  mcp-webhook-payload.json spec; previous name would have been rejected
  by spec-conformant receivers).
- Postgres task-registry CHECK narrowed back to 3 framework-written
  values; widening reserved for v6.1 taskRegistry.transition() API.
- safeFire catches async-hook promise rejections — protects against
  process crash under node --unhandled-rejections=strict.
- listAccounts wrapped in projectSync so adopter AdcpError throws
  project to structured envelope, not SERVICE_UNAVAILABLE.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants