Skip to content

feat(server): route validation by wire adcp_version (stage 3)#660

Closed
bokelley wants to merge 3 commits into
claude/versioned-schemas-stage-2from
claude/versioned-schemas-stage-3
Closed

feat(server): route validation by wire adcp_version (stage 3)#660
bokelley wants to merge 3 commits into
claude/versioned-schemas-stage-2from
claude/versioned-schemas-stage-3

Conversation

@bokelley
Copy link
Copy Markdown
Contributor

Stacked on #659.

Summary

Stage 3 of the versioned-schema-validation port. create_tool_caller now detects the buyer's claimed version off the request envelope and threads it through to the per-version validator added in Stage 2.

Detection order (mirrors the TS SDK's applyVersionEnvelope and the spec text on every *-request schema):

  1. adcp_version (3.1+ release-precision string) — normalized to release precision (\"3.0.7\"\"3.0\"); must be in COMPATIBLE_ADCP_VERSIONS or VERSION_UNSUPPORTED is raised.
  2. adcp_major_version (legacy integer) — maps to the highest supported minor for that major. Unknown major raises VERSION_UNSUPPORTED with structured details.
  3. Neither field set — falls through to the SDK pin (existing behaviour).

New module adcp.validation.envelope owns the detection logic. UnsupportedVersionError carries the wire value and supported set so the dispatcher can echo both into the error envelope's details.

What the dispatcher does now

  1. Run pre-validation hook.
  2. (new) Detect wire version. If unsupported → raise VERSION_UNSUPPORTED before any handler dispatch.
  3. validate_request(..., version=wire_version) — Stage 2 loader picks the per-version validator.
  4. Pydantic model_validate (unchanged).
  5. Handler invoked.
  6. validate_response(..., version=wire_version) — same version as request.

Subsequent stages

  • Stage 4. First legacy adapter (v2.5.sync_creatives) wired so the dispatcher can validate against a v2.5 schema and translate to v3 internal types.
  • Stage 5. Remaining adapters + spec_compat_hooks() deprecation.

Test plan

  • pytest tests/ — 4425 passed (one unrelated flaky timing test deselected; <1 microsecond drift)
  • New test_dispatcher_version_routing — 7 integration tests for the wire-level path
  • New test_validation_envelope — 12 unit tests for the detection helper
  • ruff check + mypy
  • Existing test_spec_compat_hooks payloads updated from placeholder adcp_major_version=4 to a real supported version

🤖 Generated with Claude Code

bokelley added a commit that referenced this pull request May 11, 2026
… echo

Review feedback on PR #660.

* String ``adcp_major_version`` (e.g. ``"3"``) used to fall through to
  ``None`` and silently get SDK-pin validation. A buyer that
  JSON-stringified the field deserves a loud
  ``VERSION_UNSUPPORTED``, not a quiet behaviour swap. Same for ``< 1``
  values (below the spec's ``minimum: 1`` bound).
* ``claimed_version`` in error details used to be ``str(exc.wire_value)``,
  which converted the int field to ``"4"``. Pass through verbatim so
  buyer telemetry sees the same type they sent.
* Rename ``test_no_version_field_validator_uses_sdk_pin`` to actually
  test that claim — pass a ``ValidationHookConfig`` so the validator
  fires, and assert ``version=None`` is threaded. Split the original
  test's "no validation config means no validator runs" assertion into
  its own regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bokelley and others added 2 commits May 11, 2026 06:05
Stage 3 of the versioned-schema-validation port. ``create_tool_caller``
now detects the buyer's claimed version off the request envelope and
threads it through to the per-version validator added in Stage 2.

Detection order (mirrors the TS SDK's ``applyVersionEnvelope`` and the
spec text on every ``*-request`` schema):

1. ``adcp_version`` (3.1+ release-precision string) — normalized to
   release precision (``"3.0.7"`` → ``"3.0"``); must be in
   ``COMPATIBLE_ADCP_VERSIONS`` or ``VERSION_UNSUPPORTED`` is raised.
2. ``adcp_major_version`` (legacy integer) — maps to the highest
   supported minor for that major. Unknown major raises
   ``VERSION_UNSUPPORTED`` with structured details.
3. Neither field set — falls through to the SDK pin (existing behaviour).

New module ``adcp.validation.envelope`` owns the detection logic;
``UnsupportedVersionError`` carries the wire value and supported set so
the dispatcher can echo both into the error envelope's ``details``.

Existing ``test_spec_compat_hooks`` payloads used
``adcp_major_version=4`` as a wire-shape placeholder — updated to ``3``
now that the dispatcher actually validates the field.

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

Review feedback on PR #660.

* String ``adcp_major_version`` (e.g. ``"3"``) used to fall through to
  ``None`` and silently get SDK-pin validation. A buyer that
  JSON-stringified the field deserves a loud
  ``VERSION_UNSUPPORTED``, not a quiet behaviour swap. Same for ``< 1``
  values (below the spec's ``minimum: 1`` bound).
* ``claimed_version`` in error details used to be ``str(exc.wire_value)``,
  which converted the int field to ``"4"``. Pass through verbatim so
  buyer telemetry sees the same type they sent.
* Rename ``test_no_version_field_validator_uses_sdk_pin`` to actually
  test that claim — pass a ``ValidationHookConfig`` so the validator
  fires, and assert ``version=None`` is threaded. Split the original
  test's "no validation config means no validator runs" assertion into
  its own regression test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley force-pushed the claude/versioned-schemas-stage-3 branch from fc7dd4c to d0c1944 Compare May 11, 2026 10:05
…T_VERSION_ENVELOPE)

Downstream-impact audit on PR #660 flagged that switching from "wire
schema accepts adcp_major_version 1-99" to "dispatcher rejects unknown
versions" in one release would break test fixtures using ``4`` as a
sentinel and any buyer claiming an unsupported version that happened to
pass schema validation.

Decouple legacy adapter routing (additive, ships now) from
version-envelope strictness (subtractive, ships under a gate).

* Default (``ADCP_STRICT_VERSION_ENVELOPE`` unset or ``"0"``): an
  unsupported wire version is logged at WARNING with a migration hint
  pointing at the env var. The dispatcher falls through to SDK-pin
  validation — same as 5.1.0 behaviour for that payload.
* Strict (``ADCP_STRICT_VERSION_ENVELOPE=1``): raise the
  ``VERSION_UNSUPPORTED`` envelope the spec prescribes, before any
  handler dispatch. This becomes the default in 5.3.

Existing strict-mode tests gain a ``strict_version_envelope`` fixture
so the spec-prescribed behaviour stays covered. New
``test_unsupported_version_permissive_falls_through`` exercises the
default-permissive path and asserts the migration hint shows up in
logs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley deleted the branch claude/versioned-schemas-stage-2 May 11, 2026 10:13
@bokelley bokelley closed this May 11, 2026
bokelley added a commit that referenced this pull request May 11, 2026
* feat(validation): per-version validator loader (stage 2)

``get_validator``, ``validate_request``, and ``validate_response`` now
accept an optional ``version=`` kwarg. ``None`` defaults to the SDK's
compile-time pin (existing behaviour, byte-identical for current call
sites). A wire-version string (``"3.0.7"``, ``"2.5"``,
``"3.1.0-beta.1"``) routes to the per-bundle-key cache laid down by
Stage 1 — each version's file index, compiled validators, and core
registry live in their own ``_LoaderState`` so cross-version traffic
doesn't share compilation state.

The dispatcher call sites are unchanged today; Stage 3 will detect the
buyer's ``adcp_major_version`` off the wire and thread it through.

Also widens ``resolve_bundle_key`` to accept bare ``MAJOR.MINOR``
(already a bundle key — matches the wire envelope's release-precision
``adcp_version`` field, so the dispatcher can pass it straight through).

Tests:
* ``test_schema_loader_per_version`` lays down a synthetic
  ``schemas/cache/2.5/`` fixture, asserts validators for the synthetic
  legacy tool and the real SDK-pin tools coexist independently, and
  pins ``version=None`` behaviour to the SDK pin.
* ``test_validation_version`` extended with the new pass-through and
  rejection cases.

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

* test(validation): isolate per-version loader fixture from repo working tree

Review feedback on PR #659 (Stage 2). Two nits:

* Fixture used to write into the repo's real ``schemas/cache/2.5/``,
  which means CI runs against an installed wheel would silently miss
  the synthetic bundle (packaged ``_schemas/`` wins) and the assertions
  would be vacuous. Move to ``tmp_path`` + ``monkeypatch.setattr`` on
  the loader's resolver instead.
* Teardown used a sequence of ``rmdir`` calls that fail if anything else
  lands in the directories mid-run. ``shutil.rmtree(..., ignore_errors=
  True)`` is robust.

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

* feat(server): route validation by wire ``adcp_version`` (stage 3)

Stage 3 of the versioned-schema-validation port. ``create_tool_caller``
now detects the buyer's claimed version off the request envelope and
threads it through to the per-version validator added in Stage 2.

Detection order (mirrors the TS SDK's ``applyVersionEnvelope`` and the
spec text on every ``*-request`` schema):

1. ``adcp_version`` (3.1+ release-precision string) — normalized to
   release precision (``"3.0.7"`` → ``"3.0"``); must be in
   ``COMPATIBLE_ADCP_VERSIONS`` or ``VERSION_UNSUPPORTED`` is raised.
2. ``adcp_major_version`` (legacy integer) — maps to the highest
   supported minor for that major. Unknown major raises
   ``VERSION_UNSUPPORTED`` with structured details.
3. Neither field set — falls through to the SDK pin (existing behaviour).

New module ``adcp.validation.envelope`` owns the detection logic;
``UnsupportedVersionError`` carries the wire value and supported set so
the dispatcher can echo both into the error envelope's ``details``.

Existing ``test_spec_compat_hooks`` payloads used
``adcp_major_version=4`` as a wire-shape placeholder — updated to ``3``
now that the dispatcher actually validates the field.

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

* fix(envelope): reject string adcp_major_version, preserve int type in echo

Review feedback on PR #660.

* String ``adcp_major_version`` (e.g. ``"3"``) used to fall through to
  ``None`` and silently get SDK-pin validation. A buyer that
  JSON-stringified the field deserves a loud
  ``VERSION_UNSUPPORTED``, not a quiet behaviour swap. Same for ``< 1``
  values (below the spec's ``minimum: 1`` bound).
* ``claimed_version`` in error details used to be ``str(exc.wire_value)``,
  which converted the int field to ``"4"``. Pass through verbatim so
  buyer telemetry sees the same type they sent.
* Rename ``test_no_version_field_validator_uses_sdk_pin`` to actually
  test that claim — pass a ``ValidationHookConfig`` so the validator
  fires, and assert ``version=None`` is threaded. Split the original
  test's "no validation config means no validator runs" assertion into
  its own regression test.

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

* feat(server): default-permissive VERSION_UNSUPPORTED gate (ADCP_STRICT_VERSION_ENVELOPE)

Downstream-impact audit on PR #660 flagged that switching from "wire
schema accepts adcp_major_version 1-99" to "dispatcher rejects unknown
versions" in one release would break test fixtures using ``4`` as a
sentinel and any buyer claiming an unsupported version that happened to
pass schema validation.

Decouple legacy adapter routing (additive, ships now) from
version-envelope strictness (subtractive, ships under a gate).

* Default (``ADCP_STRICT_VERSION_ENVELOPE`` unset or ``"0"``): an
  unsupported wire version is logged at WARNING with a migration hint
  pointing at the env var. The dispatcher falls through to SDK-pin
  validation — same as 5.1.0 behaviour for that payload.
* Strict (``ADCP_STRICT_VERSION_ENVELOPE=1``): raise the
  ``VERSION_UNSUPPORTED`` envelope the spec prescribes, before any
  handler dispatch. This becomes the default in 5.3.

Existing strict-mode tests gain a ``strict_version_envelope`` fixture
so the spec-prescribed behaviour stays covered. New
``test_unsupported_version_permissive_falls_through`` exercises the
default-permissive path and asserts the migration hint shows up in
logs.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@bokelley bokelley deleted the claude/versioned-schemas-stage-3 branch May 11, 2026 10:14
bokelley added a commit that referenced this pull request May 11, 2026
* feat(validation): per-version validator loader (stage 2)

``get_validator``, ``validate_request``, and ``validate_response`` now
accept an optional ``version=`` kwarg. ``None`` defaults to the SDK's
compile-time pin (existing behaviour, byte-identical for current call
sites). A wire-version string (``"3.0.7"``, ``"2.5"``,
``"3.1.0-beta.1"``) routes to the per-bundle-key cache laid down by
Stage 1 — each version's file index, compiled validators, and core
registry live in their own ``_LoaderState`` so cross-version traffic
doesn't share compilation state.

The dispatcher call sites are unchanged today; Stage 3 will detect the
buyer's ``adcp_major_version`` off the wire and thread it through.

Also widens ``resolve_bundle_key`` to accept bare ``MAJOR.MINOR``
(already a bundle key — matches the wire envelope's release-precision
``adcp_version`` field, so the dispatcher can pass it straight through).

Tests:
* ``test_schema_loader_per_version`` lays down a synthetic
  ``schemas/cache/2.5/`` fixture, asserts validators for the synthetic
  legacy tool and the real SDK-pin tools coexist independently, and
  pins ``version=None`` behaviour to the SDK pin.
* ``test_validation_version`` extended with the new pass-through and
  rejection cases.

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

* test(validation): isolate per-version loader fixture from repo working tree

Review feedback on PR #659 (Stage 2). Two nits:

* Fixture used to write into the repo's real ``schemas/cache/2.5/``,
  which means CI runs against an installed wheel would silently miss
  the synthetic bundle (packaged ``_schemas/`` wins) and the assertions
  would be vacuous. Move to ``tmp_path`` + ``monkeypatch.setattr`` on
  the loader's resolver instead.
* Teardown used a sequence of ``rmdir`` calls that fail if anything else
  lands in the directories mid-run. ``shutil.rmtree(..., ignore_errors=
  True)`` is robust.

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

* feat(server): route validation by wire ``adcp_version`` (stage 3)

Stage 3 of the versioned-schema-validation port. ``create_tool_caller``
now detects the buyer's claimed version off the request envelope and
threads it through to the per-version validator added in Stage 2.

Detection order (mirrors the TS SDK's ``applyVersionEnvelope`` and the
spec text on every ``*-request`` schema):

1. ``adcp_version`` (3.1+ release-precision string) — normalized to
   release precision (``"3.0.7"`` → ``"3.0"``); must be in
   ``COMPATIBLE_ADCP_VERSIONS`` or ``VERSION_UNSUPPORTED`` is raised.
2. ``adcp_major_version`` (legacy integer) — maps to the highest
   supported minor for that major. Unknown major raises
   ``VERSION_UNSUPPORTED`` with structured details.
3. Neither field set — falls through to the SDK pin (existing behaviour).

New module ``adcp.validation.envelope`` owns the detection logic;
``UnsupportedVersionError`` carries the wire value and supported set so
the dispatcher can echo both into the error envelope's ``details``.

Existing ``test_spec_compat_hooks`` payloads used
``adcp_major_version=4`` as a wire-shape placeholder — updated to ``3``
now that the dispatcher actually validates the field.

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

* fix(envelope): reject string adcp_major_version, preserve int type in echo

Review feedback on PR #660.

* String ``adcp_major_version`` (e.g. ``"3"``) used to fall through to
  ``None`` and silently get SDK-pin validation. A buyer that
  JSON-stringified the field deserves a loud
  ``VERSION_UNSUPPORTED``, not a quiet behaviour swap. Same for ``< 1``
  values (below the spec's ``minimum: 1`` bound).
* ``claimed_version`` in error details used to be ``str(exc.wire_value)``,
  which converted the int field to ``"4"``. Pass through verbatim so
  buyer telemetry sees the same type they sent.
* Rename ``test_no_version_field_validator_uses_sdk_pin`` to actually
  test that claim — pass a ``ValidationHookConfig`` so the validator
  fires, and assert ``version=None`` is threaded. Split the original
  test's "no validation config means no validator runs" assertion into
  its own regression test.

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

* feat(server): default-permissive VERSION_UNSUPPORTED gate (ADCP_STRICT_VERSION_ENVELOPE)

Downstream-impact audit on PR #660 flagged that switching from "wire
schema accepts adcp_major_version 1-99" to "dispatcher rejects unknown
versions" in one release would break test fixtures using ``4`` as a
sentinel and any buyer claiming an unsupported version that happened to
pass schema validation.

Decouple legacy adapter routing (additive, ships now) from
version-envelope strictness (subtractive, ships under a gate).

* Default (``ADCP_STRICT_VERSION_ENVELOPE`` unset or ``"0"``): an
  unsupported wire version is logged at WARNING with a migration hint
  pointing at the env var. The dispatcher falls through to SDK-pin
  validation — same as 5.1.0 behaviour for that payload.
* Strict (``ADCP_STRICT_VERSION_ENVELOPE=1``): raise the
  ``VERSION_UNSUPPORTED`` envelope the spec prescribes, before any
  handler dispatch. This becomes the default in 5.3.

Existing strict-mode tests gain a ``strict_version_envelope`` fixture
so the spec-prescribed behaviour stays covered. New
``test_unsupported_version_permissive_falls_through`` exercises the
default-permissive path and asserts the migration hint shows up in
logs.

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

* feat(compat): AdapterPair pattern + v2.5 sync_creatives (stage 4)

Stage 4 of the versioned-schema-validation port. Replaces the heuristic
``spec_compat_hooks()`` model with a typed per-tool adapter registry.

What changed:

* New package ``adcp.compat.legacy`` with the ``AdapterPair`` dataclass,
  registry (``get_legacy_adapter`` / ``list_legacy_adapter_tools``), and
  the ``LEGACY_ADAPTER_VERSIONS`` constant. Mirrors the TS SDK's
  ``src/lib/adapters/legacy/v2-5/``.
* First concrete adapter: ``adcp.compat.legacy.v2_5.sync_creatives``.
  Three wire-shape coercions (bare-string ``format_id`` → structured,
  ``asset_type`` inference, ``image``-without-dims → ``url`` demotion).
* ``validation.envelope.SUPPORTED_WIRE_VERSIONS`` now includes legacy
  versions, so ``detect_wire_version`` accepts both native and legacy
  claims.
* ``create_tool_caller`` routes legacy-versioned requests through the
  adapter: ``adapt_request`` runs *after* version detection and *before*
  current-schema validation, so a buggy translator surfaces as
  ``INVALID_REQUEST`` with a field-level pointer. A legacy version
  with no adapter for the requested tool raises ``INVALID_REQUEST``.
* Response side: optional ``normalize_response`` callable runs after
  current-schema validation. ``sync_creatives`` has the same response
  shape across v2.5/v3 so leaves it ``None``.

Deferred (Stage 4b): real v2.5 schema bundle. Upstream CDN doesn't
ship v2.5 tarballs; the JS SDK pins a GitHub commit SHA. Wire that
fetch in a follow-up. Today the adapter validates against the v3
output only.

Tests (25 new): registry contract, every v2.5 sync_creatives coercion
via the public adapter surface, end-to-end dispatcher routing including
missing-adapter and adapter-raises cases.

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

* refactor(compat): tighten AdapterPair contract, data-driven module loader

Review feedback on Stage 4.

* AdapterPair docstring states the contract: sync + pure (no mutation),
  no I/O, request raise → INVALID_REQUEST, response raise →
  INTERNAL_ERROR.
* _ensure_loaded reads its work from _VERSION_MODULES dict — adding a
  tool to a version is a one-line append, not a control-flow change.
* Dispatcher renames effective_version → post_adapter_validator_version
  with a comment explaining how Stage 4b (real v2.5 schema bundle)
  extends the variable's role.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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.

1 participant