Skip to content
  •  
  •  
  •  
2 changes: 0 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,6 @@ regenerate-schemas: ## Download latest schemas and skills from bundle, then rege
$(PYTHON) scripts/generate_types.py
@echo "Consolidating exports..."
$(PYTHON) scripts/consolidate_exports.py
@echo "Generating ergonomic coercion..."
$(PYTHON) scripts/generate_ergonomic_coercion.py
@echo "✓ Schemas regenerated successfully"

validate-generated: ## Validate generated code (syntax, imports, and equality with a regeneration)
Expand Down
11 changes: 0 additions & 11 deletions SCHEMA_DELTAS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,10 @@

## Field changes

- `account/list_account_changes_request.py`
- `ListAccountChangesRequest`: `+adcp_major_version`, `+adcp_version`
- `bundled/protocol/get_adcp_capabilities_response.py`
- **classes added**: C2paAction, MediaType, Method3, Persistence, Position
- **classes removed**: C2PAWatermarkAction, Disclosure3, Disclosure4, Disclosure5, Disclosure6, DisclosurePersistence, DisclosurePosition, EmbeddedProvenanceItem2, EmbeddedProvenanceItem3, EmbeddedProvenanceItem4, EmbeddedProvenanceItem5, EmbeddedProvenanceMethod, Logo1, Logo2, Logo3, Logo4, Provenance2, Provenance3, Provenance4, Provenance5, Watermark2, Watermark3, Watermark4, Watermark5, WatermarkMediaType
- `Method`: `+manifest_wrapper`, `+provenance_markers` `-access_mode`, `-destination_modes`, `-format`, `-orchestration`, `-pattern`, `-producer_identity`, `-provider`, `-reader_compatibility`, `-transport`
- `core/provenance.py`
- **classes added**: VerifyAgent10
- **classes removed**: VerifyAgent18
- `core/reporting_status_issue.py`
- `Code`: `+REVISION_UNREADABLE`
- `ReportingStatusIssue`: `+reporting_revision_id`
Expand All @@ -20,16 +15,10 @@
- `BuyProductsRequest`: `+adcp_major_version`, `+adcp_version`
- `media_buy/control_media_buy_request.py`
- `ControlMediaBuyRequest`: `+adcp_major_version`, `+adcp_version`
- `media_buy/decline_proposals_request.py`
- `DeclineProposalsRequest`: `+adcp_major_version`, `+adcp_version`
- `media_buy/list_products_request.py`
- `ListProductsRequest`: `+adcp_major_version`, `+adcp_version`
- `media_buy/refine_proposals_request.py`
- `RefineProposalsRequest`: `+adcp_major_version`, `+adcp_version`
- `media_buy/request_proposals_request.py`
- `RequestProposalsRequest`: `+adcp_major_version`, `+adcp_version`
- `media_buy/sync_reporting_status_request.py`
- `SyncReportingStatusRequest`: `+adcp_major_version`, `+adcp_version`
- `protocol/get_principal_response.py`
- **classes added**: Result2, Result3, Result5
- **classes removed**: Result6, Result7, Result9
Expand Down
357 changes: 299 additions & 58 deletions docs/design/type-generation.md

Large diffs are not rendered by default.

107 changes: 96 additions & 11 deletions docs/extending-types.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,23 +13,25 @@ This guide shows how to extend ADCP types safely while maintaining protocol comp
> overrides to walk children — Pydantic does the walking; this guide covers the two seams
> (`Field(exclude=True)` and `@model_serializer`) that hook into it.

## Object Unions Are Type Aliases
## A Nested Object Union Is a Type Alias

Object unions such as `AccountReference`, `PostalArea`, `SignalRef`, and
`PricingOption` are public union aliases, not Pydantic `RootModel` classes.
This keeps their object arms composable and subclassable when an adopter needs
A union NESTED inside another schema — `AccountReference`, `PostalArea`,
`SignalRef`, `PricingOption` and the rest — is a public union alias, not a
Pydantic `RootModel` class. (A schema ROOT whose branches partition is a class;
see *A Split Root Is a Class* below.)
This keeps their object branches composable and subclassable when an adopter needs
a stricter `model_config` or internal excluded fields, without imposing another
public wrapper around the constituent schema arms.
public wrapper around the constituent schema branches.

