fix(codegen): inherit root allOf ProtocolEnvelope on every response arm - #1144
Conversation
|
Ladon cannot review this PR until merge conflicts are resolved. |
24eb4df to
803c0a4
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve — clean fix for #1136 with no blocking or medium findings.
Checked:
- Response-arm emitter now inherits root-allOf ProtocolEnvelope across every oneOf arm; canonical clones re-declare envelope bases with correct MRO ordering.
- Regeneration output verified — no ClassNameN→ClassNameM renumbering churn, so no needless break of the aliases.py layer.
- Additive semver signal: status/replayed/adcp_version all default, so no wire/deserialization regression for existing buyers/sellers.
- Forward-compat and type-system import layering preserved; generated_poc changes are regeneration output, not hand-edits.
- New tests provide strong completeness guardrails on the response-envelope inheritance branches.
Decision-table walk: no critical/high/medium findings (rows 1, 4-6, 8 don't fire); gated_paths is false (row 2 n/a) despite review_decision=REVIEW_REQUIRED; high_risk is false (rows 3, 5 n/a); no prior decision (row 6 n/a); no no-auto-approve team match (row 7 n/a). Falls through to row 9 → approve.
Every AdCP response schema composes core/protocol-envelope.json at its root via allOf, so status, task_id, message, context_id, replayed, timestamp, push_notification_config, governance_context, context, payload and adcp_error belong to every arm of the response's oneOf. The response-arm emitter attached ProtocolEnvelope only to the arm whose own branch pinned status: submitted, so 19 of 24 *SuccessResponse aliases and their matching error arms carried those fields as pydantic extras: a seller setting response.replayed = True wrote an extra, a buyer reading it got an AttributeError, and the arms of one oneOf had no common ancestor below AdcpVersionEnvelope. Derive the base from the ROOT schema instead of the branch. Emitter.render resolves the root allOf chain through _resolve_schema_ref, so canonical https:// refs, root-relative /schemas/ refs and ../core/ relative refs all land on the same target, and every emitted arm of a root-composing response gets the base. The is_submitted path becomes a subset and still supplies the base for a response whose root does not compose the envelope. canonical_creative's dynamic clones rebuilt their fields on CanonicalBoundaryModel alone, which dropped the ancestry for the create/update media buy and get_products surfaces. _canonical_clone now re-declares the envelopes the source composes as bases, ordered before CanonicalBoundaryModel so pydantic's left-to-right model_config merge keeps the boundary's extra="allow" rather than reinstating AdCPBaseModel's extra="ignore". The stub mirrors the ancestry through a stub-only _CanonicalResponseEnvelope that relaxes status to Any, so each arm can declare its precise Literal without a per-field suppression. That relaxation is a Liskov bridge, not a public type: every concrete canonical response re-declares status with the exact annotation its runtime model carries - wide TaskStatus for ordinary arms, Literal["completed"] for the pinned sync arms, Literal[TaskStatus .submitted] for the async task-envelope arms - and each carries "= ..." to match the runtime default, so construction never falsely demands status. CreateMediaBuyResponse1's explicit constructor is precise and default- consistent too. typing_extensions.assert_type pins the completed, wide and submitted representatives, and a runtime test parses the stub and fails if any concrete response drifts back onto the bridge's Any. Serialization audit: 50 of 57 generated arms now emit status: "completed" and/or replayed: false by default; no field stops being emitted. Both are conformant - status is REQUIRED on every task response envelope, and replayed is specified as false-or-omitted. Closes #1136
803c0a4 to
9de3cd9
Compare
There was a problem hiding this comment.
Ladon verdict: Approve
Approve.
Regeneration-consistent codegen fix: the generator now attaches ProtocolEnvelope to every oneOf arm of responses whose root allOf composes core/protocol-envelope.json, moving status/task_id/replayed off the pydantic extra bag onto declared fields. The 22 regenerated generated_poc files pair correctly with the post_generate_fixes.py change, and the canonical_creative clones preserve envelope ancestry while keeping extra="allow". Change is additive at runtime under a correct fix(codegen): prefix, backed by completeness and stub-vs-runtime test guards.
No Critical/High/Medium findings. Author is not on a no-auto-approve team; high_risk is false; gated_paths is false. Consistent with the prior clean approve on the earlier head.
Decision-table walk: rows 1–8 do not fire (no findings, no gated paths, no high-risk deletion/modification, no team gate, fewer than three medium findings) → row 9 approve.
Summary
Every AdCP response schema composes
core/protocol-envelope.jsonat its root viaallOf, sostatus,task_id,message,context_id,replayed,timestamp,push_notification_config,governance_context,context,payloadandadcp_errorbelong to every arm of that response'soneOf.The response-arm emitter attached
ProtocolEnvelopeonly to the arm whose own branch pinnedstatus: submitted. Result: 19 of 24*SuccessResponsealiases and their matching error arms carried the envelope fields as pydantic extras — a seller settingresponse.replayed = Truewrote an extra, a buyer reading it got anAttributeError, and the arms of a singleoneOfhad no common ancestor belowAdcpVersionEnvelope.Emitter.rendernow resolves the rootallOfchain through_resolve_schema_ref(canonicalhttps://refs, root-relative/schemas/refs and../core/relative refs all land on the same target, nestedallOfgroupings included) and every emitted arm of a root-composing response gets the base. Theis_submittedpath becomes a subset of the new predicate and still supplies the base for a response whose root does not compose the envelope._canonical_clonerebuilt fields onCanonicalBoundaryModelalone, which dropped the ancestry for the create/update media buy and get_products surfaces. It now re-declares the envelopes the source composes as bases, ordered beforeCanonicalBoundaryModelso pydantic's left-to-rightmodel_configmerge keeps the boundary'sextra="allow"instead of reinstatingAdCPBaseModel'sextra="ignore"(that ordering trap silently dropped extension keys — pinned by a regression test)..pyityping — see below.Out of scope, as triaged: the proposed new constructible root response class per
oneOf(Path B). Only the ancestry fix (Path A) ships here.Regenerated artifacts are limited to the 22 schema-derived response modules;
post_generate_fixes.pywas re-run in place, so there is nodatamodel-code-generatorrenumbering churn.statustyping on the canonical stubThe stub mirrors the runtime ancestry through a stub-only
_CanonicalResponseEnvelopethat relaxesstatustoAny. That relaxation exists only as a Liskov bridge — narrowing an inherited mutable attribute is an override error, so relaxing once beats atype: ignoreper arm. It is not a public type, and two guards stop it leaking:Every concrete canonical response re-declares
statuswith the exact annotation its runtime model carries, introspected rather than guessed:GetProductsResponse,ListCreativesResponse,GetMediaBuysResponse,GetMediaBuyDeliveryResponse,GetCreativeDeliveryResponse,CreateMediaBuyResponse2,UpdateMediaBuyResponse2status: TaskStatus = ...CreateMediaBuyResponse1,UpdateMediaBuyResponse1status: Literal["completed"] = ...CreateMediaBuyResponse3,UpdateMediaBuyResponse3status: Literal[TaskStatus.submitted] = ...CreateMediaBuyResponse1's explicit constructor is precise and default-consistent too (status: Literal["completed"] = ...instead of a requiredstatus: Any).The
= ...is load-bearing.statusis defaulted on every runtime response; without it thedataclass_transform-synthesized__init__falsely requiresstatus=for plain constructions likeGetMediaBuysResponse(media_buys=[]),ListCreativesResponse(creatives=[])andUpdateMediaBuyResponse3(task_id=...). The bridge carriesstatus: Any = ...as well.Both are enforced, not asserted in prose:
typing_extensions.assert_typepins the completed / wide / submitted representatives. An annotation likepinned: str = created.statuswould pass vacuously fromAny;assert_typedoes not.test_canonical_response_stub_status_matches_runtimeparses the.pyiand fails if any concrete response omits its re-declaration, spells it differently from the runtime annotation, or if a newly added canonical response is missing from the stub entirely.Negative controls were run against both guards: deleting a re-declaration produces
error: Expression is of type "Any", not "TaskStatus" [assert-type]and a runtime test failure; dropping the= ...reproduces exactly the three falseMissing named argument "status"errors.Serialization audit
statusandreplayedare the only envelope fields with non-Nonedefaults, so they are the only ones the SDK'sexclude_none=Truedumps add:+ status,+ replayed+ replayedonly (arm already pinnedstatus)Both additions are conformant:
statusis REQUIRED on every task response envelope, andreplayedis specified asfalse-or-omitted. The canonical clones show the same delta only on the two create/update media buy arms; every clone keepsextra="allow".Behavior change
isinstance(response, ProtocolEnvelope)flipsFalse → Truefor the 19 previously-missing success aliases and their error arms. Additive; the reported downstream workaround (class SyncCreativesResponse(LibrarySyncCreativesSuccess, ProtocolEnvelope)) becomes a no-op and deletes cleanly.Tests
tests/test_code_generation.py— emitter unit tests over a synthetic schema: rootallOfreaches every arm (parametrized across relative / root-relative / canonical-URL$refforms), nestedallOfgroupings are detected, a root without the envelope leaves ordinary arms onAdcpVersionEnvelopewhile the submitted arm still gets it, and an unrelatedcore/ref does not attach it. Idempotent re-run asserted.tests/test_protocol_envelope_inheritance.py— public-alias regression guard (issubclass(..., ProtocolEnvelope)for every*SuccessResponse/*ErrorResponseclass alias), per-module schema-driven arm check across all 22 responses,replayed/statusround-trip as declared fields rather than extras, canonical-clone ancestry +extra="allow"+ extension-key round-trip, default-less construction, and the stub-vs-runtimestatusdrift guard.tests/type_checks/response_envelope_fields.py— newmypy --strictadopter fixture, zerotype: ignore: envelope fields statically visible on a success arm, both arms of oneoneOfsubstituting forProtocolEnvelope,assert_typeon the threestatusshapes, and construction withoutstatus.tests/test_decisioning_specialisms.py— the submitted-arm test discriminated on meretask_id/statuspresence, which every arm now has. It now asserts the pinnedLiteral[TaskStatus.submitted]annotation and its matching default, asserts that only the submitted arm's own branch makestask_idrequired, and explicitly records that presence alone no longer discriminates.Validation
Full suite on the rebased tree (
803c0a4a): 7763 passed, 42 skipped, 1 xfailed, 0 failed.The follow-up commit (
9de3cd96) changes only the stub and tests — no runtime source. Re-verified on it:mypy src/adcp/— clean (1308 files)mypy --strict tests/type_checks/— clean (23 files)scripts/check_type_ignore_contract.py— cleanruff check src/ scripts/— cleanpython -m py_compile src/adcp/types/_generated.py+scripts/generate_versioned_stubs.py --check— cleanpre-commithook set overorigin/main...HEAD— all passedRebase notes
Rebased onto
origin/mainafter #1142 (0fe71434) and #1143 (b57d43c0) merged. Both prerequisites are in the base and untouched by this diff.The
restore_response_variant_aliasesemitter overlap with #1143 resolved cleanly — the two changes sit in different parts ofemit_response_class, so_schema_permits_null/_union_with_none(required-nullability) and the root-allOfProtocolEnvelopepredicate both remain intact and compose. Regenerating withpython scripts/post_generate_fixes.pyafter the merge produced no further changes, confirming the merged tree already equals the emitter's output:CreateMediaBuyResponse1now carries both(AdcpVersionEnvelope, ProtocolEnvelope)andconfirmed_at: AwareDatetime | None.In
canonical_creative.pyi,CreateMediaBuyResponse1keeps this PR's_CanonicalResponseEnvelopebase together with #1143'sconfirmed_at: datetime | Nonefield and__init__signature. The only hand-resolved conflict wastests/test_code_generation.py, where both PRs appended to the end of the file; both blocks are kept.Closes #1136