From 179b7b1447f023d6233a182b63df0c00dd253e2f Mon Sep 17 00:00:00 2001 From: numaras Date: Mon, 19 Oct 2026 17:21:28 -0300 Subject: [PATCH] feat(creative): add signed creative addons for AdCP 3.3 Propose fragment-only addon assembly, mandatory signed chain evidence, and explicit purchase bindings. Keep WG release-scope and signing-profile approval pending. --- .changeset/creative-addons-3-3.md | 5 + docs/accounts/tasks/sync_accounts.mdx | 4 + docs/building/by-layer/L1/security.mdx | 8 +- docs/creative/addon-chain-signing.mdx | 185 +++ .../task-reference/get_creative_addons.mdx | 291 ++++ .../task-reference/get_creative_delivery.mdx | 4 + .../task-reference/accept_proposal.mdx | 4 + .../media-buy/task-reference/buy_products.mdx | 4 + .../media-buy/task-reference/get_products.mdx | 4 + docs/protocol/get_adcp_capabilities.mdx | 4 + docs/reference/test-vectors/index.mdx | 1 + docs/snippets/compliance-error-codes.mdx | 70 + package.json | 2 +- scripts/build-schemas.cjs | 8 +- scripts/generate-creative-addon-vectors.cjs | 131 ++ server/src/services/adcp-taxonomy.ts | 2 + server/src/training-agent/idempotency.ts | 1 + server/tests/unit/adcp-taxonomy.test.ts | 2 + server/tests/unit/idempotency.test.ts | 1 + .../specialisms/creative-addon/index.yaml | 119 ++ .../test-vectors/creative-addon-chain.json | 1220 +++++++++++++++++ .../creative-addon-chain.schema.json | 41 + .../account/sync-accounts-response.json | 11 + .../source/core/canonical-product.json | 6 + .../core/creative-addon-capability.json | 59 + .../source/core/creative-addon-chain.json | 449 ++++++ .../core/creative-addon-disclosure.json | 196 +++ .../source/core/creative-addon-failure.json | 59 + .../source/core/creative-addon-fragment.json | 48 + .../core/creative-addon-registration.json | 130 ++ .../core/creative-addon-signing-key.json | 63 + .../schemas/source/core/creative-variant.json | 3 + static/schemas/source/core/product.json | 6 + .../creative/get-creative-addons-request.json | 138 ++ .../get-creative-addons-response.json | 78 ++ static/schemas/source/enums/error-code.json | 21 + static/schemas/source/enums/specialism.json | 2 + static/schemas/source/index.json | 36 + .../source/media-buy/product-fields.json | 3 +- .../source/media-buy/product-purchase.json | 18 + .../get-adcp-capabilities-response.json | 10 + tests/creative-addon-vectors.test.cjs | 195 +++ tests/creative-addons.test.cjs | 169 +++ tests/mcp-schema-analysis.test.cjs | 7 +- tests/mcp-schema-projection.test.cjs | 20 +- tests/targeting-null-clear.test.cjs | 11 +- 46 files changed, 3830 insertions(+), 19 deletions(-) create mode 100644 .changeset/creative-addons-3-3.md create mode 100644 docs/creative/addon-chain-signing.mdx create mode 100644 docs/creative/task-reference/get_creative_addons.mdx create mode 100644 scripts/generate-creative-addon-vectors.cjs create mode 100644 static/compliance/source/specialisms/creative-addon/index.yaml create mode 100644 static/compliance/source/test-vectors/creative-addon-chain.json create mode 100644 static/compliance/source/test-vectors/creative-addon-chain.schema.json create mode 100644 static/schemas/source/core/creative-addon-capability.json create mode 100644 static/schemas/source/core/creative-addon-chain.json create mode 100644 static/schemas/source/core/creative-addon-disclosure.json create mode 100644 static/schemas/source/core/creative-addon-failure.json create mode 100644 static/schemas/source/core/creative-addon-fragment.json create mode 100644 static/schemas/source/core/creative-addon-registration.json create mode 100644 static/schemas/source/core/creative-addon-signing-key.json create mode 100644 static/schemas/source/creative/get-creative-addons-request.json create mode 100644 static/schemas/source/creative/get-creative-addons-response.json create mode 100644 tests/creative-addon-vectors.test.cjs create mode 100644 tests/creative-addons.test.cjs diff --git a/.changeset/creative-addons-3-3.md b/.changeset/creative-addons-3-3.md new file mode 100644 index 00000000000..17b6ffa3916 --- /dev/null +++ b/.changeset/creative-addons-3-3.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": minor +--- + +Add the proposed get_creative_addons task for operator-registered fragments adjacent to unchanged image, HTML5, and display-tag creatives. Define pre-commit disclosure through existing proposal and product-feed bindings, deterministic assembly and failure rules, and mandatory adcp/addon-chain/v1 signed variant evidence. The 3.3 target remains subject to Working Group approval of scope and the dedicated signing profile. diff --git a/docs/accounts/tasks/sync_accounts.mdx b/docs/accounts/tasks/sync_accounts.mdx index 79eeb2e25b0..b177d9802cc 100644 --- a/docs/accounts/tasks/sync_accounts.mdx +++ b/docs/accounts/tasks/sync_accounts.mdx @@ -715,3 +715,7 @@ asyncio.run(main()) - [Accounts and agents](/docs/building/by-layer/L2/accounts-and-agents) -- Billing models, trust models, and authorized operators - [Brand protocol](/docs/brand-protocol/brand-json) -- How seller agents resolve brand identity from the `brand.domain` - [get_adcp_capabilities](/docs/protocol/get_adcp_capabilities) -- Discover `supported_billing` and `require_operator_auth` before syncing accounts + +## Creative addon disclosure (proposed 3.3) + +A successful account response row may expose account-scoped `creative_addons`. It inherits that row’s account identity and does not by itself prove completeness or freshness for a product purchase. The account revision is not an addon acknowledgment. See [pre-commit disclosure](/docs/creative/task-reference/get_creative_addons#disclosure-before-commitment). diff --git a/docs/building/by-layer/L1/security.mdx b/docs/building/by-layer/L1/security.mdx index eda07ebdbdc..0a5e1cf7d7e 100644 --- a/docs/building/by-layer/L1/security.mdx +++ b/docs/building/by-layer/L1/security.mdx @@ -1024,7 +1024,9 @@ A task is admitted to the designated list only when its response payload is the - **`verify_brand_claim`** and its bulk variant **`verify_brand_claims`** (Brand Protocol). The responding brand-agent signs its response payload as a JWS envelope under the brand's `adcp_use: "response-signing"` key. The signature is load-bearing for the direction-asymmetric trust model — see [`verify_brand_claim` trust model](/docs/brand-protocol/tasks/verify_brand_claim#trust-model) and [Building a brand agent — Signing setup](/docs/brand-protocol/building-a-brand-agent#signing-setup). -Any task not on this list MUST NOT sign its response under any signing primitive. General-purpose response-signing helpers applying RFC 9421 §2.2.9 to arbitrary tools (regardless of which `tag` or `adcp_use` string they coin) are operating outside this profile and are not 3.x-conformant; the only response-signing primitive the spec authorizes in 3.x is payload-envelope JWS on the designated-tasks list. +The proposed creative addon support for 3.3 lets `get_creative_delivery` carry a signed `variants[].addon_chain` artifact under the dedicated [`adcp/addon-chain/v1` profile](/docs/creative/addon-chain-signing). Explicit human approval of this bounded artifact signature remains pending. Its proposed scope excludes signing the whole task response and using `response-signing` keys for addon evidence. + +Apart from that scoped addon artifact, any task not on the designated list MUST NOT sign its response under any signing primitive. General-purpose response-signing helpers applying RFC 9421 §2.2.9 to arbitrary tools (regardless of which `tag` or `adcp_use` string they coin) are operating outside this profile and are not 3.x-conformant; whole-response signing remains limited to payload-envelope JWS on the designated-tasks list. The `adcp_use: "response-signing"` value is therefore reserved at the JWK layer for the payload-envelope primitive. **Keys published with `adcp_use: "response-signing"` MUST sign only payload-envelope JWS as defined in this section; using such a key to produce RFC 9421 §2.2.9 transport signatures is a profile violation regardless of the task being signed.** If a future major version scopes RFC 9421 transport response signing for any task, it MUST use a distinct `adcp_use` value (e.g., `"response-transport-signing"`) so verifiers can disambiguate the primitive from the JWK alone — the brand-protocol value cannot be retconned to cover both. List growth and additional primitives are normative decisions deferred to future spec versions. @@ -2580,3 +2582,7 @@ function logSecurityEvent(eventType, details) { - **Webhooks**: See [Webhooks](/docs/building/by-layer/L3/webhooks) for webhook security patterns - **Error Handling**: See [Error Handling](/docs/building/by-layer/L3/error-handling) for authentication errors - **Orchestrator Design**: See [Orchestrator Design](/docs/building/operating/orchestrator-design) for multi-tenant security + +## Creative addon object signatures (proposed 3.3) + +The proposed [addon-chain signing profile](/docs/creative/addon-chain-signing) uses dedicated Ed25519 keys resolved through the supported 3.x identity/JWKS infrastructure. Its use in 3.3 requires explicit human approval of the relationship to DR-0023. It changes no Web Bot Auth transport behavior. Do not treat an addon signature as verified without the profile’s key-authority, context, and retained-history checks. diff --git a/docs/creative/addon-chain-signing.mdx b/docs/creative/addon-chain-signing.mdx new file mode 100644 index 00000000000..cc8a78b22dc --- /dev/null +++ b/docs/creative/addon-chain-signing.mdx @@ -0,0 +1,185 @@ +--- +title: Addon-chain signing +description: Proposed AdCP 3.3 Ed25519 object-signature profile for seller-adjacent execution evidence. +--- + + +This is the proposed `adcp/addon-chain/v1` profile for +[#6700](https://github.com/adcontextprotocol/adcp/issues/6700). +[DR-0023](https://github.com/adcontextprotocol/adcp/blob/main/governance/decisions/DR-0023-agent-identity-is-an-origin.md) +keeps legacy discovery working through 3.x and schedules broader JWS document +signing for 3.4. This bounded use in 3.3 requires explicit human approval and a +recorded decision on that sequencing. No approval is implied by this draft. + + +This profile signs the Sales Agent's assembly claim from +[`get_creative_addons`](/docs/creative/task-reference/get_creative_addons). +Its signature covers the ordered entries and actual effective configuration, bound +to the unchanged creative and its delivery context. It does not change Web Bot +Auth or substitute a transport signature for retained object evidence. + +## Encoding and hashes + +V1 uses SHA-256, encoded as `sha256:` followed by 64 lowercase hexadecimal digits. +Every JSON hash input uses [RFC 8785 JCS](https://www.rfc-editor.org/rfc/rfc8785) +and UTF-8 with no BOM. Producers and verifiers MUST reject duplicate object keys, +invalid Unicode, non-finite numbers, or data outside JCS's supported domain. +No Unicode, whitespace, newline, or URL normalization is performed on string +values. Hashing and parsing MUST use the same input bytes. + +- `content_hash`: SHA-256 of the exact UTF-8 fragment `content` bytes. `bytes` is + their byte count, not the number of characters. The seller validates the hash + before assembly. Static references use the same rule. +- `chain_hash`: SHA-256 of the JCS encoding of the complete `entries` array, + including order and every optional metadata field present. It excludes the + surrounding object and signature. +- `configuration_hash`: SHA-256 of the JCS encoding of the complete + `configuration` object carried beside the entries. +- `creative_hash`: SHA-256 of a format-specific JCS object defined below. It + identifies the selected buyer creative material immediately before addon + assembly, excluding the seller container and all addon fragments. + +| Format | Exact `creative_hash` input | +| --- | --- | +| `image` | `{ "format_kind": "image", "content_hash": H }`, where `H` hashes the exact selected image entity bytes before addon assembly, after transport content decoding and before any addon processing. A URL alone is insufficient. | +| `html5` | `{ "format_kind": "html5", "content_type": T, "content_hash": H }`. `T` is `application/zip` for the exact accepted bundle archive bytes, or `text/html` for the exact UTF-8 buyer document bytes. `H` hashes those bytes. Do not re-zip, normalize markup, or hash the seller wrapper. A route that cannot retain the source bytes is unsupported. | +| `display_tag` | `{ "format_kind": "display_tag", "asset": A }`, where `A` is the complete selected canonical display-tag asset object, including its `delivery_type` and every supplied field. Include both URLs for `paired_redirect`. Preserve macro tokens; hash before the existing authorized macro-resolution step. Do not parse or rewrite tag markup. | + +For remote images or bundles, the seller MUST retain the exact material associated +with that execution so later URL changes cannot redefine it. Buyers verify the +creative hash against the independently known selected material and source +revision. If they cannot obtain that material, they may verify the seller signature +but MUST NOT describe the creative-content binding as independently verified. +A display-tag or addon loader hash commits to the tag/loader, not to code fetched +later from a mutable URL. This profile does not make that remote code immutable. + +## JWS shape + +`signed_attestation` carries `profile: adcp/addon-chain/v1` and the flattened +[JWS](https://www.rfc-editor.org/rfc/rfc7515) members `protected`, `payload`, and +`signature`. All three use canonical, unpadded base64url. The profile member is +AdCP metadata beside the flattened JWS; no unprotected JWS header is allowed. +Detached payloads and `b64: false` are unsupported. + +The protected header has exactly these members: + +```json +{ + "$schema": "/schemas/core/creative-addon-chain.json#/definitions/ProtectedHeader", "alg": "EdDSA", "kid": "seller-addon-key", "typ": "adcp-addon-chain+jws" } +``` + +`EdDSA` means Ed25519 in v1. Verifiers MUST reject every other algorithm or type, +unknown header members, and any embedded `jwk`, `jku`, or `x5u` trust source. +The header need not be JCS-serialized; its transmitted bytes are signed verbatim. + +`payload` is base64url of the exact UTF-8 JCS encoding of the claims object. +After decoding and parsing, a verifier MUST compare its JCS serialization to the +decoded bytes. The signing input is ASCII +`BASE64URL(protected-header-bytes) + "." + BASE64URL(payload-bytes)`. +The 64-byte Ed25519 signature is base64url-encoded. + +The [claims schema](https://adcontextprotocol.org/schemas/latest/core/creative-addon-chain.json#/definitions/Claims) +requires `typ`, `agent_url`, `publisher_domain`, `creative_id`, `creative_hash`, +`chain_hash`, `configuration_hash`, `format_kind`, `variant_id`, `media_buy_id`, +and integer NumericDate `iat`. Include `package_id`, `placement_id`, `revision_id`, +and `locale_variant_id` whenever applicable to the associated reporting context. +A verifier MUST compare both presence and value against trusted reporting context, +including identifiers inherited from the containing creative or media-buy row. +It MUST reject a missing, extra, or mismatched scoped identifier. Unknown claims +are rejected in v1; extensions require a reviewed profile version. + +A chain cannot be moved between variants, revisions, media buys, or publishers. +Any execution requiring additional scoping that v1 cannot unambiguously express +is unsupported until a reviewed profile supplies it. + +## Signing authority + +V1 uses a dedicated public Ed25519 key conforming to the +[addon signing-key schema](https://adcontextprotocol.org/schemas/latest/core/creative-addon-signing-key.json): `kty` +`OKP`, `crv` `Ed25519`, `alg` `EdDSA`, `use` `sig`, `key_ops` `["verify"]`, and +`adcp_use` `addon-chain-signing`, plus `kid` and the public `x` value. Public JWKs +MUST NOT include private `d`. The key MUST NOT reuse transport-signing material. +Introducing this purpose requires the signing-profile approval noted above. + +The verifier starts with the expected Sales Agent and publisher from its trusted +transaction context, not from the attestation. It resolves the agent through the +existing supported 3.x identity rules, including the selected +`identity.brand_json_url` entry and that agent's JWKS. The signed `agent_url` must +exactly match the selected agent identity under those rules. Preserve existing +safe-fetch, endpoint-authorization, and key-refresh requirements; a presented +attestation cannot select a replacement identity or arbitrary key URL. + +Where publisher pins apply, the signing public key MUST match both the authorized +agent JWKS and the publisher's applicable pin by key material, using the existing +[RFC 7638](https://www.rfc-editor.org/rfc/rfc7638) JWK-thumbprint comparison. +A shared `kid` is insufficient. Conflicting duplicate key IDs or purpose metadata +fail verification. The publisher is derived from trusted inventory/transaction +context; the signed `publisher_domain` must agree. Pin absence follows the existing +trust rules and MUST NOT be interpreted as permission to bypass a required pin. + +## Verification and retained history + +A verifier MUST: + +1. Validate the chain and decoded header/claims schemas. Enforce canonical encoding + and the profile identifier. +2. Resolve authorized key material from trusted identity and publisher context. + Enforce dedicated purpose, algorithm, and revocation checks. +3. Verify the JWS signature and exact canonical payload bytes. +4. Recompute `chain_hash` and `configuration_hash`; compare every covered identity + to the reporting context and the creative hash to independently known material. +5. Validate configuration coverage and ordering. Each applicable required vendor + has applied entries only; each applicable best-effort vendor has fragments or + exactly one omission. Entry identities, source, declared position, and static + hash agree with the indexed registration. No fragment body is exposed. + +`iat` is a nonnegative integer number of seconds since the Unix epoch. Reject an +issuance more than 60 seconds in the future relative to the trusted verification +clock. Historical chains do not expire solely because they are old; retention and +key authority still govern whether they can be verified. + +Rotation adds a fresh `kid` and distinct key material. Sellers SHOULD overlap +public keys through the reporting retention period and MUST retain verifiable key +authority evidence for historical chains. A verifier may use a retained key only +with a trusted record that it was authorized for this agent and publisher at the +relevant time. Archiving a bare key or trusting a key embedded in a chain is +insufficient. Do not reuse a `kid` for different material. + +Either the agent JWKS or the publisher pin can revoke authorization. Apply every applicable `revoked_at`, so the earliest revocation controls; conflicting purpose metadata also fails verification. Reject chains with `iat` at or after any applicable revocation. A compromised +key could backdate `iat`, so an earlier timestamp alone cannot establish validity. +Historical verification after revocation additionally requires a trusted record +that the exact signed JWS was observed and verified before revocation, together +with the then-valid agent authorization and applicable publisher pin. The +observation time must be at or after `iat` and before revocation. Store a digest of +the exact JWS and the covered chain/configuration alongside that evidence so it +cannot be transplanted to another signature. A publisher's explicit compromise +policy may invalidate earlier evidence as well; that policy takes precedence. + +Without sufficient historical authority or observation evidence, report the chain +as unverifiable. Do not silently trust a removed key, an old cache without authority +evidence, or a self-asserted signing time. Refreshing JWKS follows existing bounded +refresh rules and does not permit redirecting discovery through the JWS. + +Missing evidence or any failed check MUST NOT be reported as a verified addon +chain. An optional `detail_url` can expose authorized evidence but cannot override +these checks. A signature is the seller's claim about assembly, not independent +proof that JavaScript ran or that a measurement counted correctly. + +## Reproducible evidence and migration + +Published vectors live at +`static/compliance/source/test-vectors/creative-addon-chain.json`. They cover each +format, static/live sources, best-effort omission, and retained history. Negative +vectors cover altered metadata, unauthorized key purposes, mismatched key pins, +wrong delivery identities, noncanonical payloads, and revocation. The test key is +public RFC 8032 sample material and cannot be used for an operational signer. + +Run `node scripts/generate-creative-addon-vectors.cjs --check` and +`node --test tests/creative-addon-vectors.test.cjs` to reproduce the evidence. +These executable reference checks do not replace the required seller/buyer partner +integration against exact protocol versions. + +A later migration to the 3.4 document-signing framework requires explicit profile +or version negotiation. Previously issued v1 chains retain their original +interpretation and verification rules. This draft makes no change to DR-0023's +transport identity or Web Bot Auth behavior. diff --git a/docs/creative/task-reference/get_creative_addons.mdx b/docs/creative/task-reference/get_creative_addons.mdx new file mode 100644 index 00000000000..89eca606948 --- /dev/null +++ b/docs/creative/task-reference/get_creative_addons.mdx @@ -0,0 +1,291 @@ +--- +title: Get Creative Addons +description: Operator-registered fragments adjacent to an unchanged buyer creative, with pre-commit disclosure and signed execution evidence. +--- + + +Proposed for AdCP 3.3 under [#6700](https://github.com/adcontextprotocol/adcp/issues/6700). +This specification draft implements the proposed +[decision addendum](https://github.com/adcontextprotocol/adcp/issues/6700#issuecomment-6179907162). +Working Group approval, the signing-profile decision, and release readiness remain pending. + + +`get_creative_addons` returns fragments to a Sales Agent that controls the adjacent +execution container. The buyer creative remains the authoritative library asset. +The Sales Agent MUST NOT rewrite it or replace it with the assembled container. +Addon vendors MUST NOT receive its payload, including through `context`, `ext`, +asset URLs, or diagnostics. Vendors that need the creative for evaluation use the +existing creative-governance contract. + +The v1 formats are `image`, `html5`, and `display_tag`. An image executes within a +seller container; an HTML5 bundle or document remains unchanged inside that +container; a third-party display tag remains opaque and byte-preserved. A seller +MUST NOT advertise a route unless it controls an adjacent container, can preserve +the buyer asset, and can support any prerequisite required by its registered addons. +Video and audio tracker insertion are outside this task. + +The addon operation occurs when materializing a delivery or trafficking +representation. It is not an impression-time network call. One stored creative +can therefore have different addon chains on different served variants. + +## Discovery and registration + +An addon vendor MUST declare `creative` in `supported_protocols`, +`creative-addon` in `specialisms`, its `agent_name`, and the top-level +`creative_addon` capability. The latter is separate from +`creative.supported_formats`, which describes creative operations. + +```json +{ + "$schema": "/schemas/core/creative-addon-capability.json", + "supported_format_kinds": ["image", "html5", "display_tag"], + "positions": ["prepend", "append"], + "signed_chain": { "required": true, "profile": "adcp/addon-chain/v1" } +} +``` + +A Sales Agent advertising assembly support also publishes `creative_addon` and +`creative` protocol support. It claims the `creative-addon` vendor specialism only +if it exposes the fragment task itself. The new obligations apply when participants +negotiate AdCP 3.3 or later and addon support. An older or non-supporting participant +MUST NOT be treated as acknowledging undisclosed addon configuration. Ordinary +transactions outside this feature keep their existing contract. + +Registration is an operator action, outside buyer task inputs. The +[registration schema](https://adcontextprotocol.org/schemas/latest/core/creative-addon-registration.json) describes the +configuration; it creates no registration API. Each entry has exactly one +`agent_url` or `static_reference`. A live registration's `agent_name` MUST match the +vendor declaration after trimming surrounding whitespace and lowercasing. Names +MUST be unique within a registration list after that normalization; URLs and +normal agent authentication establish identity, not the label alone. + +`mode` is `required` or `best_effort`. Required entries MUST configure a positive, +finite integer `max_hold_seconds`. Authentication stays operator-local. Credentials +MUST NOT enter buyer disclosure or reporting. Removing a registration stops new +calls on the next operation and does not erase past chains. A registration or +policy change during a held operation requires re-resolution against the seller's +current policy; it cannot extend the original deadline or silently remove a +required obligation from that operation. + +A static registration supplies a fixed `content` and `position`, with supported +`format_kinds` and optional `detail_url`. It makes no vendor call. The Sales Agent +computes its content hash and applies the same validation and signing obligations +as for live fragments. Seller first-party measurement loaders use this same +registration contract. A chain attributes the seller's assembly claim; it does not +assign counting authority, which remains governed by `billing_measurement`. +Disclosure of broader trackers is coordinated through +[#6878](https://github.com/adcontextprotocol/adcp/issues/6878). + +## Request and response + +The Sales Agent MUST send one batch per vendor for all creative contexts sharing +an operation and placement. It MUST exclude undeclared format kinds before calling. +Separate placement contexts require separate batches. `operator_url` is verified +through normal agent authentication; `operator_name` is an informational label. +`account_id`, if present, is the vendor-assigned account, not a buyer execution +account. Duplicate `creative_id` values within a batch are invalid. + +```json +{ + "$schema": "/schemas/creative/get-creative-addons-request.json", + "adcp_version": "3.3", + "idempotency_key": "addon-operation-0001", + "operator_name": "Example Sales", + "operator_url": "https://sales.example/agent", + "publisher_domain": "publisher.example", + "placement": { "property_id": "homepage", "product_id": "display" }, + "creatives": [ + { "creative_id": "creative-1", "format_kind": "image", "dimensions": { "w": 300, "h": 250 } } + ] +} +``` + +The vendor returns `addons`, whose items follow the +[fragment schema](https://adcontextprotocol.org/schemas/latest/core/creative-addon-fragment.json). Each contains a +requested `creative_id`, a declared `position`, exact UTF-8 `content`, and its +`content_hash`. Optional `vendor_ref` is an opaque policy/version reference. +Optional `detail_url` MUST use HTTPS and enforce access control before exposing +nonpublic evidence. It confers no right to retrieve fragment source. + +```json +{ + "$schema": "/schemas/creative/get-creative-addons-response.json", "status": "completed", "adcp_version": "3.3", "addons": [] } +``` + +An empty array is a valid vendor response. For a best-effort registration it means +a deliberate omission; for a required registration it fails the seller requirement. +Vendors cannot exempt themselves from required mode with a `not_applicable` result. +The vendor task is synchronous in v1. Terminal vendor errors use `status: failed` +and `errors`, without `addons`, following the existing transport error mapping. +Async holds belong to the invoking seller task. + +The new task requires `idempotency_key` and follows the protocol's idempotency +contract. A seller operation may contain several vendor evaluation attempts. +The vendor key identifies one evaluation attempt; the seller's existing `task_id` +and original required-hold deadlines identify the enclosing operation. + +An ambiguous transport outcome or an in-flight evaluation MUST be retried with the +same key and identical payload, including `prior_failures` and `context`. The +seller MUST NOT use a new key to bypass an unresolved attempt. Once that attempt +has definitively completed with an empty, failed, or seller-invalidated response, +the seller MAY start a fresh evaluation with a new key if the original hold still +permits recovery. That new request can carry the prior attempt's failure feedback. +It retains the same seller task and original per-vendor deadline. A key MUST NOT +be reused with new feedback or other changed request content. + +For example, an empty required result is recorded under vendor key A. A new +attempt under key B can report that failure and obtain repaired fragments while +the seller task remains working. A timeout of attempt B retries B unchanged; it +does not start C until B's outcome is resolved. At the deadline the seller task +fails even if a vendor attempt remains unresolved; a late response cannot resume +the failed task or authorize delivery. + +A distinct addon operation uses fresh vendor keys. Exact replays of one attempt +reuse its recorded outcome; live fragments MUST NOT be cached across distinct +operations. The seller MUST avoid copying buyer correlation data into vendor +context; it supplies vendor-safe correlation and receives that `context` echoed +unchanged. + +## Validation, ordering, and failure + +The Sales Agent MUST verify each fragment's hash before placement and validate +membership in the request batch, the declared format, and allowed position. It +also checks compatibility with its execution container. A malformed response or +unknown creative identity fails the vendor batch. An invalid fragment attributable +to a known creative discards all of that vendor's fragments for that creative; +valid fragments from unrelated creative contexts can remain eligible. The seller +MUST NOT partially apply a vendor's response to an affected creative. + +For each creative, final placement is all `prepend` fragments in registration +order, the unchanged buyer creative, then all `append` fragments in registration +order. Fragments from the same vendor and position preserve response order. +`fragment_index` is the zero-based index within that vendor's fragments for this +creative before sorting by position. Indices MUST be unique and contiguous from zero for each applied vendor/creative set, even when fragments occupy different positions. Static references use index zero. + +Placement order does not promise completion order of asynchronous scripts. If a +required addon needs a prerequisite before creative invocation, the seller MUST +provide that prerequisite on the advertised route, or mark the route unsupported. +This task defines no general dependency scheduler. + +| Failure | Classification | +| --- | --- | +| [`ADDON_UNREACHABLE`](/docs/building/verification/compliance-catalog#error-code-addon-unreachable) | Connection failure or call timeout | +| [`ADDON_AUTH_FAILED`](/docs/building/verification/compliance-catalog#error-code-addon-auth-failed) | Vendor rejected operator credentials | +| [`ADDON_HASH_MISMATCH`](/docs/building/verification/compliance-catalog#error-code-addon-hash-mismatch) | Content bytes do not match the declared hash | +| [`ADDON_INVALID_POSITION`](/docs/building/verification/compliance-catalog#error-code-addon-invalid-position) | Unsupported or undeclared relative position | +| [`ADDON_UNDECLARED_FORMAT`](/docs/building/verification/compliance-catalog#error-code-addon-undeclared-format) | Undeclared format or unknown creative identity | +| [`ADDON_MALFORMED_FRAGMENT`](/docs/building/verification/compliance-catalog#error-code-addon-malformed-fragment) | Invalid container content or a required vendor supplied no valid fragment | + +For `best_effort`, the seller proceeds and records one omission for the affected +registration, with `reason: vendor_omission` for a valid empty response or +`reason: failure` and `error_code` for a failure. These states MUST remain distinct. + +For `required`, the affected execution MUST NOT be trafficked or delivered without +the required fragments. If its invoking task supports async execution, it uses +that task's existing `submitted`/`working` flow and stable `task_id`. The deadline is +the first failed attempt time plus `max_hold_seconds`; retries, restarts, and policy +re-resolution MUST NOT extend it. If multiple required vendors fail, each retains +its own original deadline; expiry of any unresolved requirement fails the affected +execution. Successful recovery before all applicable deadlines resumes the same +operation. Expiry, or an invoking task without async support, returns the normal +terminal error arm with [`ADDON_REQUIRED_UNAVAILABLE`](/docs/building/verification/compliance-catalog#error-code-addon-required-unavailable) and `recovery: terminal`. + +The error SHOULD identify affected `creative_ids` and the disclosed `agent_name` +in structured details, without credential or internal diagnostic leakage. The +buyer creative's lifecycle MUST remain unchanged. The seller MUST NOT silently +downgrade required mode. Buyers follow task status while nonterminal; later retries +use the invoking task's existing idempotency contract and grant no policy override. + +The operator MAY impose vendor-count, fragment-size, and latency limits. Exceeding +a local limit follows the same required/best-effort behavior. Silent truncation is +prohibited. V1 declares no universal numerical cap or `max_latency_ms` capability. + +### Vendor failure feedback + +`prior_failures` carries only the receiving vendor's pending failures using the +[failure schema](https://adcontextprotocol.org/schemas/latest/core/creative-addon-failure.json). The seller clears an +echoed batch only after receiving a successful task response that acknowledges +receipt by completing that call. A failed or ambiguous call retains the entries; +vendors MUST tolerate duplicate feedback. Optional `notification_url` receives +`{ "prior_failures": [...] }` POST bodies using the same entry shape and configured +operator authentication. A successful 2xx notification acknowledges that batch; +failed pushes retain it for retry or the next task call. No separate AdCP task is +introduced. URLs follow existing outbound safe-fetch and credential-scoping rules. + +## Disclosure before commitment + +`creative_addons` on [`get_products`](/docs/media-buy/task-reference/get_products) product rows describes known product scope; +canonical [`list_products`](/docs/media-buy/task-reference/list_products) rows expose the same field for direct purchase. +[`sync_accounts`](/docs/accounts/tasks/sync_accounts) may return account-scoped configuration on each successful account +row. A failed row MUST NOT present configuration as resolved. Account scope inherits +the identity of that row and does not imply completeness for a particular product. +The account `revision` is not an addon acknowledgment token. + +A disclosure states `scope`, `complete`, and its ordered `vendors`, with the signing +profile. Omission means undeclared. Only `complete: true` with an empty `vendors` +array means an explicitly empty set. Each vendor discloses its normalized identity, +source, mode, declared formats and positions, and required hold period if applicable. +Static references also disclose their fixed `static_content_hash`, without content. +The seller resolves account and product rules into one complete product-scoped +snapshot; the buyer MUST NOT concatenate partial disclosures or infer precedence. + +Before spend commitment the seller MUST establish that the complete configuration +seen by the buyer still applies. V1 uses these existing binding mechanisms: + +| Purchase path | Binding and freshness rule | +| --- | --- | +| [`request_proposals`](/docs/media-buy/task-reference/request_proposals) / [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) → [`accept_proposal`](/docs/media-buy/task-reference/accept_proposal) | Each `commercial_terms.purchases[]` carries its complete resolved `creative_addons`. Existing `terms_digest` covers this field. At finalize and atomically at acceptance, the seller compares the snapshot with current effective configuration. A change requires a successor proposal; return [`TERMS_REJECTED`](/docs/building/verification/compliance-catalog#error-code-terms-rejected) before commitment. | +| `list_products` → [`buy_products`](/docs/media-buy/task-reference/buy_products) | The returned product feed must be resolved for the authenticated account and include complete product disclosures. The existing `feed_version` identifies that immutable account-scoped view and changes whenever an effective addon rule changes. At purchase, the seller checks that version and current configuration atomically, records the exact disclosure in the accepted proposal, and rejects stale terms with `TERMS_REJECTED`. A global or partial feed that cannot bind account-specific configuration cannot support this route. | +| Legacy [`create_media_buy`](/docs/media-buy/task-reference/create_media_buy) / [`update_media_buy`](/docs/media-buy/task-reference/update_media_buy), or any path lacking either binding | Addon-enabled spend commitments return [`UNSUPPORTED_FEATURE`](/docs/building/verification/compliance-catalog#error-code-unsupported-feature) before mutation. The caller can use a supported canonical path. A future binding needs explicit schema and WG review before the path is advertised. | + +This adds no buyer-echoed configuration token. `get_products` or `sync_accounts` +can inform compatibility, but a successful read alone does not prove freshness at +commitment. The check, accepted snapshot, and commitment MUST be atomic with respect +to configuration changes. The same rule applies to amendments that add or change +addon-enabled delivery obligations. Non-spend operations and cancellation retain +their existing lifecycle contracts. + +A buyer can decline a transaction before commitment. No request may disable, +substitute, or reorder a seller-required addon. + +### Changes after commitment + +The accepted snapshot records the configuration disclosed at purchase. Publisher +authority continues after purchase: the seller MAY change its execution +configuration after commitment. It MUST retain the +accepted snapshot and each actual execution's later configuration; a new +configuration cannot overwrite historical evidence or create a runtime buyer veto. +Each changed execution configuration is reported on a distinct immutable variant +with its own signed chain, as specified below. The purchase binding establishes +freshness at commitment; it does not promise the same addon set for the entire buy. +Code deployed outside this addon mechanism remains outside its completeness claim. + +## Signed delivery reporting + +Every execution represented by negotiated addon support carries an `addon_chain` +on its [`get_creative_delivery`](/docs/creative/task-reference/get_creative_delivery) variant, including an empty configured set, +static-only chains, and best-effort omissions. The reporting row MUST identify the +source creative and its `media_buy_id`. A change in applied content, omissions, or +effective configuration creates a distinct immutable `variant_id`; reporting MUST +NOT aggregate different chains under one variant. The chain is never written back +to [`list_creatives`](/docs/creative/task-reference/list_creatives) or the buyer's creative asset. + +The chain contains the actual complete configuration and ordered applied/omitted +entries. Applied entries expose their fragment hash and UTF-8 byte count. Fragment +bodies and credentials MUST NOT appear. Entries include their `registration_index` +so a verifier can match them to the disclosed configuration. Applied entries appear +first in deterministic placement order; omitted entries follow in registration +order. `order` is each entry's zero-based index in that serialized list, not a +claim that an omitted fragment executed. + +Every applicable registered vendor MUST have either its applied fragments or one +omission; required vendors cannot be omitted on a delivered execution. Each entry's +identity and source MUST match its registration. Vendors whose declared formats do +not cover the creative are outside that execution's applicable set. + +The [addon-chain signing profile](/docs/creative/addon-chain-signing) defines the +exact hash and trust checks. The signature attributes the Sales Agent's assembly +claim. It does not independently prove browser execution or measurement accuracy. +Retain chains and the verification evidence for the existing reporting/audit +retention period. Independent vendor signatures and a separate audit retrieval +task are deferred. diff --git a/docs/creative/task-reference/get_creative_delivery.mdx b/docs/creative/task-reference/get_creative_delivery.mdx index 354b0ce9687..aeb90e1f3e3 100644 --- a/docs/creative/task-reference/get_creative_delivery.mdx +++ b/docs/creative/task-reference/get_creative_delivery.mdx @@ -493,3 +493,7 @@ Agents that support this task expose `get_creative_delivery` in their tool surfa ``` Buyers discover candidate creative agents through the registry, then confirm the live endpoint's tool and `get_adcp_capabilities` surfaces. This applies to any endpoint implementing the Creative Protocol, including a sales agent with creative capabilities. + +## Creative addon evidence (proposed 3.3) + +When addon support is negotiated, each served variant carries its immutable `addon_chain`, including the effective configuration and mandatory Sales Agent signature. Different chains must not be aggregated under one variant. Fragment bodies remain private. See [signed delivery reporting](/docs/creative/task-reference/get_creative_addons#signed-delivery-reporting) and the [signing profile](/docs/creative/addon-chain-signing). diff --git a/docs/media-buy/task-reference/accept_proposal.mdx b/docs/media-buy/task-reference/accept_proposal.mdx index f615c2b6a97..9a093ee0234 100644 --- a/docs/media-buy/task-reference/accept_proposal.mdx +++ b/docs/media-buy/task-reference/accept_proposal.mdx @@ -75,3 +75,7 @@ Before mutating any state, the seller re-resolves every entry in the proposal's ``` The buyer calls [`refine_proposals`](/docs/media-buy/task-reference/refine_proposals) to obtain fresh pins, reviews the changed definition, and finalizes again. A definition withdrawn from a successfully fetched document is changed and omits `current_content_digest`; a document that cannot be fetched or parsed is transient, so the seller returns [`SERVICE_UNAVAILABLE`](/docs/building/verification/compliance-catalog#error-code-service-unavailable) without mutating state. An idempotent replay of an acceptance that already completed returns the stored result without re-verifying pins. + +## Creative addon commitments (proposed 3.3) + +For negotiated addon support, `commercial_terms.purchases[].creative_addons` contains the complete resolved configuration and is covered by `terms_digest`. Finalization and acceptance must reject stale configuration before commitment. See [the complete binding rules](/docs/creative/task-reference/get_creative_addons#disclosure-before-commitment). diff --git a/docs/media-buy/task-reference/buy_products.mdx b/docs/media-buy/task-reference/buy_products.mdx index 8dede98eda0..f62ca50224e 100644 --- a/docs/media-buy/task-reference/buy_products.mdx +++ b/docs/media-buy/task-reference/buy_products.mdx @@ -75,3 +75,7 @@ A completed purchase MAY include `warnings[]` for non-blocking observations at c A submitted arm MAY carry `expected_confirmation_at` (added in 3.3) when confirmation depends on a scheduled event such as a booking round or manual review window; buyers SHOULD schedule a status check for that time instead of polling tightly. It is an estimate, not a commitment. See [Expected confirmation time](/docs/media-buy/task-reference/create_media_buy#expected-confirmation-time). The accepted snapshot records the published terms the buyer purchased. It does not imply that every direct product is non-guaranteed: discovery mode, buyer composition, and delivery guarantee are independent dimensions. + +## Creative addon commitments (proposed 3.3) + +Addon-enabled direct purchases require a complete account-resolved disclosure in the selected product feed. The existing `feed_version` must change whenever the effective addon configuration changes. The seller atomically validates freshness and retains the exact disclosure in the accepted commercial snapshot. See [the complete binding rules](/docs/creative/task-reference/get_creative_addons#disclosure-before-commitment). diff --git a/docs/media-buy/task-reference/get_products.mdx b/docs/media-buy/task-reference/get_products.mdx index e2c391ebfc1..197141df935 100644 --- a/docs/media-buy/task-reference/get_products.mdx +++ b/docs/media-buy/task-reference/get_products.mdx @@ -2143,3 +2143,7 @@ After discovering products: - [Pricing Models](/docs/media-buy/advanced-topics/pricing-models) - CPM, CPCV, CPP explained - [Brief Expectations](/docs/media-buy/product-discovery/brief-expectations) - How to write effective briefs - [Media Products](/docs/media-buy/product-discovery/media-products) - Product structure and fields + +## Creative addon disclosure (proposed 3.3) + +A product may disclose known seller-side addons in `creative_addons`. Omission means undeclared; a complete empty vendor list explicitly declares none. Product and account information must resolve to a complete, current snapshot before commitment. See [disclosure and purchase bindings](/docs/creative/task-reference/get_creative_addons#disclosure-before-commitment). diff --git a/docs/protocol/get_adcp_capabilities.mdx b/docs/protocol/get_adcp_capabilities.mdx index 62f7f261d19..795bdd7e2ef 100644 --- a/docs/protocol/get_adcp_capabilities.mdx +++ b/docs/protocol/get_adcp_capabilities.mdx @@ -2396,3 +2396,7 @@ After discovering capabilities: - [adagents.json Specification](/docs/governance/property/adagents) - Publisher authorization files - [Product Filters](/docs/media-buy/task-reference/get_products#filters) - Capability-aware filtering - [Content Standards](/docs/governance/content-standards) - Brand safety configuration + +## Creative addon capability (proposed 3.3) + +The top-level `creative_addon` block declares supported adjacent-execution formats and positions, with mandatory `adcp/addon-chain/v1` signing. Vendors also declare `agent_name` and the `creative-addon` specialism. See [get_creative_addons](/docs/creative/task-reference/get_creative_addons#discovery-and-registration). diff --git a/docs/reference/test-vectors/index.mdx b/docs/reference/test-vectors/index.mdx index a8437e5cfeb..ba3d61c5684 100644 --- a/docs/reference/test-vectors/index.mdx +++ b/docs/reference/test-vectors/index.mdx @@ -28,6 +28,7 @@ SDKs SHOULD fetch versioned paths where available and record the version under t | Set | What it pins | Source | CDN | |---|---|---|---| +| [`creative-addon-chain`](https://github.com/adcontextprotocol/adcp/blob/main/static/compliance/source/test-vectors/creative-addon-chain.json) | Proposed 3.3 `adcp/addon-chain/v1` Ed25519/JCS signatures and seller assembly evidence, including revocation and bounded recovery cases. WG approval remains pending. | `static/compliance/source/test-vectors/creative-addon-chain.json` | `/compliance/latest/test-vectors/creative-addon-chain.json` | | [`supply-path`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/supply-path) | Fail-closed owner-sold supply-path states, leg diagnostics, collection/property scope, revocations and applicable IAB evidence | `static/compliance/source/test-vectors/supply-path/` | `/compliance/latest/test-vectors/supply-path/` | | [`reporting-summary`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/reporting-summary) | Future expectations in complete reporting summaries, scope guards, timestamp formats, and unchanged view and health constraints (next 3.2 RC) | `static/compliance/source/test-vectors/reporting-summary/` | `/compliance/latest/test-vectors/reporting-summary/` | | [`request-signing`](https://github.com/adcontextprotocol/adcp/tree/main/static/compliance/source/test-vectors/request-signing) | RFC 9421 request-signing profile: canonical signature base, covered components, signature params, tag namespace, alg allowlist, `adcp_use` discriminator, replay dedup, revocation, content-digest semantics, and URL canonicalization | `static/compliance/source/test-vectors/request-signing/` | `/compliance/latest/test-vectors/request-signing/` | diff --git a/docs/snippets/compliance-error-codes.mdx b/docs/snippets/compliance-error-codes.mdx index dd029c0d1e6..b824f92e994 100644 --- a/docs/snippets/compliance-error-codes.mdx +++ b/docs/snippets/compliance-error-codes.mdx @@ -21,6 +21,13 @@ description: "Canonical AdCP error codes with recovery classifications, remediat | `ACCOUNT_SETUP_REQUIRED` | correctable | check details.setup for URL or instructions | | `ACCOUNT_SUSPENDED` | terminal | contact seller to resolve suspension | | `ACTION_NOT_ALLOWED` | correctable | branch on error.details.reason: for wrong_status, wait for or transition to a status listed under the action's allowed_statuses; for mode_mismatch, this is a flow switch (not a retry against update_media_buy) — follow the mode named in available_actions[<action>].mode; for condition_unresolved, wait until the seller advertises the action after resolving its opaque condition; for not_supported_on_product or not_supported_on_buy, do not retry — the action is unavailable on this buy and buyer must select a different product or renegotiate, using error.details.decline_reason when present to inform that renegotiation | +| `ADDON_AUTH_FAILED` | terminal | operator repairs vendor credentials; buyer credentials are unchanged | +| `ADDON_HASH_MISMATCH` | terminal | discard affected fragments and have the vendor correct the content hash | +| `ADDON_INVALID_POSITION` | terminal | vendor corrects the position to one declared for this route | +| `ADDON_MALFORMED_FRAGMENT` | terminal | operator or vendor repairs the required fragment integration | +| `ADDON_REQUIRED_UNAVAILABLE` | terminal | surface the seller infrastructure failure; a later user-directed retry follows the invoking task idempotency contract | +| `ADDON_UNDECLARED_FORMAT` | terminal | vendor corrects the format or creative identity in the response | +| `ADDON_UNREACHABLE` | transient | seller retries within the original finite hold or records best-effort omission | | `AGENT_BLOCKED` | terminal | surface to a human at the buyer — the relationship is permanently denied and is reinstated only through offline operator action with the seller, not via any seller-callable AdCP task | | `AGENT_SUSPENDED` | terminal | surface to a human at the buyer — the agent cannot unilaterally lift a suspension; re-onboarding with the seller offline may resolve | | `AMBIGUOUS_BIDDING_POLICY` | correctable | remove either the canonical bidding block or all legacy bidding fields from the effective package | @@ -218,6 +225,69 @@ Account has been suspended. Recovery: terminal (contact seller to resolve suspen The requested mutation maps to an action that is not currently available on this media buy. Sellers MUST populate {"error.details"} with {"attempted_action"} (the structured available-action identifier the request maps to), {"reason"} (an {"action-not-allowed-reason"} value: {"wrong_status"}, {"not_supported_on_product"}, {"not_supported_on_buy"}, {"mode_mismatch"}, or {"condition_unresolved"}), and {"currently_available_actions"} (echo of the buy's resolved {"available_actions[]"} so the buyer SDK can offer recovery without a separate get_media_buys round-trip). For {"not_supported_on_buy"}, sellers SHOULD also populate {"decline_reason"} from {"seller-policy-decline-reason"} when they have a structured reason and disclosure does not expose private thresholds or enforcement controls. {"decline_reason"} SHOULD be omitted for structural reasons. All producer-authored explanatory text, including message, suggestion, and extension detail values, MUST remain buyer-safe and MUST NOT contain vendor identifiers, internal object names, internal IDs, stack traces, or other producer-private implementation details. Recovery: correctable when {"reason"} is {"wrong_status"} (wait for or transition to an allowed status), {"mode_mismatch"} (re-issue through the appropriate flow), or {"condition_unresolved"} (wait until the seller can resolve the condition and advertises the action). Terminal-for-this-buy when {"reason"} is {"not_supported_on_product"} or {"not_supported_on_buy"} — buyers select a different product or renegotiate buy terms. + + + + + +**Suggested action:** operator repairs vendor credentials; buyer credentials are unchanged + +Addon vendor rejected operator credentials. Recovery: terminal (operator repairs vendor credentials; buyer credentials are unchanged). + + + + + + +**Suggested action:** discard affected fragments and have the vendor correct the content hash + +Fragment content_hash differs from SHA-256 of the exact UTF-8 content. Discard all this vendor's fragments for the affected creative. Recovery: terminal (vendor must correct the fragment). + + + + + + +**Suggested action:** vendor corrects the position to one declared for this route + +Fragment position is unsupported by the contract or the vendor declaration. Recovery: terminal (vendor must correct the response). + + + + + + +**Suggested action:** operator or vendor repairs the required fragment integration + +Fragment cannot be safely assembled in the declared seller container, or a required vendor supplied no valid fragment. Recovery: terminal (operator or vendor repairs the integration). + + + + + + +**Suggested action:** surface the seller infrastructure failure; a later user-directed retry follows the invoking task idempotency contract + +Seller-required addon could not be applied within the finite original hold deadline, or the invoking task cannot hold asynchronously. The affected execution MUST NOT deliver; buyer creative lifecycle is unchanged. Use the invoking task's normal terminal error arm. Recovery: terminal (surface seller infrastructure failure; a later user-directed retry follows the original task's idempotency contract). + + + + + + +**Suggested action:** vendor corrects the format or creative identity in the response + +Fragment targets an undeclared format or unknown creative context. Recovery: terminal (vendor must correct the response). + + + + + + +**Suggested action:** seller retries within the original finite hold or records best-effort omission + +The addon vendor could not be reached before the call timeout. Recovery: transient (seller follows the finite required hold or records best-effort omission). + diff --git a/package.json b/package.json index 18869018dc7..29a389fcb9e 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "verify:cdn-artifacts-cutover": "node scripts/verify-cdn-artifacts-cutover.mjs", "typecheck": "tsc --project server/tsconfig.json --noEmit && npm run typecheck:fixed-trace-rollout-tests", "typecheck:fixed-trace-rollout-tests": "node scripts/typecheck-fixed-trace-rollout-tests.mjs", - "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/sync-platform-id.test.cjs tests/assignment-landing-page.test.cjs tests/assignment-trackers.test.cjs tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/total-budget-redistribution-currency.test.cjs tests/dooh-allocation.test.cjs tests/identity-absence-coherence.test.cjs tests/execution-requirements.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-delivery-contracts.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/format-asset-type-parity.test.cjs tests/canonical-manifest-constructibility.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/definition-pin-vectors.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs tests/hold-policy.test.cjs tests/hold-policy-vectors.test.cjs tests/collection-catalog.test.cjs tests/collection-copies-vectors.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", + "test:schemas": "node tests/schema-validation.test.cjs && node --test tests/sync-platform-id.test.cjs tests/assignment-landing-page.test.cjs tests/assignment-trackers.test.cjs tests/outcome-target.test.cjs tests/trusted-match-offer-creative-data.test.cjs tests/accessibility-violation-details.test.cjs tests/portfolio-routing-scope.test.cjs tests/catalog-item-availability-updates.test.cjs tests/catalog-ingestion-capabilities.test.cjs tests/sdk-implementation-metadata.test.cjs tests/seller-optimized-package-controls.test.cjs tests/compact-product-lifecycle-storyboards.test.cjs tests/timezone-resolution-storyboards.test.cjs tests/total-budget-redistribution-currency.test.cjs tests/dooh-allocation.test.cjs tests/identity-absence-coherence.test.cjs tests/execution-requirements.test.cjs tests/schema-deprecation-metadata.test.cjs tests/products-only-brief-compatibility.test.cjs tests/async-identity-convergence.test.cjs tests/creative-rotation.test.cjs tests/canonical-forecast-point-parity.test.cjs tests/creative-revisions.test.cjs tests/creative-delivery-contracts.test.cjs tests/creative-addons.test.cjs tests/creative-addon-vectors.test.cjs tests/tracker-execution-contracts.test.cjs tests/tracker-execution-package-integration.test.cjs tests/metric-identity-coherence.test.cjs tests/print-metrics.test.cjs tests/daypart-time-of-day.test.cjs tests/audience-size-range.test.cjs tests/viewable-rate-optimization-goal.test.cjs tests/sort-contract-delivery-reporting.test.cjs tests/time-based-views-contract.test.cjs tests/metric-qualifier-parity.test.cjs tests/requested-metrics-contract.test.cjs tests/auto-breakdown-negotiation-contract.test.cjs tests/format-delivery-reporting-contract.test.cjs tests/inventory-delivery-reporting-contract.test.cjs tests/property-delivery-conformance.test.cjs tests/lint-schema-enum-drift.test.cjs tests/format-asset-type-parity.test.cjs tests/canonical-manifest-constructibility.test.cjs tests/synthetic-depiction.test.cjs tests/creative-rendering-authority.test.cjs tests/buyer-reason.test.cjs tests/error-recovery-vectors.test.cjs tests/reporting-status-contract.test.cjs tests/reporting-reconciliation-fixture.test.cjs tests/reporting-core-fixture.test.cjs tests/reporting-native-version-ref-bounds.test.cjs tests/definition-pin-vectors.test.cjs tests/trust-json-v1.test.cjs tests/refine-proposals-expected-confirmation-at.test.cjs tests/hold-policy.test.cjs tests/hold-policy-vectors.test.cjs tests/collection-catalog.test.cjs tests/collection-copies-vectors.test.cjs && npm run test:premium-display-formats && npm run test:geo-region-targeting", "test:performance-feedback": "node --test --test-force-exit --test-timeout=30000 tests/performance-feedback-contract.test.cjs", "test:dist-schema-version-ids": "node --test --test-force-exit --test-timeout=30000 tests/dist-schema-version-ids.test.cjs", "test:examples": "node tests/example-validation-simple.test.cjs && npm run test:tmp-context-merge", diff --git a/scripts/build-schemas.cjs b/scripts/build-schemas.cjs index ed4b91e747a..9dc79b6355e 100644 --- a/scripts/build-schemas.cjs +++ b/scripts/build-schemas.cjs @@ -101,6 +101,7 @@ const MCP_ROLE_PROFILE_TOOLS = { 'get_account_financials', 'get_adcp_capabilities', 'get_creative_delivery', + 'get_creative_addons', 'get_creative_features', 'get_task_status', 'list_accounts', @@ -2442,6 +2443,7 @@ function generateMcpProjectionForVersion(versionDir, urlVersion) { canonicalManifest.adcp_version, ...Object.values(canonicalManifest.tools || {}).map(tool => tool.added_in).filter(Boolean), ].filter(version => semver.valid(version)).sort(semver.rcompare)[0]; + const surfaceReleaseLine = `${semver.major(surfaceVersion)}.${semver.minor(surfaceVersion)}`; const isActiveProductionTool = tool => ( tool.protocol !== 'compliance' && (!tool.added_in || semver.lte(tool.added_in, surfaceVersion)) @@ -2503,12 +2505,12 @@ function generateMcpProjectionForVersion(versionDir, urlVersion) { profile: profileName, profile_kind: 'active-role-catalog', surface_version: surfaceVersion, - compatibility_scope: 'active-3.2-only', + compatibility_scope: `active-${surfaceReleaseLine}-only`, filters: { include_tools: toolNames, exclude_deprecated: true, }, - delivery: 'active 3.2 role-filtered discovery inputs and response schemas; canonical wire schemas remain the call-validation authority', + delivery: `active ${surfaceReleaseLine} role-filtered discovery inputs and response schemas; canonical wire schemas remain the call-validation authority`, complete_discovery_projection: '../../manifest.json', canonical_wire_manifest: '../../../../manifest.json', }, @@ -2534,7 +2536,7 @@ function generateMcpProjectionForVersion(versionDir, urlVersion) { profile_kind: 'active-role-catalog', view: 'client-prompt-inputs', surface_version: surfaceVersion, - compatibility_scope: 'active-3.2-only', + compatibility_scope: `active-${surfaceReleaseLine}-only`, filters: { include_tools: toolNames, exclude_deprecated: true, diff --git a/scripts/generate-creative-addon-vectors.cjs b/scripts/generate-creative-addon-vectors.cjs new file mode 100644 index 00000000000..e134e8cb8de --- /dev/null +++ b/scripts/generate-creative-addon-vectors.cjs @@ -0,0 +1,131 @@ +#!/usr/bin/env node +// Deterministic public conformance data. RFC 8032 test key; NEVER use for production. +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); +const canonicalize = require('canonicalize').default; +const output = path.join(__dirname, '../static/compliance/source/test-vectors/creative-addon-chain.json'); +const sha = value => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +const bytes = value => Buffer.from(canonicalize(value), 'utf8'); +const encode = value => bytes(value).toString('base64url'); +const key = { kid: 'addon-vector-key', kty: 'OKP', crv: 'Ed25519', alg: 'EdDSA', use: 'sig', + key_ops: ['verify'], adcp_use: 'addon-chain-signing', + x: Buffer.from('d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a', 'hex').toString('base64url') }; +const privateKey = { kty: key.kty, crv: key.crv, x: key.x, + d: Buffer.from('9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60', 'hex').toString('base64url') }; +const privateObject = crypto.createPrivateKey({ key: privateKey, format: 'jwk' }); +const timestamp = '2026-10-14T12:00:00Z'; +const iat = Date.parse(timestamp) / 1000; +function sign(payload, header = { alg: 'EdDSA', kid: key.kid, typ: 'adcp-addon-chain+jws' }, canonical = true) { + const protectedHeader = encode(header); + const payloadEncoded = canonical ? encode(payload) : Buffer.from(JSON.stringify(payload, null, 2)).toString('base64url'); + return { profile: 'adcp/addon-chain/v1', protected: protectedHeader, payload: payloadEncoded, + signature: crypto.sign(null, Buffer.from(`${protectedHeader}.${payloadEncoded}`), privateObject).toString('base64url') }; +} +const fragment = ''; +const fragmentHash = sha(Buffer.from(fragment, 'utf8')); +const contexts = [ + { format_kind: 'image', content_hash: sha(Buffer.from('89504e470d0a1a0a', 'hex')) }, + { format_kind: 'html5', content_type: 'text/html', content_hash: sha(Buffer.from('

