From 1da8cff19cf9770b3ef359fbb3c4e2b07b53629e Mon Sep 17 00:00:00 2001 From: Lukasz Kapusniak Date: Mon, 15 Jun 2026 11:25:51 +0200 Subject: [PATCH 1/3] fix(compliance): treat deprecated `signed-requests` specialism as universal alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolveStoryboardsForCapabilities` was throwing `unknown_specialism` on the deprecated `signed-requests` claim because the bundle lives under `universal/signed-requests.yaml`, not `specialisms/signed-requests/`. That blocked every other storyboard from running and prevented the `signed_requests_specialism_deprecated` notice (#2082) from ever firing. Adds `DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES`: when a specialism is a deprecated alias AND its universal bundle is present in the cache, resolution continues silently — the universal storyboard already runs unconditionally and the deprecation notice fires from `runner.ts:collectCapabilityNotices`. The throw is preserved when the universal bundle is absent (stale cache) so we don't silently swallow a real configuration error. Closes #2237. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/signed-requests-preflight-fix.md | 25 ++++++++ src/lib/testing/storyboard/compliance.ts | 31 +++++++++ test/lib/storyboard-security.test.js | 70 +++++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 .changeset/signed-requests-preflight-fix.md diff --git a/.changeset/signed-requests-preflight-fix.md b/.changeset/signed-requests-preflight-fix.md new file mode 100644 index 000000000..14190bc76 --- /dev/null +++ b/.changeset/signed-requests-preflight-fix.md @@ -0,0 +1,25 @@ +--- +'@adcp/sdk': patch +--- + +Fix `resolveStoryboardsForCapabilities` throwing `unknown_specialism` on the deprecated `signed-requests` specialism claim. + +The 3.1 spec keeps the `signed-requests` specialism enum value for backward +compatibility (universal/signed-requests.yaml: "Agents that still advertise +`specialisms: ['signed-requests']` are graded via this universal storyboard"). +But the pre-flight specialism resolver was looking for the bundle under +`specialisms/signed-requests/` and throwing — which blocked every other +storyboard from running for any agent still claiming the deprecated +specialism. The `signed_requests_specialism_deprecated` notice path (#2082) +was correctly wired in `runner.ts` but never fired because pre-flight +threw first. + +Adds `DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES` mapping the deprecated +specialism enum value to its universal bundle base name. When the +deprecated alias is declared AND the universal bundle is present in the +cache, resolution continues silently (the universal storyboard is already +pushed unconditionally and the deprecation notice fires from runner.ts). +Otherwise the throw is preserved — unknown specialism without a universal +fallback is still a configuration error. + +Closes adcp-client#2237. diff --git a/src/lib/testing/storyboard/compliance.ts b/src/lib/testing/storyboard/compliance.ts index 483efa241..d4527dd85 100644 --- a/src/lib/testing/storyboard/compliance.ts +++ b/src/lib/testing/storyboard/compliance.ts @@ -78,6 +78,28 @@ export const UNBASELINED_SUPPORTED_PROTOCOLS: ReadonlySet = new Set([ 'measurement', ]); +/** + * Specialism enum values that are deprecated aliases for universal-bundle + * storyboards. When an agent declares one of these in + * `get_adcp_capabilities.specialisms`, resolution MUST NOT throw + * `unknown_specialism` — the bundle lives in `universal/.yaml` and + * runs unconditionally already. The deprecated claim is honored for + * backward compatibility per the universal storyboard's + * "Backward-compatible specialism claims" clause (currently + * `signed-requests`; see `universal/signed-requests.yaml` and the + * `signed_requests_specialism_deprecated` notice emitted from + * `runner.ts:collectCapabilityNotices`). + * + * Map shape: deprecated specialism enum value → universal bundle base name + * (file under `universal/.yaml`, matched against `index.universal`). + * Removed in AdCP 4.0 along with the enum value itself + * (adcontextprotocol/adcp#3075). + */ +export const DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES: Readonly> = + Object.freeze({ + 'signed-requests': 'signed-requests', + }); + export interface ComplianceIndexProtocol { id: string; title: string | null; @@ -821,6 +843,15 @@ export function resolveStoryboardsForCapabilities( for (const specialism of declaredSpecialisms) { const entry = index.specialisms.find(s => s.id === specialism); if (!entry) { + // Deprecated alias path: some 3.0-era specialism enum values now resolve + // to a `universal/` bundle (e.g. `signed-requests` → `universal/signed-requests.yaml`). + // The universal storyboard is pushed unconditionally above; the deprecated + // claim is graded there and the deprecation notice fires from runner.ts. + // Don't throw — that would block every other storyboard. + const universalAlias = DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES[specialism]; + if (universalAlias && index.universal.includes(universalAlias)) { + continue; + } throw new CapabilityResolutionError({ code: 'unknown_specialism', specialism, diff --git a/test/lib/storyboard-security.test.js b/test/lib/storyboard-security.test.js index 4bb984462..3ea6d2b6f 100644 --- a/test/lib/storyboard-security.test.js +++ b/test/lib/storyboard-security.test.js @@ -2390,6 +2390,76 @@ describe('resolveStoryboardsForCapabilities: typed errors', () => { fs.rmSync(dir, { recursive: true, force: true }); } }); + + // Deprecated `signed-requests` specialism resolves to `universal/signed-requests.yaml` + // — must NOT throw unknown_specialism. The universal storyboard runs and the + // deprecation notice fires from runner.ts. See adcp-client#2237. + it('does NOT throw on deprecated `signed-requests` specialism alias when universal bundle exists', () => { + const dir = makeFakeComplianceCache({ + universalStoryboards: [{ id: 'signed-requests', title: 'Signed requests' }], + }); + try { + const { storyboards, not_applicable } = resolveStoryboardsForCapabilities( + { specialisms: ['signed-requests'] }, + { complianceDir: dir } + ); + assert.deepStrictEqual( + storyboards.map(s => s.id), + ['signed-requests'] + ); + assert.deepStrictEqual(not_applicable, []); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('still throws unknown_specialism for non-aliased specialisms without a bundle', () => { + const dir = makeFakeComplianceCache({ + universalStoryboards: [{ id: 'signed-requests', title: 'Signed requests' }], + }); + try { + assert.throws( + () => + resolveStoryboardsForCapabilities( + { specialisms: ['not-a-real-specialism'] }, + { complianceDir: dir } + ), + err => { + assert.ok(err instanceof CapabilityResolutionError); + assert.strictEqual(err.code, 'unknown_specialism'); + assert.strictEqual(err.specialism, 'not-a-real-specialism'); + return true; + } + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('throws unknown_specialism for deprecated alias when the universal bundle is absent (stale cache)', () => { + // Cache has neither specialisms/signed-requests/ nor universal/signed-requests.yaml + // (e.g. cache pinned to a pre-3.1 version). The deprecated-alias fast path + // must require the universal bundle's presence — otherwise we'd silently + // skip a real configuration error. + const dir = makeFakeComplianceCache({ universalStoryboards: [] }); + try { + assert.throws( + () => + resolveStoryboardsForCapabilities( + { specialisms: ['signed-requests'] }, + { complianceDir: dir } + ), + err => { + assert.ok(err instanceof CapabilityResolutionError); + assert.strictEqual(err.code, 'unknown_specialism'); + assert.strictEqual(err.specialism, 'signed-requests'); + return true; + } + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); }); // ──────────────────────────────────────────────────────────── From c6b16b51d7e7b8c7c90f92182049f5539dcc3617 Mon Sep 17 00:00:00 2001 From: Lukasz Kapusniak Date: Mon, 15 Jun 2026 20:08:05 +0200 Subject: [PATCH 2/3] style: prettier --write (CI format:check) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/lib/testing/storyboard/compliance.ts | 7 +++---- test/lib/storyboard-security.test.js | 12 ++---------- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/src/lib/testing/storyboard/compliance.ts b/src/lib/testing/storyboard/compliance.ts index d4527dd85..a353cd334 100644 --- a/src/lib/testing/storyboard/compliance.ts +++ b/src/lib/testing/storyboard/compliance.ts @@ -95,10 +95,9 @@ export const UNBASELINED_SUPPORTED_PROTOCOLS: ReadonlySet = new Set([ * Removed in AdCP 4.0 along with the enum value itself * (adcontextprotocol/adcp#3075). */ -export const DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES: Readonly> = - Object.freeze({ - 'signed-requests': 'signed-requests', - }); +export const DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES: Readonly> = Object.freeze({ + 'signed-requests': 'signed-requests', +}); export interface ComplianceIndexProtocol { id: string; diff --git a/test/lib/storyboard-security.test.js b/test/lib/storyboard-security.test.js index 3ea6d2b6f..feb952385 100644 --- a/test/lib/storyboard-security.test.js +++ b/test/lib/storyboard-security.test.js @@ -2419,11 +2419,7 @@ describe('resolveStoryboardsForCapabilities: typed errors', () => { }); try { assert.throws( - () => - resolveStoryboardsForCapabilities( - { specialisms: ['not-a-real-specialism'] }, - { complianceDir: dir } - ), + () => resolveStoryboardsForCapabilities({ specialisms: ['not-a-real-specialism'] }, { complianceDir: dir }), err => { assert.ok(err instanceof CapabilityResolutionError); assert.strictEqual(err.code, 'unknown_specialism'); @@ -2444,11 +2440,7 @@ describe('resolveStoryboardsForCapabilities: typed errors', () => { const dir = makeFakeComplianceCache({ universalStoryboards: [] }); try { assert.throws( - () => - resolveStoryboardsForCapabilities( - { specialisms: ['signed-requests'] }, - { complianceDir: dir } - ), + () => resolveStoryboardsForCapabilities({ specialisms: ['signed-requests'] }, { complianceDir: dir }), err => { assert.ok(err instanceof CapabilityResolutionError); assert.strictEqual(err.code, 'unknown_specialism'); From bb49df4aed9b61497d357c8a6f29c4336d09cb6d Mon Sep 17 00:00:00 2001 From: Lukasz Kapusniak Date: Tue, 16 Jun 2026 07:41:28 +0200 Subject: [PATCH 3/3] fix(server): widen `signedRequests` guard to accept canonical 3.1+ capability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per #2237 triage: both fixes must land together. The pre-flight relaxation in the previous commit is incomplete without this — adopters dropping the deprecated specialism for the canonical `request_signing.supported: true` form would still hit the boot guard. The previous guard required the deprecated `signed-requests` specialism claim whenever `signedRequests` was configured, contradicting the universal storyboard's "drop the now-redundant specialism claim and rely solely on request_signing.supported: true" guidance. Widens the guard to accept either discovery surface (canonical 3.1+ OR back-compat). Still rejects the "config present, nothing advertised" case. JSDoc on `SignedRequestsConfig` updated to document both paths. Three new tests cover: canonical 3.1+ accepts, both failure modes still throw, error message wording. Changeset rewritten to describe both fixes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/signed-requests-preflight-fix.md | 39 +++++++----- src/lib/server/create-adcp-server.ts | 70 ++++++++++++++------- test/server-auto-signed-requests.test.js | 57 ++++++++++++++--- 3 files changed, 123 insertions(+), 43 deletions(-) diff --git a/.changeset/signed-requests-preflight-fix.md b/.changeset/signed-requests-preflight-fix.md index 14190bc76..98fc1a715 100644 --- a/.changeset/signed-requests-preflight-fix.md +++ b/.changeset/signed-requests-preflight-fix.md @@ -2,24 +2,33 @@ '@adcp/sdk': patch --- -Fix `resolveStoryboardsForCapabilities` throwing `unknown_specialism` on the deprecated `signed-requests` specialism claim. +Unblock 3.1 `signed-requests` adopters. Two coupled fixes; the runner-side fix alone leaves adopters stuck on the boot guard, and vice versa (per #2237 triage). -The 3.1 spec keeps the `signed-requests` specialism enum value for backward -compatibility (universal/signed-requests.yaml: "Agents that still advertise -`specialisms: ['signed-requests']` are graded via this universal storyboard"). -But the pre-flight specialism resolver was looking for the bundle under -`specialisms/signed-requests/` and throwing — which blocked every other -storyboard from running for any agent still claiming the deprecated -specialism. The `signed_requests_specialism_deprecated` notice path (#2082) -was correctly wired in `runner.ts` but never fired because pre-flight -threw first. +**1. Pre-flight `resolveStoryboardsForCapabilities` (`compliance.ts`).** +`resolveStoryboardsForCapabilities` was throwing `unknown_specialism` on the +deprecated `signed-requests` claim because the bundle lives under +`universal/signed-requests.yaml`, not `specialisms/signed-requests/`. That +blocked every other storyboard from running and prevented the +`signed_requests_specialism_deprecated` notice (#2082) from ever firing. Adds `DEPRECATED_SPECIALISM_UNIVERSAL_ALIASES` mapping the deprecated -specialism enum value to its universal bundle base name. When the -deprecated alias is declared AND the universal bundle is present in the -cache, resolution continues silently (the universal storyboard is already -pushed unconditionally and the deprecation notice fires from runner.ts). +specialism enum value to its universal bundle base name. When the deprecated +alias is declared AND the universal bundle is present in the cache, +resolution continues silently (the universal storyboard is pushed +unconditionally and the deprecation notice fires from runner.ts). Otherwise the throw is preserved — unknown specialism without a universal fallback is still a configuration error. -Closes adcp-client#2237. +**2. Boot guard `createAdcpServer` (`create-adcp-server.ts`).** +The previous guard required the deprecated `signed-requests` specialism claim +whenever `signedRequests` was configured, contradicting the universal +storyboard's "drop the now-redundant specialism claim and rely solely on +`request_signing.supported: true`" guidance. Widens the guard to accept +either discovery surface: the canonical 3.1+ form +(`capabilities.request_signing.supported: true`, no deprecated claim) or +the back-compat form (`specialisms: ['signed-requests']`). Still rejects +the "config present, nothing advertised" case so buyers can't be left +unable to discover the signing requirement. JSDoc on `SignedRequestsConfig` +updated to document both paths. + +Closes #2237. diff --git a/src/lib/server/create-adcp-server.ts b/src/lib/server/create-adcp-server.ts index 386f756ff..6615034fd 100644 --- a/src/lib/server/create-adcp-server.ts +++ b/src/lib/server/create-adcp-server.ts @@ -1080,11 +1080,26 @@ export interface AdcpCapabilitiesOverrides { * mounts it as the transport-layer `preTransport` hook, so every inbound MCP * request passes the verifier before reaching the JSON-RPC router. * - * A seller that declares the `signed-requests` specialism in - * `capabilities.specialisms` MUST provide this config, and vice-versa — both - * together or neither. `createAdcpServer` throws at construction time when - * only one is set, closing the footgun where claiming the specialism - * accepts unsigned mutating traffic. + * A seller wiring this config MUST also publish a buyer-visible discovery + * surface in `capabilities`, one of: + * + * - **3.1+ canonical (recommended):** set + * `capabilities.request_signing.supported: true`. Buyers learn the agent + * verifies signatures from `get_adcp_capabilities`; no deprecated specialism + * claim required. The universal `signed_requests` storyboard grades on this + * signal alone. + * - **Back-compat:** add `'signed-requests'` to `capabilities.specialisms`. + * The 3.0-era enum value is preserved through the AdCP 4.0 deprecation cycle + * (adcp#3075); when this path is taken the runner emits + * `signed_requests_specialism_deprecated` (adcp-client#2082, adcp#4796). + * + * `createAdcpServer` throws at construction time when `signedRequests` is set + * but neither discovery surface is declared, closing the footgun where the + * verifier silently rejects every signed request from buyers who never learned + * to sign. The inverse (specialism or capability declared without a + * `signedRequests` config) is logged loudly but not thrown — legacy servers + * that hand-build the middleware via `serve({ preTransport })` stay + * conformant. * * `jwks`, `replayStore`, and `revocationStore` should be hoisted outside * the agent factory so a single verifier instance serves every request — @@ -3645,26 +3660,39 @@ export function createAdcpServer(config: AdcpServerConfig { describe('startup validation', () => { - it('throws when signedRequests config is provided without the specialism', () => { + // Canonical 3.1+ path: signedRequests config + request_signing.supported:true, + // no deprecated `signed-requests` specialism claim. Should boot. The universal + // signed_requests storyboard grades on the capability flag alone — the deprecated + // claim is only kept for back-compat through the AdCP 4.0 deprecation window. + // See adcp-client#2237. + it('does not throw when signedRequests + request_signing.supported:true (no deprecated specialism claim)', () => { + assert.doesNotThrow(() => createAdcpServer(sellerConfig({ withSignedRequests: true, withSpecialism: false }))); + }); + + it('throws when signedRequests config is provided with neither discovery surface declared', () => { assert.throws( - () => createAdcpServer(sellerConfig({ withSignedRequests: true, withSpecialism: false })), - /specialisms.*does not include "signed-requests"/ + () => + createAdcpServer({ + ...sellerConfig({ withSignedRequests: true, withSpecialism: false }), + capabilities: { + features: { inlineCreativeManagement: false }, + specialisms: [], + // request_signing omitted — no discovery surface at all. + }, + }), + err => /request_signing\.supported: true.*specialisms.*signed-requests/.test(err.message) + ); + }); + + it('throws when signedRequests + specialism omitted + request_signing.supported:false', () => { + assert.throws( + () => + createAdcpServer({ + ...sellerConfig({ withSignedRequests: true, withSpecialism: false }), + capabilities: { + features: { inlineCreativeManagement: false }, + specialisms: [], + request_signing: { supported: false }, + }, + }), + err => /request_signing\.supported: true.*specialisms.*signed-requests/.test(err.message) ); }); @@ -243,12 +275,23 @@ describe('createAdcpServer: signedRequests auto-wiring', () => { }); it('gives the expected error message shape for each misconfiguration pattern', () => { - // config + no claim + // config + no discovery surface (no claim AND request_signing absent/supported:false) assert.throws( - () => createAdcpServer(sellerConfig({ withSignedRequests: true, withSpecialism: false })), - err => /signedRequests.*is configured but.*specialisms.*does not include "signed-requests"/.test(err.message) + () => + createAdcpServer({ + ...sellerConfig({ withSignedRequests: true, withSpecialism: false }), + capabilities: { + features: { inlineCreativeManagement: false }, + specialisms: [], + request_signing: { supported: false }, + }, + }), + err => + /signedRequests.*is configured but neither.*request_signing\.supported: true.*specialisms.*signed-requests/.test( + err.message + ) ); - // claim + supported:false — third guard + // claim + supported:false — third guard (back-compat path requires capability flag too) assert.throws( () => createAdcpServer({