Repository navigation
Reserve RootModel for value types: RootModel wrappers make extra-field policy unsettable by consumers #1077
Description
Summary
Please restrict RootModel to genuine value types (a scalar or a single wrapped value like Country, Gtin, PostalArea) and model object/discriminated-union types as plain BaseModel with a discriminated field instead.
A RootModel wrapper cannot carry an extra-field policy, and pydantic refuses to let a subclass add one:
from pydantic import ConfigDict
from adcp.types.generated_poc.core.pricing_option import PricingOption
class OurPricingOption(PricingOption):
model_config = ConfigDict(extra="forbid")
# pydantic.errors.PydanticUserError:
# `RootModel` does not support setting `model_config['extra']`Verified against adcp==6.6.0 / spec 3.1.1 on pydantic 2.12.
Why this matters to a consumer
The generated option members carry extra="allow". A seller implementation that wants a stricter policy — ours is forbid in dev/CI and ignore in production, never allow — has no way to apply it to the union as a whole, because the only place to hook is the RootModel and that is exactly where extra is refused.
Two concrete consequences we hit:
-
Non-spec fields silently reach the wire.
extra="allow"accepts undeclared attributes, and they serialize. We were emitting two fields on pricing options that do not exist incore/pricing-option.json.additionalPropertiesis absent from that schema, so draft-07 permits them and no conformance check objects — the only thing that would have caught it is an extra policy we could not set. -
Legacy field names are accepted indefinitely. With
extra="allow", a pre-V3rate=keyword is accepted and echoed back rather than rejected, so a consumer gets no signal that it should have migrated tofixed_price. Settingforbidon our own subclass surfaced it immediately.
What works today, and what doesn't
The members of the union (CpmPricingOption, VcpmPricingOption, …) are plain BaseModels and subclass cleanly — we can set extra and add internal-only fields with exclude=True on each. So the members are fine; it is only the RootModel wrapper that is unworkable.
Our workaround is to subclass all nine members and rebuild the union ourselves, which means tracking the SDK's shape by hand and re-doing it whenever a member is added. CpaPricingOption and TimeBasedPricingOption already landed after our first pass, which is the maintenance cost this shape imposes.
Requested change
- Keep
RootModelfor value types where the wrapped value is the model. - For discriminated unions and object types, generate a plain
BaseModelcarrying the discriminated union as a normal field (e.g.PricingOption.option: Annotated[Cpm | Vcpm | …, Field(discriminator="pricing_model")]), or expose the union as a bareAnnotated[...]type alias so consumers can compose it themselves.
Either form lets every consumer apply its own extra policy without re-declaring the SDK's shapes.
Affected types
26 exported RootModels in adcp.types; the ones we consume are AccountReference, Country, GeoCountry, GeoRegion, PostalArea, PropertyTag, PublisherDomain, SignalRef, plus core.pricing_option.PricingOption. Several of those (Country, Gtin, PostalArea) look like legitimate value types; PricingOption and AccountReference are the object/union cases where the wrapper blocks policy.
Activity
bokelley commentedon Aug 24, 2026
Triage
Classification: Bug (live gap) + partial version mismatch
Bucket(s): types (no
clientlabel in this repo — flagged for gap)Status: ready-for-human
What the experts said:
PricingOptionis already resolved — the publicadcp.types.PricingOptionis a bare union alias since 8.0.0b7 (aliases.py line 1529).AccountReferenceis the live gap: the public export re-packages the generatedRootModel[AccountReference1 | AccountReference2], reproducing the reported subclassing error. Non-breaking additive fix exists; replacement alias is minor-breaking.RootModels in the public namespace that blockextrapolicy:AccountReference,PostalArea,SignalRef. Five additional scalar string wrappers (Country,GeoCountry,GeoRegion,PropertyTag,PublisherDomain) cause.root-access friction but are a separate concern.My take: Fixing the union-wrapper cases by shadowing the
_generatedre-exports with bare union aliases inaliases.pyis the right non-codegen path — precedent already set byPricingOption. However, it removesRootModel.model_validate()andisinstance(x, AccountReference)semantics, which is minor-breaking. Flagging for @bokelley to choose whether this ships in a patch or a minor version.Implementation scope (union-wrapper fix):
src/adcp/types/aliases.py: addAccountReference = AccountReference1 | AccountReference2,PostalArea = PostalArea1 | PostalArea2,SignalRef = SignalRef1 | SignalRef2 | SignalRef3— mirrorsPricingOptionat line 1529._generated.pyre-exports so aliases win in_eager.py.AccountReferenceById.model_validate(raw)instead ofAccountReference.model_validate(raw)— those concrete aliases already exist.AccountReferenceUnionnames and keep oldRootModel; leaves the friction in place but avoids any break.generated_poc.*is unsupported in docs (the reporter'sPricingOptionexample used that internal path).Triaged by Claude Code. Session: https://claude.ai/code/${CLAUDE_CODE_REMOTE_SESSION_ID}
Generated by Claude Code
refactor: own the pricing-option models so non-spec fields stay off t…
KonstantinMirin commentedon Oct 3, 2026
#1077 asked for a property, not two fixes: keep
RootModelfor value types, andemit a plain model or a discriminated union where the root composes. The two
classes named in the issue were resolved by adding them to
_UNWRAP_TO_UNIONinscripts/post_generate_fixes.py. The property was notadopted, and the population the issue describes has grown.
Measured against
adcp==8.0.0(repo at 6677169):adcp.typesareRootModelsubclasses.ConfigDict(extra="forbid")on a subclass.$ref, onearray, one
Literal._UNWRAP_TO_UNIONholds 43 names. It is the only thing that decides whichwrapper goes; a new schema with a composing root arrives wrapped.
Every other generated model accepts that subclass. Which side of the line a type
falls on is decided by whether someone added its name to the set.
The name-driven unwrap also drops the discriminator
This is the part the allowlist makes invisible.
unwrap_rootmodel_unionsreplaces the whole class with
TypeName = Variant1 | Variant2, discarding theroot:annotation — and with it theField(discriminator=...)the generatoremitted from the schema. Four of the 43 names come from schemas that declare
discriminator, and all four lose it.media-buy/list-products-response.jsondeclaresdiscriminator: {propertyName: "outcome"}. On 8.0.0:A sender that misspells one tag gets five errors spread across both arms and no
union_tag_invalid. A caller who sendsoutcome: "listed"and omitsproductsgets four errors, two of them about the arm they did not select. The same holds
for
ControlMediaBuyResponse(7 errors),MediaBuyCommitmentResponse(10) andPreviewCreativeResponse(9).core/deployment.jsondeclares the same thing and keeps its wrapper, so it keepsits discriminator and reports one
union_tag_invalid. The SDK has twopopulations with opposite defects, and membership is a hand-maintained list.
Layer
CODEGEN. The schemas carry everything needed:oneOf/anyOfsays the rootcomposes,
discriminatorsays how, a$refroot says the type IS the target.datamodel-code-generatorresolves all of it and emits it onto theroot:field; the post-generation pass then either keeps a wrapper that cannot be
subclassed or deletes a wrapper along with its metadata. Nothing in the schemas
asks for either.
Arm names (the related half)
Arms are named by position —
CreateMediaBuyResponse1/2/3,Deployment1/Deployment2— while the schema supplies a name.media-buy/list-products-response.jsonis typical: its arms areoutcome: "listed"andoutcome: "unchanged", emitted asListProductsResponse1andListProductsResponse2. Positional names renumberon unrelated schema edits —
Idempotency3becameIdempotency1— so aconsumer's import silently re-points at a different arm.
Counting schemas with a root-level multi-arm
oneOf/anyOf, excludingbundled/: 250 root sites, and 87 of them carry atitleon every arm.Restricted to what codegen reads (
mcp/is excluded from generation): 156 rootsites, 62 with a
titleon every arm.That is a separate change from the one in #1353, which keeps the generated arm
names. Filing it here because it is the same defect: schema information read and
then discarded.
feat(types)!: emit a composing schema root as what it composes (#1353)
fix(codegen)!: resolve structural pointer refs to the type they select (