v5.2.0
Minor Changes
-
9c2d5cc:
BrandJsonJwksResolver— discover a sender's webhook-signing keys from theirbrand.json.Receiver-side ergonomic: instead of pre-configuring a
jwks_uriper counterparty, point the verifier at the sender'sbrand.jsonand the resolver walksagents[], extracts the rightjwks_uri, and delegates caching toHttpsJwksResolver. Delivers thebrand.json → JWKS auto-resolverpiece of the #631 follow-up list.New
BrandJsonJwksResolver— implementsJwksResolver, pluggable intoverifyWebhookSignature.jwks(orverifyRequestSignature.jwks).BrandJsonResolverError+BrandJsonResolverErrorCode— typed error surface (invalid_url,invalid_house,redirect_loop,redirect_depth_exceeded,fetch_failed,invalid_body,schema_invalid,agent_not_found,agent_ambiguous,jwks_origin_mismatch). Verifier callers can fold transient failures intowebhook_signature_key_unknownwithout parsing error message strings.BrandAgentType,BrandJsonJwksResolverOptions— selector types (agent type plus optionalagentId/brandId).
Behavior
- Follows
authoritative_locationandhouseredirect variants up tomaxRedirectshops (default 3); loops and depth-exceeded chains are rejected explicitly. - Structurally validates every redirect target (scheme, no userinfo, no fragments smuggled into loop detection) before dispatch; the
housestring variant is gated on a bare-hostname regex so an attacker-supplied brand.json can't inject userinfo or paths via thehttps://${house}/…interpolation. - Honors the spec fallback: when
jwks_uriis absent on the selected agent, defaults to/.well-known/jwks.jsonon the origin of the agent'surl— but only when that origin matches the final brand.json origin. Cross-origin fallback is rejected withjwks_origin_mismatch; publishers hosting their agent on a different origin must declare an explicitjwks_uri. - Brand.json cache tracks
ETag+Cache-Control: max-age(capped bymaxAgeSeconds, default 1h). Unknownkidcascades: the inner JWKS refreshes first; if still unknown and the brand.json cooldown has elapsed, brand.json re-resolves to pick up a rotatedjwks_uri. - Ambiguous selectors (multiple agents of the same type, no
agentId) throwagent_ambiguouswith a clear error listing the candidate ids. - All fetches go through
ssrfSafeFetch, so an attacker-supplied brand.json or JWKS URL can't resolve to the receiver's private network or IMDS.
Example
import { BrandJsonJwksResolver, verifyWebhookSignature, InMemoryReplayStore, InMemoryRevocationStore, } from '@adcp/client/signing'; const jwks = new BrandJsonJwksResolver('https://publisher.example/.well-known/brand.json', { agentType: 'sales', }); await verifyWebhookSignature(request, { jwks, replayStore: new InMemoryReplayStore(), revocationStore: new InMemoryRevocationStore(), });
-
e557245: Request-signing verifier: tighten RFC 9421 conformance against new spec
vectors (#2323) and adcp#2468.@target-uricanonicalization now decodes percent-encoded unreserved
bytes (RFC 3986 §6.2.2.2) so%7Eand~produce a byte-identical
signature base.- Verifier rejects at step 1 when a signed request carries duplicate
Signature-Input dictionary keys, multi-valued Content-Type or
Content-Digest headers covered by the signature, a non-ASCII
authority (U-label), or userinfo on the@authoritycomponent. - Step 8 binds sig-params
algto the resolved JWK'salg: a missing
JWKalg, an alg mismatch, or inconsistent kty/crv per RFC 8037
(EdDSA↔OKP) / RFC 7518 (ES256↔EC/P-256) all fail with
request_signature_key_purpose_invalid. - Compliance test-vector loader accepts
jwks_overrideas an
alternative tojwks_ref; the grader routesjwks_overridevectors
through the library verifier directly since a live HTTP probe can't
mutate a target agent's JWKS per-vector.
-
fd49ecc: Rollup 5.2.0 — bundles the work that went into the unpublished 6.0.0. Treat the
heads-up section below as "breaking" if you're upgrading directly from 5.1.0.Heads-up if tracking 5.1.0 → 5.2.0
Verifier API v3 (closes #583 items 1 and 2, #584)
verifyRequestSignaturereturn shape is now a discriminated union:type VerifyResult = | { status: 'verified'; keyid: string; agent_url?: string; verified_at: number } | { status: 'unsigned'; verified_at: number };
Pre-5.2 returned a
VerifiedSignerwithkeyid: ''as a sentinel when the
request was unsigned on an operation not inrequired_for. Consumers that
branched onresult.keyid === ''must now branch onresult.status.createExpressVerifierupdatesreq.verifiedSigneraccordingly — the field is
set only whenstatus === 'verified'.VerifyRequestOptions.operationis now optional. Omitting it treats the
operation as "not in anyrequired_for" and returns an unsigned result.ExpressMiddlewareOptions.resolveOperationmay now returnundefined— bypass
required_forenforcement without losing verifier coverage on signed paths.Governance status narrowing
GovernanceCheckResult.statusnarrows to'approved' | 'denied' | 'conditions'.
TaskStatusdrops'governance-escalated'.TaskResultFailure.statusnarrows
to'failed' | 'governance-denied'. If you branch on
result.status === 'governance-escalated', fold into'governance-denied'and
inspectgovernance.findingsfor human-review signals.Governance
budget.authority_levelremovedAdCP dropped
budget.authority_levelin favor of:-
budget.reallocation_threshold: number ≥ 0/budget.reallocation_unlimited: true(mutually exclusive) -
plan.human_review_required: booleanfor GDPR Art 22 / EU AI Act Annex IIIMapping:
agent_full → reallocation_unlimited: true;agent_limited → keep reallocation_threshold;human_required → plan.human_review_required: true.Compliance cache rename:
domain→protocolcompliance/cache/{version}/domains/→.../protocols/.
PROTOCOL_TO_DOMAIN→PROTOCOL_TO_PATH.ComplianceIndexDomain→
ComplianceIndexProtocol.BundleKindvalue'domain'→'protocol'.
AdCPDomain→AdCPProtocol.TasksGetResponse.domain→protocol;
TasksListRequest.filters.{domain,domains}→{protocol,protocols};
MCPWebhookPayload.domain→protocol.PROTOCOLS_WITHOUT_BASELINEremoved.Generated-types cleanup (#621)
Typeless JSON Schema nodes (e.g.
check_governance.conditions[].required_value)
now compile tounknown/z.unknown()instead of being narrowed to
Record<string, unknown>. Spec-correct scalar responses from compliant agents
no longer fail validation. Multi-pass dedup removes ~7000 lines from
core.generated.ts.Property-list account migration
AdCP 3.0 account migration absorbed.
BudgetAuthorityLeveltype removed.
DelegationAuthoritynow re-exported from./types/core.generated.
PropertyListAdapter.listListsfilters byaccountprimitive (not removed
principal).Additions
Idempotency for v3 mutating requests (#568, #569; upstream adcp#2315)
-
Client methods for mutating tools auto-generate UUID v4
idempotency_keywhen
the caller omits one. Internal retries reuse the same key. -
result.metadata.idempotency_keysurfaces the sent key. -
result.metadata.replayedsurfaces whether the seller returned a cached
response. Side-effect-emitting agents MUST check this before re-firing. -
Typed errors:
IdempotencyConflictError(mint fresh key),IdempotencyExpiredError(look up by natural key). -
result.errorInstancecarries a typedADCPErrorsubclass when available. -
New
getIdempotencyReplayTtlSeconds()onSingleAgentClient/AgentClient.
Throws on v3 sellers that omit the REQUIRED declaration — no silent default. -
useIdempotencyKey(key)BYOK helper validates format up-front. -
Idempotency keys redacted in debug logs by default (
ADCP_LOG_IDEMPOTENCY_KEYS=1to opt in).
redactIdempotencyKey(key)exported.Server-side middleware (
@adcp/client/server) -
createIdempotencyStore({ backend, ttlSeconds })— RFC 8785 JCS payload
canonicalization, atomicputIfAbsentclaim step, auto-declares
adcp.idempotency.replay_ttl_seconds, rejects low-entropy keys, excludes
the echo-backcontextfrom the hash but keeps string-typedcontexton SI
tools. -
Backends:
memoryBackend(),pgBackend(pool, { tableName? }). -
getIdempotencyMigration()DDL +cleanupExpiredIdempotency(pool)periodic
reclaim. -
Guardrail: logs error when mutating handlers are registered without an
idempotency store.OAuth zero-config + diagnostics
-
NeedsAuthorizationError— thrown automatically on 401 Bearer challenge;
carriesagentUrl,resource,resourceMetadataUrl,authorizationServer,
authorizationEndpoint,tokenEndpoint,registrationEndpoint,
scopesSupported, parsed challenge. -
discoverAuthorizationRequirements(agentUrl, options?)— RFC 9728 +
RFC 8414 walk. -
createFileOAuthStorage({ configPath, agentKey? })— atomic writes against
the CLI's agents.json. -
bindAgentStorage/getAgentStorage— per-agent WeakMap storage binding. -
OAuth tokens now thread through
ADCPMultiAgentClientand the storyboard
runner (previously bearer-only).NonInteractiveFlowHandler+
createNonInteractiveOAuthProvider(agent, { agentHint? }). -
TestOptions.authaccepts{ type: 'oauth', tokens, client? }. -
CLI
adcp diagnose-auth <alias|url>— end-to-end OAuth diagnostic with ranked
hypotheses.runAuthDiagnosis,parseWWWAuthenticate,
decodeAccessTokenClaims,validateTokenAudience,InvalidTokenError,
InsufficientScopeErrorexported.Signing — HTTPS stores + structured headers + replay buckets
-
HttpsJwksResolver(url, options)— HTTPS-fetching JWKS withETag,
Cache-Control, lazy refetch on key-unknown, SSRF-guarded. -
HttpsRevocationStore(url, options)— cachedRevocationSnapshot, fails
closed pastnext_update + graceSecondswith
request_signature_revocation_stale. -
Parser swap to
structured-headerslibrary (RFC 8941 / RFC 9651) — profile
checks (required params, tag, alg allowlist, typing) stay as typed wrappers. -
Time-bucket replay store — O(1) amortized
has/insert/isCapHiton hot
keyids. DefaultmaxEntriesPerKeyid1M → 100k. -
ssrfSafeFetch— primitive blocking IMDS / private networks.Request-signing grader — MCP mode + review fixes
-
GradeOptions.transport: 'raw' | 'mcp'(default'raw'). MCP mode wraps
vectors intools/callenvelopes and extractsoperationfrom the vector
URL's last path segment. -
CLI:
adcp grade request-signing <agent-url>with--transport,
--skip-rate-abuse,--rate-abuse-cap,--only,--skip,
--allow-live-side-effects,--allow-http,--json. -
GradeReportexposespassed_count/failed_count/skipped_count. -
Safety: vectors 016 (
replay_window) and 020 (rate_abuse) auto-skip
against non-sandbox endpoints unlessallowLiveSideEffects: true. -
live_endpoint_warningreplaces misleadingendpoint_scope_warning. -
Skipped vectors report as
skipped: true(not scored as failures). -
Hardened
extractSignatureErrorCode(alphabet-constrained),
splitChallenges(quote-state tracked). -
New test-agent
test-agents/seller-agent-signed-mcp.ts.Storyboard runner — multi-instance mode
-
runStoryboardaccepts an array of agent URLs. Steps round-robin across
replicas so writes on one instance must be visible on another. Canonical
write on [#A] → read on [#B] → NOT_FOUNDfailure signature. -
CLI: repeated
--urlengages multi-instance mode (minimum 2). JSON output
gainsagent_urls[],multi_instance_strategy, per-stepagent_url+
agent_index.--dry-runprints the assignment plan. -
Guide:
docs/guides/MULTI-INSTANCE-TESTING.md. Implements client-side half
of adcp#2363; closes adcp#2267.Governance helpers
-
buildHumanReviewPlan(input)— stampshuman_review_required: true. -
buildHumanOverride({ reason, approver, approvedAt? })— builds the artifact
for downgradinghuman_review_required: true → falseon re-sync. Validates
reason ≥20 chars, approver is an email, no control chars, ISO 8601 dates. -
validateGovernancePlan(plan)— client-side XOR + Annex III invariant check
that codegen drops fromif/then. -
Constants:
REGULATED_HUMAN_REVIEW_CATEGORIES,ANNEX_III_POLICY_IDS.Idempotency storyboard end-to-end
-
Middleware stamps
metadata.replayed: falseon every mutating response (not
just replays). -
Replay echoes the current retry's
context(middleware stripscontext
before caching; re-injects on replay). -
MCP-level
idempotency_keyrelaxed to optional when the framework has an
idempotency store wired — middleware returns structuredadcp_error. -
Harness:
$generate:uuid_v4[#alias]placeholder, forwarded
idempotency_key,$context.<key>in validationvalue/allowed_values,
TaskOptions.skipIdempotencyAutoInjectfor compliance runs.Fixes
-
Governance E2E — removed stale
plan.campaignsassertion; approve test now
picks afixed_pricepricing option (was[0], which broke on agents that
ordered auction options first). Closes #613. -
Property-list storyboard — brand-injection builders removed so runner falls
through to spec-correctaccountprimitive. Closes #577. -
Governance: dropped non-spec
'escalated'status. Closes #589. -
Protocol rename
domain→protocolthreaded end-to-end. -
Request-signing grader vector 010 (
content-digest-mismatch) now tests
lying-signer detection, vector 009 (key-purpose-invalid) honors pinned
jwks_ref.Public API additions (overview)
// Client import { IdempotencyConflictError, IdempotencyExpiredError, NeedsAuthorizationError, generateIdempotencyKey, isMutatingTask, isValidIdempotencyKey, canonicalize, canonicalJsonSha256, closeMCPConnections, adcpErrorToTypedError, useIdempotencyKey, redactIdempotencyKey, discoverAuthorizationRequirements, createFileOAuthStorage, bindAgentStorage, getAgentStorage, createNonInteractiveOAuthProvider, runAuthDiagnosis, parseWWWAuthenticate, decodeAccessTokenClaims, validateTokenAudience, InvalidTokenError, InsufficientScopeError, buildHumanReviewPlan, buildHumanOverride, validateGovernancePlan, REGULATED_HUMAN_REVIEW_CATEGORIES, ANNEX_III_POLICY_IDS, type MutatingRequestInput, type IdempotencyCapabilities, } from '@adcp/client'; // Server import { createIdempotencyStore, memoryBackend, pgBackend, hashPayload, getIdempotencyMigration, cleanupExpiredIdempotency, HttpsJwksResolver, HttpsRevocationStore, type IdempotencyStore, type IdempotencyBackend, } from '@adcp/client/server';
-
-
7e5d228: Server-side authentication middleware: API key, OAuth JWT, or both.
AdCP agents MUST authenticate incoming requests (per the
security_baselinestoryboard in the universal track). This release adds first-class middleware so sellers can wire auth in ~5 lines.New
verifyApiKey({ keys? | verify? })— static or dynamic API-key authenticator.verifyBearer({ jwksUri, issuer, audience, requiredScopes? })— OAuth 2.0 JWT validation viajose+ JWKS. Strict audience enforcement catches the "resource URL mismatch" class of bug. Defaults to an asymmetric-only algorithm allowlist (RS_/ES_/PS*/EdDSA) to block algorithm-confusion attacks, and extracts scopes from bothscope(string) andscp(string | array) claims.anyOf(a, b, ...)— combinator for accepting API key OR OAuth. Wraps rejections in a sanitizedAuthErrorso probing attackers can't learn expected-audience or token-shape details from error responses.respondUnauthorized(req, res, opts)— RFC 6750-compliant 401/403 withWWW-Authenticate: Bearer.realmdefaults to"mcp"(stable) instead of the attacker-controlledHostheader.AuthError— exported error class with a sanitizedpublicMessage; the underlying implementation error is preserved ascausefor server-side logging.ServeOptions.authenticate— plug any authenticator intoserve(); no request reaches the MCP transport without passing.ServeOptions.publicUrl— canonical https:// URL of the MCP endpoint. Required whenprotectedResourceis configured. The RFC 9728resourcefield, the RFC 6750resource_metadataURL on 401 challenges, and the JWT audience all come from this — closes a Host-header phishing vector where a server would otherwise advertise whatever host a caller sent.ServeOptions.protectedResource— advertise OAuth 2.0 protected-resource metadata (RFC 9728) at/.well-known/oauth-protected-resource<mountPath>.- MCP
AuthInfopropagation —serve()setsreq.authfrom the auth principal (token, clientId, scopes, expiresAt, extra) so MCP tool handlers receive it viaextra.authInfo.createAdcpServerhandlers see it onctx.authInfo.
Skills
build-seller-agent/SKILL.mdgains a full "Protecting your agent" section with API key, OAuth, and both-at-once examples, plus a conformance checklist.- Short "Protecting your agent" section added to every other
build-*-agentskill (signals, creative, retail-media, governance, si, brand-rights, generative-seller) so every agent-builder walks past the auth prompt on their way to validation.
Dependency
- Promoted
josefrom transitive to direct (it was already in the tree via@modelcontextprotocol/sdk).
-
2756df6: Storyboard runner: outbound-webhook conformance grading (adcontextprotocol/adcp#2426, matching the spec shape from adcontextprotocol/adcp#2431).
Storyboard runtime:
runStoryboard/runStoryboardStepaccept awebhook_receiveroption that binds an ephemeral HTTP listener (loopback-mock mode default;proxy_urlmode accepts an operator-supplied public base). The receiver mints per-step URLs under/step/<step_id>/<operation_id>and exposes{{runner.webhook_base}}/{{runner.webhook_url:<step_id>}}substitutions so storyboards inject them intopush_notification_config.url. Downstream filters pick up the same operation_id via{{prior_step.<step_id>.operation_id}}.- Three new pseudo-tasks (step
taskvalues, not validation checks):expect_webhook— asserts a matching delivery arrived carrying a well-formedidempotency_key(pattern^[A-Za-z0-9_.:-]{16,255}$). Optionalexpect_max_deliveries_per_logical_eventcaps distinct logical events in the window — catches publishers that re-execute on replay under a fresh key.expect_webhook_retry_keys_stable— configures the receiver to reject the first N deliveries with a configurable 5xx, then asserts every observed delivery carries the byte-identicalidempotency_key. Fails withinsufficient_retries,idempotency_key_rotated, oridempotency_key_format_changed.expect_webhook_signature_valid— delegates to the new RFC 9421 webhook verifier. Gradesnot_applicablewhenwebhook_signingis not configured on runStoryboard options.
requires_contracton any webhook-assertion step gradesnot_applicablewhen the contract id is not listed inoptions.contracts— lets cross-cutting storyboards (e.g. idempotency) reference webhook assertions without forcing every runner to host a receiver.
RFC 9421 webhook signing:
verifyWebhookSignaturein@adcp/client/signing/server— 14-step verifier checklist perdocs/building/implementation/security.mdx#verifier-checklist-for-webhooks. Tagadcp/webhook-signing/v1, mandatory covered components@method,@target-uri,@authority,content-type,content-digest, key purposeadcp_use: "webhook-signing". ThrowsWebhookSignatureErrorwith a specificwebhook_signature_*code.signWebhookin@adcp/client/signing/client— companion signer for publishers emitting conformant webhooks.WEBHOOK_SIGNING_TAGandWEBHOOK_MANDATORY_COMPONENTSconstants exported from both sub-barrels.
Test coverage: 25 new tests across
test/lib/storyboard-webhook-receiver.test.jsandtest/lib/storyboard-webhook-signature.test.jscovering per-step routing, retry-replay policy, runner-variable substitution, every expect_webhook* error code, and a full E2E flow with a signing publisher. -
b4709ad: Regenerated types from latest AdCP schemas. Adds
idempotency_key(required, string) to webhook payloads —MCPWebhookPayload,ArtifactWebhookPayload,CollectionListChangedWebhook,PropertyListChangedWebhook— and renamesRevocationNotification.notification_id→idempotency_key.Upstream migrated these surfaces to a single canonical dedup field. Receivers must dedupe by
idempotency_keyscoped to the authenticated sender identity. Publishers populatingRevocationNotification.notification_idmust rename the field. -
6ec01c6: Regenerated types from latest AdCP schemas.
CreateMediaBuyResponseunion gainsCreateMediaBuySubmitted— async task envelope withstatus: 'submitted'andtask_id, returned when a media buy cannot be confirmed synchronously (IO signing, governance review, batched processing). Themedia_buy_idandpackagesland on the completion artifact, not this envelope.PushNotificationConfig.authenticationis now optional and deprecated. Omitting it opts in to the RFC 9421 webhook profile (the default in 4.0); Bearer and HMAC-SHA256 remain for legacy compatibility only.RightUseaddsai_generated_image.
Consumers of
CreateMediaBuyResponsethat exhaustively discriminate on the union must handle the new'submitted'branch. -
078b52c: Publisher-side webhook emission — the symmetric counterpart to PR #629's receiver-side dedup.
New
createWebhookEmitterin@adcp/client/server. Oneemit(url, payload, operation_id)call and the emitter handles:- RFC 9421 signing with a fresh nonce per attempt (adcp#2423).
- Stable
idempotency_keyperoperation_idreused across retries (adcp#2417) — regenerating on retry is the highest-impact at-least-once-delivery bug the runner-side conformance suite catches. - JSON serialized once with compact separators (
,/:, no spaces) and posted byte-identically — the signature-base input and the wire body come from the same bytes, preventing the Pythonjson.dumpsdefault-spacing trap pinned by adcp#2478. - Retry with exponential backoff + jitter on 5xx / 429. Terminal on 4xx and on 401 responses carrying
WWW-Authenticate: Signature error="webhook_signature_*"(retrying a signature failure produces identical bytes and identical rejection). - Pluggable
WebhookIdempotencyKeyStore(default in-memory) — swap in a durable backend for multi-replica publishers. - HMAC-SHA256 / Bearer fallback modes for legacy buyers that registered
push_notification_config.authentication.credentials. HMAC path uses the same compact-separators pinning.
createAdcpServerintegration. Newwebhooks?: { signerKey, retries?, idempotencyKeyStore?, ... }config option. When set,ctx.emitWebhookis populated on every handler's context — completion handlers post signed webhooks without constructing the signer, fetching, or tracking idempotency themselves:createAdcpServer({ name, version, webhooks: { signerKey: { keyid, alg: 'ed25519', privateKey: jwk } }, mediaBuy: { createMediaBuy: async (params, ctx) => { const media_buy_id = await persist(params); await ctx.emitWebhook({ url: params.push_notification_config.url, payload: { task: { task_id, status: 'completed', result: { media_buy_id } } }, operation_id: `create_media_buy.${media_buy_id}`, }); return { media_buy_id, packages: [] }; }, }, });
Full-stack E2E test.
test/lib/webhook-emitter-server-e2e.test.js:createAdcpServerwith a real handler →ctx.emitWebhook→ real HTTP POST → receiver captures →verifyWebhookSignatureaccepts. No mocks on the signer or verifier path. Closes the "we haven't spun up an actual server and watched the full stack verify" gap flagged during PR #631 review.Exports from
@adcp/client/server:createWebhookEmitter,memoryWebhookKeyStore- Types:
WebhookEmitter,WebhookEmitterOptions,WebhookEmitParams,WebhookEmitResult,WebhookEmitAttempt,WebhookEmitAttemptResult,WebhookIdempotencyKeyStore,WebhookRetryOptions,WebhookAuthentication HandlerContext.emitWebhook— new optional field, populated whenwebhooksconfig is set.
-
7b76326: Webhook receiver-side deduplication via
AsyncHandlerConfig.webhookDedup.AdCP webhooks use at-least-once delivery — publishers retry until they see a 2xx, so the same event can arrive more than once. The spec now requires an
idempotency_keyon every MCP, governance, artifact, and revocation webhook payload so receivers have a canonical dedup field. This release plumbs that key through the client pipeline and ships a drop-in dedup layer for the MCP envelope path.New
AsyncHandlerConfig.webhookDedup?: { backend: IdempotencyBackend; ttlSeconds?: number }— drop duplicate deliveries with a single config. ReusesIdempotencyBackendfrom@adcp/client/server, so the samememoryBackend()orpgBackend(...)used for request-side idempotency can back webhook dedup. Defaults to 24h retention.WebhookMetadata.idempotency_key?: string— extracted from the MCP envelope and passed to everyonXxxStatusChangehandler so application code can log, trace, or build its own dedup on top.WebhookMetadata.protocol?: 'mcp' | 'a2a'— transport that delivered the webhook; useful for handler code that branches on protocol (A2A lacksidempotency_key).Activityunion gains'webhook_duplicate'— surfaced viaonActivitywhen a repeat key is dropped. The typed handler is NOT called for duplicates.Activity.idempotency_key?: string— surfaced on bothwebhook_receivedandwebhook_duplicatefor correlation.
Type changes (strict-TS callers may need to update)
- The
Activity.typeunion gains'webhook_duplicate'. TypeScript users doing exhaustiveswitch (activity.type)with anever-check will see a new missing-case error. Treatwebhook_duplicatethe same aswebhook_receivedinonActivitylogging, or branch onactivity.typeto suppress side effects for duplicates.
Behavior
- Scope is per-agent under a reserved prefix (
adcp\u001fwebhook\u001fv1\u001f{agent_id}\u001f{idempotency_key}) — keys from different senders are independent, and the prefix guarantees no collision with request-side idempotency entries when sharing a backend. putIfAbsentcloses the concurrent-retry race: when two retries race on the same fresh key, exactly one wins the claim and dispatches; the rest surface aswebhook_duplicate.- MCP payloads missing or violating the
idempotency_keyformat (^[A-Za-z0-9_.:-]{16,255}$) dispatch without dedup and log aconsole.warnwith the spec pattern and a docs pointer. A2A payloads (which do not carry the field) dispatch silently — the absence is expected and unactionable. - Handler exceptions inside the dispatched handler are caught and logged as today; the dedup claim is intentionally NOT released on handler error. This preserves at-most-once handler execution: the publisher sees 2xx once (because
handleWebhookreturns normally) and won't retry, so releasing the claim would only matter on a future unrelated retry of the same key, which is never expected.
Schema sync
MCPWebhookPayload,CollectionListChangedWebhook,PropertyListChangedWebhook,ArtifactWebhookPayload, andRevocationNotificationnow includeidempotency_keyas a required field (picked up from AdCPlatest).
Example
import { AdCPClient } from '@adcp/client'; import { memoryBackend } from '@adcp/client/server'; const client = new AdCPClient(agents, { webhookUrlTemplate: 'https://your-app.com/adcp/webhook/{task_type}/{agent_id}/{operation_id}', webhookSecret: process.env.WEBHOOK_SECRET, handlers: { webhookDedup: { backend: memoryBackend() }, onCreateMediaBuyStatusChange: async (result, metadata) => { // First delivery runs here; publisher retries are dropped. }, }, });
Governance list-change / artifact / brand-rights revocation webhooks are not yet routed through
AsyncHandler; dedup for those payload types is a follow-up. -
2756df6: Close the webhook-signing conformance gap after adcontextprotocol/adcp#2445 merged canonical test vectors.
Error enum aligned with merged spec. The webhook-signature error taxonomy (
security.mdx#webhook-callbacks) folds every window-level failure into a singlewebhook_signature_window_invalidcode —webhook_signature_expiredisn't in the enum. Drops our stray_expiredcode; addswebhook_signature_rate_abuse(per-keyid cap exceeded, step 9a) andwebhook_signature_revocation_stale(revocation list past grace). Verifier step numbers realigned to the canonical 1–13 + 9a.Parser now enforces the single-alphabet rule. RFC 9421
Signature/Content-Digesttokens that mix base64url ([-_]) with standard-base64 ([+/=]) are ambiguous and the spec mandates rejection with*_header_malformed. Both verifiers inherit the fix.Storyboard error enum extended in lockstep:
signature_window_invalidreplacessignature_expired, plussignature_rate_abuse,signature_revocation_stale,signature_alg_not_allowed,signature_components_incomplete,signature_header_malformed,signature_params_incomplete. Exhaustive mapping catches new verifier codes at compile time.Conformance harness. Vendored the 7 positive + 21 negative vectors from adcontextprotocol/adcp under
test/fixtures/webhook-signing-vectors/(AdCP tarball hasn't re-released yet; swap tocompliance/cache/...on the next sync). Every vector runs throughverifyWebhookSignature— passing vectors verify cleanly, negative vectors throw with byte-matching error codes. State-dependent vectors (replay, revocation, rate-abuse, revocation-stale) install theirtest_harness_stateinto fresh stores per vector. 2 positive vectors (004-default-port-stripped,005-percent-encoded-path) are skipped pending an upstream regeneration — their baked signatures contradict the request-signing canonicalization rules the webhook spec inherits.
Patch Changes
-
c94935b:
build-seller-agentSKILL.md — document two more Common Mistakes surfaced by real seller-agent builds: (1) placing the IO-signingsetupURL at the top level of a media buy response instead of nesting it underaccount.setup(response builders now reject this at runtime), and (2) bypassing response builders and forgettingvalid_actions—mediaBuyResponseandupdateMediaBuyResponseauto-populate it fromstatus;get_media_buyscallers should usevalidActionsForStatus()per buy. -
3c293ae: Skill docs: specialism coverage tables, composition guide, AdCP 3.0 GA alignment.
Every
build-*-agent/SKILL.mdnow maps specialism IDs to concrete per-specialism deltas, with archetype splits where the contracts diverge (creative: ad-server / template / generative). RootCLAUDE.mdgets the inverse specialism → skill index.Seller skill picks up:
- Protocol-Wide Requirements:
idempotency_keyviacreateIdempotencyStore, mandatory auth pointer, signature-header transparency. - Composing OAuth + signing + idempotency: real
serve({ authenticate, preTransport })wiring,verifyBearerfrom@adcp/client/server, low-levelverifyRequestSignature(preTransport-shaped; notcreateExpressVerifierwhich is Express-shaped),resolveIdempotencyPrincipalthreading fromctx.authInfo.clientId+ multi-tenant composition. - Per-specialism sections for
sales-guaranteed(A2A task envelope for IO approval),sales-non-guaranteed(bid_price + update_media_buy),sales-broadcast-tv,sales-social,sales-proposal-mode,audience-sync,signed-requests.
Governance skill: Plan shape updated to
budget.reallocation_threshold/reallocation_unlimited+human_review_required(no moreauthority_level),content_standards.policies[]as structured array with per-entryenforcement,validate_content_delivery.artifact.assetsas array,property-lists/collection-lists(new) /content-standardsspecialism sections. Governance status enum is approved | denied | conditions — approved-with-conditions isstatus: 'conditions', not an approved + conditions array.Signals skill: async platform-activation pattern, value-type constraints, deployed_at.
Brand-rights skill: schema-accurate
logos[].background(dark-bg/light-bg/transparent-bg),tone.voicenesting,termswith required pricing_option_id/amount/currency/uses,rights_constraintwith requiredrights_agent,approval_webhookcredentials minLength 32,available_usesusing spec-valid enum values.Retail-media skill: scope note (catalog-driven ≠ retail-only).
Validated via five rounds of fresh-builder tests against the skills + one end-to-end test with the storyboard runner. Median build confidence climbed from 3/5 (round 1) to 4-5/5 (round 5). End-to-end runs surfaced three upstream spec/runner bugs now tracked in adcontextprotocol/adcp#2418, adcontextprotocol/adcp#2420, and #625.
- Protocol-Wide Requirements:
-
5d81fe9: Generator: typeless JSON Schema properties now emit
unknowninstead ofRecord<string, unknown>.JSON Schema properties declared with only a
description(notype,$ref, combinator, enum, or structural keyword) are defined by the spec to accept any JSON value — scalar or object.json-schema-to-typescriptdefaults these to{ [k: string]: unknown }, which downstream Zod generation then narrowed toz.record(z.string(), z.unknown()). That schema rejected scalar values the spec legitimately allows, e.g. a number returned forcheck_governanceconditions[].required_value.enforceStrictSchemainscripts/generate-types.tsnow annotates schema nodes whose keys are all metadata-only (description,title,$comment,examples,default,deprecated,readOnly,writeOnly,$id,$anchor,$schema) withtsType: 'unknown'before handing them tojson-schema-to-typescript, so the emitted TS isunknownand the Zod mirror isz.unknown(). Validation-only keywords likerequired(common inanyOfbranches on request schemas) are not metadata, so constraints still compose. The recursion now also reachespatternProperties, schema-valuedadditionalProperties,not,if/then/else,contains,propertyNames,unevaluatedItems/unevaluatedProperties, and schema-valueddependencies/dependentSchemas.Side fix:
removeNumberedTypeDuplicatesnow iterates passes (up to 10) until no further collapses occur. Nested numbered references (e.g.CatalogFieldMapping2referencesExtensionObject32) previously caused the outer duplicate to fail body comparison and stay in the output; they now collapse once the inner reference resolves on an earlier pass.Regenerated affected types in
src/lib/types/*.generated.ts. Notable corrections:CheckGovernanceResponse.conditions[].required_value:Record<string, unknown>→unknown.CatalogFieldMapping.value/.default:Record<string, unknown>→unknown.Response.data:Record<string, unknown>→unknown.
If you narrowed one of these fields with
as Record<string, unknown>, replace with a value-shape assertion appropriate to the spec.