Skip to content

feat(adcp/v3): generate Optional[T] for nullable request fields - #605

Draft
torbenbrodt wants to merge 1 commit into
adcontextprotocol:mainfrom
affinity-com:feat/v3-optional-request-fields
Draft

torbenbrodt wants to merge 1 commit into
adcontextprotocol:mainfrom
affinity-com:feat/v3-optional-request-fields

Conversation

@torbenbrodt

Copy link
Copy Markdown
Collaborator

Slice B of the three-state request-field design on #536 (slice A, Optional[T], merged as #595). #536 closed when 3.3.0-beta.0 merged; that was a side effect of #595's commit footer, not a decision, and B and C are still open.

Opens as a draft: it changes the Go type of released fields (escalation category 1 in AGENTS.md), so it waits for a maintainer's call rather than a review window. The question is under "Breaking" below.

What changes

The generator now emits Optional[T] tagged omitzero for every non-required request property whose schema has a null branch (type with "null", an anyOf/oneOf null branch, or a null enum/const), in structs reachable from a tool request schema and not shared with any response or webhook schema. Sixteen fields change:

Struct Fields
UpdateMediaBuyRequest daily_budget_cap, budget_cap_timezone, frequency_cap, bidding
PackageUpdate budget, min_spend_target, daily_budget_cap, bidding
ControlMediaBuyRequest daily_budget_cap, budget_cap_timezone, frequency_cap, bidding
PackageControl budget, min_spend_target, daily_budget_cap, bidding

Each schema says null removes the value ("null removes it"). Today a pointer or any field decodes null like omission, so a seller can't tell "clear the cap" from "leave it", and re-encoding drops the key. bidding and frequency_cap become Optional[any] and keep their reviewed-any reasons in the coverage gate.

  • tools/list: update_media_buy and create_media_buy advertise byte-identical schemas (a test pins update_media_buy against a reflected twin of the old types). control_media_buy now advertises five properties it didn't before, because any fields aren't advertised: daily_budget_cap, budget_cap_timezone and the package budget, daily_budget_cap, min_spend_target, typed ["null","number"] or ["null","string"] as in the schema. Additive.
  • Lint (check 2 of CI: catch silent schema/Go type drift (round trips, null, fixtures, references) #564): lint.py reports a non-required nullable request property that isn't Optional in a request-only struct, generated or paired hand-written, and an untyped field hiding nullable properties unless it is on a reviewed list. The list is exactly what lint finds today: test-controller params, CanonicalProposal.forecast, and targeting_overlay on PackageInput, PackageUpdate, PackageControl and ProductPurchaseInput (slice C types those). A stale entry fails lint.
  • Not covered yet, by design: Plan.approved_sellers (hand-written; fix(adcp/v3): keep an empty approved_sellers list on the wire #594 changes it first) and CreativeInput localization (no Go field until feat(adcp/v3): add revision_id and localization to CreativeInput #581).
  • Tests: omitted, null, a value and a zero value round-trip for all 16 fields; an update_media_buy call over MCP, through AddTool and WithIdempotency, reaches the handler as IsNull() at media-buy and package level and as Some(0) for a zero; every generated Optional field is tagged omitzero and never omitempty; generator and lint tests for the rule, the reachability scan (including webhooks) and the reviewed list.
  • Docs: MIGRATING.md gets a section with all 16 fields at 3.2.1, 3.3.0-beta.0 and now, and before/after code; docs/sdk-typing-policy.md describes the rule.

Breaking

Source-incompatible for callers of these fields: 7 were released in 3.2.1 (*float64, *string or any), and 9 first shipped as any in 3.3.0-beta.0. adcp.Float64(5) becomes adcp.Some(5.0), x.Budget != nil becomes x.Budget.Get(), and clearing is adcp.Null[float64]() instead of ClearField(). MIGRATING.md has the table. The wire doesn't change: the fields and "null removes it" are already in the pinned schemas.

@bokelley, should this land on main on its own, or would you rather fold it into #602? #602 regenerates types_gen.go anyway, so whichever lands second only reruns the generator; say the word and I'll retarget it.

reference/seller-agent (pinned to v3.0.0) uses upd.Budget != nil and will need the same change when it moves to the in-repo module (check 6 of #564).

Checks run locally

generate.py reproduces types_gen.go; lint.py --strict; --coverage-max-unreviewed-any 0; Python generator and lint tests; go test -race ./... in adcp/v3 on Go 1.26 and 1.27; go vet; golangci-lint.

@garvitkaushik-123 @sujanchalla0510, a review is welcome while the ruling is open.

🤖 Generated with Claude Code

A request property whose schema allows null means three things on the
wire: omitted (keep the stored value), null (clear it) and a value
(replace it). A pointer or `any` field decodes null like omission and
drops the key on encode, so `update_media_buy` could not clear a cap.

The generator now emits adcp.Optional[T], tagged `omitzero` and never
`omitempty`, for a property that has a null branch after $ref
resolution (a type list with "null", a null anyOf/oneOf branch, a null
enum value or const), is not required, and sits in a struct that a
`*-request.json` tool schema reaches and no response or webhook schema
reaches. T is what the generator emitted for the property without the
pointer, so the union fields that fall back to `any` become
Optional[any] and the any-coverage gate keeps tracking them with their
reviewed reasons.

Fields that change type in types_gen.go:
- UpdateMediaBuyRequest: daily_budget_cap, budget_cap_timezone,
  frequency_cap, bidding
- PackageUpdate: budget, min_spend_target, daily_budget_cap, bidding
- ControlMediaBuyRequest: daily_budget_cap, budget_cap_timezone,
  frequency_cap, bidding
- PackageControl: budget, min_spend_target, daily_budget_cap, bidding

The tools/list input schema for update_media_buy is byte-identical
before and after. control_media_buy now advertises its five typed
properties, where it advertised nothing for the former `any` fields.

lint.py checks the same rule: a non-required request property with a
null branch must be Optional[...] in Go, or its nulls must sit below a
reviewed untyped parent (comply_test_controller params,
CanonicalProposal.forecast, and targeting_overlay until it is typed).
Plan.ApprovedSellers is not covered yet; Plan stays exempt.

This is a source-incompatible change for callers that set or read these
fields; MIGRATING.md has the before and after code. An explicit null is
now kept on the wire where it was dropped before.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@garvitkaushik-123

Copy link
Copy Markdown
Collaborator

@torbenbrodt Thanks for the slice. I checked the 16 fields against the pinned 3.2.2 request schemas with my own script and they match. I read the code and did not run the generator, lint or tests.

  1. control_media_buy tools/list change is untested. The schema pin and the MCP round-trip test cover only update_media_buy. control_media_buy is the tool whose advertised schema changes (five new typed properties). Please add the same two tests for ControlMediaBuyRequest.

  2. "Each schema says null removes the value" is too strong. In 3.2.2, PackageControl.budget and min_spend_target don't say what null means. The generator comment this PR deletes said so. budget_cap_timezone: null restores the default, and bidding: null clears and the package inherits. Please soften the wording in the description and MIGRATING.md, and keep the PackageControl gap noted in a doc comment or an upstream adcp issue.

  3. MIGRATING.md: Get() skips null. The note says x.Budget != nil becomes x.Budget.Get(). Get() is false for null, so authorization code written that way ignores a cap removal. Please add: for "field is present", use !x.Budget.IsZero(). This matters when reference/seller-agent moves over, since attemptedAction checks only Budget == nil.

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.

2 participants