Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/cross-version-conversion.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,51 @@ results supplied on `list_tasks` entries. Loss pointers include `/result` or
`/tasks/<index>/result`. Unknown vendor task results remain opaque. Nested polling
wrappers remain opaque as well, bounding conversion to one wrapper level.

## Downgrades never emit a payload the older schema refuses

Dropping a newer field or enum member can leave its parent in a shape the
older release refuses: a notification config whose `event_types` were all
3.2-only becomes an empty list below 3.1's `minItems: 1`. When a node loses or
coarsens a direct child, the SDK checks the node's own constraints in the older
schema (`required`, `minItems`, `minProperties`, unions, `if`/`then`). A
refusal counts as caused by the drop only when the newer schema accepted the
original and the older schema accepts the node with the dropped children put
back. Such a node is resolved by one rule:

| Situation | Result | Loss `reason` |
| --- | --- | --- |
| Newer `boolean \| object` capability, older boolean only (3.2 `geo_regions`) | `true` when the structure declares any support, otherwise `false` | `coarsened_to_boolean` |
| Response: refused node is an optional field | Field omitted | `unrepresentable_in_target` |
| Response: refused node is an array item | Item omitted | `unrepresentable_in_target` |
| Request: any refused node | `ADCPVersionConversionError` | none |
| Response: only required fields lead to the root | `ADCPVersionConversionError` | none |

Requests 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. A request whose
`filters.required_metrics` were all 3.2-only therefore fails rather than
widening the search. Optional newer members are still dropped one at a time
(`enum_unavailable`) when others remain, as before. Losses already recorded
inside a dropped response node stay in the report, explaining why it went.

Every downgrade then validates the result against the older release's bundled
schema. A refusal the projection did not cause fails with
`ADCPVersionConversionError` instead of reaching the peer invalid. Examples are
a field the older release requires and the payload never had, such as 3.1
delivery `totals.spend`, and a required enum member the older release cannot
spell. That error's pointer locates the value in the converted payload. Its
reason never echoes payload values. A payload already invalid in its own
release is projected but neither repaired nor rejected; the caller's
validation policy decides. The server returns these errors as
`VERSION_UNSUPPORTED`, including after a handler has run. The client returns a
failed `TaskResult` with `adcp_error.code="UNSUPPORTED_FEATURE"`. Code that
reconstructs a peer's own evidence, rather than sending a payload, can pass
`require_valid_target=False` to `convert_payload` to skip the final check.

`tests/test_version_conversion_sweep.py` converts every bundled 3.2 schema
example and fixture down to 3.1 and 3.0 and requires each result to validate,
or to fail with one of the structured errors it lists.

## Conversion reports

Client results expose conversion details in
Expand Down
3 changes: 3 additions & 0 deletions src/adcp/compat/negotiated_catalog_purchase.py
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,9 @@ def _completed_payload(
# Round-trips the client's own lossless upgrade back to
# the served contract; only upgrade defaults can drop.
loss_log_level=logging.DEBUG,
# Reconstructs the seller's evidence faithfully, even
# where the seller's own response was invalid.
require_valid_target=False,
).payload
return payload

Expand Down
Loading
Loading