Construct a known arm directly. For example:
Construct a known branch directly. For example:

```python
from adcp.types import AccountReferenceById

account = AccountReferenceById(account_id="acct-1")
```

For untrusted data where the arm is not known in advance, use Pydantic's
For untrusted data where the branch is not known in advance, use Pydantic's
`TypeAdapter`:

```python
Expand All @@ -43,7 +45,90 @@ Code written against the former wrappers should replace
`AccountReference.model_validate(raw)` (and the corresponding calls on
`PostalArea` or `SignalRef`) with `TypeAdapter(...).validate_python(raw)`.
The validated wire shapes are unchanged; only the unnecessary outer public
wrapper is removed, and validation returns the selected schema arm directly.
wrapper is removed, and validation returns the selected schema branch directly.

## A Split Root Is a Class, and Its Branches Subclass It

The section above is about a union *nested inside* another schema. A schema
**root** whose branches partition by requiredness is the other case, and its
public name is a class — so the guidance inverts.

Thirteen roots are in that population, among them `CreateMediaBuyRequest`,
`CreativeAsset`, `CreativeManifest`, `WebhookChallengeResponse`,
`RequestProposalsRequest` and `ListAccountChangesResponse`. For each, the public
name is a base carrying the fields every branch shares, and one subclass per
branch carries that branch's own requiredness:

```python
from adcp.types import CreateMediaBuyRequest, CreateMediaBuyRequestBranches

# The public name validates, and returns the branch the document satisfies.
request = CreateMediaBuyRequest.model_validate(raw_request)
type(request) # CreateMediaBuyRequestCommittedProposalExecution

# It is also a real base, so this holds for every branch:
isinstance(request, CreateMediaBuyRequest) # True
```

Three consequences for an adopter.

**Validate through the public name.** `model_validate`, a field annotated with
it, and `TypeAdapter` all return the matching branch — the base carries the
branches' core schema, so every path agrees. A document satisfying no branch is
refused, and the refusal names the root's JSON Schema keyword with the branch
titles:

```
requires at least one of these field groups: Explicit packages with fixed
allocation | Explicit packages with seller-optimized allocation | Committed
proposal execution
```

**Construct a branch, not the base.** Constructing the base validates against
the branches, so `CreateMediaBuyRequest()` with no arguments raises — an empty
media buy satisfies none of the three ways to be one. Construct the branch class
whose shape you mean, and you get the stricter type for free: the branch
declares its required fields as required, and types each field it forbids as
`None`, so passing one is a type error rather than a runtime refusal. Once you
hold a branch you no longer need `getattr`, `hasattr` or `is not None` guards for
the fields that branch requires.

**Subclass the base, or subclass one branch — but know which you want.**

```python
from adcp.types import CreateMediaBuyRequestCommittedProposalExecution

class InternalProposalExecution(CreateMediaBuyRequestCommittedProposalExecution):
"""One branch, plus internal tracking."""

