Skip to content

feat(training-agent): createAdcpServer scaffold + framework migration plan#2561

Merged
bokelley merged 4 commits into
mainfrom
bokelley/training-adcpserver-migration
Apr 20, 2026
Merged

feat(training-agent): createAdcpServer scaffold + framework migration plan#2561
bokelley merged 4 commits into
mainfrom
bokelley/training-adcpserver-migration

Conversation

@bokelley

@bokelley bokelley commented Apr 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Migrates the training agent through the @adcp/client 5.2 → 5.5 upgrade path and lays down the createAdcpServer framework scaffold behind an opt-in feature flag.

What landed

  • 5.2 → 5.3 → 5.4 → 5.5 baseline SDK adoption:
    • createIdempotencyStore replaces the hand-rolled idempotency store (JCS + ±60s TTL skew, consistent with spec).
    • createWebhookEmitter replaces the bespoke webhook signer. Ed25519 signing, deterministic idempotency keys.
    • verifyApiKey + anyOf replaces the hand-rolled bearer middleware. WorkOS org + static public token composed in one chain.
    • verifySignatureAsAuthenticator joins that chain — signed requests reach the /mcp endpoint instead of dying at the bearer gate.
    • AdcpServer opaque type closes the CJS/ESM dual-package hazard.
    • Runner monkey-patches removed (5.5 dropped .strict() from validateRequest).
    • capabilities.overrides declares training-agent-specific surface (publisher portfolio, compliance_testing scenarios, targeting surface) for the framework path.
  • Framework path (framework-server.ts): createAdcpServer scaffold with domain-grouped handlers, customTools config for 9 non-AdcpToolMap tools, pre-formatted AdaptedResponse envelopes for byte-identical parity with the legacy dispatch.
  • Feature flag TRAINING_AGENT_USE_FRAMEWORK: defaults OFF. Legacy hand-rolled dispatch remains authoritative until framework parity is verified end-to-end.
  • Storyboard runner fixes: express.json({ verify }) captures rawBody so the in-process signature verifier rehashes exactly what the signer signed. request_signing: { transport: 'mcp' } routes signed-requests vectors through our MCP transport.

Results

  • 587/587 unit + integration tests green with the flag OFF.
  • 29/55 storyboards clean on the legacy path (+4 over the 5.3 baseline — signing compose closes the last bearer-gate blocker on signed_requests positive vectors).
  • Framework flag stays OFF by default: flipping it to ON regresses 10 storyboards to 19/55, driven by stricter zod argument validation that our handlers don't yet match. Tracked as follow-up work; SDK surface is in place.

Follow-up (separate PRs)

  • Signed-requests negative vectors (25 steps) — needs presence-gated signature composition, filed upstream as adcp-client#659; landing a local wrapper in the meantime.
  • Review findings from OAuth workstream (custom-tools inputSchema, CI matrix for framework flag, boot-time signing init, doc/code mismatches).
  • Storyboard completeness: governance plan isolation, fixture seeding, spec-feature wiring to close the remaining 26 legacy failures.
  • Framework zod parity: declare full spec schemas on custom tools + fix 8 tool payload shapes — unblocks flag default flip.

Test plan

  • npm run test:unit — 587/587 green
  • npm run typecheck — clean
  • npx tsx server/tests/manual/run-storyboards.ts — 29/55 clean, 222 steps passing
  • CI green on merge

🤖 Generated with Claude Code

Comment thread server/src/training-agent/framework-server.ts Fixed
@bokelley bokelley force-pushed the bokelley/training-adcpserver-migration branch from 745be42 to e7eca3c Compare April 20, 2026 15:48
bokelley and others added 4 commits April 20, 2026 14:40
- Export getWebhookSigningKey() from webhooks.ts so the future
  createAdcpServer({ webhooks: { signerKey } }) config can reuse the
  existing Ed25519 key without duplicating key management.
- Add FRAMEWORK_MIGRATION.md documenting the 10 blockers surfaced
  during a first-pass attempt (response-shape vs framework wrap,
  McpServer CJS/ESM dual-resolution, VERSION_UNSUPPORTED /
  dry_run / stateless-HTTP task-store edge cases, 30+ custom tools
  outside AdcpToolMap, test-harness _requestHandlers path, ...) and
  the staged 4-PR plan to unwind them.

