Repository navigation
codegen: a $ref into another file's properties generates a RootModel wrapper, so 27 of TargetingOverlayInput's 37 fields disagree with TargetingOverlay #1362
Description
Summary
A $ref whose fragment is a JSON pointer into another file's properties selects
that property's type. The generator emits a named single-arm RootModel wrapper for
the pointed-to position and types the field with the wrapper instead.
core/targeting-input.json declares 27 of its 37 fields that way (the other ten are
5 whole-file $refs and 5 inline type-lists), so TargetingOverlayInput and
TargetingOverlay — which describe one wire document — disagree on 27 of 37 field
types. Reading a wrapped collection field returns
something no consumer can use: indexing and len() raise, and iterating yields the
('root', [...]) field pair instead of the elements, with no error.
This is the fifth RootModel report (#138, #145, #155, #1077, #1277) and it is a
different mechanism from #1277. #1277 is a whole-file scalar schema
(core/property-tag.json) generating RootModel[str]. This is a pointer into a
structural position, and the wrapper is minted once per referencing site.
What the schemas say
core/targeting-input.json:
"audience_exclude": {
"anyOf": [
{"$ref": "https://adcontextprotocol.org/schemas/3.2.1/core/targeting.json#/properties/audience_exclude"},
{"type": "null"}
]
}The pointer selects, from core/targeting.json:
{"type": "array", "items": {"type": "string"}, "minItems": 1, "description": "..."}The same property in its own module becomes list[str]. Through the pointer it
becomes targeting.AudienceExclude = RootModel[list[str]].
core/targeting-input.json spells one field a different way, and that field proves
the schema is not asking for a wrapper. keyword_targets writes nullability as a
type list rather than a union:
"keyword_targets": {"type": ["array", "null"], "items": {"$ref": ".../keyword-target.json"}}and generates list[KeywordTarget] | None. Its 26 siblings write
anyOf: [{$ref: pointer}, {type: null}] and generate wrappers.
Current behavior
Measured against adcp==8.0.0 (spec 3.2.1, bundle key 3.2):
>>> from adcp.types import TargetingOverlayInput, TargetingOverlay
>>> A, B = TargetingOverlayInput.model_fields, TargetingOverlay.model_fields
>>> set(A) == set(B)
True
>>> sum(A[k].annotation != B[k].annotation for k in A), len(A)
(27, 37)
>>> A["geo_countries"].annotation
adcp.types.generated_poc.core.targeting.GeoCountries | None
>>> B["geo_countries"].annotation
list[adcp.types.generated_poc.core.targeting.GeoCountry] | NoneThe wrapper is not a container:
>>> overlay = TargetingOverlayInput(geo_countries=["US", "GB"])
>>> overlay.geo_countries
GeoCountries(root=[GeoCountry(root='US'), GeoCountry(root='GB')])
>>> overlay.geo_countries[0]
TypeError: 'GeoCountries' object is not subscriptable
>>> len(overlay.geo_countries)
TypeError: object of type 'GeoCountries' has no len()
>>> [c for c in overlay.geo_countries]
[('root', [GeoCountry(root='US'), GeoCountry(root='GB')])]The last line is the dangerous one. Iterating a pydantic model yields its
(name, value) pairs, so a consumer that loops over a country list gets one tuple
and no exception. Code written against TargetingOverlay reads the same field
correctly and reads it wrong on TargetingOverlayInput:
for country in overlay.geo_countries:
code = country.root # AttributeError: 'tuple' object has no attribute 'root'A static checker names the same defect. Under mypy --strict:
error: Argument 1 to "len" has incompatible type "GeoCountries"; expected "Sized" [arg-type]
error: Value of type "GeoCountries" is not indexable [index]
error: "tuple[str, Any]" has no attribute "root" [attr-defined]
The wrapper reaches the wire as a tool schema
adcp/server/mcp_tools.py:1902 builds every tool's MCP inputSchema from
model_json_schema(), so the wrapper is what a buyer's agent reads from
tools/list. The wrapper takes the field's description with it onto its own
definition and leaves the field node bare:
>>> TargetingOverlayInput.model_json_schema()["properties"]["geo_countries"]
{'anyOf': [{'$ref': '#/$defs/GeoCountries'}, {'type': 'null'}], 'default': None}
>>> TargetingOverlay.model_json_schema()["properties"]["geo_countries"]
{'anyOf': [{'items': {'$ref': '#/$defs/GeoCountry'}, 'minItems': 1, 'type': 'array'},
{'type': 'null'}],
'default': None,
'description': "Restrict delivery to specific countries. ISO 3166-1 alpha-2 codes..."}Counted over the model, then over the schema create_media_buy actually advertises:
Input fields with no description in its JSON Schema: 32 of 37
... of which the output model DOES document: 31
Input properties routed through a generated $defs wrapper: 32
property subschemas identical to the output model: 5 of 37
create_media_buy inputSchema, $defs/TargetingOverlayInput: 5 of 37 fields documented
core/targeting.json documents every one of those properties. A buyer discovering
create_media_buy sees 32 undocumented targeting fields because of the wrapper.
Population
In the 3.2 schema set, excluding bundled/ and mcp/, 79 $refs point across files
into a structural position — 74 into properties, 4 into oneOf, 1 into items.
They sit in 19 schema files, concentrated in core/targeting-input.json (27),
media-buy/product-purchase-input.json (18) and core/catalog-selection.json (6).
The generated tree carries 324 single-arm RootModel classes with 759 field
annotations referencing one.
media-buy/product-purchase-input.json is the second-largest site, with 18 pointers.
ProductPurchaseInput and ProductPurchase share 22 field names and 19 of them
disagree:
>>> a, b = ProductPurchaseInput.model_fields, ProductPurchase.model_fields
>>> sum(a[k].annotation != b[k].annotation for k in set(a) & set(b))
19
>>> a["agency_estimate_number"].annotation
product_purchase.AgencyEstimateNumber | None
>>> b["agency_estimate_number"].annotation
str | Nonecore/catalog-selection.json shows the same shape without the nullable union — six
fields whose types all arrive wrapped:
>>> from adcp.types._generated import CatalogSelection
>>> [(n, f.annotation) for n, f in CatalogSelection.model_fields.items()][1:4]
[('type', catalog.Type | None), ('ids', catalog.Ids | None), ('gtins', catalog.Gtins | None)]type is the sharpest of those: core/catalog.json declares it as a $ref to
enums/catalog-type.json, and the pointer turns the shared CatalogType enum into a
per-position wrapper.
Why — and which layer
Codegen. The schemas are ordinary and correct. Two generator behaviors compose:
- A
$refto a structural position has no name to emit a class from, so the
generator invents one and wraps the selected type in it. - A two-arm
anyOfwhose non-null arm declares atypekeyword gets a wrapper of
its own. The pointer hides thattype, so resolving the pointer alone moves the
wrapper from the pointed-to module to the referencing one rather than removing it.
Both are visible inside core/targeting-input.json: 26 fields hit (1) and then (2);
keyword_targets hits neither.
Relationship to #1181
#1181 asked that a subclass of an exported type validate wherever that type is
accepted, and its fix admitted TargetingOverlay at the fields typed
TargetingOverlayInput. That left the admitted class's own fields unusable: an
adopter who builds a TargetingOverlayInput and reads a collection field off it
still gets a wrapper. The two are independent — one is class identity, this is field
type.
Required behavior
A $ref into another file's structural position generates the type the pointer
selects, with no wrapper minted for the position.
TargetingOverlayInput.audience_exclude reads as list[str] | None, the same type
TargetingOverlay.audience_exclude has.
Acceptance criteria
- No field annotation in the generated tree is a
RootModelminted for a
pointed-to structural position. -
TargetingOverlayInputandTargetingOverlayagree on every field type whose
pointed-to subschema is a whole-file$ref, an enum, or a plain scalar or
array of them. - A collection field on an
*Inputmodel supportslen(), indexing, and
iteration over its elements. - A
tests/type_checks/fixture reads a collection field off an*Inputmodel
and passes it to a function typed with the output model's field type, under
mypy --strict.
Two kinds of divergence are deliberately outside these criteria, because neither is
caused by the pointer:
- A pointed-to subschema that nests an anonymous object or constrained scalar —
geo_countriesnests{"type": "string", "pattern": "^[A-Z]{2}$"}— has no name to
reference across files, so its element class is minted per module. Sharing those
element classes is a separate ask. - The
*_excludefields areSequence[X]on the output model andlist[X]on the
input one. That asymmetry is a post-generation step applied to response models and
is unrelated to how the field is declared.
Adopter impact
Embedded Sales Agent passes a buyer's targeting overlay to four ad-server adapters.
src/adapters/gam/managers/targeting.py:698 reads it as the schema describes it:
for country in targeting_overlay.geo_countries:
code = country.rootOn 8.0.0 that raises AttributeError: 'tuple' object has no attribute 'root' for a
request-side overlay, so the agent carries one output-shaped class for both
directions and has a per-field normalization step — 27 field shapes — recorded as
outstanding work. Every field the wrapper covers needs its own unwrap.
Activity
bokelley commentedon Oct 3, 2026
Triage
Classification: Bug
Bucket(s): client (codegen / type layer)
Status: deferred
My take: PR #1371 ("fix(codegen)!: resolve structural pointer refs to the type they select") was already open when this issue filed and explicitly addresses it — the PR body cites #1362, walks through all four acceptance criteria, and documents the residuals (10 fields still differing from two causes the issue deliberately excludes). The duplicate/open-PR gate closes here.
Blocked-on: #1371 — resurfaces on merge
Triaged by Claude Code. Session: https://claude.ai/code/session_01T5NFzErVh3z46BE8j3WW81
Generated by Claude Code
fix(codegen)!: resolve structural pointer refs to the type they select (