fix(types): derive response arms from schemas#922
Conversation
f7efd6a to
1f619f6
Compare
There was a problem hiding this comment.
Substance is right — this kills the post_generate_fixes.py stubs that were silently overwriting spec-faithful codegen output with status: Any = None payloads. Holding for one semver question before I approve: a public response model flipped from all-optional to required, shipped under fix:.
ad-tech-protocol-expert: sound. The 22 emitted arms match the 3.1.0-rc.9 bundle the SDK pins. The build_creative submitted arm is genuinely in the spec now, so flipping test_build_creative_response_has_no_submitted_arm → ..._includes_submitted_arm retires the adcp#3392 tripwire correctly rather than papering over it. names: dict[str,Any] → list[dict[str,str]] and house: str → House{domain,name} are corrections, not regressions.
python-expert: sound-with-caveats. Emitter is correct and idempotent-by-construction for the schemas it consumes; every finding is a latent gap, not a live bug.
code-reviewer: no blockers. Its "non-reproducible build_creative_response.py" Major is a false positive — it read the pre-PR on-disk file. The committed submitted arms all carry status: Literal[task_status_1.TaskStatus.submitted] (build_creative_response.py BuildCreativeResponse6, create_media_buy CreateMediaBuyResponse3); the emitter emits the plain TaskStatus form and inject_literal_discriminator_defaults — reordered to run after restore_response_variant_aliases in main() — rewrites it. Full pipeline reproduces.
The question (what flips this to approve)
fix(types): cuts a patch under release-please. Two changes in here read as contract changes, not patches:
-
BuildCreativeSuccessResponse(=BuildCreativeResponse1) flipped a field from optional to required. Old stub wasstatus: Any = None; new arm requirescreative_manifest(build_creative_response.py:130).BuildCreativeSuccessResponse.model_validate({...})on a payload that omitscreative_manifest— previously accepted — now raisesValidationError. Real wire deserialization of conformant responses is unaffected or improved, and the public export set is purely additive (public_api_snapshot.jsonis +10/-0), so this is narrow — but it is an optional→required flip on a name reachable fromadcp.*. -
use_enum_values=Trueremoved from the submitted media-buy arms (create_media_buy_response.pyCreateMediaBuyResponse3, and the new sync arms).response.statusis now aTaskStatusmember, not a plainstr. Guards are safe (TaskStatusis aStrEnum, so== \"submitted\"holds — verified task_status.py:10), and JSONmodel_dumpis identical, but python-modemodel_dump()now yields the enum member. The PR body documents neither change.
Flip to approve on either: (a) confirm no adopter depends on the old stub shapes / pre-enum dump, so patch semver is intended, or (b) re-cut as fix!: with a one-line migration note. Maintainer's call on adopter reality — I can't see it from the diff.
Things I checked
- Arm reorder
(0,4,1,2,3,5)keeps the public numbering load-bearing: Response1=success, Response2=error, Response6=submitted — matchesBuildCreativeErrorResponse = BuildCreativeResponse2(aliases.py:476) andBuildCreativeSubmittedResponse = BuildCreativeResponse6.test_type_aliases.pyasserts all three. public_api_snapshot.json+10/-0 — additive only. No public export removed or renamed despite the_generated.pyflat-namespace churn (ArtifactRecord,Fallback,FontRole,OpentypeFeature,WeightRangeItemdropped from the internal consolidation but not from the curated public surface).- New
is_*_submittedguards short-circuit before success/error (guards.py:248-271), andis_build_creative_success/is_build_creative_errornow exclude the submitted envelope. Exported fromadcp.typesand re-checked intest_type_guards.py. - Union parse safety:
_validate_union_typeis left-to-right first-match, andextra='allow'waives only extra keys, not required-field validation — a submitted payload can't match an earlier success/error arm and vice versa (ad-tech-protocol-expertconfirmed againstresponse_parser.py). - Type-system layering intact: no new non-internal importer of
generated_poc/**(guards.py:106-110 imports the numbered arms, but guards.py is internal).
Follow-ups (non-blocking — file as issues)
BuildCreativeResponse4partial-failure ambiguity. Thecreativesarm declares optionalerrors;is_adcp_errorreturns True on any non-emptyerrors, so a Response4 success carrying per-item errors is classified as error byis_build_creative_errorand excluded fromis_build_creative_success. Newly-surfaced arm — worth a comment or a dedicated guard branch.- The
(0,4,1,2,3,5)reorder is gated onlen(arms)==6. If the schema gains or drops abuild_creativearm the reorder silently disables andBuildCreativeResponse2stops being the error arm, breaking the alias with no test failure. Assert the arm count or key the reorder on arm shape. - Emitter robustness gaps (
python-expert), none triggered by current inputs: no identifier sanitization (a future response field named after a keyword or with a hyphen emits a SyntaxError);_merge_all_ofsilently widensallOf-with-$reftoAny;_generated_class_namefalls back toclass_names[-1], which guesses the wrong class if a module's primary class diverges from both its title and stem; a self-referential local#/ref recurses unbounded inref_type. Prefer failing loud over the[-1]guess. - The post-generate idempotency test monkeypatches
OUTPUT_DIRto an empty tmp dir, so cross-module ref resolution takes the title-pascal fallback, not the real read-the-generated-module path. It passes only because the two coincide today — it won't catch aclass_names[-1]mis-binding. - Add a
_normalize_legacy_statusround-trip conformance test (ad-tech-protocol-expert): a raw{status:'active'}with nomedia_buy_statusshould land asstatus='completed', media_buy_status='active'. That validator is the load-bearing path for the adcp#4906 deprecation window and the success arm'sstatus: Literal['completed']override depends on it.
Minor nits (non-blocking)
server/responses.py:680docstring now says "List matches BuildCreativeResponse3 (multi-format)" — correct for the new numbering, and the function returns a dict so behavior is unchanged. Fine; noting it tracks the renumber.- Interesting that the third drift-correction pass (
restore_signal_catalog_type_alias,restore_format_asset_numbered_aliases,restore_response_variant_aliases) had to move ahead ofinject_literal_discriminator_defaultsinmain()to land the Literal rewrite — the pass ordering is now itself load-bearing. Worth a comment at themain()list so the next reorder doesn't quietly undo it.
Held for the semver confirmation above — answer either way and I'll approve.
There was a problem hiding this comment.
Schema-derived arms are the right architecture, but the emitter drops nullability on required-nullable fields and ships a create_media_buy arm that can't parse a spec-valid response. One concrete file:line regression blocks; the rest is sound.
The fix itself is the right shape — restore_response_variant_aliases() deriving arms from the JSON schemas instead of hand-written status: Any stubs is exactly the way to stop the post-generate layer from silently overwriting codegen with non-spec payloads. ad-tech-protocol-expert: sound — the BuildCreativeResponse reorder (0,4,1,2,3,5) faithfully maps the schema oneOf (Success/MultiSuccess/Variant/Estimate/Error/Submitted) onto the stable public numbering, keeping Response1=success / Response2=error so adopters don't shift; the submitted arms are genuinely in the upstream schemas; extra='allow' is preserved on every arm so forward-compat is intact.
MUST FIX (blocking)
-
Required-nullable fields lose
| None—create_media_buysuccess arm can't deserialize a provisional buy.Emitter.type_forcollapses"type": ["string","null"]to"string"(non_null = [item for item in schema_type if item != "null"]) and discards the null arm;emit_response_classthen emits required fields with no| None. Result:src/adcp/types/generated_poc/media_buy/create_media_buy_response.pyCreateMediaBuyResponse1.confirmed_at: AwareDatetime— required, non-nullable. Butschemas/cache/3.1.0-rc.9/media-buy/create-media-buy-response.json:42-46,161typesconfirmed_atas["string","null"]and lists it inrequired, with the description spelling it out: "May be null in deferred or manual-approval flows… provisional buys with confirmed_at: null cannot be active." What breaks for adopters: a spec-compliant response{"media_buy_id": …, "revision": 1, "confirmed_at": null, "status": "completed", "packages": […]}raisesValidationError. The prior hand-written stub hadconfirmed_at: AwareDatetime | Noneand parsed it. Corroborated: theAdCP storyboard runner — sales-proposal-mode (proposal_finalize)check is FAILURE on this PR (create_media_buystep:✗ Response matches create-media-buy-response.json schema) and passes onmain.Fix: thread nullability through the list-type branch — when
"null"is present in atypelist, append| Noneto the emitted type even when the field is required. Then audit every otherrequired+[…, "null"]field across the 22 regenerated arms the same way —confirmed_atis the one the storyboard caught, not necessarily the only one. Reconfirmproposal_finalizegoes green before re-pushing.
Things I checked
confirmed_atschema shape and required-membership against the packagedADCP_VERSION(3.1.0-rc.9) —["string","null"], required. Bug confirmed.- BuildCreative arm reorder +
src/adcp/server/responses.py:680docstring correctly updatedBuildCreativeResponse2 → BuildCreativeResponse3for multi-format. Consistent. - Submitted arms dropping
use_enum_values=Trueand the_normalize_submitted_statusbefore-validator: safe —TaskStatusis aStrEnum(src/adcp/types/generated_poc/enums/task_status.py:10), so Pydantic accepts the raw"submitted"againstLiteral[TaskStatus.submitted]andresponse.status == "submitted"holds for the guards. The newtest_*_accepts_submitted_response/ guard tests exercise exactly this and the Py3.13 job passed. - Type guards (
guards.py):is_*_submittedshort-circuits before success/error;BuildCreativeSuccessBranchesunions the four non-error/non-submitted arms. No gap. main()reordering of the threerestore_*fns:restore_response_variant_aliasesis now fully self-contained (reads schema, rewrites whole file), no broken ordering dependency.- Public-API surface: new exports are additive (
*SubmittedResponse,SyncCreativesResponse3, new guards); removed_generatednames (FontRole,Style,OpentypeFeature,WeightRangeItem,ArtifactRecord,Fallback) are not intests/fixtures/public_api_snapshot.json— internal generated names, not a public break.fix(types):semver is correct for the additive/conformance surface. - Import layering: only
_generated.py/aliases.pyreach intogenerated_poc/. No breach.
Follow-ups (non-blocking — file as issues)
BuildCreativeResponse2/CreateMediaBuyResponse2construction tightening. The error arms went from loose stubs toerrors: Field(min_length=1). An adopter constructing the error arm with an emptyerrorslist now fails validation.ad-tech-protocol-expertflagged this as defensible underfix:(the old shape was non-spec, the arm was already "error") — agreed, but note it in the changelog prose so adopters aren't surprised.
Minor nits (non-blocking)
- Header timestamp. Regenerated arms carry
# timestamp: preserved-by-post-generate-fixeswith a double space after the colon (codegen emits a single space). Cosmetic; harmless.
A PR whose stated purpose is to stop the SDK drifting from protocol shape, landing one | None short of protocol shape, is a notable place to land. Fix the nullability, re-green the storyboard, and this is ready to ship.
1f619f6 to
d78e9cf
Compare
d78e9cf to
7947169
Compare
There was a problem hiding this comment.
Block on one concrete deserialization regression. The schema-derived emitter is the right architecture — single code path, spec-faithful arms, no more hand-written stubs drifting from the wire — but the [type, "null"] collapse drops nullability on required fields, and that breaks a currently-working path.
MUST FIX
CreateMediaBuySuccessResponse.confirmed_at regresses required-nullable → required-non-null.
- Schema
schemas/cache/3.1.0-rc.9/media-buy/create-media-buy-response.json:43-48declaresconfirmed_atastype: ["string","null"], listed inrequiredat:161, with the description "May be null in deferred or manual-approval flows until seller commitment occurs." The schema even carries anif confirmed_at is nullconditional that depends on null being legal. - The old stub had it right:
confirmed_at: AwareDatetime | None(required key, nullable value). The new emitter producesconfirmed_at: AwareDatetime(non-null) —src/adcp/types/generated_poc/media_buy/create_media_buy_response.py,CreateMediaBuyResponse1. - What breaks for adopters: a spec-conformant seller
create_media_buyresponse in a deferred / manual-approval flow carrying"confirmed_at": nullnow raisesValidationError. That payload parsed before this PR. The PR's stated goal is spec-faithfulness; for this field it moves away from spec.
Root cause is in the load-bearing file, scripts/post_generate_fixes.py: Emitter.type_for collapses ["string","null"] to the single non-null type and discards the null arm, then emit_response_class emits a bare field: T for anything in required — no | None. The emitter has no way to express "present-but-nullable." Fix is narrow: when collapsing a [T, "null"] type array, union None into the result even for required fields, so the field lands as confirmed_at: AwareDatetime | None (required key, no default). Re-run codegen after.
ad-tech-protocol-expert: sound-with-caveats — every other arm I had checked (build_creative 6-arm, sync_creatives, get_brand_identity, acquire_rights) is faithful; this is the one field that diverges. update_media_buy.implementation_date has the same [string,null] shape but is not required, so it lands on | None via the optionality path and masks how systemic the bug is — any required-nullable field hits it.
Semver
Flagged by both experts and worth resolving in the same pass. The union widening (new BuildCreativeResponse6, SyncCreativesResponse3, etc.) is additive. But the success-arm tightenings — GetBrandIdentityResponse1.house str → House object, names dict → list[dict[str,str]], and the confirmed_at change once corrected to required-nullable — are breaking for adopters who hand-construct or deserialize these arms. house: str → object is a genuine spec-correction (the old type couldn't deserialize a conformant payload), so I'm not blocking on the tightenings themselves. But fix: understates the impact. Re-tag as fix!: with a BREAKING note in the body, or split the additive arms from the breaking shape changes.
Things I checked
TaskStatusis aStrEnum(enums/task_status.py→_str_enum.py), so droppinguse_enum_values=Truefrom the submitted arms is safe:.statusis now the enum member butTaskStatus.submitted == "submitted"still holds, and theis_*_submittedguards (guards.py) pass. One caveat not exercised by tests:model_dump()now emits the enum member, not a plain"submitted"string — confirm no serializer / idempotency-cache path does a strict JSON-type check on dumpedstatus.- BuildCreative arm reorder
(0,4,1,2,3,5)inEmitter.rendermatches the schema oneOf order (success / error / submitted) and the aliases (Success=R1,Error=R2,Submitted=R6, success-branchesR1|R3|R4|R5). - Public API snapshot diff is additions-only (
BuildCreativeSubmittedResponse,Sync*SubmittedResponse,SyncCreativesResponse3) — no public name removed. Thegenerated_pocnested renames (ArtifactRecord→Artifact,PreviewInput→Input,Style/Fallback/OpentypeFeature/WeightRangeItemdropped) are internal-only; the import-layering allowlist keeps them off the public surface. - Inverting
test_build_creative_response_has_no_submitted_arm→..._includes_submitted_armis correct: build_creative is async-capable in rc.9 (the schema's own arm 6 is thesubmittedenvelope), and the original guard's docstring prescribed exactly this update when adcp#3392 landed. _normalize_legacy_statusvalidators on the media-buy success arms are preserved and only touchstatus/media_buy_status, notconfirmed_at.
Follow-ups (non-blocking — file as issues)
- build_creative success/error guards misclassify the capped variant arm.
is_build_creative_success/is_build_creative_error(guards.py) still key offis_adcp_error(non-emptyerrors).BuildCreativeResponse4carries advisoryerrors[]alongsidebudget_status: "capped"— a successful-but-capped multi response reads asis_build_creative_error → True. Gate the build-creative guards on the discriminating fields (creatives/creative_manifest(s)/estimate) rather thanerrorspresence. - Idempotence is reasoned-stable but under-tested.
test_post_generate_sync_creatives_response_arms_match_schema_creative_fieldsrunsrestore_response_variant_aliases()twice in isolation for one module. Themain()reorder now runs the threerestore_*passes beforeinject_literal_discriminator_defaultsand friends, so on a real run those passes mutate freshly-emitted arms. Worth a test that runs the relevantmain()slice twice and asserts byte-stability.
Minor nits (non-blocking)
- Stray
"SyncEventSourcesResponse"in the guards__all__. It's inserted among guard-function names, between"is_sync_catalogs_submitted"and the content-standards comment (guards.py). It's a defined TypeAlias soimport *doesn't break, but every other union alias in that module is deliberately unexported. Drop it, or export them uniformly. - BuildCreative reorder is hard-gated on
len(arms) == 6. If a future spec adds or removes a branch, the reorder silently no-ops whilealiases.pystill hard-codesBuildCreativeResponse3/4/5/6— a silent mismapping rather than a failure. Assert the arm count, or derive the error/submitted index by title /status.constinstead of the positional tuple.
Fix confirmed_at, re-run codegen, re-tag the breaking scope, and this is ready.
There was a problem hiding this comment.
Right fix. The root cause is named correctly: post_generate_fixes.py was overwriting datamodel-codegen's spec-faithful output with hand-written status: Any = None stubs, and those stubs silently drifted from the protocol. Deriving the arms from the JSON schema makes the compatibility layer unable to lie about wire shape — that's the right shape for this layer.
Verified the load-bearing claim: ad-tech-protocol-expert confirms the 6-arm regeneration of build_creative_response.py matches schemas/cache/3.1.0-rc.9/media-buy/build-creative-response.json exactly (single-success, error, multi-format, per-creative, estimate, submitted), and that the historical-numbering reorder (0,4,1,2,3,5) produces the right Response1..6. adcp#3392 has effectively landed between rc.2 and rc.9 — replacing test_build_creative_response_has_no_submitted_arm with test_build_creative_response_includes_submitted_arm is correct, not a regression.
Things I checked
- Public surface is strictly additive.
tests/fixtures/public_api_snapshot.jsonhas only+lines — no removals. The dropped internal names (Fallback,FontRole,Style,WeightRangeItem,ArtifactRecord,OpentypeFeature) were never in the public snapshot, so this is internal generated-name churn, not a public break. - Submitted-arm simplification is safe on the wire. Dropping
use_enum_values=Trueand_normalize_submitted_statuswhile keepingstatus: Literal[TaskStatus.submitted] = TaskStatus.submitted+validate_default=Truestill parses raw{\"status\": \"submitted\", \"task_id\": ...}becauseTaskStatusis a realstrsubclass (_str_enum.py) and pydantic v2 coerces the matching string to the member.server/responses.py:180dumpsmode=\"json\", which emits the bare string. New tests intest_code_generation.pyandtest_type_guards.pyexercise raw-string parsing across build_creative/sync_creatives/sync_catalogs — they genuinely cover the path. schema_loader.pydate-time FormatChecker is correct.start-timing.jsonisoneOf: [{const: \"asap\"}, {format: \"date-time\"}]with no date-only branch, so rejecting\"2026-04-01\"and accepting\"asap\"is spec-faithful. jsonschema treatsformatas a non-validating annotation by default — registering the checker is necessary foroneOfbranch selection. Non-string →Trueearly return matches JSON Schema format semantics._merge_all_offails open toAnyon$ref/oneOf/anyOf/non-object parts — widens rather than emitting a wrong type.extra='allow'on every arm means a future 7th upstream variant still deserializes leniently.
Follow-ups (non-blocking — file as issues)
- The
len(arms) == 6reorder inrestore_response_variant_aliasesis a time-bomb.arms = [arms[index] for index in (0, 4, 1, 2, 3, 5)]is gated on arm count, andaliases.py:477,479hard-mapBuildCreativeErrorResponse=Response2/BuildCreativeSubmittedResponse=Response6on those positions. When upstream adds a 7thbuild_creativearm, the gate goes False, arms fall back to raw schema order, andResponse2silently becomes a success branch — miscompiling the error/submitted aliases andis_build_creative_*guards with no failure at codegen time. Harden it: detect the error arm by requirederrorsand the submitted arm bystatus.const == \"submitted\", orraisewhen the arm count/shape changes so the next regeneration fails loud instead of producing wrong types. This is the one thing to fix before the next upstream schema bump. - Construction-time break for adopters.
BuildCreativeResponse1now requirescreative_manifest(was astatus: Any = Nonestub);CreateMediaBuyResponse1now requiresconfirmed_at/revision/packages. Deserialization is unaffected (real payloads carry these;extra='allow'), but anyone hand-constructing the old stub shape now hitsValidationError. Shipping underfix(types):is defensible since the prior shape was never spec-valid — but call it out in the release notes so it isn't a surprise patch-bump. - Lock the wire contract with one serialization assertion. The new tests cover guards and round-trip validation but never assert
BuildCreativeSubmittedResponse(...).model_dump(mode=\"json\")[\"status\"] == \"submitted\". Cheap insurance against a futureuse_enum_valuesreintroduction breaking the dumped shape.
Minor nits (non-blocking)
- Stray
__all__entry.guards.pyadds\"SyncEventSourcesResponse\"to__all__under the# Catalog guardscomment (diff L4548). It's defined (guards.py:139), soimport *won't break — but every other entry there is anis_*guard, and no other response union is exported from guards. Copy/paste slip; drop it. datetime.fromisoformatis stricter than the regex on 3.10. The_RFC3339_DATE_TIMEregex already validates structure; the secondaryfromisoformatcheck rejects some RFC3339-valid offset/fractional forms on the 3.10 CI leg. Low impact for AdCP payloads, but the regex alone is the safer gate.
The generator was lying about the wire shape; now it can't. LGTM. Follow-ups noted below.
PR #922 (derive response arms from schemas) reshaped the generated_poc modules, changing the set of colliding bare type names. Re-snapshot the checked-in allowlist against the post-#922 tree so the consolidate-step build guard and test_allowlist_matches_current_collisions_exactly pass. Net +13 names (197 -> 210): added BrandContext, Colors, CreditLimit, Disclosure, Feature, House, Input2, Logo, Preview, Preview2, Preview3, Result, Rights, Setup; removed PreviewInput. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
This replaces the hand-written post-generate response-arm stubs with schema-derived generation so numbered SDK response classes stay aligned with the JSON schemas.
It regenerates the affected response modules, including full sync_creatives Creative fields, BuildCreative submitted/error numbering, submitted aliases/guards, and refreshed public exports/snapshot.
The root cause was post_generate_fixes.py silently overwriting datamodel-codegen's spec-faithful output with non-spec manual payload shapes.
Testing