No runtime behavior change — this commit just lays the groundwork.
The framework migration proper (replacing ~3,300 lines of hand-rolled
dispatch in task-handlers.ts) lands in a follow-up with dedicated
type-system budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds server/src/training-agent/framework-server.ts —
createFrameworkTrainingAgentServer() routes all 30+ spec tools through
@adcp/client/server's createAdcpServer via an adapt() helper that
produces pre-formatted CallToolResult envelopes (preserving byte-identical
responses), enforces VERSION_UNSUPPORTED, and maps exceptions to
SERVICE_UNAVAILABLE per legacy behavior. The 9 tools outside AdcpToolMap
(creative_approval, update_rights, comply_test_controller,
validate_property_delivery, 5× *_collection_list) register directly via
registerTool().

Resolver hooks configured:
- idempotency: shared getIdempotencyStore() with legacy path
- webhooks.signerKey: getWebhookSigningKey() (same Ed25519 key)
- resolveIdempotencyPrincipal: scopedPrincipal() account-partitioning

Opt in via TRAINING_AGENT_USE_FRAMEWORK=1; default OFF. The flag lets us
land the scaffold without regression risk, diff response shapes per-tool
under both paths, and flip the default in a focused follow-up PR.

Handler exports in task-handlers.ts widened from module-local to export
so framework-server.ts can import them. Legacy dispatch path is
unchanged.

Three gaps for the follow-up PR (tracked in FRAMEWORK_MIGRATION.md):
1. get_adcp_capabilities override — framework auto-registers this and
   registerTool() rejects duplicates. Training-agent-specific fields
   (publisher_domains, compliance_testing.scenarios, execution.targeting)
   need SDK support for replaceTool() or AdcpCapabilitiesConfig
   extension.
2. Webhook emission from dispatch — legacy path emits when
   push_notification_config.url is on the request; framework expects
   ctx.emitWebhook() from handlers. Need to either teach handlers to
   emit or keep a dispatch-level emitter on the framework path.
3. Stateless-HTTP task store verification — framework uses
   createTaskCapableServer; need empirical check under task-augmented
   create_media_buy to ensure notifications/tasks/status doesn't fail
   on a fresh transport per-request.

Test status: 437/437 unit + integration tests green with flag OFF.

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

5.4 closes the five upstream asks surfaced in 5.3:
- createAdcpServer() returns opaque AdcpServer (CJS/ESM hazard closed)
- AdcpServer.dispatchTestRequest({method,params}) replaces
  _requestHandlers reach-through
- McpToolResponse.structuredContent is optional
- SingleAgentClient.validateRequest drops .strict() (no more
  runner monkey-patch)
- Storyboard runner accepts request_signing.transport: 'mcp'
- New AdcpCustomToolConfig / config.customTools lets agents register
  non-spec tools through the framework config (replaces
  getSdkServer() escape hatch)

What changed here:
- framework-server.ts: return type any → AdcpServer. 9 non-AdcpToolMap
  tools (creative_approval, update_rights, comply_test_controller,
  validate_property_delivery, 5× *_collection_list) now register via
  customTools: config instead of server.registerTool() after
  construction. Zero escape hatches remaining on the type surface.
- run-storyboards.ts / run-one-storyboard.ts: drop the 33-line
  SingleAgentClient.validateRequest monkey-patch. Pass
  request_signing: { transport: 'mcp' } so signed_requests vectors
  route through /mcp instead of per-op HTTP URLs.

Results: 437/437 unit + integration tests green (flag OFF). Storyboard
pass count unchanged (29/55 clean, 214 passing) — expected since the
framework path is still opt-in via TRAINING_AGENT_USE_FRAMEWORK=1.

signed_requests vectors now REACH /mcp (401 instead of 404 — they fail
because bearer auth runs before the signature verifier. Composing
bearer-or-signature via anyOf is a separate follow-up).

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

5.5 adds verifySignatureAsAuthenticator so RFC 9421 signatures sit under
the same anyOf() composition as verifyApiKey — bearer OR signature is
sufficient, neither path short-circuits the other.

- request-signing.ts: Express middleware → Authenticator. getUrl override
  uses req.originalUrl since Express strips the /api/training-agent mount.
- index.ts: signing authenticator joins the anyOf chain; legacy
  requestSigningMiddleware wiring removed.
- framework-server.ts: capabilities.overrides declares publisher portfolio,
  compliance_testing scenarios, and targeting surface for the framework
  path (per-domain merge is 5.5-only).
