Skip to content

Reserve RootModel for value types: RootModel wrappers make extra-field policy unsettable by consumers #1077

Description

@KonstantinMirin

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:

  1. 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 in core/pricing-option.json. additionalProperties is 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.

  2. Legacy field names are accepted indefinitely. With extra="allow", a pre-V3 rate= keyword is accepted and echoed back rather than rejected, so a consumer gets no signal that it should have migrated to fixed_price. Setting forbid on 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 RootModel for value types where the wrapped value is the model.
  • For discriminated unions and object types, generate a plain BaseModel carrying the discriminated union as a normal field (e.g. PricingOption.option: Annotated[Cpm | Vcpm | …, Field(discriminator="pricing_model")]), or expose the union as a bare Annotated[...] 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions