Skip to content

Commit c23f87f

Browse files
committed
fix(creative): close RC3 release gaps
Reject unsupported validator bundles, make capability routing deterministic, preserve opaque request bags, enforce safe one-to-one durable routes, and give validated publisher/community snapshots precedence over bundled AAO fallbacks. Vendor and pin the complete TypeScript 13.0.0-rc.3 transition corpus.
1 parent 41add3a commit c23f87f

25 files changed

Lines changed: 6068 additions & 126 deletions

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ downloads/
1919
eggs/
2020
.eggs/
2121
lib/
22+
# The pinned TypeScript RC3 transition corpus intentionally mirrors an
23+
# upstream test/lib directory inside test fixtures.
24+
!tests/fixtures/canonical/typescript-13.0.0-rc.3/test/lib/
25+
!tests/fixtures/canonical/typescript-13.0.0-rc.3/test/lib/**
2226
lib64/
2327
parts/
2428
sdist/

src/adcp/canonical_formats/identity.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,10 @@ def canonicalize_agent_url(raw: object) -> str:
3333
port = parts.port
3434
if port is not None and port == _DEFAULT_PORTS.get(scheme):
3535
port = None
36-
netloc = host if port is None else f"{host}:{port}"
36+
# ``urlsplit().hostname`` deliberately removes the brackets around an
37+
# IPv6 literal. Put them back when rebuilding the authority; otherwise
38+
# the result is no longer a valid URL and downstream safety checks can
39+
# mistake a private IPv6 address for an ordinary hostname.
40+
authority_host = f"[{host}]" if ":" in host else host
41+
netloc = authority_host if port is None else f"{authority_host}:{port}"
3742
return urlunsplit((scheme, netloc, parts.path or "/", parts.query, ""))

src/adcp/canonical_formats/projection.py

Lines changed: 164 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,16 @@ class LegacyFormatConversionContext:
161161
LegacyFormatConverter = Callable[[LegacyFormatConversionContext], Format | Mapping[str, Any] | None]
162162

163163

164+
@dataclass(frozen=True)
165+
class _SnapshotLegacyFormatConverter:
166+
"""Distinct trusted channel for pre-resolved catalog snapshots."""
167+
168+
convert: LegacyFormatConverter
169+
170+
def __call__(self, context: LegacyFormatConversionContext) -> Format | Mapping[str, Any] | None:
171+
return self.convert(context)
172+
173+
164174
@dataclass(frozen=True)
165175
class CanonicalFormatLegacyResolutionContext:
166176
declaration: Format
@@ -396,6 +406,22 @@ def project_legacy_format_id(
396406
resolution_failure="no_match",
397407
)
398408
)
409+
snapshot_converter = (
410+
legacy_format_converter
411+
if isinstance(legacy_format_converter, _SnapshotLegacyFormatConverter)
412+
else None
413+
)
414+
if snapshot_converter is not None:
415+
# Pre-resolved publisher/community snapshots are validated catalog
416+
# sources, not an arbitrary compatibility callback. Their declared
417+
# precedence is publisher -> approved mirror -> configured/agent, all
418+
# ahead of the SDK's bundled AAO fallback.
419+
converted = _converted_format(
420+
snapshot_converter,
421+
LegacyFormatConversionContext(ref, product_id, field),
422+
)
423+
if converted is not None:
424+
return converted
399425
exact_key = (owner, ref.id)
400426
exact = index.by_owner_and_id.get(exact_key)
401427
if exact_key in index.by_owner_and_id and exact is None:
@@ -411,7 +437,7 @@ def project_legacy_format_id(
411437

412438
if exact is None:
413439
unique = index.by_unique_id.get(ref.id) if index._allow_bare_id_fallback else None
414-
if legacy_format_converter is not None:
440+
if legacy_format_converter is not None and snapshot_converter is None:
415441
converted = _converted_format(
416442
legacy_format_converter,
417443
LegacyFormatConversionContext(ref, product_id, field),
@@ -444,7 +470,7 @@ def project_legacy_format_id(
444470

445471
canonical = entry.get("canonical") if isinstance(entry, Mapping) else None
446472
if not isinstance(canonical, Mapping) or not isinstance(canonical.get("kind"), str):
447-
if legacy_format_converter is not None:
473+
if legacy_format_converter is not None and snapshot_converter is None:
448474
converted = _converted_format(
449475
legacy_format_converter,
450476
LegacyFormatConversionContext(ref, product_id, field),
@@ -654,7 +680,18 @@ def visit(item: Any, field_path: str) -> Any:
654680
if not isinstance(item, Mapping):
655681
return item
656682

657-
result = {key: visit(child, f"{field_path}.{key}") for key, child in item.items()}
683+
# Context and extension bags are application-owned opaque values. They
684+
# are neither creative-dialect evidence nor protocol selectors, so the
685+
# projector must preserve them verbatim rather than interpreting a
686+
# coincidental ``format_id``/``format_ids`` key inside the bag.
687+
result = {
688+
key: (
689+
deepcopy(child)
690+
if key in {"context", "ext"}
691+
else visit(child, f"{field_path}.{key}")
692+
)
693+
for key, child in item.items()
694+
}
658695
fields = result.get("fields")
659696
if isinstance(fields, list):
660697
result["fields"] = list(
@@ -801,6 +838,8 @@ def register_declaration(
801838
option_id: str,
802839
declaration: Format,
803840
) -> None:
841+
if scope == "publisher":
842+
owner = _normalized_publisher_domain(owner) or None
804843
key = (scope, owner, option_id)
805844
if key not in declaration_routes:
806845
declaration_routes[key] = declaration
@@ -875,7 +914,11 @@ def declaration_for_ref(
875914
raise CanonicalFormatLegacyResolutionError(
876915
f"{field_path} publisher reference has no publisher_domain"
877916
)
878-
key = ("publisher", publisher_domain, option_id)
917+
key = (
918+
"publisher",
919+
_normalized_publisher_domain(publisher_domain),
920+
option_id,
921+
)
879922
else:
880923
raise CanonicalFormatLegacyResolutionError(
881924
f"{field_path} has unsupported format option scope {scope!r}"
@@ -1051,6 +1094,102 @@ def _snapshot_priority(snapshot: Mapping[str, Any]) -> int:
10511094
return 2
10521095

10531096

1097+
def _normalized_publisher_domain(value: object) -> str:
1098+
"""Match RC3 publisher option identity normalization."""
1099+
1100+
return str(value or "").strip().lower().rstrip(".")
1101+
1102+
1103+
_CANONICAL_ONLY_FORMAT_KINDS = {
1104+
"agent_placement",
1105+
"image_carousel",
1106+
"responsive_creative",
1107+
"sponsored_placement",
1108+
}
1109+
1110+
1111+
def _legacy_route_key(ref: LegacyFormatId) -> tuple[str, str, int | None, int | None, float | None]:
1112+
owner = _catalog_owner(ref.agent_url)
1113+
if owner is None or not _is_safe_public_https_owner(ref.agent_url):
1114+
raise ValueError("bidirectional projection adapters require safe public HTTPS owners")
1115+
return (owner, ref.id, ref.width, ref.height, ref.duration_ms)
1116+
1117+
1118+
def _assert_bidirectional_projection_catalogs(
1119+
snapshots: Sequence[Mapping[str, Any]],
1120+
) -> None:
1121+
"""Reject asymmetric or ambiguous durable routes exactly as RC3 does."""
1122+
1123+
route_by_canonical: dict[
1124+
tuple[str, str], tuple[str, str, int | None, int | None, float | None]
1125+
] = {}
1126+
canonical_by_legacy: dict[
1127+
tuple[str, str, int | None, int | None, float | None], tuple[str, str]
1128+
] = {}
1129+
for snapshot in snapshots:
1130+
declarations_at_tier: set[
1131+
tuple[
1132+
tuple[str, str],
1133+
tuple[str, str, int | None, int | None, float | None],
1134+
]
1135+
] = set()
1136+
for raw in _snapshot_formats(snapshot):
1137+
refs = raw.get("v1_format_ref")
1138+
if raw.get("canonical_formats_only") is True or not isinstance(refs, list) or not refs:
1139+
continue
1140+
kind = raw.get("format_kind")
1141+
if kind in _CANONICAL_ONLY_FORMAT_KINDS:
1142+
raise ValueError(
1143+
"bidirectional projection adapters cannot downgrade a canonical-only "
1144+
"format kind"
1145+
)
1146+
option_id = raw.get("format_option_id")
1147+
if not isinstance(option_id, str) or not option_id:
1148+
raise ValueError(
1149+
"bidirectional projection adapters require a stable format_option_id"
1150+
)
1151+
publisher = _normalized_publisher_domain(
1152+
raw.get("publisher_domain", snapshot.get("publisher_domain"))
1153+
)
1154+
if not publisher:
1155+
raise ValueError(
1156+
"bidirectional projection adapters require publisher-scoped format options"
1157+
)
1158+
try:
1159+
parsed_refs = [LegacyFormatId.model_validate(ref) for ref in refs]
1160+
except ValidationError as exc:
1161+
raise ValueError(
1162+
"bidirectional projection adapters contain an invalid legacy route"
1163+
) from exc
1164+
legacy_keys = {_legacy_route_key(ref) for ref in parsed_refs}
1165+
if len(legacy_keys) != 1:
1166+
raise ValueError(
1167+
"bidirectional projection adapters require exactly one legacy route per "
1168+
"canonical format option"
1169+
)
1170+
canonical_key = (publisher, option_id)
1171+
legacy_key = next(iter(legacy_keys))
1172+
declaration_key = (canonical_key, legacy_key)
1173+
if declaration_key in declarations_at_tier:
1174+
raise ValueError(
1175+
"bidirectional projection adapters contain duplicate declarations at one "
1176+
"precedence tier"
1177+
)
1178+
declarations_at_tier.add(declaration_key)
1179+
existing_legacy = route_by_canonical.get(canonical_key)
1180+
if existing_legacy is not None and existing_legacy != legacy_key:
1181+
raise ValueError(
1182+
"bidirectional projection adapters contain conflicting reverse routes"
1183+
)
1184+
existing_canonical = canonical_by_legacy.get(legacy_key)
1185+
if existing_canonical is not None and existing_canonical != canonical_key:
1186+
raise ValueError(
1187+
"bidirectional projection adapters contain conflicting forward routes"
1188+
)
1189+
route_by_canonical[canonical_key] = legacy_key
1190+
canonical_by_legacy[legacy_key] = canonical_key
1191+
1192+
10541193
def canonical_format_legacy_resolver_from_catalog_snapshots(
10551194
snapshots: Iterable[Mapping[str, Any]],
10561195
) -> CanonicalFormatLegacyResolver:
@@ -1071,6 +1210,7 @@ def canonical_format_legacy_resolver_from_catalog_snapshots(
10711210
if (
10721211
not isinstance(option_id, str)
10731212
or not isinstance(kind, str)
1213+
or kind in _CANONICAL_ONLY_FORMAT_KINDS
10741214
or not isinstance(refs, list)
10751215
):
10761216
continue
@@ -1080,8 +1220,10 @@ def canonical_format_legacy_resolver_from_catalog_snapshots(
10801220
continue
10811221
if not parsed:
10821222
continue
1083-
publisher = raw.get("publisher_domain", snapshot.get("publisher_domain"))
1084-
key = (publisher if isinstance(publisher, str) else None, option_id, kind)
1223+
publisher = _normalized_publisher_domain(
1224+
raw.get("publisher_domain", snapshot.get("publisher_domain"))
1225+
)
1226+
key = (publisher or None, option_id, kind)
10851227
candidate = (deepcopy(raw.get("params") or {}), parsed)
10861228
existing = routes.get(key)
10871229
if existing is None or priority < existing[0]:
@@ -1095,11 +1237,15 @@ def resolve(context: CanonicalFormatLegacyResolutionContext) -> Sequence[LegacyF
10951237
return None
10961238
ranked = routes.get(
10971239
(
1098-
declaration.publisher_domain,
1240+
_normalized_publisher_domain(declaration.publisher_domain) or None,
10991241
declaration.format_option_id,
11001242
declaration.format_kind.value,
11011243
)
11021244
)
1245+
if ranked is not None and ranked[1] is None:
1246+
raise CanonicalFormatLegacyResolutionError(
1247+
"projection catalog contains ambiguous canonical format option aliases"
1248+
)
11031249
route = ranked[1] if ranked else None
11041250
if not route:
11051251
return None
@@ -1131,6 +1277,8 @@ def legacy_format_converter_from_catalog_snapshots(
11311277
for raw in _snapshot_formats(snapshot):
11321278
if raw.get("canonical_formats_only") is True:
11331279
continue
1280+
if raw.get("format_kind") in _CANONICAL_ONLY_FORMAT_KINDS:
1281+
continue
11341282
refs = raw.get("v1_format_ref")
11351283
if not isinstance(refs, list):
11361284
continue
@@ -1159,10 +1307,17 @@ def convert(context: LegacyFormatConversionContext) -> Mapping[str, Any] | None:
11591307
if owner is None:
11601308
return None
11611309
ranked = routes.get((owner, ref.id, ref.width, ref.height, ref.duration_ms))
1310+
if ranked is not None and ranked[1] is None:
1311+
raise LegacyCreativeProjectionError(
1312+
"projection catalog contains ambiguous legacy aliases"
1313+
)
11621314
route = ranked[1] if ranked else None
11631315
return deepcopy(route) if route else None
11641316

1165-
return convert
1317+
# Catalog snapshots are pre-resolved, validated sources with protocol
1318+
# precedence above the bundled AAO fallback. The private wrapper makes the
1319+
# trusted channel structurally distinct from an ordinary adopter callback.
1320+
return _SnapshotLegacyFormatConverter(convert)
11661321

11671322

11681323
def projection_adapters_from_catalog_snapshots(
@@ -1171,6 +1326,7 @@ def projection_adapters_from_catalog_snapshots(
11711326
"""Build symmetric forward and durable reverse routes from one corpus."""
11721327

11731328
materialized = list(snapshots)
1329+
_assert_bidirectional_projection_catalogs(materialized)
11741330
return ProjectionCatalogAdapters(
11751331
legacy_format_converter=legacy_format_converter_from_catalog_snapshots(materialized),
11761332
canonical_format_legacy_resolver=canonical_format_legacy_resolver_from_catalog_snapshots(

src/adcp/server/builder.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,15 @@ async def auto_capabilities(params: Any, context: Any = None) -> dict[str, Any]:
215215
class DynamicHandler(ADCPHandler[Any]):
216216
pass
217217

218+
# The decorator framework's primary handler surface is canonical.
219+
# Keep that server-owned fact separate from negotiated discovery
220+
# responses: a shared handler may serve 3.0 and 3.1 callers
221+
# concurrently, and a 3.0 response intentionally omits this feature.
222+
if "media_buy" in self._detect_domains():
223+
DynamicHandler._framework_adcp_capabilities = { # type: ignore[attr-defined]
224+
"media_buy": {"features": {"canonical_creatives": True}}
225+
}
226+
218227
for task_name, fn in handlers.items():
219228
# Wrap standalone functions to accept self
220229
async def _bound_method(

0 commit comments

Comments
 (0)