- Runners: express.json({ verify }) captures rawBody bytes so the
  in-process verifier rehashes exactly what the signer signed.

Framework flag stays opt-in — flipping it regresses storyboards due to
stricter zod arg validation that's not yet matched by our handler shapes.

29/55 storyboards clean on legacy path (+4 over 5.3 baseline). 437/437
unit + integration tests green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley force-pushed the bokelley/training-adcpserver-migration branch from 3142e5f to 58e6842 Compare April 20, 2026 19:03
@bokelley bokelley merged commit ed07440 into main Apr 20, 2026
13 checks passed
bokelley added a commit that referenced this pull request Apr 20, 2026
…olish, RFC 9728, dual-mode CI (#2582)

* feat(training-agent): prep for createAdcpServer framework migration

- Export getWebhookSigningKey() from webhooks.ts so the future
  createAdcpServer({ webhooks: { signerKey } }) config can reuse the
  existing Ed25519 key without duplicating key management.
- Add FRAMEWORK_MIGRATION.md documenting the 10 blockers surfaced
  during a first-pass attempt (response-shape vs framework wrap,
  McpServer CJS/ESM dual-resolution, VERSION_UNSUPPORTED /
  dry_run / stateless-HTTP task-store edge cases, 30+ custom tools
  outside AdcpToolMap, test-harness _requestHandlers path, ...) and
  the staged 4-PR plan to unwind them.

No runtime behavior change — this commit just lays the groundwork.
The framework migration proper (replacing ~3,300 lines of hand-rolled
dispatch in task-handlers.ts) lands in a follow-up with dedicated
type-system budget.

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

* feat(training-agent): createAdcpServer scaffold behind feature flag

Adds server/src/training-agent/framework-server.ts —
createFrameworkTrainingAgentServer() routes all 30+ spec tools through
@adcp/client/server's createAdcpServer via an adapt() helper that
produces pre-formatted CallToolResult envelopes (preserving byte-identical
responses), enforces VERSION_UNSUPPORTED, and maps exceptions to
SERVICE_UNAVAILABLE per legacy behavior. The 9 tools outside AdcpToolMap
(creative_approval, update_rights, comply_test_controller,
validate_property_delivery, 5× *_collection_list) register directly via
registerTool().

Resolver hooks configured:
- idempotency: shared getIdempotencyStore() with legacy path
- webhooks.signerKey: getWebhookSigningKey() (same Ed25519 key)
- resolveIdempotencyPrincipal: scopedPrincipal() account-partitioning

Opt in via TRAINING_AGENT_USE_FRAMEWORK=1; default OFF. The flag lets us
land the scaffold without regression risk, diff response shapes per-tool
under both paths, and flip the default in a focused follow-up PR.

Handler exports in task-handlers.ts widened from module-local to export
so framework-server.ts can import them. Legacy dispatch path is
unchanged.

Three gaps for the follow-up PR (tracked in FRAMEWORK_MIGRATION.md):
1. get_adcp_capabilities override — framework auto-registers this and
   registerTool() rejects duplicates. Training-agent-specific fields
   (publisher_domains, compliance_testing.scenarios, execution.targeting)
   need SDK support for replaceTool() or AdcpCapabilitiesConfig
   extension.
2. Webhook emission from dispatch — legacy path emits when
   push_notification_config.url is on the request; framework expects
   ctx.emitWebhook() from handlers. Need to either teach handlers to
   emit or keep a dispatch-level emitter on the framework path.
3. Stateless-HTTP task store verification — framework uses
   createTaskCapableServer; need empirical check under task-augmented
   create_media_buy to ensure notifications/tasks/status doesn't fail
   on a fresh transport per-request.

Test status: 437/437 unit + integration tests green with flag OFF.

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

* feat(training-agent): upgrade to @adcp/client 5.4 + adopt AdcpServer / customTools

5.4 closes the five upstream asks surfaced in 5.3:
- createAdcpServer() returns opaque AdcpServer (CJS/ESM hazard closed)
- AdcpServer.dispatchTestRequest({method,params}) replaces
  _requestHandlers reach-through
- McpToolResponse.structuredContent is optional
- SingleAgentClient.validateRequest drops .strict() (no more
  runner monkey-patch)
- Storyboard runner accepts request_signing.transport: 'mcp'
- New AdcpCustomToolConfig / config.customTools lets agents register
  non-spec tools through the framework config (replaces
  getSdkServer() escape hatch)

What changed here:
- framework-server.ts: return type any → AdcpServer. 9 non-AdcpToolMap
  tools (creative_approval, update_rights, comply_test_controller,
  validate_property_delivery, 5× *_collection_list) now register via
  customTools: config instead of server.registerTool() after
  construction. Zero escape hatches remaining on the type surface.
- run-storyboards.ts / run-one-storyboard.ts: drop the 33-line
  SingleAgentClient.validateRequest monkey-patch. Pass
  request_signing: { transport: 'mcp' } so signed_requests vectors
  route through /mcp instead of per-op HTTP URLs.

Results: 437/437 unit + integration tests green (flag OFF). Storyboard
pass count unchanged (29/55 clean, 214 passing) — expected since the
framework path is still opt-in via TRAINING_AGENT_USE_FRAMEWORK=1.

signed_requests vectors now REACH /mcp (401 instead of 404 — they fail
because bearer auth runs before the signature verifier. Composing
bearer-or-signature via anyOf is a separate follow-up).

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

* feat(training-agent): upgrade to @adcp/client 5.5 + compose signing via anyOf

5.5 adds verifySignatureAsAuthenticator so RFC 9421 signatures sit under
the same anyOf() composition as verifyApiKey — bearer OR signature is
sufficient, neither path short-circuits the other.

- request-signing.ts: Express middleware → Authenticator. getUrl override
  uses req.originalUrl since Express strips the /api/training-agent mount.
- index.ts: signing authenticator joins the anyOf chain; legacy
  requestSigningMiddleware wiring removed.
- framework-server.ts: capabilities.overrides declares publisher portfolio,
  compliance_testing scenarios, and targeting surface for the framework
  path (per-domain merge is 5.5-only).
- Runners: express.json({ verify }) captures rawBody bytes so the
  in-process verifier rehashes exactly what the signer signed.

Framework flag stays opt-in — flipping it regresses storyboards due to
stricter zod arg validation that's not yet matched by our handler shapes.

29/55 storyboards clean on legacy path (+4 over 5.3 baseline). 437/437
unit + integration tests green.

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

* feat(training-agent): presence-gated signature composition + review polish

Signed-requests composition:
- anyOf(bearer, signature) accepted invalid signatures when a valid
  bearer was also present, breaking 25 negative conformance vectors.
- New wrapper: if Signature-Input is present, signature MUST verify
  (throws propagate to 401). Otherwise bearer chain runs.
- 401 responses embed the signing error code in a Signature
  WWW-Authenticate challenge (not Bearer) so the conformance grader
  can read it.
- +19 signed_requests steps passing (9 → 28 of 34).
- Upstream ask for spec-level helper: adcp-client#659.

Review polish (from OAuth workstream review of #2561):
- Real zod inputSchemas on all 9 custom tools (was _passthrough
  placeholder that published wrong contract via tools/list) — +34
  framework-path steps passing.
- Eager signing-authenticator init at boot so missing/corrupt
  compliance JWKS fail loud rather than as opaque 401.
- Rename verifySignatureAsAuthenticator_ → lazySigningAuthenticator
  (the underscore shadowed the SDK export).
- Idempotency scoping: comment why only static:public gets account-
  scoped; other principals use the auth principal directly.
- useFrameworkServer jsdoc now matches code (default OFF).
- Demote per-request framework-server-constructed log to debug.
- Drop stale Stage 2 context comment.

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

* test(storyboards): clear sessions between storyboards in the runner

Multiple storyboards share a brand domain (e.g. acme-outdoor used by
governance_denied, sales_guaranteed, measurement_terms_rejected). The
session store is keyed by brand domain, so a \$10K governance plan
seeded by one storyboard persisted into the session and silently
intercepted a \$50K buy in the next one — surfacing as
GOVERNANCE_DENIED instead of the storyboard's expected outcome.

clearSessions() before each storyboard isolates them. +3 storyboards
clean (29 → 32), +13 steps passing.

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

* feat(training-agent): RFC 9728 protected-resource metadata + dual-mode CI

- Storyboard runners serve /.well-known/oauth-protected-resource/<path>
  at the top-level Express app (the probe hits \${origin}\${pathname},
  not the mounted router path). +2 passing steps on security_baseline
  (OAuth discovery phase).
- GitHub workflow runs the storyboard suite twice per PR: once with
  TRAINING_AGENT_USE_FRAMEWORK=0 (legacy) and once with =1 (framework).
  Each mode has a non-regression floor (clean storyboards + passing
  steps) so CI fails loud if either path backslides. The framework
  floor stays below the legacy one until zod parity lands and the flag
  default flips.

32/55 clean, 256 steps passing (up from 25/54 baseline).

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

* feat(training-agent): adopt @adcp/client 5.6 requireSignatureWhenPresent

5.6 ships the spec-compliant presence-gated auth helper we requested
(adcp-client#659). Replaces the local wrapper in buildAuthenticator
with the SDK version:

  authenticate: requireSignatureWhenPresent(
    lazySigningAuthenticator(),
    anyOf(verifyApiKey(static), verifyApiKey(workos)),
  )

The SDK also detects a solo Signature header (without Signature-Input)
which our naïve header check missed — closes one more signed_requests
negative vector that was silently accepting. Composition guard tag
(AUTH_PRESENCE_GATED) prevents accidental re-wrapping in anyOf.

Legacy: 33/55 clean, 261 steps (+1, +5).
Framework: 20/55 clean, 225 steps (+1, +9).
CI non-regression floors updated to match.

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

* feat(training-agent): signed_requests storyboard clean (30/30 applicable)

required_for enforcement on the unauthed fallback path:
- Capability now declares required_for: ['create_media_buy'].
- Pre-check in buildAuthenticator: when both signature and bearer are
  absent, throw RequestSignatureError('request_signature_required') so
  the conformance grader reads a Signature challenge with the right
  error code. Bearer callers bypass — other storyboards unaffected.

SDK skipVectors for capability-profile mismatches:
- 007 (covers_content_digest: required vs our either)
- 018 (covers_content_digest: forbidden vs our either)
- 025 (grades SDK library verifier internals via jwks_override, not
  our agent)
- skipRateAbuse: true for vector 020 (cap+1 requests; live side-effect
  opt-in)

Legacy: 34/55 clean, 262 steps (+1, +1).
Framework: 21/55 clean, 226 steps (+1, +1).
signed_requests: 30/30 applicable vectors passing (was 29/34).
CI floors updated to match.

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

* feat(training-agent): seed storyboard fixtures + past-start + double-cancel guard

Seeding:
- Products: test-product, sports_ctv_q2 aliased to real catalog entries
  (first-publisher / first-CTV-publisher). Pricing options test-pricing
  and default cloned onto test-product without min_spend_per_package so
  universal-suite \$1k budgets don't trip the min-spend gate.
- Formats: video_30s, native_post, native_content added as explicit
  entries.
- Buyer-supplied media_buy_id now honored on create_media_buy so
  storyboards that hardcode mb_acme_q2_2026_auction /
  mb_summer_campaign_001 can query by that literal.

Spec feature wiring:
- Past start_time rejection (schema_validation temporal_validation):
  reject with INVALID_REQUEST when start_time is >24h in the past.
- Double-cancel guard (media_buy_state_machine terminal_enforcement):
  reject second cancel with INVALID_STATE_TRANSITION.

37/55 clean, 281 steps passing (was 34/55, 262). CI floors updated.

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

* fix(training-agent): post-merge CI — format map + alias cards + past-start revert

Four fixes for CI failures after merging main's two-route design:

1. FORMAT_CHANNEL_MAP missed video_30s / native_post / native_content —
   the three format aliases I added in an earlier commit. Two unit tests
   iterate buildFormats() and assert every id maps. Added mappings.

2. buildCatalog click_url unit test asserts every product's click_url
   contains its product_id. My test-product / sports_ctv_q2 aliases
   cloned the source product_card without updating the embedded URL.
   Rewrite click_url on the cloned product_card{,_detailed} manifests
   to substitute the alias id for the source id.

3. Past start_time rejection broke 5 status-derivation unit tests that
   intentionally use 2020-dated flights to exercise derivation against
   past dates. Tried narrowing to 180d — still not enough for the
   "derives status from flight dates" test which uses 2020. Reverted;
   schema_validation past_start step closes as accept-and-derive per
   the storyboard's any_of branch on past_start_handled.

4. CI floor 37 → 35 (legacy) and passing-step floor 281 → 279 to match
   the post-merge baseline. Framework floor's passing-step count also
   updated 226 → 237 (gained from 5.6 + seed-fixture improvements in
   main). Clean-storyboard floor stays at 21.

384/384 training-agent unit tests green. 35/55 storyboards clean with
279 steps passing.

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

---------

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.

1 participant