Creative

', 'utf8')) }, + { format_kind: 'display_tag', asset: { asset_type: 'display_tag', delivery_type: 'inline_markup', markup_type: 'javascript', markup: '' } }, +]; +const valid = contexts.map((creative_hash_input, i) => { + const source = i === 1 ? 'static_reference' : 'live'; + const vendor = { agent_name: 'measure', source, mode: i === 2 ? 'best_effort' : 'required', + format_kinds: ['image', 'html5', 'display_tag'], positions: ['append'], + ...(source === 'live' ? { agent_url: 'https://measurement.example/agent' } : { static_content_hash: fragmentHash }), + ...(i === 2 ? {} : { max_hold_seconds: 60 }) }; + const configuration = { scope: 'product', product_id: 'display', complete: true, signed_chain_profile: 'adcp/addon-chain/v1', vendors: [vendor] }; + const entry = { order: 0, registration_index: 0, agent_name: vendor.agent_name, source, + ...(source === 'live' ? { agent_url: vendor.agent_url } : {}), timestamp, + ...(i === 2 ? { status: 'omitted', reason: 'vendor_omission' } : { status: 'applied', position: 'append', fragment_index: 0, content_hash: fragmentHash, bytes: Buffer.byteLength(fragment) }) }; + const expected = { agent_url: 'https://sales.example/agent', publisher_domain: 'publisher.example', + creative_id: `creative-${i}`, format_kind: creative_hash_input.format_kind, variant_id: `variant-${i}`, + media_buy_id: 'buy-1', package_id: 'package-1', revision_id: 'revision-1', product_id: 'display', + creative_hash: sha(bytes(creative_hash_input)) }; + const chain = { creative_id: expected.creative_id, creative_hash: expected.creative_hash, configuration, entries: [entry], chain_hash: sha(bytes([entry])) }; + const { product_id, ...covered } = expected; + const claims = { typ: 'adcp-addon-chain+jws', ...covered, chain_hash: chain.chain_hash, configuration_hash: sha(bytes(configuration)), iat }; + chain.signed_attestation = sign(claims); + return { id: ['live-image', 'static-html5', 'omitted-display-tag'][i], creative_hash_input, fragment, fragment_hash: fragmentHash, chain, + trust: { key: structuredClone(key), publisher_pin: structuredClone(key), expected, now: iat + 30 } }; +}); +const invalid = []; +function negative(id, expected, mutate, base = 0) { + const changed = structuredClone(valid[base]); mutate(changed); + const edits = []; + function diff(a,b,at='') { + for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) { + const location = at ? `${at}.${k}` : k; + if (!(k in b)) { edits.push({ path: location, remove: true }); continue; } + if (!(k in a)) { edits.push({ path: location, value: b[k] }); continue; } + if (a[k] && b[k] && typeof a[k] === 'object' && typeof b[k] === 'object') diff(a[k], b[k], location); + else if (a[k] !== b[k]) edits.push({ path: location, value: b[k] }); + } + } + diff(valid[base], changed);invalid.push({ id, base, edits, expected }); +} +function resign(vector, mutateClaims = () => {}) { + const claims = JSON.parse(Buffer.from(vector.chain.signed_attestation.payload, 'base64url')); + mutateClaims(claims); + vector.chain.signed_attestation = sign(claims); +} +negative('altered-fragment-hash', 'chain_hash', v => { v.chain.entries[0].content_hash = `sha256:${'0'.repeat(64)}`; }); +negative('altered-configuration', 'configuration_hash', v => { v.chain.configuration.vendors[0].mode = 'best_effort'; }); +negative('wrong-key-purpose', 'key_purpose', v => { v.trust.key.adcp_use = 'request-signing'; }); +negative('same-kid-wrong-publisher-material', 'publisher_pin', v => { v.trust.publisher_pin.x = 'A'.repeat(43); }); +negative('wrong-variant', 'context', v => { v.trust.expected.variant_id = 'another-variant'; }); +negative('wrong-publisher', 'context', v => { v.trust.expected.publisher_domain = 'other.example'; }); +negative('wrong-creative', 'creative_hash', v => { v.trust.expected.creative_hash = `sha256:${'0'.repeat(64)}`; }); +negative('wrong-revision', 'context', v => { v.trust.expected.revision_id = 'revision-2'; }); +negative('missing-delivery-binding', 'context', v => { resign(v, p => delete p.package_id); }); +negative('jws-key-url-not-a-trust-root', 'header', v => { const p=JSON.parse(Buffer.from(v.chain.signed_attestation.payload,'base64url'));v.chain.signed_attestation=sign(p,{alg:'EdDSA',kid:key.kid,typ:'adcp-addon-chain+jws',jku:'https://attacker.example/jwks'}); }); +negative('noncanonical-payload', 'canonicalization', v => { const p=JSON.parse(Buffer.from(v.chain.signed_attestation.payload,'base64url'));v.chain.signed_attestation=sign(p,undefined,false); }); +negative('corrupt-signature', 'signature', v => { v.chain.signed_attestation.signature = 'A'.repeat(86); }); +negative('padded-base64url', 'encoding', v => { v.chain.signed_attestation.protected += '='; }); +negative('future-issuance', 'time', v => resign(v, p => { p.iat += 120; })); +negative('revoked-at-issuance', 'revocation', v => { v.trust.key.revoked_at=timestamp; }); +negative('backdated-without-observed-history', 'revocation', v => { v.trust.key.revoked_at='2026-10-14T12:01:00Z';v.trust.now=iat+120; }); +negative('rewritten-observation-history', 'revocation', v => { v.trust.key.revoked_at='2026-10-14T12:01:00Z';v.trust.observed_at=iat+20;v.trust.observed_chain_hash=`sha256:${'0'.repeat(64)}`; }); +negative('signed-required-omission', 'required_omission', v => { v.chain.configuration.vendors[0].mode='required';v.chain.configuration.vendors[0].max_hold_seconds=60;resign(v,p=>{p.configuration_hash=sha(bytes(v.chain.configuration));}); },2); +negative('signed-registration-mismatch', 'entry', v => { v.chain.entries[0].agent_name='other-vendor';v.chain.chain_hash=sha(bytes(v.chain.entries));resign(v,p=>{p.chain_hash=v.chain.chain_hash;}); }); +negative('signed-invalid-order', 'entry', v => { v.chain.entries[0].order=1;v.chain.chain_hash=sha(bytes(v.chain.entries));resign(v,p=>{p.chain_hash=v.chain.chain_hash;}); }); +negative('publisher-pin-revoked-at-issuance', 'revocation', v => { v.trust.publisher_pin.revoked_at=timestamp; }); +negative('publisher-pin-purpose-conflict', 'publisher_pin', v => { v.trust.publisher_pin.adcp_use='request-signing'; }); +negative('signed-missing-byte-count', 'schema', v => { delete v.chain.entries[0].bytes;v.chain.chain_hash=sha(bytes(v.chain.entries));resign(v,p=>{p.chain_hash=v.chain.chain_hash;}); }); +negative('unknown-signed-claim', 'schema', v => { resign(v,p=>{p.unrecognized='claim';}); }); +negative('signed-fragment-index-gap', 'entry', v => { v.chain.entries[0].fragment_index=1;v.chain.chain_hash=sha(bytes(v.chain.entries));resign(v,p=>{p.chain_hash=v.chain.chain_hash;}); }); +negative('signed-reversed-omission-order', 'order', v => { + v.chain.configuration.vendors.push({...v.chain.configuration.vendors[0],agent_name:'second',agent_url:'https://second.example/agent'}); + const first=v.chain.entries[0]; + v.chain.entries=[{...first,order:0,registration_index:1,agent_name:'second',agent_url:'https://second.example/agent'},{...first,order:1}]; + v.chain.chain_hash=sha(bytes(v.chain.entries));resign(v,p=>{p.chain_hash=v.chain.chain_hash;p.configuration_hash=sha(bytes(v.chain.configuration));}); +},2); +negative('signed-duplicate-fragment-index-across-positions', 'entry', v => { + v.chain.configuration.vendors[0].positions=['prepend','append']; + v.chain.entries=[{...v.chain.entries[0],position:'prepend'},{...v.chain.entries[0],order:1,position:'append'}]; + v.chain.chain_hash=sha(bytes(v.chain.entries));resign(v,p=>{p.chain_hash=v.chain.chain_hash;p.configuration_hash=sha(bytes(v.chain.configuration));}); +}); +const historical = structuredClone(valid[0]);historical.id='historical-after-rotation-and-revocation';historical.trust.key.revoked_at='2026-10-14T12:01:00Z';historical.trust.observed_at=iat+20;historical.trust.observed_chain_hash=historical.chain.chain_hash;historical.trust.observed_attestation_hash=sha(bytes(historical.chain.signed_attestation));historical.trust.now=iat+86400;valid.push(historical); +const required = { source: 'live', mode: 'required', max_hold_seconds: 60, fragments: [] }; +const behavior = [ + { id:'required-empty-keeps-original-task', input:{async:true,task_id:'task-1',elapsed_seconds:59,vendors:[required]},expected:{status:'working',task_id:'task-1'} }, + { id:'required-empty-at-deadline-fails', input:{async:true,task_id:'task-1',elapsed_seconds:60,vendors:[required]},expected:{status:'failed',code:'ADDON_REQUIRED_UNAVAILABLE'} }, + { id:'required-without-async-fails', input:{async:false,task_id:'task-1',elapsed_seconds:0,vendors:[required]},expected:{status:'failed',code:'ADDON_REQUIRED_UNAVAILABLE'} }, + { id:'best-effort-deliberate-omission',input:{vendors:[{...required,mode:'best_effort'}]},expected:{status:'completed',fragments:[],omitted:[{registration_index:0,reason:'vendor_omission'}]} }, + { id:'best-effort-network-failure',input:{vendors:[{...required,mode:'best_effort',error_code:'ADDON_UNREACHABLE'}]},expected:{status:'completed',fragments:[],omitted:[{registration_index:0,reason:'failure'}]} }, + { id:'required-bad-hash-fails',input:{async:false,elapsed_seconds:0,vendors:[{...required,fragments:[{position:'append',content:'x',content_hash:`sha256:${'0'.repeat(64)}`}]}]},expected:{status:'failed',code:'ADDON_REQUIRED_UNAVAILABLE'} }, + { id:'static-does-not-call-vendor',input:{vendors:[{source:'static_reference',mode:'required',position:'append',content:'static'}]},expected:{status:'completed',fragments:['static'],omitted:[]} }, + { id:'prepend-order-is-not-reversed',input:{vendors:[{source:'live',mode:'required',fragments:[{position:'append',content:'A1',content_hash:sha('A1')},{position:'prepend',content:'A0',content_hash:sha('A0')}]},{source:'live',mode:'required',fragments:[{position:'prepend',content:'B0',content_hash:sha('B0')},{position:'append',content:'B1',content_hash:sha('B1')}]}]},expected:{status:'completed',fragments:['A0','B0','A1','B1'],omitted:[]} } +]; +behavior.push( + { id:'late-recovery-cannot-resume-expired-operation',input:{async:true,task_id:'task-1',elapsed_seconds:61,vendors:[{...required,first_failure_seconds:0,fragments:[{position:'append',content:'repaired',content_hash:sha('repaired')}]}]},expected:{status:'failed',code:'ADDON_REQUIRED_UNAVAILABLE'} }, + { id:'later-expired-vendor-overrides-earlier-pending',input:{async:true,task_id:'task-1',elapsed_seconds:30,vendors:[required,{...required,max_hold_seconds:10}]},expected:{status:'failed',code:'ADDON_REQUIRED_UNAVAILABLE'} }, + { id:'per-vendor-original-deadline-survives-restart',input:{async:true,task_id:'task-1',elapsed_seconds:90,vendors:[{...required,first_failure_seconds:30,max_hold_seconds:60},{...required,first_failure_seconds:70,max_hold_seconds:60}]},expected:{status:'failed',code:'ADDON_REQUIRED_UNAVAILABLE'} }, + { id:'later-first-failure-keeps-its-own-deadline',input:{async:true,task_id:'task-1',elapsed_seconds:90,vendors:[{...required,first_failure_seconds:50,max_hold_seconds:60}]},expected:{status:'working',task_id:'task-1'} }, + { id:'recovered-earlier-vendor-cannot-mask-expired-other-vendor',input:{async:true,task_id:'task-1',elapsed_seconds:30,vendors:[{...required,fragments:[{position:'append',content:'repaired',content_hash:sha('repaired')}]},{...required,max_hold_seconds:10}]},expected:{status:'failed',code:'ADDON_REQUIRED_UNAVAILABLE'} } +); +const fixture = { $schema:'creative-addon-chain.schema.json', profile:'adcp/addon-chain/v1', note:'Public RFC 8032 test key only. Reference vectors are not production integration evidence.',test_private_key:privateKey,valid,invalid,behavior }; +const serialized=JSON.stringify(fixture,null,2)+'\n'; +if(process.argv.includes('--check')) { if(!fs.existsSync(output)||fs.readFileSync(output,'utf8')!==serialized) { console.error('Creative addon vectors are stale');process.exitCode=1; } else console.log('Creative addon vectors are reproducible'); } +else { fs.writeFileSync(output,serialized);console.log(`Wrote ${valid.length} valid, ${invalid.length} invalid, and ${behavior.length} behavior vectors`); } diff --git a/server/src/services/adcp-taxonomy.ts b/server/src/services/adcp-taxonomy.ts index edc8d14682c..9f4b8e5ac50 100644 --- a/server/src/services/adcp-taxonomy.ts +++ b/server/src/services/adcp-taxonomy.ts @@ -103,6 +103,7 @@ export type AdcpSpecialism = | 'collection-lists' | 'content-standards' | 'creative-ad-server' + | 'creative-addon' | 'creative-generative' | 'creative-template' | 'creative-transformers' @@ -137,6 +138,7 @@ export const ADCP_SPECIALISMS: readonly AdcpSpecialism[] = [ 'collection-lists', 'content-standards', 'creative-ad-server', + 'creative-addon', 'creative-generative', 'creative-template', 'creative-transformers', diff --git a/server/src/training-agent/idempotency.ts b/server/src/training-agent/idempotency.ts index dcc99c14107..2c9e49b8369 100644 --- a/server/src/training-agent/idempotency.ts +++ b/server/src/training-agent/idempotency.ts @@ -95,6 +95,7 @@ export const MUTATING_TOOLS: ReadonlySet = new Set([ 'delete_collection_list', 'delete_property_list', 'decline_proposals', + 'get_creative_addons', 'log_event', 'provide_performance_feedback', 'request_proposals', diff --git a/server/tests/unit/adcp-taxonomy.test.ts b/server/tests/unit/adcp-taxonomy.test.ts index 703765ca6f5..e6827fa67ca 100644 --- a/server/tests/unit/adcp-taxonomy.test.ts +++ b/server/tests/unit/adcp-taxonomy.test.ts @@ -46,6 +46,8 @@ describe('specialism status', () => { it('respects preview status from the compliance catalog', () => { expect(getSpecialismStatus('sponsored-intelligence')).toBe('preview'); expect(isStableSpecialism('sponsored-intelligence')).toBe(false); + expect(getSpecialismStatus('creative-addon')).toBe('preview'); + expect(isStableSpecialism('creative-addon')).toBe(false); }); it('treats unknown specialisms as stable (safe default)', () => { diff --git a/server/tests/unit/idempotency.test.ts b/server/tests/unit/idempotency.test.ts index 7995f756fe9..5adfdf19a92 100644 --- a/server/tests/unit/idempotency.test.ts +++ b/server/tests/unit/idempotency.test.ts @@ -76,6 +76,7 @@ describe('idempotency facade', () => { 'request_proposals', 'refine_proposals', 'decline_proposals', 'si_initiate_session', 'si_send_message', 'acquire_rights', 'update_rights', 'creative_approval', + 'get_creative_addons', ]) { expect(isMutatingTool(name), `${name} should be mutating`).toBe(true); } diff --git a/static/compliance/source/specialisms/creative-addon/index.yaml b/static/compliance/source/specialisms/creative-addon/index.yaml new file mode 100644 index 00000000000..1cfa2442c5d --- /dev/null +++ b/static/compliance/source/specialisms/creative-addon/index.yaml @@ -0,0 +1,119 @@ +id: creative_addon +version: "1.0.0" +title: "Creative addon vendor" +protocol: creative +category: creative_addon +summary: "Fragment-only vendor discovery and operation-scoped generation for seller-controlled adjacent execution." +status: preview +track: creative +required_tools: + - get_adcp_capabilities + - get_creative_addons + +narrative: | + A Sales Agent asks a registered addon vendor for fragments using creative + context only. The vendor has no buyer asset or markup. This specialism checks + the vendor wire contract; it does not certify a seller's assembly runtime, + key resolution, signing implementation, or spend-commitment atomicity. + Seller signing and failure cases are published separately in + test-vectors/creative-addon-chain.json. A named seller/buyer integration is + still a release gate for the proposed 3.3 feature. + +agent: + interaction_model: stateless_transform + capabilities: + - creative_addon +caller: + role: sales_agent + example: "Example Sales" +prerequisites: + description: | + Use an authenticated operator identity authorized to request fragments. + The example image context must be supported by the registered vendor. + Vendors supporting only another declared format substitute that format + when exercising the same contract. No buyer creative payload is supplied. + +phases: + - id: discovery + title: "Discover the vendor contract" + steps: + - id: capabilities + title: "Verify addon capability and mandatory signing profile" + task: get_adcp_capabilities + schema_ref: "protocol/get-adcp-capabilities-request.json" + response_schema_ref: "protocol/get-adcp-capabilities-response.json" + doc_ref: "/protocol/get_adcp_capabilities" + stateful: false + expected: "Declare the creative protocol and creative-addon specialism, with the mandatory signed-chain profile." + sample_request: + context: + correlation_id: "creative_addon--capabilities" + validations: + - check: response_schema + description: "Capability response conforms to the public schema" + - check: field_contains + path: "supported_protocols[*]" + value: "creative" + description: "Vendor participates in the creative protocol" + - check: field_contains + path: "specialisms[*]" + value: "creative-addon" + description: "Vendor claims fragment-only addon support" + - check: field_present + path: "agent_name" + description: "Operator can check the declared vendor identity" + - check: field_value + path: "creative_addon.signed_chain.required" + value: true + description: "Signed assembly evidence is mandatory" + - check: field_value + path: "creative_addon.signed_chain.profile" + value: "adcp/addon-chain/v1" + description: "Vendor declares the supported signature profile" + - check: field_value + path: "context.correlation_id" + value: "creative_addon--capabilities" + description: "Caller context is echoed unchanged" + - id: fragments + title: "Request operation-scoped fragments" + steps: + - id: image_context + title: "Return fragments without receiving creative content" + task: get_creative_addons + schema_ref: "creative/get-creative-addons-request.json" + response_schema_ref: "creative/get-creative-addons-response.json" + doc_ref: "/creative/task-reference/get_creative_addons" + stateful: true + expected: | + Return a completed addons response. An empty array is a deliberate + vendor omission; seller-required handling is exercised separately. + Every returned creative_id must be creative-1, and every fragment's + content_hash must hash its exact UTF-8 content. The automated checks + below cover wire shape; membership and hash checks need the seller + integration evidence and are not claimed by response_schema. + sample_request: + adcp_version: "3.3" + idempotency_key: "$generate:uuid_v4#creative_addon_fragments" + operator_name: "Example Sales" + operator_url: "https://sales.example/agent" + publisher_domain: "publisher.example" + creatives: + - creative_id: "creative-1" + format_kind: "image" + dimensions: { w: 300, h: 250 } + context: + correlation_id: "creative_addon--fragments" + validations: + - check: response_schema + description: "Fragment response conforms to the public schema" + - check: field_value + path: "status" + value: "completed" + description: "The vendor completed this batch" + - check: field_present + path: "addons" + description: "The completed response includes the fragments or an explicit empty array" + - check: field_value + path: "context.correlation_id" + value: "creative_addon--fragments" + description: "Caller context is echoed unchanged" diff --git a/static/compliance/source/test-vectors/creative-addon-chain.json b/static/compliance/source/test-vectors/creative-addon-chain.json new file mode 100644 index 00000000000..bbd887cb6ea --- /dev/null +++ b/static/compliance/source/test-vectors/creative-addon-chain.json @@ -0,0 +1,1220 @@ +{ + "$schema": "creative-addon-chain.schema.json", + "profile": "adcp/addon-chain/v1", + "note": "Public RFC 8032 test key only. Reference vectors are not production integration evidence.", + "test_private_key": { + "kty": "OKP", + "crv": "Ed25519", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "d": "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A" + }, + "valid": [ + { + "id": "live-image", + "creative_hash_input": { + "format_kind": "image", + "content_hash": "sha256:4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6" + }, + "fragment": "", + "fragment_hash": "sha256:1a80add994605ab8ceebe062b3a2014ab0170388bb12bbfcee4f276154622a39", + "chain": { + "creative_id": "creative-0", + "creative_hash": "sha256:749731ec1830ea3a59a517479b60ae955b54929fb38ebe2b81a444b7a4bc388d", + "configuration": { + "scope": "product", + "product_id": "display", + "complete": true, + "signed_chain_profile": "adcp/addon-chain/v1", + "vendors": [ + { + "agent_name": "measure", + "source": "live", + "mode": "required", + "format_kinds": [ + "image", + "html5", + "display_tag" + ], + "positions": [ + "append" + ], + "agent_url": "https://measurement.example/agent", + "max_hold_seconds": 60 + } + ] + }, + "entries": [ + { + "order": 0, + "registration_index": 0, + "agent_name": "measure", + "source": "live", + "agent_url": "https://measurement.example/agent", + "timestamp": "2026-10-14T12:00:00Z", + "status": "applied", + "position": "append", + "fragment_index": 0, + "content_hash": "sha256:1a80add994605ab8ceebe062b3a2014ab0170388bb12bbfcee4f276154622a39", + "bytes": 61 + } + ], + "chain_hash": "sha256:42781004a452d65343e590f72c4c47dc431baec0cbacbbc19b3e5adc40a85c15", + "signed_attestation": { + "profile": "adcp/addon-chain/v1", + "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImFkZG9uLXZlY3Rvci1rZXkiLCJ0eXAiOiJhZGNwLWFkZG9uLWNoYWluK2p3cyJ9", + "payload": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjQyNzgxMDA0YTQ1MmQ2NTM0M2U1OTBmNzJjNGM0N2RjNDMxYmFlYzBjYmFjYmJjMTliM2U1YWRjNDBhODVjMTUiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMCJ9", + "signature": "UvuimFPxx79MVfKbPEd6DvXvqGHGK26FpMuD5kQ2rM8vw4qXKgxtaUItx1N2pJyHoxD2KjMXYjdc0RJnq4JLDw" + } + }, + "trust": { + "key": { + "kid": "addon-vector-key", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "addon-chain-signing", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "publisher_pin": { + "kid": "addon-vector-key", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "addon-chain-signing", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "expected": { + "agent_url": "https://sales.example/agent", + "publisher_domain": "publisher.example", + "creative_id": "creative-0", + "format_kind": "image", + "variant_id": "variant-0", + "media_buy_id": "buy-1", + "package_id": "package-1", + "revision_id": "revision-1", + "product_id": "display", + "creative_hash": "sha256:749731ec1830ea3a59a517479b60ae955b54929fb38ebe2b81a444b7a4bc388d" + }, + "now": 1791979230 + } + }, + { + "id": "static-html5", + "creative_hash_input": { + "format_kind": "html5", + "content_type": "text/html", + "content_hash": "sha256:e52b8569e88070ceab86c5dedcc82d01df6ab3211ebabcc88a1940dc84a01207" + }, + "fragment": "", + "fragment_hash": "sha256:1a80add994605ab8ceebe062b3a2014ab0170388bb12bbfcee4f276154622a39", + "chain": { + "creative_id": "creative-1", + "creative_hash": "sha256:57ce6fe02588e83b2a80eb153bf7d29419c18539f32de89f9e36e03ff0871eb5", + "configuration": { + "scope": "product", + "product_id": "display", + "complete": true, + "signed_chain_profile": "adcp/addon-chain/v1", + "vendors": [ + { + "agent_name": "measure", + "source": "static_reference", + "mode": "required", + "format_kinds": [ + "image", + "html5", + "display_tag" + ], + "positions": [ + "append" + ], + "static_content_hash": "sha256:1a80add994605ab8ceebe062b3a2014ab0170388bb12bbfcee4f276154622a39", + "max_hold_seconds": 60 + } + ] + }, + "entries": [ + { + "order": 0, + "registration_index": 0, + "agent_name": "measure", + "source": "static_reference", + "timestamp": "2026-10-14T12:00:00Z", + "status": "applied", + "position": "append", + "fragment_index": 0, + "content_hash": "sha256:1a80add994605ab8ceebe062b3a2014ab0170388bb12bbfcee4f276154622a39", + "bytes": 61 + } + ], + "chain_hash": "sha256:7dae8229ac74444076a764412bb013bce1963d8613bdcb0f403488f000624977", + "signed_attestation": { + "profile": "adcp/addon-chain/v1", + "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImFkZG9uLXZlY3Rvci1rZXkiLCJ0eXAiOiJhZGNwLWFkZG9uLWNoYWluK2p3cyJ9", + "payload": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjdkYWU4MjI5YWM3NDQ0NDA3NmE3NjQ0MTJiYjAxM2JjZTE5NjNkODYxM2JkY2IwZjQwMzQ4OGYwMDA2MjQ5NzciLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6M2U1OGYwYjNlZmJlMzkxMDYyMWY0NWY1MzU1YmU4MWFkNjVlNzIzYzc1MDgyMjM0MmZjMTI3MDNlYTBiNDBmMiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NTdjZTZmZTAyNTg4ZTgzYjJhODBlYjE1M2JmN2QyOTQxOWMxODUzOWYzMmRlODlmOWUzNmUwM2ZmMDg3MWViNSIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMSIsImZvcm1hdF9raW5kIjoiaHRtbDUiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMSJ9", + "signature": "cnEP9Eycpgb-Jur3W-yoC66Ca8HHd6Ut7Sk6HcalfiUIq3-qCO6__rRGeOyK7lmAYUjE9C9GFKXbW8HQX9x_DA" + } + }, + "trust": { + "key": { + "kid": "addon-vector-key", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "addon-chain-signing", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "publisher_pin": { + "kid": "addon-vector-key", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "addon-chain-signing", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "expected": { + "agent_url": "https://sales.example/agent", + "publisher_domain": "publisher.example", + "creative_id": "creative-1", + "format_kind": "html5", + "variant_id": "variant-1", + "media_buy_id": "buy-1", + "package_id": "package-1", + "revision_id": "revision-1", + "product_id": "display", + "creative_hash": "sha256:57ce6fe02588e83b2a80eb153bf7d29419c18539f32de89f9e36e03ff0871eb5" + }, + "now": 1791979230 + } + }, + { + "id": "omitted-display-tag", + "creative_hash_input": { + "format_kind": "display_tag", + "asset": { + "asset_type": "display_tag", + "delivery_type": "inline_markup", + "markup_type": "javascript", + "markup": "" + } + }, + "fragment": "", + "fragment_hash": "sha256:1a80add994605ab8ceebe062b3a2014ab0170388bb12bbfcee4f276154622a39", + "chain": { + "creative_id": "creative-2", + "creative_hash": "sha256:01c38412f035a6c4b8db2519b705aeef63d7bc7ca832675c96cbd0487398faa2", + "configuration": { + "scope": "product", + "product_id": "display", + "complete": true, + "signed_chain_profile": "adcp/addon-chain/v1", + "vendors": [ + { + "agent_name": "measure", + "source": "live", + "mode": "best_effort", + "format_kinds": [ + "image", + "html5", + "display_tag" + ], + "positions": [ + "append" + ], + "agent_url": "https://measurement.example/agent" + } + ] + }, + "entries": [ + { + "order": 0, + "registration_index": 0, + "agent_name": "measure", + "source": "live", + "agent_url": "https://measurement.example/agent", + "timestamp": "2026-10-14T12:00:00Z", + "status": "omitted", + "reason": "vendor_omission" + } + ], + "chain_hash": "sha256:c4b6cc12dd74b8bc0aa97661fc7fcb180f84fcea8b5fe37d6fb3414a981f614b", + "signed_attestation": { + "profile": "adcp/addon-chain/v1", + "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImFkZG9uLXZlY3Rvci1rZXkiLCJ0eXAiOiJhZGNwLWFkZG9uLWNoYWluK2p3cyJ9", + "payload": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OmM0YjZjYzEyZGQ3NGI4YmMwYWE5NzY2MWZjN2ZjYjE4MGY4NGZjZWE4YjVmZTM3ZDZmYjM0MTRhOTgxZjYxNGIiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6YmUxODNjODA1OTI0NmRiNTYyZDg4NDZiZTAzZDE5MjE0NjBlYzkwNTJjMTExOTA2MDUyZTc1YzIyMWNlYmJkOSIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6MDFjMzg0MTJmMDM1YTZjNGI4ZGIyNTE5YjcwNWFlZWY2M2Q3YmM3Y2E4MzI2NzVjOTZjYmQwNDg3Mzk4ZmFhMiIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMiIsImZvcm1hdF9raW5kIjoiZGlzcGxheV90YWciLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMiJ9", + "signature": "8Ex1UrQzVbr7pXjsTcF-NtCjglqCwLp08sa4apPAZ_RmnLNH7MGfFlhKXuA8wqadeXhXvQ_BvJ02i16sqbgEDg" + } + }, + "trust": { + "key": { + "kid": "addon-vector-key", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "addon-chain-signing", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "publisher_pin": { + "kid": "addon-vector-key", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "addon-chain-signing", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "expected": { + "agent_url": "https://sales.example/agent", + "publisher_domain": "publisher.example", + "creative_id": "creative-2", + "format_kind": "display_tag", + "variant_id": "variant-2", + "media_buy_id": "buy-1", + "package_id": "package-1", + "revision_id": "revision-1", + "product_id": "display", + "creative_hash": "sha256:01c38412f035a6c4b8db2519b705aeef63d7bc7ca832675c96cbd0487398faa2" + }, + "now": 1791979230 + } + }, + { + "id": "historical-after-rotation-and-revocation", + "creative_hash_input": { + "format_kind": "image", + "content_hash": "sha256:4c4b6a3be1314ab86138bef4314dde022e600960d8689a2c8f8631802d20dab6" + }, + "fragment": "", + "fragment_hash": "sha256:1a80add994605ab8ceebe062b3a2014ab0170388bb12bbfcee4f276154622a39", + "chain": { + "creative_id": "creative-0", + "creative_hash": "sha256:749731ec1830ea3a59a517479b60ae955b54929fb38ebe2b81a444b7a4bc388d", + "configuration": { + "scope": "product", + "product_id": "display", + "complete": true, + "signed_chain_profile": "adcp/addon-chain/v1", + "vendors": [ + { + "agent_name": "measure", + "source": "live", + "mode": "required", + "format_kinds": [ + "image", + "html5", + "display_tag" + ], + "positions": [ + "append" + ], + "agent_url": "https://measurement.example/agent", + "max_hold_seconds": 60 + } + ] + }, + "entries": [ + { + "order": 0, + "registration_index": 0, + "agent_name": "measure", + "source": "live", + "agent_url": "https://measurement.example/agent", + "timestamp": "2026-10-14T12:00:00Z", + "status": "applied", + "position": "append", + "fragment_index": 0, + "content_hash": "sha256:1a80add994605ab8ceebe062b3a2014ab0170388bb12bbfcee4f276154622a39", + "bytes": 61 + } + ], + "chain_hash": "sha256:42781004a452d65343e590f72c4c47dc431baec0cbacbbc19b3e5adc40a85c15", + "signed_attestation": { + "profile": "adcp/addon-chain/v1", + "protected": "eyJhbGciOiJFZERTQSIsImtpZCI6ImFkZG9uLXZlY3Rvci1rZXkiLCJ0eXAiOiJhZGNwLWFkZG9uLWNoYWluK2p3cyJ9", + "payload": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjQyNzgxMDA0YTQ1MmQ2NTM0M2U1OTBmNzJjNGM0N2RjNDMxYmFlYzBjYmFjYmJjMTliM2U1YWRjNDBhODVjMTUiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMCJ9", + "signature": "UvuimFPxx79MVfKbPEd6DvXvqGHGK26FpMuD5kQ2rM8vw4qXKgxtaUItx1N2pJyHoxD2KjMXYjdc0RJnq4JLDw" + } + }, + "trust": { + "key": { + "kid": "addon-vector-key", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "addon-chain-signing", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", + "revoked_at": "2026-10-14T12:01:00Z" + }, + "publisher_pin": { + "kid": "addon-vector-key", + "kty": "OKP", + "crv": "Ed25519", + "alg": "EdDSA", + "use": "sig", + "key_ops": [ + "verify" + ], + "adcp_use": "addon-chain-signing", + "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo" + }, + "expected": { + "agent_url": "https://sales.example/agent", + "publisher_domain": "publisher.example", + "creative_id": "creative-0", + "format_kind": "image", + "variant_id": "variant-0", + "media_buy_id": "buy-1", + "package_id": "package-1", + "revision_id": "revision-1", + "product_id": "display", + "creative_hash": "sha256:749731ec1830ea3a59a517479b60ae955b54929fb38ebe2b81a444b7a4bc388d" + }, + "now": 1792065600, + "observed_at": 1791979220, + "observed_chain_hash": "sha256:42781004a452d65343e590f72c4c47dc431baec0cbacbbc19b3e5adc40a85c15", + "observed_attestation_hash": "sha256:2946301a346536615159adc3ad2fd41951a8f342cd3a1d2da2263ed46fee61fe" + } + } + ], + "invalid": [ + { + "id": "altered-fragment-hash", + "base": 0, + "edits": [ + { + "path": "chain.entries.0.content_hash", + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "expected": "chain_hash" + }, + { + "id": "altered-configuration", + "base": 0, + "edits": [ + { + "path": "chain.configuration.vendors.0.mode", + "value": "best_effort" + } + ], + "expected": "configuration_hash" + }, + { + "id": "wrong-key-purpose", + "base": 0, + "edits": [ + { + "path": "trust.key.adcp_use", + "value": "request-signing" + } + ], + "expected": "key_purpose" + }, + { + "id": "same-kid-wrong-publisher-material", + "base": 0, + "edits": [ + { + "path": "trust.publisher_pin.x", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ], + "expected": "publisher_pin" + }, + { + "id": "wrong-variant", + "base": 0, + "edits": [ + { + "path": "trust.expected.variant_id", + "value": "another-variant" + } + ], + "expected": "context" + }, + { + "id": "wrong-publisher", + "base": 0, + "edits": [ + { + "path": "trust.expected.publisher_domain", + "value": "other.example" + } + ], + "expected": "context" + }, + { + "id": "wrong-creative", + "base": 0, + "edits": [ + { + "path": "trust.expected.creative_hash", + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "expected": "creative_hash" + }, + { + "id": "wrong-revision", + "base": 0, + "edits": [ + { + "path": "trust.expected.revision_id", + "value": "revision-2" + } + ], + "expected": "context" + }, + { + "id": "missing-delivery-binding", + "base": 0, + "edits": [ + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjQyNzgxMDA0YTQ1MmQ2NTM0M2U1OTBmNzJjNGM0N2RjNDMxYmFlYzBjYmFjYmJjMTliM2U1YWRjNDBhODVjMTUiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicHVibGlzaGVyX2RvbWFpbiI6InB1Ymxpc2hlci5leGFtcGxlIiwicmV2aXNpb25faWQiOiJyZXZpc2lvbi0xIiwidHlwIjoiYWRjcC1hZGRvbi1jaGFpbitqd3MiLCJ2YXJpYW50X2lkIjoidmFyaWFudC0wIn0" + }, + { + "path": "chain.signed_attestation.signature", + "value": "kNpwmX7tiffEvX6CUThSAouShIRrTX_w2FLVjQUX5iCpR0qlHPcQ_zez9-zuJ6zH1rL7dFhnKq_avi3zbehiAw" + } + ], + "expected": "context" + }, + { + "id": "jws-key-url-not-a-trust-root", + "base": 0, + "edits": [ + { + "path": "chain.signed_attestation.protected", + "value": "eyJhbGciOiJFZERTQSIsImprdSI6Imh0dHBzOi8vYXR0YWNrZXIuZXhhbXBsZS9qd2tzIiwia2lkIjoiYWRkb24tdmVjdG9yLWtleSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIn0" + }, + { + "path": "chain.signed_attestation.signature", + "value": "je-zScRhk5j-LDjUFythR_WaaSkDfSIqLiS4Zc306AIsb0cSER-wqP19gHsZrXjqmCYOg2aegI8C86QV4Au6BA" + } + ], + "expected": "header" + }, + { + "id": "noncanonical-payload", + "base": 0, + "edits": [ + { + "path": "chain.signed_attestation.payload", + "value": "ewogICJhZ2VudF91cmwiOiAiaHR0cHM6Ly9zYWxlcy5leGFtcGxlL2FnZW50IiwKICAiY2hhaW5faGFzaCI6ICJzaGEyNTY6NDI3ODEwMDRhNDUyZDY1MzQzZTU5MGY3MmM0YzQ3ZGM0MzFiYWVjMGNiYWNiYmMxOWIzZTVhZGM0MGE4NWMxNSIsCiAgImNvbmZpZ3VyYXRpb25faGFzaCI6ICJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsCiAgImNyZWF0aXZlX2hhc2giOiAic2hhMjU2Ojc0OTczMWVjMTgzMGVhM2E1OWE1MTc0NzliNjBhZTk1NWI1NDkyOWZiMzhlYmUyYjgxYTQ0NGI3YTRiYzM4OGQiLAogICJjcmVhdGl2ZV9pZCI6ICJjcmVhdGl2ZS0wIiwKICAiZm9ybWF0X2tpbmQiOiAiaW1hZ2UiLAogICJpYXQiOiAxNzkxOTc5MjAwLAogICJtZWRpYV9idXlfaWQiOiAiYnV5LTEiLAogICJwYWNrYWdlX2lkIjogInBhY2thZ2UtMSIsCiAgInB1Ymxpc2hlcl9kb21haW4iOiAicHVibGlzaGVyLmV4YW1wbGUiLAogICJyZXZpc2lvbl9pZCI6ICJyZXZpc2lvbi0xIiwKICAidHlwIjogImFkY3AtYWRkb24tY2hhaW4randzIiwKICAidmFyaWFudF9pZCI6ICJ2YXJpYW50LTAiCn0" + }, + { + "path": "chain.signed_attestation.signature", + "value": "0ygZiUh8Gw_fwLofa2Q7XwYWyZxHqf0Dq_Wbj3qt-wlwNufFT5XcBNzxuisZAssrpv5qXyBRKAqiDSgbjlYhBw" + } + ], + "expected": "canonicalization" + }, + { + "id": "corrupt-signature", + "base": 0, + "edits": [ + { + "path": "chain.signed_attestation.signature", + "value": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + } + ], + "expected": "signature" + }, + { + "id": "padded-base64url", + "base": 0, + "edits": [ + { + "path": "chain.signed_attestation.protected", + "value": "eyJhbGciOiJFZERTQSIsImtpZCI6ImFkZG9uLXZlY3Rvci1rZXkiLCJ0eXAiOiJhZGNwLWFkZG9uLWNoYWluK2p3cyJ9=" + } + ], + "expected": "encoding" + }, + { + "id": "future-issuance", + "base": 0, + "edits": [ + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjQyNzgxMDA0YTQ1MmQ2NTM0M2U1OTBmNzJjNGM0N2RjNDMxYmFlYzBjYmFjYmJjMTliM2U1YWRjNDBhODVjMTUiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkzMjAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMCJ9" + }, + { + "path": "chain.signed_attestation.signature", + "value": "41FK4gHr4ehN8ieNLMkyr0nz3AzvY4VG6saDOGsgwmBspM1_wEB6f6AmtrUqT_hSN6ARJfvN-qMo8DrNBphLAA" + } + ], + "expected": "time" + }, + { + "id": "revoked-at-issuance", + "base": 0, + "edits": [ + { + "path": "trust.key.revoked_at", + "value": "2026-10-14T12:00:00Z" + } + ], + "expected": "revocation" + }, + { + "id": "backdated-without-observed-history", + "base": 0, + "edits": [ + { + "path": "trust.key.revoked_at", + "value": "2026-10-14T12:01:00Z" + }, + { + "path": "trust.now", + "value": 1791979320 + } + ], + "expected": "revocation" + }, + { + "id": "rewritten-observation-history", + "base": 0, + "edits": [ + { + "path": "trust.key.revoked_at", + "value": "2026-10-14T12:01:00Z" + }, + { + "path": "trust.observed_at", + "value": 1791979220 + }, + { + "path": "trust.observed_chain_hash", + "value": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "expected": "revocation" + }, + { + "id": "signed-required-omission", + "base": 2, + "edits": [ + { + "path": "chain.configuration.vendors.0.mode", + "value": "required" + }, + { + "path": "chain.configuration.vendors.0.max_hold_seconds", + "value": 60 + }, + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OmM0YjZjYzEyZGQ3NGI4YmMwYWE5NzY2MWZjN2ZjYjE4MGY4NGZjZWE4YjVmZTM3ZDZmYjM0MTRhOTgxZjYxNGIiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6MDFjMzg0MTJmMDM1YTZjNGI4ZGIyNTE5YjcwNWFlZWY2M2Q3YmM3Y2E4MzI2NzVjOTZjYmQwNDg3Mzk4ZmFhMiIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMiIsImZvcm1hdF9raW5kIjoiZGlzcGxheV90YWciLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMiJ9" + }, + { + "path": "chain.signed_attestation.signature", + "value": "8-WYqDhoPIMw53nB5xBolEHml_B-PD3CxOJcGWzKiI4dYdj56cgqH0_W-CbkUbYikO1iiC2tERw061XQunu_DQ" + } + ], + "expected": "required_omission" + }, + { + "id": "signed-registration-mismatch", + "base": 0, + "edits": [ + { + "path": "chain.entries.0.agent_name", + "value": "other-vendor" + }, + { + "path": "chain.chain_hash", + "value": "sha256:d3ead4079358d552a47c24dc3f9b3eafab74b8b72547abfcec0c468d94d25d98" + }, + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OmQzZWFkNDA3OTM1OGQ1NTJhNDdjMjRkYzNmOWIzZWFmYWI3NGI4YjcyNTQ3YWJmY2VjMGM0NjhkOTRkMjVkOTgiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMCJ9" + }, + { + "path": "chain.signed_attestation.signature", + "value": "xxAJc5KZXfCroenUGBeZiy94M35bGRjI4qu4UFp1HjD0Kn42Yfpz1pESc52KBxrGpWpJ0X2d05VB7SuPE0EdDQ" + } + ], + "expected": "entry" + }, + { + "id": "signed-invalid-order", + "base": 0, + "edits": [ + { + "path": "chain.entries.0.order", + "value": 1 + }, + { + "path": "chain.chain_hash", + "value": "sha256:c041763c53c4b16d434924d5871857a209f02a2cd332bf84627b595295240d85" + }, + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OmMwNDE3NjNjNTNjNGIxNmQ0MzQ5MjRkNTg3MTg1N2EyMDlmMDJhMmNkMzMyYmY4NDYyN2I1OTUyOTUyNDBkODUiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMCJ9" + }, + { + "path": "chain.signed_attestation.signature", + "value": "1wlUsByPkWwEdFNQ85ObtgJXX-HXFbbPCkEqJXgRpWJzOZoSIOE2NiipIGCq-qbOcHMBSDSkCtGzVSXV6dG6CA" + } + ], + "expected": "entry" + }, + { + "id": "publisher-pin-revoked-at-issuance", + "base": 0, + "edits": [ + { + "path": "trust.publisher_pin.revoked_at", + "value": "2026-10-14T12:00:00Z" + } + ], + "expected": "revocation" + }, + { + "id": "publisher-pin-purpose-conflict", + "base": 0, + "edits": [ + { + "path": "trust.publisher_pin.adcp_use", + "value": "request-signing" + } + ], + "expected": "publisher_pin" + }, + { + "id": "signed-missing-byte-count", + "base": 0, + "edits": [ + { + "path": "chain.entries.0.bytes", + "remove": true + }, + { + "path": "chain.chain_hash", + "value": "sha256:7ea0f8c6400acefdde46c0b5c0a7726170c4725461fb9c4d5760d207b53e827a" + }, + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjdlYTBmOGM2NDAwYWNlZmRkZTQ2YzBiNWMwYTc3MjYxNzBjNDcyNTQ2MWZiOWM0ZDU3NjBkMjA3YjUzZTgyN2EiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMCJ9" + }, + { + "path": "chain.signed_attestation.signature", + "value": "W9C-9S4uZdnK9uPgYlkh8-vbF2umuEPyUkIdkiQtUrlysDODVkwjtJN4n61jLGGky9T3Fm4VG8rV-mzqJlfhBA" + } + ], + "expected": "schema" + }, + { + "id": "unknown-signed-claim", + "base": 0, + "edits": [ + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjQyNzgxMDA0YTQ1MmQ2NTM0M2U1OTBmNzJjNGM0N2RjNDMxYmFlYzBjYmFjYmJjMTliM2U1YWRjNDBhODVjMTUiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidW5yZWNvZ25pemVkIjoiY2xhaW0iLCJ2YXJpYW50X2lkIjoidmFyaWFudC0wIn0" + }, + { + "path": "chain.signed_attestation.signature", + "value": "1MysOVMqWoB0ssw9Vu7EF4gtbnFwbf-JRFYbkqfMgQuEUJuJdBBNAdYVj0P68h2EWRw1qPMV6ykz7mVR1Rb1DA" + } + ], + "expected": "schema" + }, + { + "id": "signed-fragment-index-gap", + "base": 0, + "edits": [ + { + "path": "chain.entries.0.fragment_index", + "value": 1 + }, + { + "path": "chain.chain_hash", + "value": "sha256:64a3173f8ce60a786d957e38c11cd8a486f184069034da6ed99cfe318bc33a7c" + }, + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjY0YTMxNzNmOGNlNjBhNzg2ZDk1N2UzOGMxMWNkOGE0ODZmMTg0MDY5MDM0ZGE2ZWQ5OWNmZTMxOGJjMzNhN2MiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZmM5MTJiODAyZmRhNTZmZGFhZTIyMjhiZTM2OTVlZTkxY2Q2YTY4ZGM3YWE5M2Y4OTlmNGE1NDYwOTZiM2Y3ZiIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMCJ9" + }, + { + "path": "chain.signed_attestation.signature", + "value": "VTrI2TtGFgQIhz8fzYVIHFPjpCBXQtKPqtddLza4fli2w9ofP9Eawrp568c6DmizTxNYM31W1ADqK6DwlxH3Dg" + } + ], + "expected": "entry" + }, + { + "id": "signed-reversed-omission-order", + "base": 2, + "edits": [ + { + "path": "chain.configuration.vendors.1", + "value": { + "agent_name": "second", + "source": "live", + "mode": "best_effort", + "format_kinds": [ + "image", + "html5", + "display_tag" + ], + "positions": [ + "append" + ], + "agent_url": "https://second.example/agent" + } + }, + { + "path": "chain.entries.0.registration_index", + "value": 1 + }, + { + "path": "chain.entries.0.agent_name", + "value": "second" + }, + { + "path": "chain.entries.0.agent_url", + "value": "https://second.example/agent" + }, + { + "path": "chain.entries.1", + "value": { + "order": 1, + "registration_index": 0, + "agent_name": "measure", + "source": "live", + "agent_url": "https://measurement.example/agent", + "timestamp": "2026-10-14T12:00:00Z", + "status": "omitted", + "reason": "vendor_omission" + } + }, + { + "path": "chain.chain_hash", + "value": "sha256:4981dc7ee322641c28bd05018359dfad11813303360d7df8799b159697560953" + }, + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjQ5ODFkYzdlZTMyMjY0MWMyOGJkMDUwMTgzNTlkZmFkMTE4MTMzMDMzNjBkN2RmODc5OWIxNTk2OTc1NjA5NTMiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6MzI4M2FmZDgzMGZlZmE1ODBkODg3ZWIxZjk5NzYwYjQwNGI5OWY5N2RmNTNkOGYxZmFmYTVhNTk2ODNjMTkyOSIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6MDFjMzg0MTJmMDM1YTZjNGI4ZGIyNTE5YjcwNWFlZWY2M2Q3YmM3Y2E4MzI2NzVjOTZjYmQwNDg3Mzk4ZmFhMiIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMiIsImZvcm1hdF9raW5kIjoiZGlzcGxheV90YWciLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMiJ9" + }, + { + "path": "chain.signed_attestation.signature", + "value": "ekBYpIzXufRrNKtsFroMIXLlCK42mffO5gywP4Sw3YcvODk08qTMPgoKcANSOP14xpHuIqCCY5PX-8FPvK6LBg" + } + ], + "expected": "order" + }, + { + "id": "signed-duplicate-fragment-index-across-positions", + "base": 0, + "edits": [ + { + "path": "chain.configuration.vendors.0.positions.0", + "value": "prepend" + }, + { + "path": "chain.configuration.vendors.0.positions.1", + "value": "append" + }, + { + "path": "chain.entries.0.position", + "value": "prepend" + }, + { + "path": "chain.entries.1", + "value": { + "order": 1, + "registration_index": 0, + "agent_name": "measure", + "source": "live", + "agent_url": "https://measurement.example/agent", + "timestamp": "2026-10-14T12:00:00Z", + "status": "applied", + "position": "append", + "fragment_index": 0, + "content_hash": "sha256:1a80add994605ab8ceebe062b3a2014ab0170388bb12bbfcee4f276154622a39", + "bytes": 61 + } + }, + { + "path": "chain.chain_hash", + "value": "sha256:0a53ca7066358b14218f9b27f64664ea71e273d2653cf9399f34148c22d4a6d6" + }, + { + "path": "chain.signed_attestation.payload", + "value": "eyJhZ2VudF91cmwiOiJodHRwczovL3NhbGVzLmV4YW1wbGUvYWdlbnQiLCJjaGFpbl9oYXNoIjoic2hhMjU2OjBhNTNjYTcwNjYzNThiMTQyMThmOWIyN2Y2NDY2NGVhNzFlMjczZDI2NTNjZjkzOTlmMzQxNDhjMjJkNGE2ZDYiLCJjb25maWd1cmF0aW9uX2hhc2giOiJzaGEyNTY6ZDRlMzE2MDVkMzFjOTFjMTI2ZWJkZTJiMWRlY2E3YWY1OGIyYTQ5ODU3OWU4YTRhNjI4OTM4MTg3ZTNjNDFkYyIsImNyZWF0aXZlX2hhc2giOiJzaGEyNTY6NzQ5NzMxZWMxODMwZWEzYTU5YTUxNzQ3OWI2MGFlOTU1YjU0OTI5ZmIzOGViZTJiODFhNDQ0YjdhNGJjMzg4ZCIsImNyZWF0aXZlX2lkIjoiY3JlYXRpdmUtMCIsImZvcm1hdF9raW5kIjoiaW1hZ2UiLCJpYXQiOjE3OTE5NzkyMDAsIm1lZGlhX2J1eV9pZCI6ImJ1eS0xIiwicGFja2FnZV9pZCI6InBhY2thZ2UtMSIsInB1Ymxpc2hlcl9kb21haW4iOiJwdWJsaXNoZXIuZXhhbXBsZSIsInJldmlzaW9uX2lkIjoicmV2aXNpb24tMSIsInR5cCI6ImFkY3AtYWRkb24tY2hhaW4randzIiwidmFyaWFudF9pZCI6InZhcmlhbnQtMCJ9" + }, + { + "path": "chain.signed_attestation.signature", + "value": "SHgEW66J6mwKeDk_Lw5KCLu_5PUEjVQef6w61HopHZS5ZtKWkIrf9AV4rKJCDVYM1ETKmoN3V63UskXntOIeCw" + } + ], + "expected": "entry" + } + ], + "behavior": [ + { + "id": "required-empty-keeps-original-task", + "input": { + "async": true, + "task_id": "task-1", + "elapsed_seconds": 59, + "vendors": [ + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [] + } + ] + }, + "expected": { + "status": "working", + "task_id": "task-1" + } + }, + { + "id": "required-empty-at-deadline-fails", + "input": { + "async": true, + "task_id": "task-1", + "elapsed_seconds": 60, + "vendors": [ + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [] + } + ] + }, + "expected": { + "status": "failed", + "code": "ADDON_REQUIRED_UNAVAILABLE" + } + }, + { + "id": "required-without-async-fails", + "input": { + "async": false, + "task_id": "task-1", + "elapsed_seconds": 0, + "vendors": [ + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [] + } + ] + }, + "expected": { + "status": "failed", + "code": "ADDON_REQUIRED_UNAVAILABLE" + } + }, + { + "id": "best-effort-deliberate-omission", + "input": { + "vendors": [ + { + "source": "live", + "mode": "best_effort", + "max_hold_seconds": 60, + "fragments": [] + } + ] + }, + "expected": { + "status": "completed", + "fragments": [], + "omitted": [ + { + "registration_index": 0, + "reason": "vendor_omission" + } + ] + } + }, + { + "id": "best-effort-network-failure", + "input": { + "vendors": [ + { + "source": "live", + "mode": "best_effort", + "max_hold_seconds": 60, + "fragments": [], + "error_code": "ADDON_UNREACHABLE" + } + ] + }, + "expected": { + "status": "completed", + "fragments": [], + "omitted": [ + { + "registration_index": 0, + "reason": "failure" + } + ] + } + }, + { + "id": "required-bad-hash-fails", + "input": { + "async": false, + "elapsed_seconds": 0, + "vendors": [ + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [ + { + "position": "append", + "content": "x", + "content_hash": "sha256:0000000000000000000000000000000000000000000000000000000000000000" + } + ] + } + ] + }, + "expected": { + "status": "failed", + "code": "ADDON_REQUIRED_UNAVAILABLE" + } + }, + { + "id": "static-does-not-call-vendor", + "input": { + "vendors": [ + { + "source": "static_reference", + "mode": "required", + "position": "append", + "content": "static" + } + ] + }, + "expected": { + "status": "completed", + "fragments": [ + "static" + ], + "omitted": [] + } + }, + { + "id": "prepend-order-is-not-reversed", + "input": { + "vendors": [ + { + "source": "live", + "mode": "required", + "fragments": [ + { + "position": "append", + "content": "A1", + "content_hash": "sha256:16a36e86f6fed5d465ff332511a0ce1a863b55d364b25a7cdaa25db19abf9648" + }, + { + "position": "prepend", + "content": "A0", + "content_hash": "sha256:aa508c2187fca56f397ff75adc52b94e02f38122cdd48bd42105106e5e0f8e14" + } + ] + }, + { + "source": "live", + "mode": "required", + "fragments": [ + { + "position": "prepend", + "content": "B0", + "content_hash": "sha256:05816a1560db947d6ff798e30909816f400f14230e9a06afac8f8b213127aa21" + }, + { + "position": "append", + "content": "B1", + "content_hash": "sha256:5b950e77941d01cdf246d00b1ece546bc95234b77d98b44c9187e2733afa696a" + } + ] + } + ] + }, + "expected": { + "status": "completed", + "fragments": [ + "A0", + "B0", + "A1", + "B1" + ], + "omitted": [] + } + }, + { + "id": "late-recovery-cannot-resume-expired-operation", + "input": { + "async": true, + "task_id": "task-1", + "elapsed_seconds": 61, + "vendors": [ + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [ + { + "position": "append", + "content": "repaired", + "content_hash": "sha256:3baf75ce884746c426cafdff7e0a3ed3a52b4d70fb6dbf94bc754be4273a868a" + } + ], + "first_failure_seconds": 0 + } + ] + }, + "expected": { + "status": "failed", + "code": "ADDON_REQUIRED_UNAVAILABLE" + } + }, + { + "id": "later-expired-vendor-overrides-earlier-pending", + "input": { + "async": true, + "task_id": "task-1", + "elapsed_seconds": 30, + "vendors": [ + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [] + }, + { + "source": "live", + "mode": "required", + "max_hold_seconds": 10, + "fragments": [] + } + ] + }, + "expected": { + "status": "failed", + "code": "ADDON_REQUIRED_UNAVAILABLE" + } + }, + { + "id": "per-vendor-original-deadline-survives-restart", + "input": { + "async": true, + "task_id": "task-1", + "elapsed_seconds": 90, + "vendors": [ + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [], + "first_failure_seconds": 30 + }, + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [], + "first_failure_seconds": 70 + } + ] + }, + "expected": { + "status": "failed", + "code": "ADDON_REQUIRED_UNAVAILABLE" + } + }, + { + "id": "later-first-failure-keeps-its-own-deadline", + "input": { + "async": true, + "task_id": "task-1", + "elapsed_seconds": 90, + "vendors": [ + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [], + "first_failure_seconds": 50 + } + ] + }, + "expected": { + "status": "working", + "task_id": "task-1" + } + }, + { + "id": "recovered-earlier-vendor-cannot-mask-expired-other-vendor", + "input": { + "async": true, + "task_id": "task-1", + "elapsed_seconds": 30, + "vendors": [ + { + "source": "live", + "mode": "required", + "max_hold_seconds": 60, + "fragments": [ + { + "position": "append", + "content": "repaired", + "content_hash": "sha256:3baf75ce884746c426cafdff7e0a3ed3a52b4d70fb6dbf94bc754be4273a868a" + } + ] + }, + { + "source": "live", + "mode": "required", + "max_hold_seconds": 10, + "fragments": [] + } + ] + }, + "expected": { + "status": "failed", + "code": "ADDON_REQUIRED_UNAVAILABLE" + } + } + ] +} diff --git a/static/compliance/source/test-vectors/creative-addon-chain.schema.json b/static/compliance/source/test-vectors/creative-addon-chain.schema.json new file mode 100644 index 00000000000..fca2e2f7487 --- /dev/null +++ b/static/compliance/source/test-vectors/creative-addon-chain.schema.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "creative-addon-chain.schema.json", + "title": "Creative addon chain conformance vectors", + "type": "object", + "required": ["profile", "test_private_key", "valid", "invalid", "behavior"], + "properties": { + "profile": { "const": "adcp/addon-chain/v1" }, + "test_private_key": { "type": "object", "description": "Public RFC 8032 fixture secret. Never use operationally." }, + "valid": { + "type": "array", "minItems": 3, + "items": { + "type": "object", + "required": ["id", "creative_hash_input", "fragment", "fragment_hash", "chain", "trust"], + "properties": { + "id": { "type": "string" }, + "chain": { "$ref": "/schemas/core/creative-addon-chain.json" }, + "trust": { + "type": "object", "required": ["key", "publisher_pin", "expected", "now"], + "properties": { + "key": { "$ref": "/schemas/core/creative-addon-signing-key.json" }, + "publisher_pin": { "$ref": "/schemas/core/creative-addon-signing-key.json" } + } + } + } + } + }, + "invalid": { + "type": "array", "minItems": 1, + "items": { + "type": "object", "required": ["id", "base", "edits", "expected"], + "properties": { + "base": { "type": "integer", "minimum": 0 }, + "edits": { "type": "array", "minItems": 1 }, + "expected": { "type": "string" } + } + } + }, + "behavior": { "type": "array", "minItems": 1, "items": { "type": "object", "required": ["id", "input", "expected"] } } + } +} diff --git a/static/schemas/source/account/sync-accounts-response.json b/static/schemas/source/account/sync-accounts-response.json index 1c75f92a4df..e13a2337e19 100644 --- a/static/schemas/source/account/sync-accounts-response.json +++ b/static/schemas/source/account/sync-accounts-response.json @@ -28,6 +28,13 @@ "items": { "type": "object", "properties": { + "creative_addons": { + "allOf": [ + { "$ref": "/schemas/core/creative-addon-disclosure.json" }, + { "properties": { "scope": { "const": "account" } } } + ], + "description": "Account-scoped current configuration for this successful response row. Completeness is relative to this account, not all product-specific rules. No credentials or loader bodies are disclosed." + }, "account_id": { "type": "string", "description": "Seller-assigned account identifier. Use this in subsequent create_media_buy and other account-scoped operations when the seller's account model uses account_id references. For buyer-declared accounts, sellers SHOULD return it on every non-failed provisioning result and MUST continue accepting the natural-key AccountRef for subsequent calls. A seller declaring account.stable_account_id: true MUST return it, MUST accept it as an AccountRef on every account-scoped task, and MUST NOT change it for the life of the account; buyers SHOULD then send it in place of the natural key. Without that capability, buyers keep sending the natural key and MUST NOT assume the seller accepts the returned account_id as an AccountRef.", @@ -220,6 +227,10 @@ ] }, "allOf": [ + { + "if": { "properties": { "action": { "const": "failed" } }, "required": ["action"] }, + "then": { "not": { "required": ["creative_addons"] } } + }, { "description": "Settings-update-mode results (echoing the request's `account` field) anchor identity on `account` alone; provisioning-mode results anchor identity on the buyer-declared `brand` + `operator` natural key instead, never both. Mirrors the same discriminator sync_governance's response rows already use.", "if": { diff --git a/static/schemas/source/core/canonical-product.json b/static/schemas/source/core/canonical-product.json index 66d9021dd49..0fa63ba6e57 100644 --- a/static/schemas/source/core/canonical-product.json +++ b/static/schemas/source/core/canonical-product.json @@ -127,6 +127,12 @@ "items": { "$ref": "/schemas/core/canonical-product-action.json" }, "uniqueItems": true }, + "creative_addons": { + "allOf": [ + { "$ref": "/schemas/core/creative-addon-disclosure.json" }, + { "properties": { "scope": { "const": "product" } } } + ] + }, "hold_policy": { "$ref": "/schemas/core/hold-policy.json", "x-status": "experimental", diff --git a/static/schemas/source/core/creative-addon-capability.json b/static/schemas/source/core/creative-addon-capability.json new file mode 100644 index 00000000000..99e2b79ac22 --- /dev/null +++ b/static/schemas/source/core/creative-addon-capability.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/creative-addon-capability.json", + "title": "Creative Addon Capability", + "description": "Seller-adjacent fragment support. Omission means unsupported. All advertised routes require a seller-controlled adjacent container and the mandatory signing profile.", + "x-added-in": "3.3.0", + "type": "object", + "properties": { + "supported_format_kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "image", + "html5", + "display_tag" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "positions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "prepend", + "append" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "signed_chain": { + "type": "object", + "properties": { + "required": { + "type": "boolean", + "const": true + }, + "profile": { + "type": "string", + "const": "adcp/addon-chain/v1" + } + }, + "required": [ + "required", + "profile" + ], + "additionalProperties": false + } + }, + "required": [ + "supported_format_kinds", + "positions", + "signed_chain" + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/creative-addon-chain.json b/static/schemas/source/core/creative-addon-chain.json new file mode 100644 index 00000000000..016b7d7e801 --- /dev/null +++ b/static/schemas/source/core/creative-addon-chain.json @@ -0,0 +1,449 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/creative-addon-chain.json", + "title": "Creative Addon Chain", + "description": "Signed seller assembly claim for one immutable served variant. Exposes hashes and metadata, never fragment code. A new effective configuration or chain requires a distinct variant_id.", + "x-added-in": "3.3.0", + "type": "object", + "properties": { + "creative_id": { + "type": "string", + "minLength": 1, + "x-entity": "creative" + }, + "creative_hash": { + "type": "string", + "minLength": 1, + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "configuration": { + "$ref": "/schemas/core/creative-addon-disclosure.json", + "description": "Complete effective configuration actually used for this execution, retained immutably. All fields are covered by configuration_hash in signed claims." + }, + "entries": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "order": { + "type": "integer", + "minimum": 0 + }, + "registration_index": { + "type": "integer", + "minimum": 0 + }, + "agent_name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S(?:.*\\S)?$", + "description": "Canonical agent_name, normalized by trimming surrounding whitespace and converting to lowercase. Identity is checked against the selected vendor, never inferred from this label alone." + }, + "agent_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + }, + "source": { + "type": "string", + "enum": [ + "live", + "static_reference" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "const": "applied" + }, + "position": { + "type": "string", + "enum": [ + "prepend", + "append" + ] + }, + "fragment_index": { + "type": "integer", + "minimum": 0 + }, + "content_hash": { + "type": "string", + "minLength": 1, + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "bytes": { + "type": "integer", + "minimum": 1 + }, + "vendor_ref": { + "type": "string", + "minLength": 1 + }, + "detail_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + } + }, + "required": [ + "order", + "registration_index", + "agent_name", + "source", + "timestamp", + "status", + "position", + "fragment_index", + "content_hash", + "bytes" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "source": { + "const": "live" + } + }, + "required": [ + "source" + ] + }, + "then": { + "required": [ + "agent_url" + ] + }, + "else": { + "not": { + "required": [ + "agent_url" + ] + } + } + } + ] + }, + { + "type": "object", + "properties": { + "order": { + "type": "integer", + "minimum": 0 + }, + "registration_index": { + "type": "integer", + "minimum": 0 + }, + "agent_name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S(?:.*\\S)?$", + "description": "Canonical agent_name, normalized by trimming surrounding whitespace and converting to lowercase. Identity is checked against the selected vendor, never inferred from this label alone." + }, + "agent_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + }, + "source": { + "type": "string", + "enum": [ + "live", + "static_reference" + ] + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "status": { + "type": "string", + "const": "omitted" + }, + "reason": { + "type": "string", + "enum": [ + "vendor_omission", + "failure" + ] + }, + "error_code": { + "type": "string", + "enum": [ + "ADDON_UNREACHABLE", + "ADDON_AUTH_FAILED", + "ADDON_HASH_MISMATCH", + "ADDON_INVALID_POSITION", + "ADDON_UNDECLARED_FORMAT", + "ADDON_MALFORMED_FRAGMENT", + "ADDON_REQUIRED_UNAVAILABLE" + ] + } + }, + "required": [ + "order", + "registration_index", + "agent_name", + "source", + "timestamp", + "status", + "reason" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "source": { + "const": "live" + } + }, + "required": [ + "source" + ] + }, + "then": { + "required": [ + "agent_url" + ] + }, + "else": { + "not": { + "required": [ + "agent_url" + ] + } + } + }, + { + "if": { + "properties": { + "reason": { + "const": "failure" + } + }, + "required": [ + "reason" + ] + }, + "then": { + "required": [ + "error_code" + ] + }, + "else": { + "not": { + "required": [ + "error_code" + ] + } + } + } + ] + } + ], + "discriminator": { + "propertyName": "status" + } + }, + "minItems": 0 + }, + "chain_hash": { + "type": "string", + "minLength": 1, + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "signed_attestation": { + "type": "object", + "properties": { + "profile": { + "type": "string", + "const": "adcp/addon-chain/v1" + }, + "protected": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "payload": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]+$" + }, + "signature": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]{86}$" + } + }, + "required": [ + "profile", + "protected", + "payload", + "signature" + ], + "additionalProperties": false + } + }, + "required": [ + "creative_id", + "creative_hash", + "configuration", + "entries", + "chain_hash", + "signed_attestation" + ], + "additionalProperties": false, + "allOf": [ + { + "properties": { + "configuration": { + "properties": { + "complete": { + "const": true + }, + "scope": { + "const": "product" + } + } + } + } + } + ], + "definitions": { + "Claims": { + "type": "object", + "properties": { + "typ": { + "type": "string", + "const": "adcp-addon-chain+jws" + }, + "agent_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + }, + "publisher_domain": { + "type": "string", + "minLength": 1 + }, + "creative_id": { + "type": "string", + "minLength": 1, + "x-entity": "creative" + }, + "creative_hash": { + "type": "string", + "minLength": 1, + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "chain_hash": { + "type": "string", + "minLength": 1, + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "configuration_hash": { + "type": "string", + "minLength": 1, + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "format_kind": { + "type": "string", + "enum": [ + "image", + "html5", + "display_tag" + ] + }, + "variant_id": { + "type": "string", + "minLength": 1, + "x-entity": "served_variant" + }, + "media_buy_id": { + "type": "string", + "minLength": 1, + "x-entity": "media_buy" + }, + "package_id": { + "type": "string", + "minLength": 1, + "x-entity": "package" + }, + "placement_id": { + "type": "string", + "minLength": 1 + }, + "revision_id": { + "$ref": "/schemas/core/creative-revision-id.json" + }, + "locale_variant_id": { + "type": "string", + "minLength": 1, + "x-entity": "creative_locale_variant" + }, + "iat": { + "type": "integer", + "minimum": 0 + } + }, + "required": [ + "typ", + "agent_url", + "publisher_domain", + "creative_id", + "creative_hash", + "chain_hash", + "configuration_hash", + "format_kind", + "variant_id", + "media_buy_id", + "iat" + ], + "additionalProperties": false + }, + "ProtectedHeader": { + "type": "object", + "properties": { + "alg": { + "type": "string", + "const": "EdDSA" + }, + "kid": { + "type": "string", + "minLength": 1 + }, + "typ": { + "type": "string", + "const": "adcp-addon-chain+jws" + } + }, + "required": [ + "alg", + "kid", + "typ" + ], + "additionalProperties": false + } + }, + "x-adcp-validation": { + "verifier_constraints": { + "cryptographic_validation": "Verify JWS, chain/configuration hashes, dedicated key purpose, publisher authority, and every delivery identity using the addon-chain-signing specification. Shape validation is insufficient.", + "entry_coverage": "Every configured applicable registration has one omission or at least one applied entry; required registrations MUST have applied entries only. Entries MUST match their registration identity and source.", + "ordering": "Applied entries are prepend then append, ordered by registration_index then fragment_index; omissions follow in registration order. order is the zero-based position in entries." + }, + "spec": "docs/creative/addon-chain-signing.mdx" + } +} diff --git a/static/schemas/source/core/creative-addon-disclosure.json b/static/schemas/source/core/creative-addon-disclosure.json new file mode 100644 index 00000000000..f3c34902fcc --- /dev/null +++ b/static/schemas/source/core/creative-addon-disclosure.json @@ -0,0 +1,196 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/creative-addon-disclosure.json", + "title": "Creative Addon Disclosure", + "description": "Read-only seller configuration in registration order. complete=true with vendors=[] explicitly declares an empty set; omission is undeclared. Product and account disclosures are resolved into one complete product snapshot before commitment. Contains no fragment bodies or credentials.", + "x-added-in": "3.3.0", + "type": "object", + "properties": { + "scope": { + "type": "string", + "enum": [ + "product", + "account" + ] + }, + "product_id": { + "type": "string", + "minLength": 1, + "x-entity": "product" + }, + "complete": { + "type": "boolean" + }, + "signed_chain_profile": { + "type": "string", + "const": "adcp/addon-chain/v1" + }, + "vendors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "agent_name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S(?:.*\\S)?$", + "description": "Canonical agent_name, normalized by trimming surrounding whitespace and converting to lowercase. Identity is checked against the selected vendor, never inferred from this label alone." + }, + "source": { + "type": "string", + "enum": [ + "live", + "static_reference" + ] + }, + "agent_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + }, + "mode": { + "type": "string", + "enum": [ + "required", + "best_effort" + ] + }, + "max_hold_seconds": { + "type": "integer", + "minimum": 1 + }, + "format_kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "image", + "html5", + "display_tag" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "positions": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "prepend", + "append" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "static_content_hash": { + "type": "string", + "minLength": 1, + "pattern": "^sha256:[a-f0-9]{64}$" + } + }, + "required": [ + "agent_name", + "source", + "mode", + "format_kinds", + "positions" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "const": "required" + } + }, + "required": [ + "mode" + ] + }, + "then": { + "required": [ + "max_hold_seconds" + ] + } + }, + { + "if": { + "properties": { + "source": { + "const": "live" + } + }, + "required": [ + "source" + ] + }, + "then": { + "required": [ + "agent_url" + ], + "not": { + "required": [ + "static_content_hash" + ] + } + }, + "else": { + "required": [ + "static_content_hash" + ], + "not": { + "required": [ + "agent_url" + ] + } + } + } + ] + }, + "minItems": 0 + } + }, + "required": [ + "scope", + "complete", + "signed_chain_profile", + "vendors" + ], + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "scope": { + "const": "product" + } + }, + "required": [ + "scope" + ] + }, + "then": { + "required": [ + "product_id" + ] + }, + "else": { + "not": { + "required": [ + "product_id" + ] + } + } + } + ], + "x-adcp-validation": { + "verifier_constraints": { + "vendor_identity": "agent_name MUST be unique after lowercase-and-trim normalization within this configuration", + "scope_binding": "product_id MUST equal the containing product or purchase; account scope inherits identity from its sync_accounts row" + } + } +} diff --git a/static/schemas/source/core/creative-addon-failure.json b/static/schemas/source/core/creative-addon-failure.json new file mode 100644 index 00000000000..4da570d7618 --- /dev/null +++ b/static/schemas/source/core/creative-addon-failure.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/creative-addon-failure.json", + "title": "Creative Addon Failure", + "description": "Feedback to the affected vendor only. The Sales Agent MUST exclude other vendors, buyer payloads, and credentials. The same entry shape is used in prior_failures and optional notification_url POST bodies.", + "x-added-in": "3.3.0", + "type": "object", + "properties": { + "error_code": { + "type": "string", + "enum": [ + "ADDON_UNREACHABLE", + "ADDON_AUTH_FAILED", + "ADDON_HASH_MISMATCH", + "ADDON_INVALID_POSITION", + "ADDON_UNDECLARED_FORMAT", + "ADDON_MALFORMED_FRAGMENT", + "ADDON_REQUIRED_UNAVAILABLE" + ] + }, + "creative_id": { + "type": "string", + "minLength": 1, + "x-entity": "creative" + }, + "format_kind": { + "type": "string", + "enum": [ + "image", + "html5", + "display_tag" + ] + }, + "position": { + "type": "string", + "minLength": 1, + "description": "Observed position, including invalid values, for diagnosis." + }, + "vendor_ref": { + "type": "string", + "minLength": 1 + }, + "detail": { + "type": "string", + "minLength": 1 + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "error_code", + "creative_id", + "format_kind", + "timestamp" + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/creative-addon-fragment.json b/static/schemas/source/core/creative-addon-fragment.json new file mode 100644 index 00000000000..3295931f006 --- /dev/null +++ b/static/schemas/source/core/creative-addon-fragment.json @@ -0,0 +1,48 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/creative-addon-fragment.json", + "title": "Creative Addon Fragment", + "description": "Vendor-to-Sales-Agent fragment. The Sales Agent checks SHA-256 over exact UTF-8 content bytes before placement. This object MUST NOT appear in buyer reporting.", + "x-added-in": "3.3.0", + "type": "object", + "properties": { + "creative_id": { + "type": "string", + "minLength": 1, + "x-entity": "creative" + }, + "position": { + "type": "string", + "enum": [ + "prepend", + "append" + ] + }, + "content": { + "type": "string", + "minLength": 1 + }, + "content_hash": { + "type": "string", + "minLength": 1, + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "vendor_ref": { + "type": "string", + "minLength": 1 + }, + "detail_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + } + }, + "required": [ + "creative_id", + "position", + "content", + "content_hash" + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/creative-addon-registration.json b/static/schemas/source/core/creative-addon-registration.json new file mode 100644 index 00000000000..3f62465a5dd --- /dev/null +++ b/static/schemas/source/core/creative-addon-registration.json @@ -0,0 +1,130 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/creative-addon-registration.json", + "title": "Creative Addon Registration", + "description": "Operator-local configuration, never a buyer mutation surface. Exactly one live agent URL or static reference is required. Required mode has a finite deadline measured from the first failed attempt of the invoking operation.", + "x-added-in": "3.3.0", + "type": "object", + "properties": { + "agent_name": { + "type": "string", + "minLength": 1, + "pattern": "^\\S(?:.*\\S)?$", + "description": "Canonical agent_name, normalized by trimming surrounding whitespace and converting to lowercase. Identity is checked against the selected vendor, never inferred from this label alone." + }, + "display_name": { + "type": "string", + "minLength": 1 + }, + "agent_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + }, + "notification_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + }, + "mode": { + "type": "string", + "enum": [ + "required", + "best_effort" + ] + }, + "max_hold_seconds": { + "type": "integer", + "minimum": 1 + }, + "static_reference": { + "type": "object", + "properties": { + "format_kinds": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "image", + "html5", + "display_tag" + ] + }, + "minItems": 1, + "uniqueItems": true + }, + "position": { + "type": "string", + "enum": [ + "prepend", + "append" + ] + }, + "content": { + "type": "string", + "minLength": 1 + }, + "detail_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + } + }, + "required": [ + "format_kinds", + "position", + "content" + ], + "additionalProperties": false + }, + "auth": { + "type": "object", + "description": "Operator-local credential configuration. Never serialized into disclosure, vendor context, or reporting. Storage and supported credential mechanisms are implementation-specific." + } + }, + "required": [ + "agent_name", + "mode" + ], + "additionalProperties": false, + "anyOf": [ + { + "required": [ + "agent_url" + ] + }, + { + "required": [ + "static_reference" + ] + } + ], + "not": { + "required": [ + "agent_url", + "static_reference" + ] + }, + "allOf": [ + { + "if": { + "properties": { + "mode": { + "const": "required" + } + }, + "required": [ + "mode" + ] + }, + "then": { + "required": [ + "max_hold_seconds" + ] + } + } + ] +} diff --git a/static/schemas/source/core/creative-addon-signing-key.json b/static/schemas/source/core/creative-addon-signing-key.json new file mode 100644 index 00000000000..25cb9564221 --- /dev/null +++ b/static/schemas/source/core/creative-addon-signing-key.json @@ -0,0 +1,63 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/core/creative-addon-signing-key.json", + "title": "Creative Addon Signing Key", + "description": "Dedicated public key for adcp/addon-chain/v1, resolved through trusted Sales Agent identity and existing publisher pins. This schema does not establish authorization or a trust anchor.", + "x-added-in": "3.3.0", + "type": "object", + "properties": { + "kid": { + "type": "string", + "minLength": 1 + }, + "kty": { + "type": "string", + "const": "OKP" + }, + "crv": { + "type": "string", + "const": "Ed25519" + }, + "alg": { + "type": "string", + "const": "EdDSA" + }, + "use": { + "type": "string", + "const": "sig" + }, + "key_ops": { + "type": "array", + "items": { + "type": "string", + "const": "verify" + }, + "minItems": 1, + "maxItems": 1 + }, + "adcp_use": { + "type": "string", + "const": "addon-chain-signing" + }, + "x": { + "type": "string", + "minLength": 1, + "pattern": "^[A-Za-z0-9_-]{43}$" + }, + "revoked_at": { + "type": "string", + "format": "date-time" + } + }, + "required": [ + "kid", + "kty", + "crv", + "alg", + "use", + "key_ops", + "adcp_use", + "x" + ], + "additionalProperties": false +} diff --git a/static/schemas/source/core/creative-variant.json b/static/schemas/source/core/creative-variant.json index 4110d8cf044..cbe05c12449 100644 --- a/static/schemas/source/core/creative-variant.json +++ b/static/schemas/source/core/creative-variant.json @@ -54,6 +54,9 @@ "description": "Buyer-assigned locale variant that supplied the served assets. Required for every delivered variant of a localized creative, including default fallback delivery. Omitted for unlocalized creatives.", "x-entity": "creative_locale_variant" }, + "addon_chain": { + "$ref": "/schemas/core/creative-addon-chain.json" + }, "manifest": { "$ref": "/schemas/core/creative-manifest.json", "description": "The rendered creative manifest for this variant — the actual output that was served, not the input assets. In 3.2 it carries canonical `format_kind`, optional `format_option_ref`, and the resolved assets (specific headline, image, video, etc. the platform selected or generated). For Tier 2, shows which asset combination was picked. For Tier 3, contains the generated assets which may differ entirely from the input brand identity. Pass to preview_creative to re-render." diff --git a/static/schemas/source/core/product.json b/static/schemas/source/core/product.json index 507e196c75b..343388d43e2 100644 --- a/static/schemas/source/core/product.json +++ b/static/schemas/source/core/product.json @@ -170,6 +170,12 @@ "$ref": "/schemas/core/cancellation-policy.json", "description": "Cancellation terms for this product. Declares the minimum notice period required before cancellation takes effect and any penalties for insufficient notice. Relevant for guaranteed delivery products. Buyers accept these terms by creating a media buy against the product." }, + "creative_addons": { + "allOf": [ + { "$ref": "/schemas/core/creative-addon-disclosure.json" }, + { "properties": { "scope": { "const": "product" } } } + ] + }, "hold_policy": { "$ref": "/schemas/core/hold-policy.json", "x-status": "experimental", diff --git a/static/schemas/source/creative/get-creative-addons-request.json b/static/schemas/source/creative/get-creative-addons-request.json new file mode 100644 index 00000000000..8df524185bf --- /dev/null +++ b/static/schemas/source/creative/get-creative-addons-request.json @@ -0,0 +1,138 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/creative/get-creative-addons-request.json", + "title": "Get Creative Addons Request", + "description": "One operation-scoped batch from an authorized Sales Agent to a registered addon vendor. Authentication establishes operator identity; operator_name is informational.", + "x-added-in": "3.3.0", + "type": "object", + "properties": { + "adcp_version": { + "$ref": "/schemas/core/version-envelope.json#/properties/adcp_version" + }, + "adcp_major_version": { + "$ref": "/schemas/core/version-envelope.json#/properties/adcp_major_version" + }, + "idempotency_key": { + "type": "string", + "minLength": 16, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]{16,255}$" + }, + "operator_name": { + "type": "string", + "minLength": 1 + }, + "operator_url": { + "type": "string", + "minLength": 1, + "format": "uri", + "pattern": "^https://" + }, + "publisher_domain": { + "type": "string", + "minLength": 1 + }, + "account_id": { + "type": "string", + "minLength": 1, + "description": "Vendor-assigned account identity; not the buyer execution account." + }, + "placement": { + "type": "object", + "properties": { + "property_id": { + "type": "string", + "minLength": 1 + }, + "product_id": { + "type": "string", + "minLength": 1, + "x-entity": "product" + } + }, + "required": [], + "additionalProperties": false + }, + "creatives": { + "type": "array", + "items": { + "type": "object", + "properties": { + "creative_id": { + "type": "string", + "minLength": 1, + "x-entity": "creative" + }, + "format_kind": { + "type": "string", + "enum": [ + "image", + "html5", + "display_tag" + ] + }, + "dimensions": { + "type": "object", + "properties": { + "w": { + "type": "integer", + "minimum": 1 + }, + "h": { + "type": "integer", + "minimum": 1 + } + }, + "required": [ + "w", + "h" + ], + "additionalProperties": false + } + }, + "required": [ + "creative_id", + "format_kind" + ], + "additionalProperties": false + }, + "minItems": 1 + }, + "prior_failures": { + "type": "array", + "items": { + "$ref": "/schemas/core/creative-addon-failure.json" + }, + "minItems": 1 + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "required": [ + "idempotency_key", + "operator_name", + "operator_url", + "publisher_domain", + "creatives" + ], + "additionalProperties": false, + "x-tool-summary": "Return seller-adjacent fragments from creative context without receiving buyer creative content.", + "x-mutates-state": true, + "x-idempotency-key-required": true, + "allOf": [ + { + "$ref": "/schemas/core/version-envelope.json" + } + ], + "x-adcp-validation": { + "verifier_constraints": { + "privacy": "No creative payload, markup, asset URL, or buyer secrets may appear anywhere, including context and ext.", + "creative_identity": "creative_id values are unique within one batch; every returned creative_id MUST be a member of this batch.", + "idempotency": "The key identifies one vendor evaluation attempt. Ambiguous/in-flight retries preserve key and exact payload. After a definitive unusable result, a fresh attempt may use a new key and new failure feedback within the same seller task and original hold deadline. Distinct addon operations never reuse live fragments." + } + } +} diff --git a/static/schemas/source/creative/get-creative-addons-response.json b/static/schemas/source/creative/get-creative-addons-response.json new file mode 100644 index 00000000000..bf073e45ef8 --- /dev/null +++ b/static/schemas/source/creative/get-creative-addons-response.json @@ -0,0 +1,78 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "/schemas/creative/get-creative-addons-response.json", + "title": "Get Creative Addons Response", + "description": "Terminal vendor result. addons=[] is an intentional vendor omission, which the seller MUST treat as failure for a required registration. A terminal errors result supplies no fragments.", + "x-added-in": "3.3.0", + "type": "object", + "allOf": [ + { + "$ref": "/schemas/core/version-envelope.json" + }, + { + "$ref": "/schemas/core/protocol-envelope.json" + }, + { + "if": { + "required": [ + "addons" + ] + }, + "then": { + "properties": { + "status": { + "const": "completed" + } + } + }, + "else": { + "properties": { + "status": { + "const": "failed" + } + } + } + } + ], + "properties": { + "addons": { + "type": "array", + "items": { + "$ref": "/schemas/core/creative-addon-fragment.json" + }, + "minItems": 0 + }, + "errors": { + "type": "array", + "items": { + "$ref": "/schemas/core/error.json" + }, + "minItems": 1 + }, + "context": { + "$ref": "/schemas/core/context.json" + }, + "ext": { + "$ref": "/schemas/core/ext.json" + } + }, + "anyOf": [ + { + "required": [ + "addons" + ] + }, + { + "required": [ + "errors" + ] + } + ], + "not": { + "required": [ + "addons", + "errors" + ] + }, + "additionalProperties": true +} diff --git a/static/schemas/source/enums/error-code.json b/static/schemas/source/enums/error-code.json index 170a31c83d9..2f015d11427 100644 --- a/static/schemas/source/enums/error-code.json +++ b/static/schemas/source/enums/error-code.json @@ -5,6 +5,13 @@ "description": "Standard error code vocabulary for AdCP. Codes are machine-readable so agents can apply autonomous recovery strategies based on the recovery classification. Sellers MAY return codes not listed here for platform-specific errors — the error.json code field accepts any string. Agents MUST handle unknown codes by falling back to the recovery classification.", "type": "string", "enum": [ + "ADDON_UNREACHABLE", + "ADDON_AUTH_FAILED", + "ADDON_HASH_MISMATCH", + "ADDON_INVALID_POSITION", + "ADDON_UNDECLARED_FORMAT", + "ADDON_MALFORMED_FRAGMENT", + "ADDON_REQUIRED_UNAVAILABLE", "INVALID_REQUEST", "AUTH_REQUIRED", "AUTH_MISSING", @@ -129,6 +136,13 @@ "MACRO_RESOLUTION_FAILED" ], "enumDescriptions": { + "ADDON_UNREACHABLE": "The addon vendor could not be reached before the call timeout. Recovery: transient (seller follows the finite required hold or records best-effort omission).", + "ADDON_AUTH_FAILED": "Addon vendor rejected operator credentials. Recovery: terminal (operator repairs vendor credentials; buyer credentials are unchanged).", + "ADDON_HASH_MISMATCH": "Fragment content_hash differs from SHA-256 of the exact UTF-8 content. Discard all this vendor's fragments for the affected creative. Recovery: terminal (vendor must correct the fragment).", + "ADDON_INVALID_POSITION": "Fragment position is unsupported by the contract or the vendor declaration. Recovery: terminal (vendor must correct the response).", + "ADDON_UNDECLARED_FORMAT": "Fragment targets an undeclared format or unknown creative context. Recovery: terminal (vendor must correct the response).", + "ADDON_MALFORMED_FRAGMENT": "Fragment cannot be safely assembled in the declared seller container, or a required vendor supplied no valid fragment. Recovery: terminal (operator or vendor repairs the integration).", + "ADDON_REQUIRED_UNAVAILABLE": "Seller-required addon could not be applied within the finite original hold deadline, or the invoking task cannot hold asynchronously. The affected execution MUST NOT deliver; buyer creative lifecycle is unchanged. Use the invoking task's normal terminal error arm. Recovery: terminal (surface seller infrastructure failure; a later user-directed retry follows the original task's idempotency contract).", "INVALID_REQUEST": "Request is malformed, missing required fields, or violates schema constraints. Recovery: correctable (check request parameters and fix).", "AUTH_REQUIRED": "**Deprecated** — use `AUTH_MISSING` (no credentials presented) or `AUTH_INVALID` (credentials presented and rejected). Retained as a backward-compatible alias during the 3.x deprecation window. Recovery: correctable.", "AUTH_MISSING": "No credentials were presented. Sellers MUST return this code when no `Authorization` header was included in the request. Recovery: correctable (provide credentials via the auth header and retry).", @@ -253,6 +267,13 @@ "MACRO_RESOLUTION_FAILED": "One or more declared creative macro tokens cannot be resolved or safely preserved under the selected product and seller capability intersection. Sellers MUST include per-token `error.details.macro_resolution_results`, conforming to `error-details/macro-resolution-failed.json`; unknown or ambiguous tokens remain byte-preserved and MUST NOT receive guessed values. Recovery: correctable (route to a compatible execution path, correct the verified declaration, or assign the proper downstream resolver)." }, "enumMetadata": { + "ADDON_UNREACHABLE": {"recovery": "transient", "suggestion": "seller retries within the original finite hold or records best-effort omission"}, + "ADDON_AUTH_FAILED": {"recovery": "terminal", "suggestion": "operator repairs vendor credentials; buyer credentials are unchanged"}, + "ADDON_HASH_MISMATCH": {"recovery": "terminal", "suggestion": "discard affected fragments and have the vendor correct the content hash"}, + "ADDON_INVALID_POSITION": {"recovery": "terminal", "suggestion": "vendor corrects the position to one declared for this route"}, + "ADDON_UNDECLARED_FORMAT": {"recovery": "terminal", "suggestion": "vendor corrects the format or creative identity in the response"}, + "ADDON_MALFORMED_FRAGMENT": {"recovery": "terminal", "suggestion": "operator or vendor repairs the required fragment integration"}, + "ADDON_REQUIRED_UNAVAILABLE": {"recovery": "terminal", "suggestion": "surface the seller infrastructure failure; a later user-directed retry follows the invoking task idempotency contract"}, "$comment": "Structured recovery classification and remediation hints for each error code. SDKs MUST consume this block instead of parsing 'Recovery: X' from enumDescriptions prose. Each entry is { recovery, suggestion }. recovery is one of: correctable (caller can fix and retry), transient (retry with backoff), terminal (no autonomous recovery - operator intervention required). enumDescriptions is retained for human readability and will continue to carry the canonical narrative; the recovery classification embedded in that prose is normative and MUST match the value here.", "INVALID_REQUEST": { "recovery": "correctable", diff --git a/static/schemas/source/enums/specialism.json b/static/schemas/source/enums/specialism.json index caf56c1bcde..b98e849c966 100644 --- a/static/schemas/source/enums/specialism.json +++ b/static/schemas/source/enums/specialism.json @@ -20,6 +20,7 @@ "collection-lists", "content-standards", "creative-ad-server", + "creative-addon", "creative-generative", "creative-template", "creative-transformers", @@ -44,6 +45,7 @@ "sponsored-intelligence" ], "enumDescriptions": { + "creative-addon": "Operator-registered vendor that returns seller-adjacent fragments without receiving buyer creative content.", "audience-sync": "Syncs buyer-provided audience segments into a platform for activation", "brand-rights": "Brand identity and rights licensing (talent, music, stock media)", "buyer-activation": "Buyer agent that creates media buys with correct idempotency keys, handles transient failures with retry, and coordinates creative assignment", diff --git a/static/schemas/source/index.json b/static/schemas/source/index.json index d8eba4e8d74..ccd3169d8ad 100644 --- a/static/schemas/source/index.json +++ b/static/schemas/source/index.json @@ -45,6 +45,34 @@ "$ref": "/schemas/core/product.json", "description": "Represents available advertising inventory" }, + "creative-addon-capability": { + "$ref": "/schemas/core/creative-addon-capability.json", + "description": "Creative Addon Capability" + }, + "creative-addon-registration": { + "$ref": "/schemas/core/creative-addon-registration.json", + "description": "Creative Addon Registration" + }, + "creative-addon-disclosure": { + "$ref": "/schemas/core/creative-addon-disclosure.json", + "description": "Creative Addon Disclosure" + }, + "creative-addon-fragment": { + "$ref": "/schemas/core/creative-addon-fragment.json", + "description": "Creative Addon Fragment" + }, + "creative-addon-failure": { + "$ref": "/schemas/core/creative-addon-failure.json", + "description": "Creative Addon Failure" + }, + "creative-addon-chain": { + "$ref": "/schemas/core/creative-addon-chain.json", + "description": "Creative Addon Chain" + }, + "creative-addon-signing-key": { + "$ref": "/schemas/core/creative-addon-signing-key.json", + "description": "Creative Addon Signing Key" + }, "canonical-product": { "$ref": "/schemas/core/canonical-product.json", "description": "Canonical-only product view for the AdCP 3.2 split product and proposal tools" @@ -2154,6 +2182,14 @@ "description": "Response payload with transformer descriptors — input/output formats, typed config params, account-scoped enumerable option values when expanded, and per-account pricing" } }, + "get-creative-addons": { + "request": { + "$ref": "/schemas/creative/get-creative-addons-request.json" + }, + "response": { + "$ref": "/schemas/creative/get-creative-addons-response.json" + } + }, "get-creative-delivery": { "request": { "$ref": "/schemas/creative/get-creative-delivery-request.json", diff --git a/static/schemas/source/media-buy/product-fields.json b/static/schemas/source/media-buy/product-fields.json index 4d401db30f1..72eb770750c 100644 --- a/static/schemas/source/media-buy/product-fields.json +++ b/static/schemas/source/media-buy/product-fields.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "/schemas/media-buy/product-fields.json", "title": "Product Response Fields", - "description": "Canonical product fields a buyer requests from list_products. Required product_id and name fields are always returned. list_applications also overrides projection whenever a property or collection list is in the effective targeting because it is the decision receipt for seller-specific list matching. targeting_resolution and expires_at override projection whenever the seller returns modifications, as does collection_targeting_allowed whenever returned overlay_support declares collection_list. Legacy named-format fields remain available only through get_products. A projection that omits collections also omits the response-level collections array.", + "description": "Canonical product fields a buyer requests from list_products. Required product_id and name fields are always returned. For negotiated addon support, complete creative_addons disclosure overrides projection on every offer eligible for direct purchase; a caller cannot suppress the pre-commit compatibility snapshot. list_applications also overrides projection whenever a property or collection list is in the effective targeting because it is the decision receipt for seller-specific list matching. targeting_resolution and expires_at override projection whenever the seller returns modifications, as does collection_targeting_allowed whenever returned overlay_support declares collection_list. Legacy named-format fields remain available only through get_products. A projection that omits collections also omits the response-level collections array.", "type": "array", "minItems": 1, "uniqueItems": true, @@ -19,6 +19,7 @@ "sponsored_placement_types", "social_placement_surfaces", "format_options", + "creative_addons", "placements", "delivery_type", "exclusivity", diff --git a/static/schemas/source/media-buy/product-purchase.json b/static/schemas/source/media-buy/product-purchase.json index fadbc814edf..5277770cd3e 100644 --- a/static/schemas/source/media-buy/product-purchase.json +++ b/static/schemas/source/media-buy/product-purchase.json @@ -14,6 +14,24 @@ } }, "properties": { + "creative_addons": { + "allOf": [ + { + "$ref": "/schemas/core/creative-addon-disclosure.json" + }, + { + "properties": { + "complete": { + "const": true + }, + "scope": { + "const": "product" + } + } + } + ], + "description": "Seller-resolved complete configuration for this purchase, covered by commercial_terms and terms_digest. Required when addon support is negotiated. At commitment the seller atomically compares it with current effective configuration; stale terms fail before mutation." + }, "product_id": { "type": "string", "minLength": 1, diff --git a/static/schemas/source/protocol/get-adcp-capabilities-response.json b/static/schemas/source/protocol/get-adcp-capabilities-response.json index bef9cffcc93..8b342df2e6e 100644 --- a/static/schemas/source/protocol/get-adcp-capabilities-response.json +++ b/static/schemas/source/protocol/get-adcp-capabilities-response.json @@ -19,6 +19,8 @@ } }, "allOf": [ + {"if": {"required": ["creative_addon"]}, "then": {"properties": {"supported_protocols": {"contains": {"const": "creative"}}}}}, + {"if": {"properties": {"specialisms": {"contains": {"const": "creative-addon"}}}, "required": ["specialisms"]}, "then": {"required": ["creative_addon", "agent_name"]}}, { "$ref": "/schemas/core/version-envelope.json" }, @@ -2666,6 +2668,14 @@ }, "additionalProperties": true }, + "agent_name": { + "type": "string", + "minLength": 1, + "description": "Addon vendor identity; required with creative-addon specialism. Operators compare the declared and registered names after lowercase-and-trim normalization." + }, + "creative_addon": { + "$ref": "/schemas/core/creative-addon-capability.json" + }, "creative": { "type": "object", "description": "Creative protocol capabilities. Only present if creative is in supported_protocols.", diff --git a/tests/creative-addon-vectors.test.cjs b/tests/creative-addon-vectors.test.cjs new file mode 100644 index 00000000000..f7ed0fc9ff3 --- /dev/null +++ b/tests/creative-addon-vectors.test.cjs @@ -0,0 +1,195 @@ +// Executable reference checks for the normative vectors. This is not a seller runtime. +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const crypto = require('node:crypto'); +const { canonicalize } = require('@adcp/sdk'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); +const schemas = path.join(__dirname, '../static/schemas/source'); +const ajv = new Ajv({ strict: false, allErrors: true, discriminator: true }); +addFormats(ajv); +for (const file of fs.readdirSync(schemas, { recursive: true })) { + if (!file.endsWith('.json')) continue; + const schema = JSON.parse(fs.readFileSync(path.join(schemas, file))); + if (schema.$id) ajv.addSchema(schema); +} +const validChain = ajv.getSchema('/schemas/core/creative-addon-chain.json'); +const validClaims = ajv.getSchema('/schemas/core/creative-addon-chain.json#/definitions/Claims'); +const validHeader = ajv.getSchema('/schemas/core/creative-addon-chain.json#/definitions/ProtectedHeader'); +const validKey = ajv.getSchema('/schemas/core/creative-addon-signing-key.json'); +const fixturePath = path.join(__dirname, '../static/compliance/source/test-vectors/creative-addon-chain.json'); +const sha = value => `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +const jsonBytes = value => Buffer.from(canonicalize(value), 'utf8'); +const encode = value => jsonBytes(value).toString('base64url'); +function decode(value) { + assert.match(value, /^[A-Za-z0-9_-]+$/); + const bytes = Buffer.from(value, 'base64url'); + assert.equal(bytes.toString('base64url'), value, 'noncanonical base64url'); + return bytes; +} +function keyThumbprint(key) { return sha(jsonBytes({ crv: key.crv, kty: key.kty, x: key.x })); } +function evaluate(chain, trust) { + try { + const jws = chain.signed_attestation; + if (jws.profile !== 'adcp/addon-chain/v1') return 'profile'; + const header = JSON.parse(decode(jws.protected)); + if (!validHeader(header)) return 'header'; + const key = trust.key; + if (!validKey(key) || key.kid !== header.kid) return 'key_purpose'; + if (trust.publisher_pin) { + if (keyThumbprint(key) !== keyThumbprint(trust.publisher_pin)) return 'publisher_pin'; + for (const field of ['alg', 'use', 'adcp_use']) { + if (trust.publisher_pin[field] !== undefined && trust.publisher_pin[field] !== key[field]) return 'publisher_pin'; + } + if (trust.publisher_pin.key_ops && !trust.publisher_pin.key_ops.includes('verify')) return 'publisher_pin'; + } + if (!crypto.verify(null, Buffer.from(`${jws.protected}.${jws.payload}`), crypto.createPublicKey({ key, format: 'jwk' }), decode(jws.signature))) return 'signature'; + const payloadBytes = decode(jws.payload); + const claims = JSON.parse(payloadBytes); + if (!jsonBytes(claims).equals(payloadBytes)) return 'canonicalization'; + if (!validChain(chain) || !validClaims(claims)) return 'schema'; + if (!Number.isSafeInteger(claims.iat) || claims.iat < 0 || claims.iat > trust.now + 60) return 'time'; + for (const authority of [key, trust.publisher_pin].filter(Boolean)) { + if (!authority.revoked_at) continue; + const revoked = Date.parse(authority.revoked_at) / 1000; + if (!Number.isFinite(revoked) || claims.iat >= revoked || !trust.observed_at || trust.observed_at >= revoked || trust.observed_at < claims.iat || trust.observed_chain_hash !== chain.chain_hash || trust.observed_attestation_hash !== sha(jsonBytes(jws))) return 'revocation'; + } + for (const field of ['agent_url', 'publisher_domain', 'creative_id', 'format_kind', 'variant_id', 'media_buy_id', 'package_id', 'placement_id', 'revision_id', 'locale_variant_id']) { + if (claims[field] !== trust.expected[field]) return 'context'; + } + if (claims.typ !== 'adcp-addon-chain+jws' || claims.creative_id !== chain.creative_id || claims.creative_hash !== chain.creative_hash || chain.creative_hash !== trust.expected.creative_hash) return 'creative_hash'; + if (sha(jsonBytes(chain.entries)) !== chain.chain_hash || claims.chain_hash !== chain.chain_hash) return 'chain_hash'; + if (sha(jsonBytes(chain.configuration)) !== claims.configuration_hash) return 'configuration_hash'; + if (chain.configuration.complete !== true || chain.configuration.scope !== 'product' || chain.configuration.product_id !== trust.expected.product_id) return 'configuration'; + let lastApplied = [-1, -1, -1]; + let sawOmission = false; + let lastOmission = -1; + const compare = (a, b) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; + for (const [i, entry] of chain.entries.entries()) { + if (entry.order !== i || 'content' in entry || 'auth' in entry) return 'entry'; + const vendor = chain.configuration.vendors[entry.registration_index]; + if (!vendor || entry.agent_name !== vendor.agent_name || entry.source !== vendor.source || entry.agent_url !== vendor.agent_url) return 'entry'; + if (entry.status === 'applied') { + if (sawOmission || !vendor.positions.includes(entry.position) || !vendor.format_kinds.includes(claims.format_kind)) return 'order'; + const tuple = [entry.position === 'prepend' ? 0 : 1, entry.registration_index, entry.fragment_index]; + if (compare(tuple, lastApplied) <= 0) return 'order'; + lastApplied = tuple; + if (vendor.source === 'static_reference' && entry.content_hash !== vendor.static_content_hash) return 'entry'; + } else if (entry.status === 'omitted') { + sawOmission = true; + if (entry.registration_index <= lastOmission) return 'order'; + lastOmission = entry.registration_index; + if (vendor.mode === 'required') return 'required_omission'; + if (!['failure', 'vendor_omission'].includes(entry.reason) || (entry.reason === 'failure') !== ('error_code' in entry)) return 'entry'; + } else return 'entry'; + } + for (const [index, vendor] of chain.configuration.vendors.entries()) { + const entries = chain.entries.filter(entry => entry.registration_index === index); + if (!vendor.format_kinds.includes(claims.format_kind)) { if (entries.length) return 'entry'; continue; } + if (!entries.length || (entries.some(e => e.status === 'omitted') && entries.length !== 1)) return 'coverage'; + const fragments = entries.filter(e => e.status === 'applied').map(e => e.fragment_index).sort((a, b) => a - b); + if (fragments.some((value, index) => value !== index)) return 'entry'; + } + return 'verified'; + } catch { return 'encoding'; } +} +function assemble(input) { + // Model the seller decision boundary; vendors are supplied as recorded outcomes. + const applied = []; const omitted = []; + let pending = false; + for (const [registration_index, vendor] of input.vendors.entries()) { + if (vendor.mode === 'required' && vendor.first_failure_seconds !== undefined && + input.elapsed_seconds >= vendor.first_failure_seconds + vendor.max_hold_seconds) { + return { status: 'failed', code: 'ADDON_REQUIRED_UNAVAILABLE' }; + } + if (vendor.source === 'static_reference') { + applied.push({ registration_index, fragment_index: 0, position: vendor.position, content: vendor.content }); + continue; + } + const invalid = vendor.fragments?.some(f => sha(Buffer.from(f.content, 'utf8')) !== f.content_hash || !['prepend', 'append'].includes(f.position)); + const failed = vendor.error_code || invalid || !vendor.fragments?.length; + if (failed) { + if (vendor.mode === 'required') { + const deadline = (vendor.first_failure_seconds ?? 0) + vendor.max_hold_seconds; + if (!input.async || input.elapsed_seconds >= deadline) return { status: 'failed', code: 'ADDON_REQUIRED_UNAVAILABLE' }; + pending = true; + continue; + } + omitted.push({ registration_index, reason: vendor.error_code || invalid ? 'failure' : 'vendor_omission' }); + continue; + } + vendor.fragments.forEach((f, fragment_index) => applied.push({ ...f, registration_index, fragment_index })); + } + if (pending) return { status: 'working', task_id: input.task_id }; + applied.sort((a, b) => (a.position === 'prepend' ? 0 : 1) - (b.position === 'prepend' ? 0 : 1) || a.registration_index - b.registration_index || a.fragment_index - b.fragment_index); + return { status: 'completed', fragments: applied.map(f => f.content), omitted }; +} + +test('published addon vectors exist and verify using independent crypto and JCS implementations', () => { + assert.ok(fs.existsSync(fixturePath), 'missing published creative addon vectors'); + const fixture = JSON.parse(fs.readFileSync(fixturePath)); + for (const vector of fixture.valid) { + assert.equal(sha(Buffer.from(vector.fragment, 'utf8')), vector.fragment_hash, vector.id); + assert.equal(sha(jsonBytes(vector.creative_hash_input)), vector.chain.creative_hash, vector.id); + assert.equal(evaluate(vector.chain, vector.trust), 'verified', vector.id); + assert.equal(crypto.sign(null, Buffer.from(`${vector.chain.signed_attestation.protected}.${vector.chain.signed_attestation.payload}`), crypto.createPrivateKey({ key: fixture.test_private_key, format: 'jwk' })).toString('base64url'), vector.chain.signed_attestation.signature, `${vector.id} deterministic signature`); + } +}); + +test('negative signature vectors reject altered chains, wrong authority and mismatched delivery context', () => { + assert.ok(fs.existsSync(fixturePath), 'missing published creative addon vectors'); + const fixture = JSON.parse(fs.readFileSync(fixturePath)); + const failures = []; + for (const vector of fixture.invalid) { + const base = structuredClone(fixture.valid[vector.base || 0]); + for (const edit of vector.edits) { + let target = base; + const keys = edit.path.split('.'); + const last = keys.pop(); + for (const key of keys) target = target[key]; + if (edit.remove) delete target[last]; else target[last] = edit.value; + } + const actual = evaluate(base.chain, base.trust); + if (actual !== vector.expected) failures.push({ id: vector.id, actual, expected: vector.expected }); + } + assert.deepEqual(failures, []); +}); + +test('behavior vectors preserve required holds and explicit best-effort omissions', () => { + assert.ok(fs.existsSync(fixturePath), 'missing published creative addon vectors'); + const fixture = JSON.parse(fs.readFileSync(fixturePath)); + const actual = fixture.behavior.map(vector => ({ id: vector.id, result: assemble(vector.input) })); + const expected = fixture.behavior.map(vector => ({ id: vector.id, result: vector.expected })); + assert.deepEqual(actual, expected); +}); + +// Check a recorded recovery exchange, including the identity boundary between +// transport replay and a fresh vendor evaluation. No network/runtime is simulated. +function verifyRecoveryTrace(trace) { + const first = trace[0]; + for (let i = 1; i < trace.length; i++) { + const before = trace[i - 1]; const after = trace[i]; + if (after.task_id !== first.task_id || after.deadline !== first.deadline) return 'seller_operation_changed'; + const sameKey = before.request.idempotency_key === after.request.idempotency_key; + if (sameKey && !jsonBytes(before.request).equals(jsonBytes(after.request))) return 'idempotency_conflict'; + if (before.outcome === 'ambiguous' && !sameKey) return 'unresolved_attempt'; + if (before.outcome !== 'ambiguous' && sameKey && after.outcome !== before.outcome) return 'changed_replay'; + if (after.at >= after.deadline && after.outcome === 'applied') return 'late_delivery'; + } + return 'valid'; +} + +test('required empty response can recover with fresh evaluation and feedback while ambiguous retries preserve the exact request', () => { + const first = { task_id: 'seller-task-1', deadline: 60, at: 1, + request: { idempotency_key: 'vendor-attempt-0001', creatives: [{ creative_id: 'c', format_kind: 'image' }] }, outcome: 'empty' }; + const repairedRequest = { ...first.request, idempotency_key: 'vendor-attempt-0002', prior_failures: [{ error_code: 'ADDON_MALFORMED_FRAGMENT', creative_id: 'c', format_kind: 'image', timestamp: '2026-10-14T12:00:01Z' }] }; + const pending = { ...first, at: 20, request: repairedRequest, outcome: 'ambiguous' }; + const recovered = { ...pending, at: 25, outcome: 'applied' }; + assert.equal(verifyRecoveryTrace([first, pending, recovered]), 'valid'); + assert.equal(verifyRecoveryTrace([first, { ...pending, request: { ...repairedRequest, idempotency_key: first.request.idempotency_key } }]), 'idempotency_conflict'); + assert.equal(verifyRecoveryTrace([first, pending, { ...recovered, request: { ...repairedRequest, idempotency_key: 'vendor-attempt-0003' } }]), 'unresolved_attempt'); + assert.equal(verifyRecoveryTrace([first, { ...pending, deadline: 90 }]), 'seller_operation_changed'); + assert.equal(verifyRecoveryTrace([first, pending, { ...recovered, at: 60 }]), 'late_delivery'); +}); diff --git a/tests/creative-addons.test.cjs b/tests/creative-addons.test.cjs new file mode 100644 index 00000000000..61c17802e2a --- /dev/null +++ b/tests/creative-addons.test.cjs @@ -0,0 +1,169 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); +const Ajv = require('ajv'); +const addFormats = require('ajv-formats'); + +const root = path.join(__dirname, '../static/schemas/source'); +const ajv = new Ajv({ strict: false, allErrors: true, discriminator: true }); +addFormats(ajv); +ajv.addFormat('uri-template', true); +for (const file of fs.readdirSync(root, { recursive: true })) { + if (!file.endsWith('.json')) continue; + const schema = JSON.parse(fs.readFileSync(path.join(root, file))); + if (schema.$id) ajv.addSchema(schema); +} +const clone = structuredClone; +function validate(name, value, expected = true) { + const validator = ajv.getSchema(`/schemas/${name}.json`); + assert.ok(validator, `missing ${name} contract`); + assert.equal(validator(value), expected, JSON.stringify(validator.errors)); +} +const hash = `sha256:${'a'.repeat(64)}`; +const registration = { + agent_name: 'sentinel', mode: 'required', max_hold_seconds: 60, + agent_url: 'https://addons.example/agent', +}; +const disclosure = { + scope: 'product', product_id: 'display', complete: true, + signed_chain_profile: 'adcp/addon-chain/v1', + vendors: [{ agent_name: 'sentinel', source: 'live', agent_url: 'https://addons.example/agent', + mode: 'required', max_hold_seconds: 60, format_kinds: ['image'], positions: ['prepend'] }], +}; +const request = { + idempotency_key: 'addon-operation-0001', operator_name: 'Example Sales', + operator_url: 'https://sales.example/agent', publisher_domain: 'publisher.example', + creatives: [{ creative_id: 'creative-1', format_kind: 'image', dimensions: { w: 300, h: 250 } }], +}; +const fragment = { creative_id: 'creative-1', position: 'prepend', content: '', content_hash: hash }; +const chain = { + creative_id: 'creative-1', creative_hash: hash, configuration: disclosure, + entries: [{ order: 0, registration_index: 0, fragment_index: 0, agent_name: 'sentinel', + agent_url: 'https://addons.example/agent', source: 'live', status: 'applied', position: 'prepend', + content_hash: hash, bytes: 17, timestamp: '2026-10-14T12:00:00Z' }], + chain_hash: hash, + signed_attestation: { profile: 'adcp/addon-chain/v1', protected: 'e30', payload: 'e30', signature: 'a'.repeat(86) }, +}; + +test('fragment request batches context without admitting creative payloads or unsupported formats', () => { + validate('creative/get-creative-addons-request', request); + for (const format_kind of ['html5', 'display_tag']) { + validate('creative/get-creative-addons-request', { ...request, creatives: [{ creative_id: 'c', format_kind }] }); + } + for (const mutation of [ + r => { r.creatives[0].format_kind = 'video_vast'; }, + r => { r.creatives[0].creative_manifest = { assets: [] }; }, + r => { r.creatives[0].content = 'buyer content'; }, + r => { r.creatives = []; }, + r => { delete r.idempotency_key; }, + r => { r.creatives[0].dimensions.w = 0; }, + ]) { const r = clone(request); mutation(r); validate('creative/get-creative-addons-request', r, false); } +}); + +test('response separates successful omissions from terminal errors and rejects invalid fragments', () => { + validate('creative/get-creative-addons-response', { status: 'completed', addons: [fragment] }); + validate('creative/get-creative-addons-response', { status: 'completed', addons: [] }); + validate('creative/get-creative-addons-response', { status: 'failed', errors: [{ code: 'SERVICE_UNAVAILABLE', message: 'Unavailable' }] }); + validate('creative/get-creative-addons-response', { addons: [], errors: [{ code: 'SERVICE_UNAVAILABLE' }] }, false); + validate('creative/get-creative-addons-response', { errors: [] }, false); + for (const change of [{ position: 'inside' }, { content_hash: 'sha256:wrong' }, { content: '' }]) { + validate('core/creative-addon-fragment', { ...fragment, ...change }, false); + } +}); + +test('registration requires one source and a finite positive hold for seller-required vendors', () => { + validate('core/creative-addon-registration', registration); + const staticVendor = { agent_name: 'meter', mode: 'best_effort', static_reference: { + format_kinds: ['image'], position: 'append', content: '' } }; + validate('core/creative-addon-registration', staticVendor); + validate('core/creative-addon-registration', { ...staticVendor, agent_url: registration.agent_url }, false); + const noHold = clone(registration); delete noHold.max_hold_seconds; + validate('core/creative-addon-registration', noHold, false); + for (const value of [0, -1, 0.1]) validate('core/creative-addon-registration', { ...registration, max_hold_seconds: value }, false); +}); + +test('disclosure separates undeclared, complete-empty and scoped resolved configuration without exposing loader code', () => { + validate('core/creative-addon-disclosure', disclosure); + validate('core/creative-addon-disclosure', { ...disclosure, vendors: [] }); + const bad = clone(disclosure); delete bad.product_id; + validate('core/creative-addon-disclosure', bad, false); + validate('core/creative-addon-disclosure', { ...disclosure, vendors: [{ ...disclosure.vendors[0], content: 'secret' }] }, false); + validate('core/canonical-product', { product_id: 'display', name: 'Display', creative_addons: disclosure }); + validate('media-buy/product-purchase', { product_id: 'display', pricing_option_id: 'cpm', creative_addons: disclosure }); + validate('media-buy/product-purchase', { product_id: 'display', pricing_option_id: 'cpm', creative_addons: { ...disclosure, complete: false } }, false); + validate('media-buy/product-purchase-input', { product_id: 'display', pricing_option_id: 'cpm' }); + validate('media-buy/product-purchase-input', { product_id: 'display', pricing_option_id: 'cpm', creative_addons: disclosure }, false); +}); + +test('buyer-facing chains require signatures and distinguish omission reasons without fragment disclosure', () => { + validate('core/creative-addon-chain', chain); + validate('core/creative-variant', { variant_id: 'variant-1', addon_chain: chain }); + const unsigned = clone(chain); delete unsigned.signed_attestation; + validate('core/creative-addon-chain', unsigned, false); + for (const key of ['content', 'auth']) { + const bad = clone(chain); bad.entries[0][key] = 'secret'; + validate('core/creative-addon-chain', bad, false); + } + const omitted = clone(chain); + omitted.entries = [{ order: 0, registration_index: 0, agent_name: 'sentinel', source: 'live', + agent_url: registration.agent_url, status: 'omitted', reason: 'vendor_omission', timestamp: '2026-10-14T12:00:00Z' }]; + validate('core/creative-addon-chain', omitted); + omitted.entries[0].reason = 'failure'; + validate('core/creative-addon-chain', omitted, false); + omitted.entries[0].error_code = 'ADDON_UNREACHABLE'; + validate('core/creative-addon-chain', omitted); +}); + +test('signing keys enforce dedicated public Ed25519 material and reject private keys', () => { + const key = { kid: 'addon-1', kty: 'OKP', crv: 'Ed25519', alg: 'EdDSA', use: 'sig', + key_ops: ['verify'], adcp_use: 'addon-chain-signing', x: 'a'.repeat(43) }; + validate('core/creative-addon-signing-key', key); + for (const change of [{ adcp_use: 'request-signing' }, { alg: 'none' }, { key_ops: ['sign'] }, { d: 'secret' }]) { + validate('core/creative-addon-signing-key', { ...key, ...change }, false); + } +}); + +test('addon capability cannot opt out of signed chains or claim unsupported formats', () => { + const capability = { supported_format_kinds: ['image'], positions: ['prepend'], + signed_chain: { required: true, profile: 'adcp/addon-chain/v1' } }; + validate('core/creative-addon-capability', capability); + validate('core/creative-addon-capability', { ...capability, signed_chain: { ...capability.signed_chain, required: false } }, false); + validate('core/creative-addon-capability', { ...capability, supported_format_kinds: ['video_vast'] }, false); + validate('enums/specialism', 'creative-addon'); +}); + +test('addon task is discoverable in the creative protocol and terminal status matches its domain outcome', () => { + const index = JSON.parse(fs.readFileSync(path.join(root, 'index.json'))); + assert.ok(index.schemas.creative.tasks['get-creative-addons'], 'task belongs to creative protocol'); + validate('creative/get-creative-addons-response', { status: 'failed', addons: [] }, false); + validate('creative/get-creative-addons-response', { status: 'completed', errors: [{ code: 'SERVICE_UNAVAILABLE' }] }, false); + validate('creative/get-creative-addons-response', { status: 'completed', addons: [], errors: [{ code: 'SERVICE_UNAVAILABLE' }] }, false); +}); + +test('published positive vectors validate their wire shapes and decoded signed claims', () => { + const fixtures = path.join(__dirname, '../static/compliance/source/test-vectors'); + const fixture = JSON.parse(fs.readFileSync(path.join(fixtures, 'creative-addon-chain.json'))); + const vectorSchema = JSON.parse(fs.readFileSync(path.join(fixtures, 'creative-addon-chain.schema.json'))); + const validator = ajv.compile(vectorSchema); + assert.equal(validator(fixture), true, JSON.stringify(validator.errors)); + const claims = ajv.getSchema('/schemas/core/creative-addon-chain.json#/definitions/Claims'); + const header = ajv.getSchema('/schemas/core/creative-addon-chain.json#/definitions/ProtectedHeader'); + for (const vector of fixture.valid) { + assert.equal(claims(JSON.parse(Buffer.from(vector.chain.signed_attestation.payload, 'base64url'))), true, JSON.stringify(claims.errors)); + assert.equal(header(JSON.parse(Buffer.from(vector.chain.signed_attestation.protected, 'base64url'))), true, JSON.stringify(header.errors)); + } +}); + +test('product and account disclosures cannot cross scopes or report failed resolution as complete', () => { + const accountDisclosure = { ...disclosure, scope: 'account' }; delete accountDisclosure.product_id; + validate('core/canonical-product', { product_id: 'display', name: 'Display', creative_addons: accountDisclosure }, false); + const account = { account: { account_id: 'account-1' }, action: 'unchanged', status: 'active', creative_addons: accountDisclosure }; + validate('account/sync-accounts-response', { status: 'completed', accounts: [account] }); + validate('account/sync-accounts-response', { status: 'completed', accounts: [{ ...account, creative_addons: disclosure }] }, false); + validate('account/sync-accounts-response', { status: 'completed', accounts: [{ ...account, action: 'failed', errors: [{ code: 'PERMISSION_DENIED' }] }] }, false); +}); + +test('canonical product projections can request the addon compatibility disclosure', () => { + validate('media-buy/product-fields', ['product_id', 'creative_addons']); +}); diff --git a/tests/mcp-schema-analysis.test.cjs b/tests/mcp-schema-analysis.test.cjs index 0b698188573..e4eee5e09d2 100644 --- a/tests/mcp-schema-analysis.test.cjs +++ b/tests/mcp-schema-analysis.test.cjs @@ -51,8 +51,9 @@ test("input-field weight report attributes the largest transitive schema graphs" // list_collections (#8183) adds one tool and the collection selector, // collection kind, and genre taxonomy definitions it alone reaches, plus the // shared locale tag its languages filter references. - assert.equal(report.definition_instances, 693); - assert.equal(report.unique_definitions, 188); + // Seller-resolved addon disclosure adds one shared purchase definition. + assert.equal(report.definition_instances, 694); + assert.equal(report.unique_definitions, 189); assert.equal(report.repeated_definitions, 133); assert.ok(report.repeated_definition_bytes > 180_000); @@ -177,7 +178,7 @@ test("shared dictionary resolves every experimental tool schema when explicitly assert.equal(view.dictionary.$id, DICTIONARY_ID); // Must match the intentionally pinned unique-definition inventory above. - assert.equal(Object.keys(view.dictionary.$defs).length, 188); + assert.equal(Object.keys(view.dictionary.$defs).length, 189); for (const tool of Object.values(view.tools)) { assert.equal(tool.inputSchema.$defs, undefined); assert.match( diff --git a/tests/mcp-schema-projection.test.cjs b/tests/mcp-schema-projection.test.cjs index b54249e33d1..0081dbafc19 100644 --- a/tests/mcp-schema-projection.test.cjs +++ b/tests/mcp-schema-projection.test.cjs @@ -49,15 +49,15 @@ const STORYBOARD_DIR = path.join(REPO_ROOT, 'static', 'compliance', 'source'); const LATEST_DIR = path.join(REPO_ROOT, 'dist', 'schemas', 'latest'); const PROJECTION_DIR = path.join(LATEST_DIR, 'mcp', MCP_PROTOCOL_VERSION); const PRODUCTION_PROFILE_DIR = path.join(PROJECTION_DIR, 'profiles', 'production'); -// The generated profiles describe the active surface of the package's release -// line. Before the line's GA (x.y.0-rc.N) that is the x.y.0 surface the tool -// manifest declares via added_in; once the line has a stable release it is the -// released package version itself (3.2 GA ships as 3.2.1 because 3.2.0 is -// permanently withdrawn; see RELEASING.md). +// The active source surface includes get_creative_addons in 3.3 even while +// package.json still names the previous stable release. Preserve newer package +// versions once that release line ships; prereleases describe their GA surface. const PACKAGE_VERSION = require('../package.json').version; -const ACTIVE_SURFACE_VERSION = semver.prerelease(PACKAGE_VERSION) +const PACKAGE_SURFACE_VERSION = semver.prerelease(PACKAGE_VERSION) ? `${semver.major(PACKAGE_VERSION)}.${semver.minor(PACKAGE_VERSION)}.0` : PACKAGE_VERSION; +const ACTIVE_SURFACE_VERSION = semver.rsort([PACKAGE_SURFACE_VERSION, '3.3.0'])[0]; +const ACTIVE_RELEASE_LINE = `${semver.major(ACTIVE_SURFACE_VERSION)}.${semver.minor(ACTIVE_SURFACE_VERSION)}`; // Macro occurrence contracts and representation-set resolution add shared, // structurally enforced graphs to media-buy tasks. The 3.2 tracker contract // adds one seller-bound destination contract to build_creative; keep that @@ -1632,11 +1632,13 @@ test('comply-test-controller projected schema uses embedded-resource refs, not r } }); -test('generated production profile exposes the active 3.2 surface without compliance annotations', () => { +test('generated production profile exposes the active surface without compliance annotations', () => { assert.ok(fs.existsSync(PRODUCTION_PROFILE_DIR), 'production profile is missing'); const canonicalManifest = readJson(path.join(LATEST_DIR, 'manifest.json')); const profile = readJson(path.join(PRODUCTION_PROFILE_DIR, 'manifest.json')); + assert.equal(canonicalManifest.tools.get_creative_addons.added_in, '3.3.0'); + assert.ok(profile.tools.get_creative_addons); assert.equal(profile.profile, 'production'); assert.equal(profile.surface_version, ACTIVE_SURFACE_VERSION); assert.equal(profile.annotation_mode, 'structural'); @@ -1793,7 +1795,7 @@ test('generated role profiles are host-compatible discovery catalogs with bounde assert.equal(profile.profile, profileName); assert.equal(profile.profile_kind, 'active-role-catalog'); assert.equal(profile.surface_version, ACTIVE_SURFACE_VERSION); - assert.equal(profile.compatibility_scope, 'active-3.2-only'); + assert.equal(profile.compatibility_scope, `active-${ACTIVE_RELEASE_LINE}-only`); assert.equal(profile.annotation_mode, 'structural'); assert.deepEqual(profile.schema_fields, ['inputSchema', 'outputSchema']); assert.deepEqual(profile.filters, { @@ -1806,6 +1808,8 @@ test('generated role profiles are host-compatible discovery catalogs with bounde assert.equal(modelContext.profile, profileName); assert.equal(modelContext.view, 'client-prompt-inputs'); + assert.equal(modelContext.surface_version, ACTIVE_SURFACE_VERSION); + assert.equal(modelContext.compatibility_scope, `active-${ACTIVE_RELEASE_LINE}-only`); assert.equal(modelContext.annotation_mode, 'model-context'); assert.equal(modelContext.discovery_profile, '../manifest.json'); assert.equal( diff --git a/tests/targeting-null-clear.test.cjs b/tests/targeting-null-clear.test.cjs index c79e3e24a27..7b5a1085072 100644 --- a/tests/targeting-null-clear.test.cjs +++ b/tests/targeting-null-clear.test.cjs @@ -170,9 +170,16 @@ describe('request-only targeting null-clear semantics', () => { test('codegen-safe product purchase input copies remain in strict-schema parity', () => { const strict = readSchema('/schemas/media-buy/product-purchase.json'); const input = readSchema('/schemas/media-buy/product-purchase-input.json'); - assert.deepEqual(Object.keys(input.properties).sort(), Object.keys(strict.properties).sort()); + // creative_addons is seller-resolved evidence in the accepted snapshot. + // Direct buyers bind it through feed_version, without echoing the disclosure. + assert.ok(Object.hasOwn(strict.properties, 'creative_addons')); + assert.equal(Object.hasOwn(input.properties, 'creative_addons'), false); + assert.deepEqual( + Object.keys(input.properties).sort(), + Object.keys(strict.properties).filter(property => property !== 'creative_addons').sort() + ); for (const property of Object.keys(strict.properties)) { - if (property === 'targeting_overlay') continue; + if (property === 'targeting_overlay' || property === 'creative_addons') continue; const strictPropertyRef = `/schemas/media-buy/product-purchase.json#/properties/${property}`; if (input.properties[property].$ref === strictPropertyRef) continue; assert.deepEqual(