Skip to content

fix(codegen): inherit root allOf ProtocolEnvelope on every response arm - #1144

Merged
bokelley merged 1 commit into
mainfrom
conductor/restore-protocol-envelopes-1136
Sep 11, 2026
Merged

fix(codegen): inherit root allOf ProtocolEnvelope on every response arm#1144
bokelley merged 1 commit into
mainfrom
conductor/restore-protocol-envelopes-1136

Conversation

@bokelley

@bokelley bokelley commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Summary

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 that response's oneOf.

The response-arm emitter attached ProtocolEnvelope only to the arm whose own branch pinned status: submitted. Result: 19 of 24 *SuccessResponse aliases and their matching error arms carried the envelope fields as pydantic extras — a seller setting response.replayed = True wrote an extra, a buyer reading it got an AttributeError, and the arms of a single oneOf had no common ancestor below AdcpVersionEnvelope.

  • CodegenEmitter.render now resolves the root allOf chain through _resolve_schema_ref (canonical https:// refs, root-relative /schemas/ refs and ../core/ relative refs all land on the same target, nested allOf groupings included) and every emitted arm of a root-composing response gets the base. The is_submitted path becomes a subset of the new predicate and still supplies the base for a response whose root does not compose the envelope.
  • canonical_creative clones_canonical_clone rebuilt fields on CanonicalBoundaryModel alone, 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 before CanonicalBoundaryModel so pydantic's left-to-right model_config merge keeps the boundary's extra="allow" instead of reinstating AdCPBaseModel's extra="ignore" (that ordering trap silently dropped extension keys — pinned by a regression test).
  • .pyi typing — 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.py was re-run in place, so there is no datamodel-code-generator renumbering churn.

status typing on the canonical stub

The stub mirrors the runtime ancestry through a stub-only _CanonicalResponseEnvelope that relaxes status to Any. That relaxation exists only as a Liskov bridge — narrowing an inherited mutable attribute is an override error, so relaxing once beats a type: ignore per arm. It is not a public type, and two guards stop it leaking:

  1. Every concrete canonical response re-declares status with the exact annotation its runtime model carries, introspected rather than guessed:

    shape responses stub declaration
    wide GetProductsResponse, ListCreativesResponse, GetMediaBuysResponse, GetMediaBuyDeliveryResponse, GetCreativeDeliveryResponse, CreateMediaBuyResponse2, UpdateMediaBuyResponse2 status: TaskStatus = ...
    pinned sync arm CreateMediaBuyResponse1, UpdateMediaBuyResponse1 status: Literal["completed"] = ...
    async task-envelope arm CreateMediaBuyResponse3, UpdateMediaBuyResponse3 status: Literal[TaskStatus.submitted] = ...

    CreateMediaBuyResponse1's explicit constructor is precise and default-consistent too (status: Literal["completed"] = ... instead of a required status: Any).

  2. The = ... is load-bearing. status is defaulted on every runtime response; without it the dataclass_transform-synthesized __init__ falsely requires status= for plain constructions like GetMediaBuysResponse(media_buys=[]), ListCreativesResponse(creatives=[]) and UpdateMediaBuyResponse3(task_id=...). The bridge carries status: Any = ... as well.

Both are enforced, not asserted in prose:

  • typing_extensions.assert_type pins the completed / wide / submitted representatives. An annotation like pinned: str = created.status would pass vacuously from Any; assert_type does not.
  • test_canonical_response_stub_status_matches_runtime parses the .pyi and 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 false Missing named argument "status" errors.

Serialization audit

status and replayed are the only envelope fields with non-None defaults, so they are the only ones the SDK's exclude_none=True dumps add:

delta arms
+ status, + replayed 47
+ replayed only (arm already pinned status) 3
unchanged (already inherited the envelope) 7
any field removed from the default dump 0

Both additions are conformant: status is REQUIRED on every task response envelope, and replayed is specified as false-or-omitted. The canonical clones show the same delta only on the two create/update media buy arms; every clone keeps extra="allow".

Behavior change

isinstance(response, ProtocolEnvelope) flips False → True for 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: root allOf reaches every arm (parametrized across relative / root-relative / canonical-URL $ref forms), nested allOf groupings are detected, a root without the envelope leaves ordinary arms on AdcpVersionEnvelope while the submitted arm still gets it, and an unrelated core/ ref does not attach it. Idempotent re-run asserted.
  • tests/test_protocol_envelope_inheritance.py — public-alias regression guard (issubclass(..., ProtocolEnvelope) for every *SuccessResponse/*ErrorResponse class alias), per-module schema-driven arm check across all 22 responses, replayed/status round-trip as declared fields rather than extras, canonical-clone ancestry + extra="allow" + extension-key round-trip, default-less construction, and the stub-vs-runtime status drift guard.
  • tests/type_checks/response_envelope_fields.py — new mypy --strict adopter fixture, zero type: ignore: envelope fields statically visible on a success arm, both arms of one oneOf substituting for ProtocolEnvelope, assert_type on the three status shapes, and construction without status.
  • tests/test_decisioning_specialisms.py — the submitted-arm test discriminated on mere task_id/status presence, which every arm now has. It now asserts the pinned Literal[TaskStatus.submitted] annotation and its matching default, asserts that only the submitted arm's own branch makes task_id required, 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:

  • focused generator / envelope / media-buy-response tests — 179 passed
  • mypy src/adcp/ — clean (1308 files)
  • mypy --strict tests/type_checks/ — clean (23 files)
  • scripts/check_type_ignore_contract.py — clean
  • ruff check src/ scripts/ — clean
  • python -m py_compile src/adcp/types/_generated.py + scripts/generate_versioned_stubs.py --check — clean
  • full pre-commit hook set over origin/main...HEAD — all passed

Rebase notes

Rebased onto origin/main after #1142 (0fe71434) and #1143 (b57d43c0) merged. Both prerequisites are in the base and untouched by this diff.

The restore_response_variant_aliases emitter overlap with #1143 resolved cleanly — the two changes sit in different parts of emit_response_class, so _schema_permits_null/_union_with_none (required-nullability) and the root-allOf ProtocolEnvelope predicate both remain intact and compose. Regenerating with python scripts/post_generate_fixes.py after the merge produced no further changes, confirming the merged tree already equals the emitter's output: CreateMediaBuyResponse1 now carries both (AdcpVersionEnvelope, ProtocolEnvelope) and confirmed_at: AwareDatetime | None.

In canonical_creative.pyi, CreateMediaBuyResponse1 keeps this PR's _CanonicalResponseEnvelope base together with #1143's confirmed_at: datetime | None field and __init__ signature. The only hand-resolved conflict was tests/test_code_generation.py, where both PRs appended to the end of the file; both blocks are kept.

Closes #1136

@aao-secretariat

Copy link
Copy Markdown

Ladon cannot review this PR until merge conflicts are resolved.

@bokelley
bokelley force-pushed the conductor/restore-protocol-envelopes-1136 branch from 24eb4df to 803c0a4 Compare September 11, 2026 02:44
aao-secretariat[bot]
aao-secretariat Bot previously approved these changes Sep 11, 2026

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@aao-secretariat aao-secretariat Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bokelley
bokelley merged commit b4d2a89 into main Sep 11, 2026
24 checks passed
@bokelley
bokelley deleted the conductor/restore-protocol-envelopes-1136 branch September 11, 2026 03:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

codegen: oneOf response branches lose the root allOf ProtocolEnvelope (19 of 24 SuccessResponse aliases)

1 participant