Skip to content

chore: merge main into next through 9.2.0 - #1578

Merged
bokelley merged 58 commits into
nextfrom
sync/main-into-next-9.0.0
Oct 27, 2026
Merged

bokelley merged 58 commits into
nextfrom
sync/main-into-next-9.0.0

Conversation

@bokelley

@bokelley bokelley commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Forward-ports main through the SDK 9.2.0 release (v9.2.0, 5314faa9) into next with a true merge, the same way as #1541. Merge with "Create a merge commit", never squash or rebase.

This PR was first opened for the 9.0.0 sync (2e942f93, description below). It now carries two more merge commits on top: fde93379 brings in next's #1549 and #1544, and 52ad11b4 merges main through 9.2.0 (2e942f93..5314faa9, 37 commits).

What comes from main in the 9.1 / 9.2 refresh

9.1.0 (#1585), 9.1.1 (#1612) and 9.2.0 (#1627); the adcp-reporting-storage 0.1.0-beta.1 … beta.4 releases (#1571, #1593, #1596, #1603); the wire-fidelity work #1591 (unset optional fields stay off the wire from any parent), #1625 (every hand-written redeclaration inherits its generated field) and #1617 (wire bytes kept on parsed URLs, datetimes, UUIDs and emails, with the round-trip conformance suite); the companion-release publish gate #1605; the compat fixes #1609, #1611, #1628 and #1630 (never downgrade into a payload the older schema refuses); #1579, #1592, #1594, #1597, #1598, #1599, #1600, #1601, #1602, #1608, #1626, #1629, #1631, #1632, #1633, #1576, #1577, #1590 and the two rebase-merged storage commits.

Conflicts and resolutions (this refresh)

  • packages/adcp-reporting-storage/pyproject.toml: the only conflict. main moved the storage floor to adcp>=9.0.0,<10 (fix(reporting-storage): require the adcp release that contains the split #1577); next carries <11 so the workspace installs once next cuts a 10.0.0 pre-release. Resolved to adcp>=9.0.0,<11, exactly as RELEASING.md prescribes ("take main's floor, keep <11"). test_storage_distribution_pins_one_adcp_major already accepts the new floor on both lines.
  • Everything else merged cleanly, including tests/fixtures/public_api_snapshot.json, scripts/export_resolution.py and scripts/normalize_pyproject_prerelease.py, which conflicted in the 9.0.0 round.

Decision: main's companion-release gate (#1605) does not run for a next publish

#1605 makes release-publish.yml refuse an adcp release that no published adcp-reporting-storage release installs with. On PyPI the storage package pins adcp>=9.0.0,<10, so the gate would refuse every next pre-release (10.0.0-beta.N) until the storage package's adcp-10 release, which is cut from main at ratification (RELEASING.md, "Ratification"). A next pre-release is also opt-in (pip install --pre adcp), so it cannot strand a storage adopter the way 9.0.0 GA did. The step is now conditioned on inputs.package == 'adcp' && github.ref == 'refs/heads/main'; test_release_please_keeps_next_on_the_alpha_channel pins that condition, and RELEASING.md records it under "Releasing from next".

adcp-reporting-storage is otherwise still not released from next (decision below, unchanged).

Release identity

next's release files are untouched: release-please-config.next.json (prerelease / alpha, CHANGELOG.next.md), .release-please-manifest.next.json. main's release-please-config.json, .release-please-manifest.json, CHANGELOG.md, packages/adcp-reporting-storage/CHANGELOG.md and the pyproject.toml version lines come along as on every sync; only main's workflow reads them.

Generated code and wire fidelity

Validation (local, Python 3.12)

  • make lint, make typecheck-all (mypy, strict adopter fixtures, pyright, type-ignore contract), make validate-generated: pass.
  • make test equivalent (pytest tests/ -n auto): see the CI checks on this PR for the authoritative run. Locally, one tests/integration/test_a2a_context_id.py case failed once under xdist with Unknown skill: create_media_buy on its second call and passes in isolation and on rerun (a cross-worker server collision, not a merge regression).

Original 9.0.0 description (2e942f93)

What comes from main

#1546 / #1548 creative-sync constraints, #1518 / #1553 format parameter comparisons, #1501 open asset unions, #1559 minor-version negotiation, #1550 reporting readiness and CI rerun fixes, #1552 / #1570 reporting_webhook.operation_id, #1554 migration helpers, #1555 the generator NoExplicitNull rule, #1557 the adcp-reporting-storage split, #1563, #1566, #1567, and the 9.0.0 release (#1540). #1576 is still open, so it is not included.

main's own release files (release-please-config.json, .release-please-manifest.json, CHANGELOG.md, the pyproject.toml version line) come along as on every sync, and only main's workflow reads them. next's release identity is unchanged: release-please-config.next.json stays on prerelease / alpha with CHANGELOG.next.md, and .release-please-manifest.next.json is untouched.

Conflicts and resolutions

  • scripts/export_resolution.py: snapshots both next's adcp.signing and adcp.types.experimental_identity namespaces and main's adcp.reporting, adcp.reporting.ledger and adcp_reporting_storage namespaces.
  • scripts/normalize_pyproject_prerelease.py: keeps next's per-target-branch release lines and main's per-component pyproject.toml mapping and duplicate-PR refusal. main accepts the adcp and adcp-reporting-storage release PRs; next accepts only adcp's.
  • tests/fixtures/public_api_snapshot.json: regenerated with scripts/regenerate_public_api_snapshot.py. I checked that it equals an entry-level three-way merge of base, next and main: 0 differences across 8333 names.
  • tests/test_release_configuration.py: keeps main's stable-channel and storage-component tests and next's alpha-channel test, which now also pins the storage decision below.

Semantic fixes

Decision: adcp-reporting-storage is not released from next

  • release-please-config.next.json has no storage package. Its root adcp package gets main's exclude-paths: ["packages/adcp-reporting-storage"], so storage-only commits stay out of the 10.0 alpha changelog. The channel keys are unchanged.
  • The normalizer refuses a storage release PR into next (new test case).
  • release-publish.yml refuses package=adcp-reporting-storage unless it runs from main.
  • Storage pin on next: main pins the storage package to adcp>=9.0.0b4,<10. Under PEP 440, <10 excludes 10.0.0aN, so next's first alpha release PR could not install the workspace (-e . -e ./packages/adcp-reporting-storage), and its CI and publish would fail. On next the range is adcp>=9.0.0b4,<11. test_storage_distribution_pins_one_adcp_major checks <10 on main and <11 on next.
  • RELEASING.md records this under "Releasing from next", including how to resolve a future floor change from main (take main's floor, keep <11). It also adds a ratification step to narrow the range to adcp 10 and plan the storage package's adcp-10 release from main.

Generated code

  • next's wire pin is still AdCP 3.2.4 (src/adcp/ADCP_VERSION, schemas/cache), the same as main. next's 3.3 work is the separate identity preview (scripts/generate_identity_preview.py → src/adcp/types/_identity_preview.py).
  • scripts/generate_types.py on next's pinned schemas reproduces the merged src/adcp/types/ tree byte for byte: 1088 files, 0 changes, no ClassNameN → ClassNameM renumbering to discard. make validate-generated is clean.
  • Not done here: the identity preview has its own datamodel-codegen invocation and does not go through the fix(codegen)!: refuse a JSON null on optional properties whose schema forbids one #1555 NoExplicitNull pass. make validate-generated does not check it either. Applying NoExplicitNull there would change the experimental identity types' behaviour, so it belongs in its own PR.

reporting_webhook.operation_id (#1552 / #1570)

next's generated ReportingWebhook does not declare operation_id, because its wire schemas are 3.2.4. So the forward-compat patch stays live on next, exactly as on main. Its existing if "operation_id" not in ReportingWebhook.model_fields guard turns it into a no-op once a 3.3 bundle declares the field (adcp#7895). No change needed.

Other overlap checked

Validation (local)

  • make lint, make typecheck-all (mypy, the strict adopter checks, pyright, the type-ignore contract), and make validate-generated: pass.
  • make test: before the two test fixes above, 17722 passed, 5 failed (the 4 WBA cases and the release-config test), 2672 skipped. After the fixes, both affected files pass (57 passed).
  • Postgres pg-conformance core lane (the CI command, through scripts/reporting_test_harness.py) against a local PostgreSQL 16 UTF8 database: 2817 passed, 7 skipped.
  • CI: all 41 checks pass on 2e942f93.

🤖 Generated with Claude Code


Open workspace in Conductor

bokelley and others added 18 commits October 20, 2026 10:49
Match AdCP 3.2 by making the canonical SyncCreativesRequest.creatives
field optional while retaining the nonempty constraint when supplied.
The inherited validator still requires assets or assignments, and
older-version schema validation continues to require creatives.

Guard the reference seller's upload loop so existing creative assignments
can proceed without uploading assets. Cover public Python/JSON parsing,
typed dispatch, schema compatibility, canonical assets, and the example's
upstream attachment behavior.

Fixes #1545

BREAKING CHANGE: SyncCreativesRequest.creatives is now list[CreativeAsset] | None. Handlers must guard it before iteration or use request.creatives or [] for assignment-only requests.
…python-issue-1545

fix(types)!: allow assignment-only creative syncs
* feat(compat): negotiate and translate AdCP minor versions

* fix(compat): harden negotiated version boundaries and regressions

* docs(compat): clarify preflight failure results

* fix(client): retain callback evidence and discovery failures

* fix(a2a): isolate capability discovery from business sessions
…clares (#1518)

`core/format-id.json` declares four parameters beyond the `(agent_url, id)`
identity — `width`, `height`, `duration_ms` and `pixel_ratio`. Three helpers in
`compat_helpers.py` each named three of them in a hand-written tuple, so none of
them looked at `pixel_ratio`:

- `format_is_supported` accepted a creative that claimed no pixel density for a
  product requiring 2x, and accepted one claiming a conflicting density;
- `formats_are_equivalent` called two references with conflicting densities
  equal;
- `_upgrade_legacy_format_id` discarded the `pixel_ratio` it was handed.

Derive the set from the model's fields instead of listing it, so a parameter
added to the schema is compared without anyone remembering to name it. A fourth
literal would only reset the clock: an independent implementation of the same
comparison hit this bug from its own hand-written list of the same three names.

`pixel_ratio` keeps one deliberate per-field rule, because the schema gives it
one: omitting it selects "the backward-compatible 1x variant", so an absent
density reads as 1x rather than as unspecified. A product fixed at 2x therefore
refuses a silent request while one fixed at 1x still serves it — without that,
closing the gap would introduce the mirror-image false rejection.

The new parametrized test reads the parameter list from the bundled schema
rather than from the model, so it fails when a helper stops comparing something
the spec declares.

Closes #1517
…ves field (#1548)

* fix(types): inherit the generated constraints on the canonical creatives field

The canonical SyncCreativesRequest redeclares `creatives` to narrow the element
type to CreativeAsset, and assigned a fresh Field() to do it. A fresh FieldInfo
replaces the inherited one wholesale, so the generated field's max_length=100 and
the bundle's description were lost: measured against the pinned 3.2 schema, the
model published a node with neither maxItems nor description, and accepted 101
creatives where the schema declares maxItems: 100. That drop predates #1546 --
the line read `Field(min_length=1)` before it, with the same two pieces missing.

`_inherit` in the same module exists for this case; its docstring warns that a
bare Field() "would silently drop min_length and friends". The redeclaration now
spells only the annotation it narrows and inherits everything else.

What this does NOT do: it does not refuse an explicit JSON null for `creatives`.
The generated parent renders the optional property as `list[Creative] | None`, so
a null is admitted before any canonical redeclaration is consulted -- deleting
the redeclaration outright leaves it accepted. That half is a generator-level
rule, filed separately as #1547.

Two tests: one grades the published node against the bundled node on type,
description, minItems and maxItems; the other asserts the ceiling is enforced
rather than advertised, on both the model and the JSON-Schema arm.

* fix(types): inherit the generated creatives constraints on PackageUpdate too

PackageUpdate redeclared creatives with only min_length=1, dropping the
generated maxItems=100 and description, so it accepted 101 creatives
that validate_request rejects. Use _inherit like SyncCreativesRequest,
and restore the note on why SyncCreativesRequest.creatives is optional.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Brian O'Kelley <bokelley@scope3.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… they route by (#1501)

* fix(types): generate the open asset unions, and derive the vocabulary they route by

Closes #1486.

`Format.assets` routed 14 of the 21 `asset_type` values the pinned schema
declares, and the repeatable group's `assets` routed 12 of its 21. The missing
ones did not fail: they matched the `_unknown` Tag and validated as
`UnknownFormatAsset` / `UnknownGroupAsset`, so a consumer got an untyped object
with the asset's fields in `__pydantic_extra__` where the schema promises a
typed one -- while the generated class for every missing value sat in
`domains/core/format.py`, unused.

    Format.assets                missing  card daast_tracker display_tag
                                          pixel_tracker published_post
                                          vast_tracker zip
    RepeatableAssetGroup.assets  missing  those, plus brief catalog

The cause was a hand-written vocabulary beside a generated union. `aliases.py`
declared both unions, both fallback classes, and two frozensets of 14 and 12
`asset_type` values; `_forward_compat._apply_forward_compat()` then rewrote
`Format.assets` and the group's `assets` over the generated annotation at
import time. The frozensets are what went stale, and they decide first: the
callable `Discriminator` reads the frozenset and returns `_unknown` BEFORE
pydantic reaches the arms, so adding arms would have changed routing not at
all. `_forward_compat`'s own docstring used `pixel_tracker` as its EXAMPLE of a
"novel asset_type"; by then the example was an instance of the defect.

So the union is now emitted where its arms are declared. `_OPEN_ASSET_UNIONS`
in `scripts/post_generate_fixes.py` writes, per slot, the fallback class, the
`asset_type` vocabulary, the callable discriminator and the tagged union into
`domains/core/format.py`, and repoints the carrying field. The generator knows
each arm's `asset_type` literal because it emitted that literal, so nothing is
declared and nothing can go stale: a schema that adds an asset type adds its
class AND its tag in the same run. `_forward_compat`'s asset patch is deleted
rather than rewired -- with the generated annotation correct there is nothing
left to patch -- and `aliases.py` re-exports the four names instead of defining
them.

The arms keep their genuine asymmetry. Each union is derived from its own base
class, because a group slot carries no nested `repeatable_group`. The group
container is derived too, by its `item_type` discriminator rather than by name,
for the reason `restore_format_asset_numbered_aliases` already derives it: the
generator renumbers that class (`Assets94` once, `Assets29` now).

`_unknown` is still an arm, so completeness costs none of the openness #744
bought: a value the schema does not declare still reaches the fallback.

Six derived refusals, each fire-probed on a mutated copy of the generated
module rather than on the schema, so none of them can pass vacuously:

  * an arm the derivation cannot see while the field still carries it, and an
    arm the field carries that the base does not own -- the membership check
    requires the field's current element union to resolve to exactly the arm
    set the alias was built from, and names the symmetric difference;
  * a duplicate `asset_type` across two arms (a `Tag` must be unique);
  * a carrying field that is renamed, or that stops being a list;
  * no class declaring `item_type='repeatable_group'`.

Four tests in `tests/test_forward_compat_assets.py` asserted that
`pixel_tracker` parses as `UnknownFormatAsset`. The obligation is right and the
value was wrong -- they agreed with the stale frozenset rather than with the
schema -- so the novel value is now derived from the generated classes: a
hardcoded "novel" type can become declared by a schema refresh without the test
noticing, which is how those four came to be wrong. A fifth restated the stale
14-value list as its own assertion and now reads the derivation. Added
`TestDeclaredAssetTypesAreNotDegraded`, parametrized over the generated classes
on both slots, asserting at the PARSE level that each declared type reaches its
own class with no untyped extras -- plus one case comparing that population
against the two arms of the pinned schema, read at their own depths and selected
by `item_type` const, so a generator that stopped emitting a class would shrink
the population loudly rather than grade fewer rows in silence.

`adcp.types.UnknownFormatAsset` and `UnknownGroupAsset` now resolve to the
classes in `domains.core.format` rather than the ones in `aliases`. The public
name tracks the class the unions actually route to, as it did before, so
`isinstance` against the public name is unaffected; `tests/fixtures/
public_api_snapshot.json` records the repoint and the gained arms.

* fix(types): refuse a non-string asset_type instead of crashing the asset discriminator

A list or dict asset_type raised TypeError (unhashable) from the frozenset
membership check, which escapes a caller's except ValidationError. Route
non-strings to the fallback arm so validation refuses them. Fixed in the
post-generation template and the regenerated format module. Also add an
adopter type-check fixture so FormatAssetUnion stays usable as a type
expression under mypy and pyright.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Brian O'Kelley <bokelley@scope3.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…orted side too (#1553)

* fix(canonical-formats): read an omitted pixel_ratio as 1x on the supported side too

`format_is_supported` applied the implied-1x rule only to the requested
reference. A supported 300x250 that omits `pixel_ratio` was skipped as
"unfixed", so it accepted a 2x creative — the mirror image of the gap #1518
closed for a 2x product receiving a request that omits the field.

`core/format-id.json` describes the omitted field as "the backward-compatible
1x variant" without naming a side, so read it the same way on both. The
reading applies only to a dimensioned reference: `pixel_ratio` "Requires
`width` and `height`", so a bare `display_image` template names no variant to
be 1x of and stays broad. A legacy `display_300x250` upgrades to a
dimensioned reference and is therefore the 1x variant.

`formats_are_equivalent` keeps its documented loose contract (an omitted
parameter is unspecified on either side), which is already symmetric.

Refs #1518, #1517

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(canonical-formats): apply implied-1x pixel_ratio in formats_are_equivalent too

`formats_are_equivalent` still treated an omitted `pixel_ratio` as
unspecified, so it called 300x250 and 300x250 at 2x the same format while
`format_is_supported` refused one for the other. An omitted width or duration
names the template, a family covering every variant, so "unspecified" is
right for those. An omitted density on a sized reference names one variant —
the 1x render of a 300x250 image, not the 600x500 one — so read it as 1x here
as well. A size-less template still has no density and stays broad.

The schema sanitizer's legacy-tuple shape check listed the reference's
properties by hand and missed `pixel_ratio`; derive the set from the model.

Refs #1518, #1517

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ery reports (#1552)

AdCP 3.3 (adcp#7895, closing adcp#7885) standardizes an optional
reporting_webhook.operation_id with push_notification_config.operation_id's
constraints: sellers echo it verbatim on every media_buy_delivery report and
MUST NOT send reports to a registration that omits it.

- Align the 3.2 ReportingWebhook.operation_id extension's description with
  the 3.3 semantics; pin constraint parity with PushNotificationConfig so the
  9.x extension and the SDK 10 generated field agree.
- Add create_media_buy_delivery_webhook_payload and
  WebhookSender.prepare_media_buy_delivery / send_media_buy_delivery, which
  build the envelope from the stored registration (URL, operation_id, token).
  A registration without operation_id raises
  ReportingWebhookOperationIdMissingError before any network I/O and logs a
  structured WARNING (adcp_event=reporting_webhook.operation_id_missing).
- create_mcp_webhook_payload no longer defaults notification_id to
  "{task_id}.terminal" for media_buy_delivery: delivery reports are
  point-in-time events deduped by idempotency_key.
- Docs: seller migration for existing registrations, buyer guidance.

Refs #1092

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ack for 9.0 adopters (#1554)

* feat(reporting): replace empty ledgers from sync migrations, and roll back the owned ledger

Add replace_empty_legacy_reporting_sync for Alembic and other synchronous
migration frameworks. It accepts a sync psycopg 3 or psycopg2 connection, or a
SQLAlchemy Connection on either driver (op.get_bind()), and joins the caller's
transaction in a savepoint, so the drop commits atomically with the revision's
version bump and a refusal leaves the caller's transaction usable. It never
starts an event loop.

Add replace_empty_owned_reporting (async and sync), the reverse: under the same
isolation check, schema advisory lock and ACCESS EXCLUSIVE table locks it drops
an empty caller-owned ledger so an 8.x image can start again. Rows written by
installation and startup (the unpruned change-feed horizon, row-storage
bindings) do not count as state; a registered quarantine archive is refused.

All four entry points share one statement generator, so the sync and async
paths enforce identical guards. Functions that reference a reporting table or
its row type are now dropped before the tables, and the rest after them, so
CHECK constraints on packaged functions no longer block removal.

The migration guide gains an Alembic recipe and an 8.x rollback section.

Refs #1449, #1441

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(types): use Format to read and publish declarations, ProductFormatDeclaration to author them

Stored declarations can predate the strict root-schema check, so grading them
on every read turns data quality into failed product listings. Say plainly that
Format is the lenient read and publish path and ProductFormatDeclaration the
authoring check, and show how to report issues without failing the read.

Refs #1449

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Main CI has failed the conventional-commit policy on every push since
5ce9ee4, the GitHub merge commit for #1546. Its body carries only the
PR title fix(types)!, while the merged commit 81c2a8b carries the
BREAKING CHANGE footer, so the release signal is intact. Add the exact
sha and subject to HISTORICAL_BREAKING_SUBJECTS, like the two earlier
exceptions.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Read current PR messages on CI reruns without changing the validated head.
Pin existing reporting exports and give row-storage operators executable,
quoted foreign-key remedies with accurate staging-lock documentation.

Fixes #1383
Fixes #1493
Refs #1490, #1495, #1510
… forbids one (#1555)

The generator rendered every optional property as `T | None = None`, which
admitted an explicit JSON `null` the pinned schema rejects. A pre-generation
pass now grades each optional property with the bundled validator and marks
the ones that refuse `null`; the first post-generation fix turns the mark
into `adcp.types.base.NoExplicitNull`, appended to the field's `Annotated`
metadata. The annotation, default and attribute access are unchanged.

`NoExplicitNull` is a `json_or_python` core schema: the JSON side is the
type without its null arm, so `model_validate_json` refuses `null` with the
type's own error and `model_json_schema()` publishes no null arm; the Python
side keeps `T | None`, so `Model(field=None)` and `model_validate` on a dict
still read `None` as "not supplied", symmetric with `model_dump`'s
`exclude_none` default. `ADCP_STRICT_VALIDATION=1` refuses a Python `None`
too. Properties whose schema admits `null` are generated without the marker.

Also: the eight submitted response arms drop a leftover
`validate_default=True`; the three literal-widening fixes carry the marker;
the parity suite grades the model in JSON mode and no longer declares the
explicit-null divergence.

Fixes #1547
Refs #1522, #1414, #1409, #1397, #1359, #1362

BREAKING CHANGE: a JSON document carrying `null` for an optional property
whose schema does not admit `null` is now refused by `model_validate_json`
and `TypeAdapter.validate_json`, and the published JSON Schema of such a
property has no null arm. A Python `None` is still accepted. JSON produced
with `exclude_none=False` must be read back in Python mode
(`model_validate(json.loads(...))`).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nge (#1563)

* docs(review): require a pathway back for every 3.x wire change

Adds the adcontextprotocol/adcp DR-0033 rule (and DR-0032's lossless-or-refuse translation bar) to PR review.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* docs(review): apply the pathway rule to every major's minor releases

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1570)

#1555 added NoExplicitNull to every generated optional, non-nullable
property, including push_notification_config.operation_id. The 3.2
forward-compat reporting_webhook.operation_id from #1552 is pinned to
that field's constraints and kept the old metadata, so the parity test
failed on main. Add the marker to the extension, compare the published
schema against the push field, and cover the explicit-null refusal.

Also raise the Postgres core conformance lane timeout from 30 to 45
minutes; it runs 25-29 minutes on main and slow runners cancelled it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ng-storage (#1557)

Publish the PostgreSQL ledger store, the chunked row storage with its
filesystem, GCS, S3 and Azure providers, the host change feed, period
retirement, row migration, the caller-ownership migration helpers and
the PostgreSQL service factory as adcp-reporting-storage, a second
distribution in this repository with its own Release Please component.
adcp keeps the store protocols, in-memory stores, producer, handlers,
row encoding and digests, and never imports the new package. The root
component excludes packages/adcp-reporting-storage so storage-only
commits do not release adcp.

Fixes #1510. Refs #1490, #1488, #1489, #1491.

BREAKING CHANGE: the PostgreSQL reporting stores, the row storage providers, the change feed, the ownership-migration helpers and `ReliableReportingService.postgres` leave `adcp`; install `adcp-reporting-storage[pg]` (plus `gcs`/`s3`/`azure`) and import them from `adcp_reporting_storage`. The `adcp[reporting-gcs]`, `[reporting-s3]` and `[reporting-azure]` extras are removed. See docs/reporting-storage-package-migration.md.
Remove the prerelease keys from the root release-please package so the
next adcp release on main is stable 9.0.0, flip the release-channel
contract test, and update the releasing guide and README install notes.
adcp-reporting-storage stays on its beta channel; SDK 10 prereleases
continue on next.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Release-As: 9.0.0
Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com>
Brings main through the SDK 9.0.0 GA release (1dcb846) into next: #1546
and #1548 creative-sync constraints, #1518 and #1553 format parameter
comparisons, #1501 open asset unions, #1559 minor-version negotiation,
#1550 reporting readiness, #1552 and #1570 reporting_webhook.operation_id,
#1554 migration helpers, #1555 the NoExplicitNull codegen rule, #1557 the
adcp-reporting-storage split, #1563, #1566, and #1567 with the 9.0.0
release. main's release files (release-please-config.json, its manifest,
CHANGELOG.md, the pyproject.toml version) come along as on every sync;
next's alpha config, manifest and changelog are unchanged in channel.

Conflicts:
- scripts/export_resolution.py: snapshot both next's signing and
  experimental identity namespaces and main's reporting, ledger and
  adcp_reporting_storage namespaces.
- scripts/normalize_pyproject_prerelease.py: keep next's per-target-branch
  release lines and main's per-component pyproject mapping and duplicate
  refusal. main accepts the adcp and adcp-reporting-storage release PRs;
  next accepts only adcp's.
- tests/fixtures/public_api_snapshot.json: regenerated; it equals an
  entry-level three-way merge of both sides.
- tests/test_release_configuration.py: keep main's stable-channel and
  storage-component tests and next's alpha-channel test.

Semantic fixes:
- tests/test_wba_authorization_errors.py: #1559's typed client discovers
  the seller with get_adcp_capabilities before the first task, so next's
  fake MCP session (#1543) answers discovery the way main's #1559 fakes do.
- tests/test_normalize_pyproject_prerelease.py: next refuses a storage
  release PR.

adcp-reporting-storage on next: not released from next. next's config has
no storage package and excludes its path from the root, the normalizer
refuses a storage release PR into next, release-please-next.yml queues
release-publish.yml with package=adcp, and release-publish.yml refuses
package=adcp-reporting-storage outside main. The storage package's adcp
range on next is >=9.0.0b4,<11: main's <10 would reject 10.0.0aN, so
next's first alpha release PR could not install the workspace. RELEASING.md
records this and adds a ratification step that narrows the range again.

Generated code: scripts/generate_types.py on next's pinned schemas
(still 3.2.4; next's 3.3 work is the separate identity preview) reproduces
the merged src/adcp/types tree exactly, with no renumbering, and
make validate-generated is clean. Next's generated ReportingWebhook does not
declare operation_id, so main's forward-compat patch stays live; its
existing guard retires it once a bundle declares the field (adcp#7895).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon is not auto-reviewing this PR because it modifies the review system itself (LADON.md). A human reviewer should review and merge this PR; Ladon resumes on subsequent PRs once these changes land on main.

bokelley and others added 9 commits October 24, 2026 10:02
…#1576)

The test proves the pinned transport still validates the hostname; a
certificate mismatch raises before any response. Asserting 200 made it
fail whenever example.com answered 503, which happened twice this week
on main and on #1557.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…lit (#1577)

adcp 9.0.0b4 predates the storage split and lacks the modules this
package imports (adcp.reporting.ledger._record_rows,
adcp.reporting.production.store, adcp.reporting.outbox.durable), so
installing it with the b4 beta fails at import. Require adcp 9.0.0.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore(main): release adcp-reporting-storage 0.1.0-beta.1

* chore: normalize prerelease version to PEP 440

---------

Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com>
…#1579)

PackageRequest.creatives redeclared with only min_length=1, dropping the
generated maxItems=100 and description, so the model accepted 101
inline creatives that validate_request rejects. #1548 fixed the same
override on SyncCreativesRequest and PackageUpdate; use _inherit here
too and cover both package models.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…class

postgres_reliable_reporting_service always constructed the base
ReliableReportingService, while ReliableReportingService.memory builds
cls. Hosts override the installed tool methods on a subclass, so the
Embedded Sales Agent had to copy the factory body to keep its subclass.
Add a service_class keyword, typed so the call returns the subclass.

Fixes #1588

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The rc41-rc48 lockfiles under scripts/ci/reporting_interop/npm pin
historical JavaScript SDK releases whose installed bytes the interop
matrix verifies. A Dependabot bump (#1556) breaks those controls, so
ignore every dependency in those directories.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1592)

build_mcp_error_result and the A2A failed-task payload carried only the
adcp_error envelope. AdCP's L3 error handling says a fatal failure
SHOULD populate both layers, and storyboards such as
media_buy_seller/product_signal_targeting read errors[0].field, so a
seller raising AdcpError as documented failed those steps. Add
errors: [adcp_error] on both transports.

Fixes #1558

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
* chore(main): release adcp-reporting-storage 0.1.0-beta.2

* chore: normalize prerelease version to PEP 440

---------

Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com>
aao-ipr-bot Bot and others added 22 commits October 25, 2026 06:56
* chore(main): release adcp-reporting-storage 0.1.0-beta.3

* chore: normalize prerelease version to PEP 440

---------

Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com>
…st (#1601)

The IPR push policy maps every main commit to the PR whose merge commit
it is. #1589 and #1595 were rebase-merged, so their first commits match
no PR and main's IPR check has failed since. List those two exact
commits with their PR numbers; authorship is still checked. Document
squash-only merges in the releasing guide.

#1591's test expected ext={'note': None} to dump as {} by default, but
#1600 deliberately keeps nulls inside opaque ext and context objects.
Expect the null.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…shed (#1602)

Their release commits failed main CI (a rebase-merge IPR mapping, fixed
in #1601), which release-publish requires, so neither reached PyPI.
Release their changes as 0.1.0b4.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Release-As: 0.1.0-beta.4
Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com>
* chore(main): release adcp-reporting-storage 0.1.0-beta.4

* chore: normalize prerelease version to PEP 440

---------

Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com>
Co-authored-by: Brian O'Kelley <bokelley@scope3.com>
…1608)

#1559 shipped in 9.0.0 without a breaking-change entry. Since then a
3.1 request reaches the handler upgraded to adcp_version 3.2, so code
that branched on the version in handler params lost that branch.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…all with (#1605)

9.0.0 moved the PostgreSQL reporting stores into adcp-reporting-storage
before that package was on PyPI, so the documented install failed and
Postgres adopters were stuck on the last beta. Before an adcp release
builds, require each companion distribution to have a published,
non-yanked release whose adcp requirement accepts the new version.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… summary (#1609)

convert_payload logged one WARNING per dropped field on every conversion,
on both sides of the wire. A 3.2 seller answering a 3.1 get_products with
50 products wrote 150 WARNING lines, three per product, for drops it must
make and already records in ToolContext.metadata.

convert_payload now takes loss_log_level (default WARNING) and every
caller passes it explicitly:

- server dispatch (request upgrade and response downgrade, MCP and A2A):
  DEBUG per loss, plus one INFO summary per conversion with tool,
  source -> target, loss count and distinct pointers with array indexes
  collapsed to *, capped at ten
- client outgoing request and incoming response conversion: WARNING
- account-key recomputation and catalog evidence reconstruction: DEBUG,
  since they repeat a conversion that already warned or round-trip the
  client's own lossless upgrade

Embedded polling results are converted through the non-logging core, so
their losses are logged once with the prefixed pointer instead of twice.

ESA feedback on 9.0 version conversion.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1611)

Since 9.0.0 (#1559) server dispatch upgrades an older buyer's request
before the handler runs, so resolve_requested_adcp_version(params) inside
a handler returns the served version ("3.2" for an explicit 3.1 claim).
Adopters branching on it lost those branches silently. Document the
behaviour as the breaking change it is and point handlers at
ToolContext.resolved_adcp_version for the buyer's negotiated contract.

- expand resolve_requested_adcp_version's docstring
- rewrite the ToolContext.resolved_adcp_version docstring (wire contract,
  server pin, MCP 3.0 / A2A None for unversioned traffic, capabilities)
- add a section to docs/cross-version-conversion.md and a row to
  MIGRATION_v8_to_v9.md
- pin the documented contract in the existing server dispatch test

ESA feedback on 9.0 version conversion.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com>
…as import (#1626)

The deprecated adcp.types.generated_poc.* alias registers the canonical
adcp.types.domains.* module object under a second sys.modules key.
_realize_lazy_exports filtered its snapshot on the sys.modules key but marked
module.__name__ visited, so the alias key was never retired and the walk
never terminated.

Mark the key the snapshot filtered on and walk each module object once by
identity, so an alias and its target are the same module. As a backstop the
walk is capped at 100 passes (the SDK surface stabilizes in about 4); if it
still finds newly imported modules after that it raises RuntimeError naming
them instead of spinning.

Fixes #1619

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
normalize_url(" https://interchange.io ") returned "interchange.io " because
urlparse drops leading whitespace but keeps trailing whitespace in the path,
so an adagents.json entry with a padded url did not match its agent. Strip
before parsing; _normalize_domain already does the same.

Signing paths keep byte-exact canonicalization: canonicalize_target_uri is a
signature-base canonicalizer, and the brand.json / adagents entry matches in
signing select keys or the signer identity and fail closed.

Fixes #1624

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…t key (#1628)

coerce_legacy_assets() / infer_asset_type() let an exact type-name key win
over field presence, but the type names came from a hand-kept list of 14 that
predated AdCP 3.2. An untyped asset under `display_tag` was inferred as
text, url or image from its other keys.

Derive the vocabulary from the generated AssetVariant union instead, so the
key list is the 21 asset types the pinned schema declares (adds display_tag,
zip, card, published_post, pixel_tracker, vast_tracker, daast_tracker) and a
schema bump extends it in the same regeneration. Imported lazily on first use.

Fixes #1624

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1630)

* fix(compat): never downgrade into a payload the older schema refuses

Projection dropped what the target release could not hold but never
checked that the parent it left behind was still valid. A notification
config whose event types were all 3.2-only kept event_types: [] below
3.1's minItems: 1, and a structured 3.2 geo_regions capability became {}
where 3.1 allows only a boolean.

When a node loses or coarsens a direct child, its own shape keywords
(required, minItems, minProperties, unions, if/then) are now checked
against the older schema. If the newer schema accepted the original and
the older one refuses the remainder:

- a boolean | object capability whose older contract is boolean maps to
  true when the structure declares any support (coarsened_to_boolean)
- an optional field is omitted (unrepresentable_in_target)
- a response array item is omitted (unrepresentable_in_target)
- a request array item is never dropped: a shortened sync list can read
  as removal under delete_missing, so the nearest optional field holding
  it is omitted instead
- a required chain to the root raises ADCPVersionConversionError at the
  refused value

Every downgrade then validates against the older bundled schema and
raises a structured error for anything still refused, such as a required
enum member the older release cannot spell. Payloads already invalid in
their own release are projected but neither repaired nor rejected.

Also fixes a crash resolving $ref pointers that index into a list
(#/oneOf/N, comply_test_controller).

A new sweep converts every bundled 3.2 schema example, the canonical
parity cases, the audience-evidence vectors and new fixtures down to 3.1
and 3.0. Before: 21 of 168 conversions failed (19 refused by the target
schema, 2 crashes). After: 0; 10 are listed, intentional structured errors.

Fixes #1621

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(compat): blame only drops that cause a refusal; never repair requests

Review follow-ups:

- A node is dropped only when the drop caused the refusal: the target must
  accept it with the dropped children restored. Otherwise the final check
  raises, so a 3.1 delivery row missing spend is not silently discarded
  just because a 3.2-only sibling was also dropped.
- Requests are never repaired by omission: an absent filter widens the
  search and a shortened sync list can read as removal. They fail with a
  structured error instead.
- The final check validates against the same portable schemas the
  projection used, without the validator's payload size cap.
- Structured-error reasons are value-free; pointers from the final check
  locate the value in the converted payload.
- Chained $ref nodes resolve fully for the shape checks.
- convert_payload(require_valid_target=False) for evidence reconstruction,
  used by the negotiated catalog purchase round-trip.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* fix(compat): keep the final check on for status-less responses

The SDK validators default a missing status envelope; the final check now
does the same, so a direct caller converting a status-less 3.2 response
still gets the guarantee. Also restore only removed (not coarsened) list
items when attributing a refusal, and word false-schema refusals.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…1633)

PrincipalResolverError(403, code="identity_org_mismatch") answers
403 {"error": "identity_org_mismatch"} on MCP and A2A. Codes must match
^[a-z][a-z0-9_]{0,63}$; anything else falls back to the generic body.
Exception text is never sent, and the default bodies are unchanged.

Fixes #1623

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, unresolved references as REFERENCE_NOT_FOUND (#1631)

installed_reporting_call projected STATUS_PROJECTION_UNAVAILABLE,
HISTORY_UNAVAILABLE and LOOKUP_UNAVAILABLE as terminal. A pending status
lifecycle and retained history the seller still owes resolve on the seller
side, so they are transient. A reporting reference that does not resolve for
the caller answers the standard, nondisclosing REFERENCE_NOT_FOUND
(correctable) with a generic message.

Fixes #1618

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…own wire error (#1632)

_invoke_platform_method caught only AdcpError and pydantic ValidationError,
so IdempotencyConflictError raised by IdempotencyStore.wrap reached the buyer
as INTERNAL_ERROR / terminal. ADCPTaskError subclasses now keep their code and
recovery through the same field extraction serve() uses, on the sync path and
in handoff tasks.

Fixes #1622

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ration (#1625)

Fixes #1620. Same class as #1587/#1610; a mechanical extension of #1548, #1579 and #1591.

## The defect

Optional fields that `adcp.types.canonical_creative` redeclares did not carry the never-null marker (`_RefuseJsonNull` plus `exclude_if`) their generated counterparts do, so a nested dump through a plain Pydantic parent emitted `null` for them (`Product.placements`, `Placement.format_options`, `CreateMediaBuyRequest.packages`, `UpdateMediaBuyRequest.packages` / `new_packages`, `GetProductsResponse.products`, `GetProductsRequest.filters`, `ListCreativesRequest.filters`). Root cause: hand redeclarations that narrow the annotation used a bare `Field(...)` instead of inheriting the generated `FieldInfo`. The same redeclarations had also dropped descriptions and, on `CreateMediaBuyRequest.packages` / `UpdateMediaBuyRequest.packages`, the generated `min_length=1`.

## The fix

- `adcp.types.base.inherit_field(source, name, *, required=False)` — the former `canonical_creative._inherit`, moved to `base` so every hand-written layer can use it; `required=True` drops the default for a subclass that makes an optional generated field mandatory (`Product.format_options`, `CreativeAsset.format_kind`, `Creative.format_kind`). `_inherit` remains as the alias `canonical_creative` has used since #1548.
- Every redeclaration of a generated field now goes through it: 17 in `canonical_creative.py`, `capabilities.MediaBuy.reporting_delivery`, and `projections` (`AccountResponse.billing_entity`, `AccountResponse.notification_configs` — whose `max_length=16` is now inherited rather than restated — and `_NotificationConfigResponse.authentication`).
- `_forward_compat.py` was checked: its only redeclarations (`adcp_version`, `adcp_major_version` on the readback models) already match their generated parent.

## Guard test

`tests/test_redeclared_fields_inherit.py` imports every `adcp` module, finds every hand-written subclass of a generated model, and asserts each redeclared field keeps its generated parent's never-null marker, constraints, description and alias. Defaults may differ (narrowing to required is the point). Two exemptions, each with a reason: `BusinessEntityResponse.bank` and `_NotificationAuthenticationResponse.credentials` are write-only projections — excluded from every dump, any value refused — so the marker and the generated `min_length` have nothing to apply to. A stale exemption fails the suite. The issue's reproduction and the eight listed fields are tested directly.

`make lint`, `make typecheck-all` pass; the related suites (canonical subclasses, capabilities, composability, account projections, public API) pass. Pre-commit cannot build its python3.11 hook environment in this sandbox, so the commit skipped hooks; black, ruff and mypy were run directly.

Squash-merge only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- conductor-workspace-link -->

---

[Open workspace in Conductor](https://app.conductor.build/pro-user-232ad008-36c5-45c0-b430-d68f028abd4b/w/851)
…ils so digests round-trip (#1617)

AnyUrl, AwareDatetime, UUID and EmailStr rewrite what they parse (a
trailing slash on a bare origin, +00:00 to Z, dropped .000), so a
digest-covered object such as AudienceEvidence could not reproduce its
own content_digest after parse and dump. Generated uri, date-time, uuid
and email fields now use wire-preserving subclasses that keep the input
text when it is strictly valid wire syntax naming the same host, and
normalize otherwise. AudienceEvidence and its pin, selection and
canonical variants are exported from adcp.types, and
AdCPBaseModel.model_dump_as_received() dumps an object as received.

Behaviour change: parsed URLs, datetimes, UUIDs and emails keep their
received spelling in str() and JSON output (no added trailing slash, +00:00
kept). Runtime values are subclasses, so type(x) is datetime is false while
isinstance holds, and str(url)-keyed caches see different spellings of the
same URL as different. Strings that are not strict wire syntax are still
normalized. The JSON Schema is unchanged.

Fixes #1610

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: aao-ipr-bot[bot] <279263549+aao-ipr-bot[bot]@users.noreply.github.com>
Forward-ports main through v9.2.0 (5314faa) into next with a true merge.
Conflict: packages/adcp-reporting-storage/pyproject.toml takes main's
adcp floor (>=9.0.0) and keeps next's <11 ceiling, per RELEASING.md.
main's companion-release gate (#1605) now runs only for a publish from
main; next cuts opt-in pre-releases and the storage distribution's
adcp-10 release is cut from main at ratification.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@bokelley bokelley changed the title chore: merge main into next after 9.0.0 chore: merge main into next through 9.2.0 Oct 26, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon is not auto-reviewing this PR because it modifies the review system itself (LADON.md). A human reviewer should review and merge this PR; Ladon resumes on subsequent PRs once these changes land on main.

…the retired generated_poc alias test

main's #1625 guard requires every hand-written redeclaration of a generated
field to inherit it; next's experimental identity facades predate that
rule. main's #1626 test imports adcp.types.generated_poc, which next
removed in SDK 10 (#1536), so it skips when the alias is absent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ladon is not auto-reviewing this PR because it modifies the review system itself (LADON.md). A human reviewer should review and merge this PR; Ladon resumes on subsequent PRs once these changes land on main.

@bokelley
bokelley merged commit 25275dd into next Oct 27, 2026
41 checks passed
@bokelley
bokelley deleted the sync/main-into-next-9.0.0 branch October 27, 2026 02:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants