Skip to content
Draft
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
66 changes: 66 additions & 0 deletions .changeset/v2-to-v1-projection-prototype.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
'@adcp/sdk': minor
---

feat(v2): prototype v2 → v1 Product projection layer

Working prototype of the v2 → v1 Product projection from the 8.0 design
proposal at `docs/development/v3.1-sdk-design.md` (PR #1809). Used when
the SDK negotiates to a v1 seller for a buyer that wrote V2 code —
adopters never see v1 vocabulary; SDK does the wire-level translation.

Implements the resolution order from `v1-canonical-mapping.json`,
inverted:

1. `format_kind: "custom"` + `canonical_formats_only: true` →
`FORMAT_DECLARATION_V1_UNREACHABLE` diagnostic (explicit opt-out).
2. `v1_format_ref` present → use verbatim (seller-asserted equivalence).
3. Registry reverse-lookup → invertible match (canonical + params
narrow compatibly to a literal v1 named format).
4. Registry says "family exists but ambiguous" →
`FORMAT_DECLARATION_V1_AMBIGUOUS` diagnostic.
5. Registry says "no entry for this canonical" →
`FORMAT_DECLARATION_V1_UNREACHABLE` diagnostic.

Diagnostics emitted on the structured channel (`ProjectionDiagnostic[]`)
per the spec's resolution-order amendment — never logger-only.

**Exercised against all 13 spec reference fixtures.** Coverage report:
- 1/13 clean v1 emit (`nytimes_homepage_mrec` — image 300x250 matches
the IAB MREC registry entry).
- 1/13 explicit opt-out (`nytimes_homepage_takeover_custom`).
- 7/13 ambiguous (the family has registry entries but none invert
cleanly — `display_tag`, `video_hosted`, `html5`, `audio_hosted`,
`audio_daast`, `video_vast`).
- 4/13 no registry coverage (`sponsored_placement`, `agent_placement`,
`responsive_creative`, `image_carousel` — these are genuinely new
canonical concepts in v2 with no v1 equivalent).

Headline implication for the 8.0 design: without sellers adding
`v1_format_ref` to their declarations, only ~8% of v2 fixtures
project cleanly. The "v2-only public type with projection at the
boundary" stance still holds — products that can't downgrade
gracefully surface via diagnostics — but the migration story for v1
sellers depends on sellers actively annotating their v2 declarations.

**Surfaced upstream-spec bug**: the IAB-named registry entries
(`iab/mrec_300x250` etc.) contain slashes, but `format-id.json` only
allows `^[a-zA-Z0-9_-]+$`. Projecting `nytimes_homepage_mrec` produces
a synthesized `format_id.id` that wouldn't pass wire validation — this
is the same cross-schema mismatch flagged in
adcontextprotocol/adcp upstream.

**Scope notes**:

- Hand-rolled TypeScript types in `src/lib/v2/projection/types.ts`
rather than full versioned codegen — separate piece of the 8.0
enablement. Types match the schema shape at projection-relevant
precision; params bodies stay loose (`Record<string, unknown>`).
- v1 → v2 (upgrade direction) is the symmetric counterpart and not
in this prototype. Will land in a follow-up once we agree on
the design surface.
- No public barrel export yet — the projection layer is internal-only
until the auto-negotiation surface lands. Tests import directly
from `dist/lib/v2/projection/v2-to-v1.js`.
- Public types and behavior may change as the 8.0 design firms up
(see PR #1809 for the architecture proposal).
8 changes: 7 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,10 @@ docs/
# Vendored AdCP conformance vectors — byte-identical to upstream. Prettier
# would reformat and diverge from the source of truth. Remove once vectors
# load from compliance/cache/ (already ignored) instead of test/fixtures/.
test/fixtures/webhook-signing-vectors/
test/fixtures/webhook-signing-vectors/

# Vendored v2 canonical-format reference fixtures from adcontextprotocol/adcp#3307
# (static/examples/products/canonical/). Same byte-fidelity rule as
# webhook-signing-vectors — these are spec-authored examples and reformatting
# would diverge from the source of truth.
test/lib/v2-projection-fixtures/
94 changes: 94 additions & 0 deletions src/lib/v2/projection/canonical-properties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Read structural properties off the canonical format schemas at
* `schemas/cache/<version>/formats/canonical/<kind>.json`. The
* projection layer needs `v1_translatable` per canonical to honor the
* normative rule from `v1-canonical-mapping.json`:
*
* > SDKs encountering `v1_translatable: false` on a canonical SHOULD
* > NOT emit `FORMAT_PROJECTION_FAILED` (which signals registry-
* > coverage gap) — instead surface the inherent v1-unreachability
* > as a different diagnostic or skip silently. The 4 inherently-v2
* > canonicals at 3.1 GA: `image_carousel`, `sponsored_placement`,
* > `responsive_creative`, `agent_placement`.
*
* Cached per canonical kind. Falls back to the `_base.json` default
* (`true`) when a canonical doesn't override the field — matches the
* spec's default semantics.
*/

import { readFileSync, existsSync } from 'fs';
import path from 'path';
import type { CanonicalFormatKind } from './types';

interface CanonicalSchema {
properties?: {
v1_translatable?: {
default?: boolean;
};
};
}

let cache: Map<CanonicalFormatKind, boolean> | null = null;
let baseDefault: boolean | null = null;

function loadCanonicalSchema(kind: CanonicalFormatKind, cacheRoot: string): CanonicalSchema | null {
const file = path.join(cacheRoot, 'formats', 'canonical', `${kind}.json`);
if (!existsSync(file)) return null;
return JSON.parse(readFileSync(file, 'utf-8')) as CanonicalSchema;
}

function findCacheRoot(): string {
const candidates = [
path.join(__dirname, '..', '..', '..', '..', 'schemas', 'cache', '3.1.0-beta.0'),
path.join(__dirname, '..', '..', '..', '..', 'schemas', 'cache', 'latest'),
];
for (const c of candidates) {
if (existsSync(c)) return c;
}
throw new Error(
`No 3.1+ schema cache found. Run \`npm run sync-schemas\` for a 3.1+ AdCP version. ` +
`Looked in: ${candidates.join(', ')}.`
);
}

/**
* Returns true when this canonical has a v1 named-format equivalent;
* false when v2-only. The 4 inherently-v2 canonicals at 3.1 GA are
* `image_carousel`, `sponsored_placement`, `responsive_creative`,
* `agent_placement`; everything else inherits `true` from
* `formats/canonical/_base.json`'s default.
*
* `custom` returns `true` (the per-declaration `canonical_formats_only`
* flag is the actual signal there — custom isn't a baked-in v1/v2
* classification).
*/
export function isCanonicalV1Translatable(kind: CanonicalFormatKind): boolean {
if (cache && cache.has(kind)) return cache.get(kind)!;

if (cache === null) cache = new Map();

if (kind === 'custom') {
cache.set(kind, true);
return true;
}

const cacheRoot = findCacheRoot();

if (baseDefault === null) {
const base = loadCanonicalSchema('_base' as CanonicalFormatKind, cacheRoot);
const bd = base?.properties?.v1_translatable?.default;
baseDefault = typeof bd === 'boolean' ? bd : true;
}

const schema = loadCanonicalSchema(kind, cacheRoot);
const override = schema?.properties?.v1_translatable?.default;
const value = typeof override === 'boolean' ? override : baseDefault;
cache.set(kind, value);
return value;
}

/** Test hook: reset the memoized canonical properties cache. */
export function _resetCanonicalPropertiesCache(): void {
cache = null;
baseDefault = null;
}
213 changes: 213 additions & 0 deletions src/lib/v2/projection/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
/**
* Loader for the AAO canonical-formats catalog (`reference-formats.json`).
* Source of truth: `server/src/creative-agent/reference-formats.json` in
* the adcontextprotocol/adcp repo — published by the AAO with the
* `canonical` annotation on entries that have a v2 canonical-format
* equivalent.
*
* The catalog is the v1→v2 direction's primary lookup table: each entry
* is a v1 format definition, and 32 of the 57 entries in 3.1-beta carry
* a `canonical: <kind>` annotation that names the v2 canonical the v1
* format projects to. This is the spec's resolution-order step 2 —
* "seller-asserted on the v1 file" — applied to AAO-published v1 formats.
*
* Loader is keyed by `agent_url` (with trailing-slash normalization) +
* `format_id.id`. Same scope as the v2→v1 registry: AAO catalog only.
* Seller-specific catalogs (from a publisher's
* `list_creative_formats`) are out of scope for the prototype — the
* full 8.0 enablement would fetch them via an `AgentClient` injected
* by the auto-negotiation surface.
*/

import { readFileSync, existsSync } from 'fs';
import path from 'path';
import type { CanonicalFormatKind, V1FormatId } from './types';

/**
* v1 format definition shape from `reference-formats.json`. Only carries
* the fields the projection algorithm reads — name/description and the
* full asset/macro/requirement bodies are passed through opaquely when
* the caller wants them.
*/
export interface V1FormatDefinition {
format_id: V1FormatId;
name?: string;
description?: string;
type?: string;
accepts_parameters?: string[];
assets?: Array<{
item_type?: string;
asset_id?: string;
required?: boolean;
asset_type?: string;
requirements?: Record<string, unknown>;
}>;
/**
* v2 canonical kind this v1 format projects to. Present on 32 of the
* 57 AAO catalog entries at 3.1-beta GA. Absent entries fall through
* to the registry / structural-match steps of the resolution order.
*/
canonical?: CanonicalFormatKind;
// Pass-through for caller code that wants the rest.
[k: string]: unknown;
}

interface CatalogIndex {
/** Keyed by `<normalized-agent-url>::<id>` for O(1) lookup. */
byKey: Map<string, V1FormatDefinition>;
entries: V1FormatDefinition[];
}

let cached: CatalogIndex | null = null;

/**
* Normalize agent_url for indexing. Adds trailing slash when missing.
* The AAO publishes the canonical form as `https://creative.adcontextprotocol.org/`
* (with slash); some v2 fixtures use the no-slash form. Both refer to
* the same agent; this lookup folds them.
*/
function normalizeAgentUrl(u: string): string {
if (!u) return u;
return u.endsWith('/') ? u : u + '/';
}

function indexKey(agentUrl: string, id: string): string {
return `${normalizeAgentUrl(agentUrl)}::${id}`;
}

/**
* Load the catalog from a known path. Tries the test-fixture location
* (vendored copy) first, then the workspace `.context/adcp-3307/`
* checkout if present. Memoized — catalog is small and immutable per
* spec version.
*
* @param explicitPath caller-supplied path; takes precedence over
* fallback resolution.
*/
export function loadCatalog(explicitPath?: string): CatalogIndex {
if (cached) return cached;

const candidates = explicitPath
? [explicitPath]
: [
path.join(
__dirname,
'..',
'..',
'..',
'..',
'test',
'lib',
'v2-projection-fixtures',
'aao-reference-formats.json'
),
path.join(
__dirname,
'..',
'..',
'..',
'..',
'.context',
'adcp-3307',
'server',
'src',
'creative-agent',
'reference-formats.json'
),
];

for (const file of candidates) {
if (existsSync(file)) {
const raw = JSON.parse(readFileSync(file, 'utf-8')) as V1FormatDefinition[];
const byKey = new Map<string, V1FormatDefinition>();
for (const entry of raw) {
if (entry?.format_id?.agent_url && entry?.format_id?.id) {
byKey.set(indexKey(entry.format_id.agent_url, entry.format_id.id), entry);
}
}
cached = { byKey, entries: raw };
return cached;
}
}

throw new Error(
`AAO catalog (reference-formats.json) not found. Looked in: ${candidates.join(', ')}. ` +
`Vendor a copy at test/lib/v2-projection-fixtures/aao-reference-formats.json.`
);
}

/**
* Look up a v1 format definition by its format_id. Returns undefined
* when not in the catalog — caller falls through to registry / structural
* match per the resolution order.
*/
export function lookupV1Format(formatId: V1FormatId, explicitPath?: string): V1FormatDefinition | undefined {
const catalog = loadCatalog(explicitPath);
return catalog.byKey.get(indexKey(formatId.agent_url, formatId.id));
}

/** Test hook: reset the memoized catalog. */
export function _resetCatalogCache(): void {
cached = null;
}

/**
* Find a catalog entry matching `(canonical, width, height)` at the same
* publisher domain as `agentUrl`. Used by the v2 → v1 multi-size fan-out:
* when a v2 declaration says `sizes: [W1×H1, W2×H2, ...]` and the
* seller-asserted `v1_format_ref` points at the catalog entry for one of
* those sizes, the SDK can look up the entries for the OTHER sizes and
* emit additional v1 format_ids — giving v1 buyers full size coverage
* instead of just the rep.
*
* The catalog uses a stable id-pattern convention
* (`<prefix>_<W>x<H>_<suffix>`) for per-size entries — image, html5, and
* generative all follow it. This function parses ids matching that
* pattern and matches on extracted dimensions, so the lookup is
* dimensionally honest without requiring the spec to add explicit
* width/height fields to catalog entries.
*
* Returns undefined when no matching entry exists at this publisher.
* Caller falls back to the seller-asserted rep + lossy advisory.
*/
const SIZED_ID_RE = /^([a-z]+)_(\d+)x(\d+)_([a-z]+)$/;

/**
* Extract the `<prefix>_<W>x<H>_<suffix>` template from a sized catalog
* id. Used by `findCatalogEntryByCanonicalAndSize` to filter fan-out
* candidates to siblings of the seller-asserted ref. Multiple catalog
* entries can share the same `canonical:` annotation but represent
* different families (e.g., `image` is both `display_*_image` and
* `display_*_generative`); without a suffix filter, fan-out would
* collide families.
*/
export function parseSizedIdTemplate(id: string): { prefix: string; suffix: string } | undefined {
const m = id.match(SIZED_ID_RE);
if (!m) return undefined;
return { prefix: m[1]!, suffix: m[4]! };
}

export function findCatalogEntryByCanonicalAndSize(
canonical: CanonicalFormatKind,
width: number,
height: number,
agentUrl: string,
options?: { prefix?: string; suffix?: string; explicitPath?: string }
): V1FormatDefinition | undefined {
const catalog = loadCatalog(options?.explicitPath);
const normalizedAgentUrl = normalizeAgentUrl(agentUrl);
for (const entry of catalog.entries) {
if (entry.canonical !== canonical) continue;
if (!entry.format_id?.id || !entry.format_id?.agent_url) continue;
if (normalizeAgentUrl(entry.format_id.agent_url) !== normalizedAgentUrl) continue;
const m = entry.format_id.id.match(SIZED_ID_RE);
if (!m) continue;
const [, prefix, wStr, hStr, suffix] = m;
if (options?.prefix && prefix !== options.prefix) continue;
if (options?.suffix && suffix !== options.suffix) continue;
if (parseInt(wStr!, 10) === width && parseInt(hStr!, 10) === height) {
return entry;
}
}
return undefined;
}
Loading
Loading