Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/offline-legacy-format-fixtures.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"adcontextprotocol": patch
---

Define an explicitly gated, offline test-kit catalog for legacy creative asset fixtures. Require exact format references and distinguish missing preparation from malformed fixture inputs without imposing Creative Protocol tools on media-buy-only sellers.
16 changes: 10 additions & 6 deletions scripts/lint-storyboard-sample-request-schema.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -307,16 +307,19 @@ function normalizeSubstitutions(value, schema) {
* Keep ordinary slots, malformed instructions, and every other request field
* visible to schema validation. Direct validateStep calls remain wire-strict.
*/
function normalizeExecutionOnlyAssetDirectives(value, isAssetMap = false) {
if (Array.isArray(value)) return value.map(item => normalizeExecutionOnlyAssetDirectives(item));
function normalizeExecutionOnlyAssetDirectives(value, isAssetMap = false, allowTestKitCatalog = false) {
if (Array.isArray(value)) return value.map(item => normalizeExecutionOnlyAssetDirectives(item, false, allowTestKitCatalog));
if (!value || typeof value !== 'object') return value;
const out = {};
for (const [key, item] of Object.entries(value)) {
if (isAssetMap && key === '$build_assets_from_format'
&& typeof item === 'string' && /^\$context\.[A-Za-z_][A-Za-z0-9_]*$/.test(item)) {
const contextRef = candidate => typeof candidate === 'string'
&& /^\$context\.[A-Za-z_][A-Za-z0-9_]*$/.test(candidate);
const catalogDirective = allowTestKitCatalog && item && typeof item === 'object' && !Array.isArray(item)
&& Object.keys(item).length === 2 && item.catalog === 'test_kit' && contextRef(item.format_id);
if (isAssetMap && key === '$build_assets_from_format' && (contextRef(item) || catalogDirective)) {
continue;
}
out[key] = normalizeExecutionOnlyAssetDirectives(item, key === 'assets');
out[key] = normalizeExecutionOnlyAssetDirectives(item, key === 'assets', allowTestKitCatalog);
}
return out;
}
Expand All @@ -329,7 +332,8 @@ function normalizeExecutionOnlyAssetDirectives(value, isAssetMap = false) {
* coverage; this lint must still catch broken bindings and authored payloads.
*/
function normalizeStoryboardSampleRequests(storyboard, step, schema) {
const sampleRequest = normalizeExecutionOnlyAssetDirectives(step.sample_request);
const sampleRequest = normalizeExecutionOnlyAssetDirectives(step.sample_request, false,
Array.isArray(storyboard.requires) && storyboard.requires.includes('test_kit_format_catalog_runner'));
if (!storyboard.creative_build_probe || step.task !== 'build_creative'
|| step.requires_contract !== 'creative_build_probe_runner') {
return [normalizeSubstitutions(sampleRequest, schema)];
Expand Down
35 changes: 34 additions & 1 deletion static/compliance/source/universal/storyboard-schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
# never claimed the capability.
#
# requires: string[] (optional — storyboard-level runtime requirement gate.
# Known values: controller | seeded_state | real_wire | collect_product_pages_runner | field_excludes_runner | webhook_receiver | multi_agent |
# Known values: controller | seeded_state | real_wire | collect_product_pages_runner | field_excludes_runner | test_kit_format_catalog_runner | webhook_receiver | multi_agent |
# trusted_match_publisher_auth_runner | property_list_reference_runner (forward-compat:
# unrecognized values are NOT schema violations — runners emit
# requirement_unmet at runtime; see Forward compatibility below).
Expand All @@ -164,6 +164,13 @@
#
# Values and availability:
#
# test_kit_format_catalog_runner — intrinsic support for the explicit offline
# legacy-format catalog form of $build_assets_from_format
# described below. Storyboards using that form MUST declare
# this requirement. Older runners report requirement_unmet
# before fixture mutation. Availability means syntax support,
# not that the operator supplied a matching declaration.
#
# collect_product_pages_runner — intrinsic support for the opt-in wholesale
# product-page collector. No operator configuration is needed.
# A storyboard using collect_product_pages MUST declare this
Expand Down Expand Up @@ -1185,6 +1192,32 @@
# example, `legal`, `disclaimer`, or `alt_text`) from an arbitrary fallback
# category.
#
# Offline legacy declarations: to test a media-buy-only seller without imposing
# list_creative_formats on it, the directive MAY instead use this object form:
# $build_assets_from_format:
# format_id: "$context.legacy_format_id"
# catalog: test_kit
# The wrapper MUST contain exactly those two keys, and the storyboard MUST
# require test_kit_format_catalog_runner. The captured value MUST be a valid
# complete FormatId. The operator supplies test_kit.creative_formats as an
# array of full core/format.json declarations obtained from their declaring
# agents. Each entry MUST include an explicit assets array (empty is allowed),
# valid slot declarations and unique asset identifiers. These are fixture
# inputs, not seller responses or seeds; runners MUST NOT overwrite discovery
# context or the seller's catalog with them, or fetch any reference implicitly.
#
# Match the entire format_id by structural JSON equality after canonicalizing
# agent_url per the URL canonicalization rules. Preserve every other field,
# including width, height, duration_ms, pixel_ratio and extension parameters.
# Do not instantiate a template, override dimensions, or match by slug alone.
# An absent catalog or absent exact match is fixture_unavailable, naming
# "format declaration" as the unavailable slot and "format" as its type.
# Malformed catalogs, duplicate exact matches and malformed captured references
# are harness authoring errors, not fixture_unavailable. Unresolved context
# remains a prerequisite/harness error; it is not evidence of a catalog gap.
# Unsupported declared constraints are fixture_unavailable. The ordinary
# preflight and whole-storyboard termination rules below apply to this form.
#
# Asset-fixture coverage exception: when the captured format is valid but the
# runner cannot populate a required slot from the test kit — including an
# unsupported asset type, an unknown text-slot semantic, or no image fixture
Expand Down
37 changes: 37 additions & 0 deletions tests/lint-storyboard-sample-request-schema.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -436,6 +436,43 @@ test('static authoring excludes only valid runtime asset directives while wire r
}
});

test('offline format directives require the runtime gate and an exact wrapper before static exclusion', async () => {
const { storyboard, steps } = cancellationCreativeAuthoringFixture();
const step = structuredClone(steps[0]);
const schema = loadSchema(step.schema_ref);
const directive = { format_id: '$context.legacy_format_id', catalog: 'test_kit' };
step.sample_request.creatives[0].assets = { $build_assets_from_format: directive };
const gated = { ...storyboard, requires: ['test_kit_format_catalog_runner'] };
const validate = async (source, candidate) => Promise.all(normalizeStoryboardSampleRequests(source, candidate, schema)
.map(payload => validateStep({ schemaRef: step.schema_ref, payload })));
assert.ok((await validate(gated, step)).every(result => result.ok));
assert.ok((await validate({ ...storyboard, requires: ['real_wire'] }, step)).every(result => !result.ok),
'an older runner must be gated before the wrapper can appear');
for (const requires of ['test_kit_format_catalog_runner', 'not_test_kit_format_catalog_runner', {}, null]) {
assert.ok((await validate({ ...storyboard, requires }, step)).every(result => !result.ok),
'only an exact member of a requirement array enables the wrapper');
}
assert.equal((await validateStep({ schemaRef: step.schema_ref, payload: step.sample_request })).ok, false,
'the wrapper is never a valid wire asset');
for (const malformed of [
{ ...directive, extra: true },
{ ...directive, catalog: 'remote' },
{ ...directive, format_id: '$context.' },
{ catalog: 'test_kit' },
[directive],
]) {
const broken = structuredClone(step);
broken.sample_request.creatives[0].assets.$build_assets_from_format = malformed;
assert.ok((await validate(gated, broken)).every(result => !result.ok), JSON.stringify(malformed));
}
const brokenSibling = structuredClone(step);
brokenSibling.sample_request.creatives[0].assets.bad = 'not an asset';
assert.ok((await validate(gated, brokenSibling)).every(result => !result.ok));
const brokenAssignment = structuredClone(step);
brokenAssignment.sample_request.assignments[0].creative_id = 42;
assert.ok((await validate(gated, brokenAssignment)).every(result => !result.ok));
});

test('cancellation directives materialize real test-kit assets before strict request validation', async () => {
const { expandCreativeAssetDirectives, findUnresolvedCreativeAssetDirectives, injectContext } = require('@adcp/sdk/testing');
const { testKit, steps } = cancellationCreativeAuthoringFixture();
Expand Down
Loading