Repository navigation
fix(compat): never downgrade into a payload the older schema refuses - #1630
Conversation
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>
…ests 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>
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>
| _NODE_VALIDATORS[key] = cached | ||
| try: | ||
| error = best_match(cached[2].iter_errors(value)) | ||
| except Exception: # noqa: BLE001 - an unresolvable node is checked whole later |
There was a problem hiding this comment.
Medium: This blanket except Exception: return None fails open on a verification path. The comment ("checked whole later") holds for the shape=True calls in _check_representable — their later whole check is _require_target_valid. But _require_target_valid itself calls _refusal(target_schema, target_schema, result, shape=False); if that iter_errors raises (e.g. an unresolvable $ref in a bundled node), the swallow returns None, the refusal is read as "target accepts," and the downgrade emits the payload unchecked. There is no later check for that path. That silently weakens the stated guarantee "a downgrade never emits a payload the older schema refuses." Trigger likelihood is low (bundled schemas resolve), but the failure is invisible — at minimum log/capture the swallow so a verification that could not run is observable rather than indistinguishable from a pass.
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — downgrade pathway change is on-mandate (DR-0032/0033) and well-tested.
What I checked:
- The core behavior change: downgrade now refuses rather than emitting a payload the older schema rejects — directly satisfies the "Refused" pathway of the minor-release wire-change mandate.
src/adcp/compat/versions.py(+363/-47):drop_unavailable_nulls→responserename is internal-only (no external callers), the_resolvearray-index fix (L154),_check_representable's symmetric restore-and-recheck that only blames drops actually causing a refusal,_require_target_valid, and the additiverequire_valid_targetkwarg (server maps the raise to VERSION_UNSUPPORTED; client catches ADCPFeatureUnsupportedError).- draft-07
$ref-sibling wrapper assumption is safe — loader excludes the 2020-12mcp/subtree. - New sweep test covers every bundled 3.2 example + fixtures (90+ cases), asserting each downgrades-valid or fails with a listed structured error. Pathway test requirement met.
Medium findings (1):
- src/adcp/compat/versions.py:309 —
_refusal's blanketexcept Exception: return Nonefails open on the final shape=False check in_require_target_valid, silently weakening the stated guarantee. Low trigger likelihood; worth a human eye but non-blocking.
Decision path: high_risk is true but both reasons are (modified) with only ONE medium finding — row 5 requires a medium AND is satisfied by the finding, so this escalates. Re-checking: row 5 fires (high_risk true + (modified) + a medium finding present).
Medium findings
- src/adcp/compat/versions.py:309 — _refusal swallows all exceptions, failing open on the final target-valid check
Fixes #1621
Root cause
_projectdrops whatever the older release can't hold: unknown fields, newer enum members, and nulls it can't express. It never checked that the parent left behind was still valid in the older schema, so it emitted payloads the older schema refuses. The two repros in #1621:event_typeswere all 3.2-only keptevent_types: [], below 3.1'sminItems: 1.geo_regions(anyOf[boolean, geo-region-support]) projected to{}, where 3.1 allows only a boolean.Nothing validated the result either, so the same mechanism let other refusals through. For example, a field 3.1 requires but 3.2 made optional (delivery
totals.spend), or a required enum member with no 3.0 spelling. The new sweep also found a crash:_resolvecouldn't follow a$refthat indexes into a list (#/oneOf/N,comply_test_controller).Fix: one mechanism, defined rules
When a node loses or coarsens a direct child, the converter checks the node's own constraints in the older schema:
required,minItems,minProperties,oneOf/anyOf,if/then,contains,not,dependencies. The check is shallow; children are checked where they were projected.The drop takes the blame only when two things hold:
Otherwise an unrelated sibling drop can't make a row disappear.
reasonboolean | objectcapability, older boolean only (the union says the boolean is the coarse spelling)trueif the structure declares any support, elsefalsecoarsened_to_booleanunrepresentable_in_targetunrepresentable_in_targetADCPVersionConversionErrorADCPVersionConversionErrorRequests are never repaired by omission, because omitting a request field changes its meaning. A shortened sync list can read as removal under
delete_missing, and an absent filter matches everything. So aget_productswhosefilters.required_metricswere all 3.2-only fails instead of widening the search.After projecting, every downgrade validates the result against the same portable schemas the projection used, with no payload-size cap. It defaults
statusexactly as the SDK validators do. Any refusal the projection didn't cause raises a structuredADCPVersionConversionError, with a value-free reason and a pointer into the converted payload. This replaces the old "required values remain for validation to reject" behaviour. Payloads already invalid in their own release are projected but neither repaired nor rejected.convert_payload(require_valid_target=False)skips only that final check. It is used by the negotiated-catalog evidence round-trip, which reconstructs the seller's own response rather than sending it.The #1621 repros now produce:
notification_configs: the all-3.2 config is dropped (unrepresentable_in_target, on top of the existing per-eventenum_unavailable). The mixed config keeps["product.updated"].geo_regions:true, with acoarsened_to_booleanloss at/media_buy/execution/targeting/geo_regions.Sweep
tests/test_version_conversion_sweep.pyconverts down to 3.1 and 3.0 every bundled 3.2 schema example (via the publiclist_validator_keys/get_portable_schema), plus:tests/fixtures/version_conversion_sweep.json(the 3.2 to 3.1 conversion emits payloads 3.1 refuses: empty notification event_types, and geo_regions as {} #1621 repros and a request-side notification-config case).Each result must validate against the target's bundled schema, or raise one of the structured errors pinned in
EXPECTED_STRUCTURED_ERRORS; any new error fails the test. A second pass repeats every response case withoutstatus.main)The 16 structured errors:
format_idthat only the legacy creative dialect can supply (real 3.0 boundaries negotiate it first);sync_accountsrequests and results, which older releases can't key;get_signals(3.0);media_buysellers withoutaccountcapabilities;Unit tests in
tests/test_cross_version_conversion.pycover:required_metricsfilter);Behaviour notes for reviewers
VERSION_UNSUPPORTED, even after the handler has run and regardless ofvalidation.responses, because the issue requires that no converted payload fails the target schema. The client returns a failedTaskResultwithUNSUPPORTED_FEATURE.totals.spend.get_productsdowngrade (3.2 → 3.1, 150 losses) goes from ~32 ms to ~86 ms locally. Most of the difference is the final full validation.docs/cross-version-conversion.md, "Downgrades never emit a payload the older schema refuses".make lint,make typecheck-allandmake testwere run locally.🤖 Generated with Claude Code
Open workspace in Conductor