fix(types)!: enforce closed creative format kinds - #1248
Conversation
Review — strict
|
|
Addressed the non-blocking DeliveryCreative type-contract observation in
Requesting db56's recheck of this test-only follow-up. |
Exact-head verdict —
|
|
Full All 228 focused tests, Requesting db56's recheck of this test-only fixture correction. |
Exact-head verdict —
|
| site | value | why it's correct |
|---|---|---|
tests/type_checks/creative_asset_binding.py:42 |
future_canonical_format |
the deliberate DeliveryCreative fixture proving the open readback path |
tests/test_codegen_contract_compatibility.py:95,107 |
unknown |
both inside pytest.raises(ValidationError) — negative assertions requiring rejection |
So the closed enum shouldn't surface any further fixture breakage in the full run.
What I ran at 1d467863
test_rc3_media_buy_runtime.py+test_forward_compat_format_kind.py+test_codegen_contract_compatibility.py— 134 passed- Enum membership check for both literals, and the tests-wide
format_kindscan above
Everything from my 32cfc5b8 and 4c702286 reviews stands: the closed/open asymmetry is correct, schema-consistent through rc.7, both halves now equally enforced by assert_type, and the BREAKING CHANGE footer is in place for the merged main-push policy.
CI on this head: 2 SUCCESS / 1 skipped / 7 pending, no failures.
|
CI triage on 1d46786: Validate conventional commit format rejects the breaking subject because the actual commit message lacks a BREAKING CHANGE footer. Add the footer to the breaking commit (not only the PR body), then push and recheck CI. The production/test review remains clear on this head. |
|
Correction to my CI triage: the current PR title/body and the breaking commit both pass the policy validator, including the required footer. The failed run used an earlier pull_request event body (it fails at validate_message(pr title + body), before commit checks). No commit-message fix is needed. A fresh synchronize after the rc2 rebase will use the current body and rerun this check; please keep the present footer. |
1d46786 to
1be9b59
Compare
|
The conventional-commit job rejected the PR title/body captured before the description had a literal Pushed Requesting db56 to confirm the unchanged source on this refreshed head. |
Exact-head confirmation —
|
There was a problem hiding this comment.
Ladon verdict: Request changes
Request changes — 1 blocking finding.
Blocking findings
- src/adcp/types/canonical_creative.py — Removing the
CreativeManifestwidening also closesformat_kindon the manifest nested inside the deliberately-openDeliveryCreativereadback path (variants[].manifest). A delivery response carrying a future canonical kind now raisesValidationErrorand zeros the whole response, contradicting the stated tolerant-readback contract. This is forward-compat regression on a load-bearing discriminated-union/readback path (high).
The intentional breaking change on the producer models (CreativeAsset/Creative) is well-signaled — fix!: prefix, BREAKING CHANGE: footer, migration guide, and CHANGELOG prose all present, satisfying the semver-signal requirement. The issue is scope leakage: the closure must not extend to the tolerant DeliveryCreative readback manifest. Preserve the open/forward-compatible arm on the delivery readback path (e.g. retain the widening or add an UnknownFormatAsset-style escape hatch for the nested manifest) so future canonical kinds deserialize without raising.
Decision table row 1 applies (one high finding) → request-changes.
Blocking findings
- src/adcp/types/canonical_creative.py — Closing format_kind on the DeliveryCreative tolerant-readback manifest (variants[].manifest) causes ValidationError on future canonical kinds, breaking forward-compat readback contract (high)
|
Aao's exact-head review identified a blocking readback regression in |
|
Brian's #1241 product-boundary decision (2026-09-29): keep Please add a regression proving an unknown |
|
Brian boundary implemented on 530b2c5: public CreativeAsset, Creative, CreativeManifest, and CreativeVariant remain strict; DeliveryCreative now nests private tolerant manifest and variant models. A complete GetCreativeDeliveryResponse with an unknown nested format_kind round-trips, while public CreativeManifest rejects it. Changelog and typing updated. Focused tests: 101 passed; lint, source and adopter typing, and commit hooks passed. I pushed directly to the PR branch; please sync before any further edits. Re-requesting exact-head review once CI confirms this head. |
|
Ladon cannot review this PR until merge conflicts are resolved. |
Use CanonicalFormatKind for CreativeAsset and Creative, and stop widening CreativeManifest in the public clone and forward compatibility patch. Match the public stubs, cover strict validation and known-value round trips, and document caller migration. BREAKING CHANGE: CreativeAsset, Creative, and CreativeManifest reject unknown format_kind values instead of retaining arbitrary strings. Closes #1241
Use schema-defined display_tag so the localization regression reaches its explicit-null assertion after canonical enum validation.
530b2c5 to
72b2fcf
Compare
|
Follow-up on current head 6e76ceb: the generated/legacy GetCreativeDeliveryResponse had the same nested-manifest rejection. I reproduced it red, added a private generated delivery-only manifest/variant view, and now both canonical and generated complete responses round-trip unknown nested format_kind while both public/generated CreativeManifest remain strict. 102 focused tests, lint, typecheck-all, and commit hooks pass. The prior Ladon run was superseded; please review this exact head. Full CI is running. |
Exact-head verdict —
|
| symbol | variants element |
|---|---|
adcp.types.canonical_creative.DeliveryCreative (used by GetCreativeDeliveryResponse) |
_DeliveryCreativeVariant — tolerant |
adcp.types.DeliveryCreative (the public export) |
generated_poc.core.creative_variant.CreativeVariant — closed |
The public export resolves to generated_poc.creative.get_creative_delivery_response.DeliveryCreative. I checked the merge base and this split already exists there, unchanged — so it is not introduced here and is not a blocker for this PR.
It is, however, more consequential after this change than before it. Previously both classes rejected nested unknown kinds, so the split was invisible; now they differ behaviourally, and an adopter writing from adcp.types import DeliveryCreative and validating a payload will reject readback the SDK's own response parsing accepts. The migration guide's line — "DeliveryCreative keeps its separate tolerant response parsing contract" — reads as though it applies to the exported symbol, which it doesn't.
Worth either aligning the export to the canonical clone or naming the distinction in the migration guide. Either is a follow-up, not a condition on this PR.
What I ran at 72b2fcf907 (isolated worktree)
tests/test_forward_compat_format_kind.py— 101 passed- Private-type export scan, response-vs-export model introspection, direct nested-kind probes on both classes, and the same introspection at the merge base for attribution
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean pass, no blocking or medium findings.
This is a subsequent review following a prior request-changes. The prior run recorded no critical/high/medium findings, and this run is also clean: the developer's changes preserve the intended behavior. The PR correctly closes CreativeAsset/Creative/CreativeManifest to the canonical enum on seller-input surfaces while preserving tolerant delivery readback via private _DeliveryCreativeManifest/_DeliveryCreativeVariant subclasses on both the canonical (GetCreativeDeliveryResponse) and generated (DeliveryCreative alias, GeneratedGetCreativeDeliveryResponse) paths, each covered by a parametrized round-trip test.
Semver signal is correct: fix(types)!: + BREAKING CHANGE footer + migration guide + CHANGELOG entry. No import-layering violations, no generated-code hand-edits, no credential issues.
Decision-table walk:
- Row 1 (critical/high): no findings → skip.
- Row 2 (gated_paths + not APPROVED):
gated_paths: false→ skip. - Row 3 (high-risk deleted):
_forward_compat.pyis(modified), not deleted → skip. - Row 4 (medium data-loss/schema/infra): no medium findings → skip.
- Row 5 (high-risk modified + medium finding):
_forward_compat.pyis modified but there are zero medium findings → does not fire. - Row 6 (sticky escalate): prior outcome was
request-changes, notescalate→ skip. - Row 7 (no-auto-approve team): no team match → skip.
- Row 8 (≥3 medium): zero medium → skip.
- Row 9: approve.
Note: review_decision is CHANGES_REQUESTED, but the deprecated protected-branch/review-status gate does not apply here because gated_paths is false and there are no blocking findings. The high_risk flag is a look-closer heuristic on a modified (not deleted) load-bearing file with no medium-or-higher concern, so it is presumed safe per the reasons guidance.
|
Follow-up on I have preserved the rebase and both published fixes locally. The correction uses independent readback clones, retains strict input validation, and restores typed composition without widening input models. Focused tests, static checks, and the full suite are next; please hold merge for that follow-up. |
Clone rendered manifest and variant fields without inheriting the strict input models, so a readback instance cannot bypass their enum validation. Preserve typed input composition, standalone asset normalization, field constraints, and legacy identity only on the legacy response surface. Cover full Python/JSON delivery round-trips, adapter parsing, direct-instance and dumped-input validation, and the exact canonical response readback type. Clarify the breaking migration boundary for releases through 8.0.0-rc.2.
|
Please recheck Validation: 266 focused tests, lint, source/adopter typing, generated checks, and normal commit hooks pass. Tests cover both response surfaces, Python/JSON and adapter parsing, direct-instance/dump validation, constraints/defaults, and exact canonical readback types. Full local |
There was a problem hiding this comment.
Ladon verdict: Request changes
Request changes — 1 blocking finding.
Blocking findings
- src/adcp/types/canonical_creative.py — Dropping the open-enum patch on the generated
CreativeManifestnarrows thepreview_creativeandbuild_creativereadback responses to strict deserialization. A newer seller's unknown canonical format kind will break buyer-side deserialization on those paths, while onlyGetCreativeDeliveryResponsereceived a tolerant replacement. This is a forward-compat / wire-shape regression on load-bearing response models (high per repo forward-compat and response-shape rules).
Row 1 of the decision table fires: a high severity finding is present, so the outcome is request-changes. The remaining aspects the reviewer checked (semver signal, migration docs, .pyi fidelity, import layering, delivery-readback tolerance) are correct — the fix is to extend the tolerant/open-union treatment consistently across the preview_creative and build_creative readback paths.
Blocking findings
- src/adcp/types/canonical_creative.py — Open-enum patch dropped on CreativeManifest narrows preview_creative and build_creative readback responses to strict; unknown canonical format kinds from newer sellers break buyer-side deserialization (high, forward-compat/response-shape regression)
Exact-head verdict —
|
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Subsequent pass on PR #1248. This is a net-clean iteration: the prior approve run found nothing, and this run also surfaces no critical/high/medium findings.
What was checked:
- Delivery-readback tolerance extended into nested rendered manifests (DeliveryCreative.variants[].manifest retains unknown future format_kind) while input CreativeAsset/Creative/CreativeManifest tighten to strict CanonicalFormatKind — forward-compat fallback arms preserved, not removed.
- Forward-compat clones copy fields without subclassing strict inputs (bypass-tested); sole generated validator re-attached; both generated and canonical delivery paths round-trip.
- semver BREAKING signal + migration doc present.
- No generated-source hand-edits, no import-layering breaches; delivery-only types stay unexported.
Decision-table walkthrough:
- Row 1 (critical/high): none.
- Row 2 (gated_paths + not APPROVED): gated_paths is false — does not fire.
- Row 3 (high-risk deleted): _forward_compat.py is (modified), no deletions.
- Rows 4–5 (medium findings): none.
- Row 6 (sticky escalate): prior decision was approve, not escalate.
- Row 7 (no-auto-approve team): no match.
- Row 8 (3+ medium): none.
- Row 9: approve.
Note: review_decision is CHANGES_REQUESTED, but that only forces a gate when gated_paths is true (row 2), which it is not here. No blocking findings exist, so the diff falls through to row 9.
Superseded by Ladon approval of d77a96e.
|
Merge remains held despite the d77a96e Ladon APPROVED: discussion_r4136922407 is still unresolved. It identifies generated preview_creative and build_creative response manifests that use strict CreativeManifest, so a future format_kind can still fail buyer readback. Please reproduce and extend private tolerant response views (keeping public/generated CreativeManifest strict), or document a justified exemption on that thread. Resolve it before final handoff. |
Blocker — buyer
|
|
Brian's latest product decision supersedes the delivery-only scope noted in the resolved thread: strict The owner is implementing and testing this response boundary on the same branch. Please hold merge of d77a96e until the new head has green CI and renewed exact-head review. |
Keep public and generated input manifests strict while composing private readback views into preview, build, async task, and trusted-match responses. Preserve strict direct creative fields, nested constraints, typed source composition, and the breaking migration guidance. Refs #1241
Exact-head review —
|
| known kind | unknown kind | |
|---|---|---|
BASE ad7e8229 (rc.2) |
missing ('creatives',0,'status') |
missing ('creatives',0,'status') — kind accepted |
HEAD b10d5308 |
missing ('creatives',0,'status') |
enum ('creatives',0,'format_kind') |
(The missing status is my fixture being incomplete; it's the constant across both sides that isolates the comparison.)
So at rc.2 a buyer could read a list_creatives response containing a future kind, and at this head it fails. The impact chain is identical to the one I reported for build/preview: list_creatives is in the client's task→response map, and protocols/base.py:_parse_response converts the ValidationError into TaskResult(status=FAILED, success=False, data=None) — an otherwise-successful listing reported as failed with the data dropped.
This is a regression against rc.2, not a pre-existing gap, which is the distinction that matters for whether "strict for now" is a deferral or a break. list_creatives is also the highest-traffic of these readbacks: it's how a buyer enumerates a seller's existing creatives, so it fails as soon as a seller has any creative using a future kind, not just when one is newly built.
On the scope decision
Keeping Creative/CreativeAsset strict as input models is clearly right — that's the whole point of the PR. The question is only the handful of response fields typed as Creative, and the same seller/buyer asymmetry applies: authoring strict, readback tolerant. _BuildReadbackCreative already exists, so the pattern is in place.
If the intent is to defer, it'd be worth saying so in the migration guide alongside the other guidance, because an adopter reading "response manifests are tolerant" will reasonably assume response creatives are too.
Verdict
Not clear. The build/preview blocker is resolved and the construction is good — but list_creatives readback regresses against rc.2 in the same way, and I'd want it either covered by Option B or explicitly justified on the record before merge.
|
The The current PR description explicitly lists So the regression is acknowledged as an intentional, documented breaking change within the approved policy, not deferred as a pre-existing issue. Please assess the remaining clearance against that explicit boundary. The broader creative-response tolerance would require Brian to supersede this specific part of the decision; I am preserving his current instruction. |
Conditional review cleared — exact head
|
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Subsequent pass over the b10d530 delta ("tolerate manifests across buyer responses"). This is a net improvement / continuation of a prior clean approve.
What I checked:
- The breaking narrowing of
CreativeAsset/Creative/CreativeManifest.format_kindto the closedCanonicalFormatKindcarries the correct semver signal:fix(types)!prefix +BREAKING CHANGE:footer, migration guide (docs/canonical-format-kinds-migration.md), andCHANGELOG.mdprose — satisfies the mandatory public-surface semver gate. - The private tolerant readback views deliberately do not subclass strict inputs, preserve
min_length/version-envelope/default constraints, patch only fields that exist, and keep generated imports within the allowlisted_forward_compat.py— type-system import layering respected. - Round-trip, bypass, and constraint tests confirm the behavior.
High-risk flag is true only because src/adcp/types/_forward_compat.py is (modified) — no medium-or-higher finding was raised on it, so the modification is presumed safe per the high-risk rules. No deletions.
Decision-table walk: no critical/high (row 1 no); gated_paths false (row 2 no); no (deleted) (row 3 no); no medium data-loss/schema/infra (row 4 no); no medium finding at all so rows 5/8 no; prior decision was approve, not escalate (row 6 no); no no-auto-approve team match (row 7 no). Falls through to row 9 — approve.
No blocking or medium findings.
Creative assets, listed creatives, and input manifests accepted arbitrary
format_kindstrings despite referencing the closed canonical enum. PublicCreativeAsset,Creative, and public/generatedCreativeManifestnow validateCanonicalFormatKind; canonical stubs expose the same closed types.Under Brian's input/readback policy, manifests returned by another agent retain unknown future kinds through private, independent readback views. Known kinds still normalize to enum members. The response audit covers:
creatives[].variants[].manifestLegacyPreviewCreativeResponse3manifestLegacyBuildCreativeResponse1creative_manifestLegacyBuildCreativeResponse3creative_manifests[]LegacyBuildCreativeResponse4creatives[].variants[].creative_manifestContextMatchResponse/ router-to-publisher responseoffers[].creative_manifestoffers[].creative_manifestAdcpAsyncResponseData/ completed MCP task resultsThe generated response classes compose the private views, so existing public aliases and indirect wrappers agree. Async union and webhook validators are refreshed after composition. Input classes are not widened or inherited by tolerant views, preventing tolerant instances from bypassing strict input validation. Constraints, version envelopes, defaults, extension fields, and typed source-model composition are retained.
Direct
Creative.format_kindandCreativeAsset.format_kindresponse fields remain strict in this PR. In particular,ListCreativesResponse.creatives[].format_kindrejects unknown kinds.DeliveryCreative.format_kindretains its existing open readback type. Private manifest/variant/offer views are not added to top-level exports, and versioned wire-schema validation remains unchanged.BREAKING CHANGE: unknown kinds in public creative/input models now raise Pydantic
ValidationError. Known strings still normalize to enum members; required creative kinds and optional manifest defaults are unchanged. The changelog and migration guide explain the input/readback boundary and upgrading from releases through 8.0.0-rc.2.Validation:
make lint,make typecheck-all,make validate-generated, and normal commit hooks passed.make testpassed through the reporting harness: 12,345 passed, 2,395 skipped, 9 deselected, 1 expected failure; 81.67% coverage.Closes #1241