workflow_id: str | None = Field(default=None, exclude=True)
```

Subclassing a BRANCH gives a stricter type and keeps that branch's
requiredness. Subclassing the BASE gives a type every branch's document can
validate into, and your subclass keeps its own shape rather than the union's —
the dispatch belongs to the class that declares its branches, so a refinement
declared elsewhere is not swept into it.

`<Root>Branches` is exported from `adcp.types` beside each public base that has
one — five today: `CreateMediaBuyRequestBranches`,
`ListAccountChangesResponseBranches`, `RequestProposalsRequestBranches`,
`VerifyBrandClaimRequestBranches` and `WebhookChallengeResponseBranches`.
Annotate a variable with it to make a type checker force exhaustive narrowing.
A union is exported only where the public name is a base over exactly those
branches, which is why the other eight roots have no alias: `adcp.types` never
exported those roots either, and `adcp.types.CreativeAsset` is the canonical
model refining ONE branch, so pairing a union with that name would promise a
supertype it is not.

Each declaring module also builds a `<Root>BranchesAdapter`, the `TypeAdapter`
over its union, once at import. Import it from the module rather than from
`adcp.types`, which does not re-export it.

The design, the proof that decides which roots are in this population, and what
it does not reach are in
[design/type-generation.md](design/type-generation.md#branches-that-partition-become-types-rules-that-do-not-partition-stay-checks).
This document does not repeat that reasoning.

## Scalar Schemas Are Plain Scalars

Expand Down Expand Up @@ -105,7 +190,7 @@ assert issubclass(GetProductsResponse, ProtocolEnvelope)
every root `allOf` `$ref` in the schema bundle and fails if the generated class
does not descend from the referenced schema's class. Two shapes are refused
rather than graded, because inheritance cannot express them — a referenced
schema that renders as a union of arms (`AssetVariant`,
schema that renders as a union of branches (`AssetVariant`,
`ProductFormatDeclaration`), and one that renders as a value wrapper rather
than an object, which is what a pure `if`/`then` constraint schema produces.
Those compose as copied fields, and `TypeAdapter` is the way to validate them.
Expand Down Expand Up @@ -255,11 +340,11 @@ exact and subclassed `TargetingOverlay` instances at runtime, preserving their
object identity. These request classes are public; the nested control and
purchase container classes are internal. Their compatibility union tries
`TargetingOverlayInput` first with left-to-right validation, so raw dictionaries
still become mutation-input objects. The legacy arm is runtime-only: generated
still become mutation-input objects. The legacy branch is runtime-only: generated
JSON Schema and MCP tool discovery still advertise only the mutation input and
null. Invalid targeting produces the input schema's errors with request-document
field paths, including direct Pydantic `ValidationError.errors()`. Python-mode
validation removes the bridge's synthetic union arm names and duplicate legacy
validation removes the bridge's synthetic union branch names and duplicate legacy
errors at the targeting field boundary. Direct `model_validate_json()` uses the
Input schema's native JSON validation, preserving its error types and order
even when a canonical parent first materializes the request as Python objects.
Expand Down
2 changes: 1 addition & 1 deletion docs/reporting-production.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ deploying these changes. They collect the historical comparison boundaries,
feature-tier requirements and operational limits; final release qualification
remains separate from the integrated source.

The default protocol is AdCP 3.2.1. Set `ReportingProductionSupport`'s
The default protocol is AdCP 3.2.3. Set `ReportingProductionSupport`'s
`adcp_version="3.2"` or omit the pin for the packaged default. Historical
rc.3, rc.6 and rc.7 schemas are not advertised as live reporting contracts.
See [rc.7 adoption](protocol-3.2-rc7.md) and
Expand Down
2 changes: 1 addition & 1 deletion docs/reporting-receipt-ingress.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ path or rewrite receipt history to make an error disappear.
## Wire and replay contract

Requests negotiate `adcp_version: "3.2"`. This is the wire release spelling;
`3.2.1` is the bundle's semantic-version spelling. The reporting handler's
`3.2.3` is the bundle's semantic-version spelling. The reporting handler's
public mount pin also selects unnegotiated traffic; its default is 3.2. General
SDK fallback behavior and unsupported-version rejection remain unchanged.
Pinned, unpinned and fallback MCP inventories use an isolated schema overlay.
Expand Down
2 changes: 1 addition & 1 deletion docs/reporting-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ reauthorization and per-session destination authorization.
## Current and historical protocol versions

Live reporting mounts and callers use AdCP `3.2`, whose bundle spelling is
`3.2.1`. Omit an explicit pin to use the packaged default, or configure the
`3.2.3`. Omit an explicit pin to use the packaged default, or configure the
current supported reporting version consistently across the composition and
its mounts. Cross-cohort reporting requests are rejected even when optional
request validation is disabled.
Expand Down
Loading
Loading