Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -295,16 +295,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 @@ -317,7 +320,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 | webhook_receiver | multi_agent |
# Known values: controller | seeded_state | real_wire | collect_product_pages_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 @@ -1177,6 +1184,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 @@ -427,6 +427,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