Skip to content

gen: pointer-wrap nullable struct-shaped fields - #38

Open
ysyneu wants to merge 1 commit into
feat/ai-srefrom
fix/nullable-ref-distinguishable
Open

gen: pointer-wrap nullable struct-shaped fields#38
ysyneu wants to merge 1 commit into
feat/ai-srefrom
fix/nullable-ref-distinguishable

Conversation

@ysyneu

@ysyneu ysyneu commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Defect

A nullable $ref (or inline-object) response field decodes to a bare Go
struct value, not a pointer. encoding/json's null-handling only nils out
pointers, maps, and slices on decode — a struct value has no such state.
So when the API sends a real null for one of these fields, it silently
becomes a zero-value struct, indistinguishable from a legitimately empty
object. A caller has no way to tell "the server said null" from "the
server sent an object with everything at its zero value."

Root cause

internal/cmd/gen's isNullable() only recognized the OpenAPI 3.1 union
form type: ["T", "null"] — the convention this spec uses for every
nullable scalar field. It silently ignored the OpenAPI 3.0 boolean form
nullable: true, which is the only marker this org's OpenAPI can put on a
$ref or inline-object field at all (a $ref/object sibling can't carry a
type array naming a non-null member the way a scalar can). Even where a
struct-shaped field WAS detected as nullable, nothing pointer-wrapped it —
the existing pointerization rule only ever applied to scalars, and only on
the request side.

Fix

  • isNullable() now recognizes both spellings.
  • New isStructSchema helper resolves a property schema ($ref or inline,
    following allOf) to whether it emits as a Go struct (as opposed to a
    scalar, enum, alias, slice, or map) — mirroring the same resolution
    goTypeOf's object branch already does.
  • Any field that is both nullable and struct-shaped now pointer-wraps, in
    both directions (request and response) — unlike the pre-existing
    scalar rule, which stays response-side-unwrapped by deliberate, unchanged
    policy (see the comment above it: decoding null into a nullable scalar
    response field yielding its zero value was an accepted tradeoff for
    scalars specifically; it does not extend to structs, which have no
    nil-able zero state at all).

The fix lives entirely in the generator (internal/cmd/gen/main.go); no
generated file is hand-edited.

What actually changes

5 fields in the vendored spec are currently marked nullable: true on a
struct-shaped schema, and flip from value to pointer type on regen:
ToolCatalogResponse.{Target,Error}, ToolInvokeResponse.{Target,Error},
ToolInvokeResponseResultsItem.Error. Example: ToolCatalogResponse.target
is documented as `null` when locator could not be uniquely resolved;
ToolInvokeResponseResultsItem.error is documented as "mutually exclusive
with data" — both cases where a caller genuinely needs to distinguish
"absent" from "present but empty."

All spec-example round-trip tests (roundtrip_test.go, which decodes every
endpoint's example payload into its generated type) pass unchanged against
the new pointer fields.

What does NOT change (yet) — follow-up needed upstream

Two other fields are documented as nullable but carry no formal spec
marker at all
, only prose in their description:

  • ScheduleItem.cur_oncall / .next_oncall ($ref: ScheduleOncallGroup) —
    "Current on-call group, or null when nobody is on-call." / "Next on-call
    group, or null when unknown."
  • SessionItem.bound_environment ($ref: EnvironmentBinding) —
    EnvironmentBinding's own schema description says "Null until the first
    message."

Neither nullable: true nor a type: [..., "null"] array is present for
these — the generator has no signal to act on. I verified the mechanism is
correct and ready for when that marker exists: patching a local copy of the
spec to add nullable: true to these three properties and regenerating
correctly produces *ScheduleOncallGroup and *EnvironmentBinding pointer
fields (then reverted — not part of this PR). Closing this gap needs the
spec's source of truth to start emitting nullable: true alongside a
$ref when the underlying field is a nullable object, the same way it
already does for nullable scalars; once that lands and this repo re-syncs
its vendored spec, no further change is needed here.

Consumer impact

  • flashduty-cli is the only consumer of go-flashduty I found with real
    usage near these fields. It does not currently call the
    ToolCatalogResponse/ToolInvokeResponse endpoints at all (zero grep
    hits), so this PR's actual field changes have zero impact on it today.
  • Forward-looking, once ScheduleItem/SessionItem gain a spec marker in
    a future change: flashduty-cli/internal/cli/oncall.go has 9 usage sites
    of .CurOncall/.NextOncall that would need small, mechanical updates
    (dropping & where the field is passed to a function that already takes
    a pointer, and a couple of nil-guards). Noting this now so it's not a
    surprise later; not part of this PR.

Verification

$ go build ./...          (clean)
$ go vet ./...             (clean)
$ gofmt -l .                (clean)
$ go test -race -count=1 ./...
ok  github.com/flashcatcloud/go-flashduty            1.618s
ok  github.com/flashcatcloud/go-flashduty/internal/cmd/gen  1.503s
ok  github.com/flashcatcloud/go-flashduty/retry       1.710s
FAIL count: 0
$ go generate ./... && git diff --exit-code
(no output — generated files match the committed output exactly)
$ golangci-lint run
retry/retry_test.go:225:35: G118 gosec (context cancellation not called)
1 issues: gosec: 1

That one gosec hit is pre-existing and unrelated (confirmed present on
unmodified feat/ai-sre via git stash; the file is untouched by this PR).

Load-bearing test proof: reverted the isNullable/isStructSchema changes
in internal/cmd/gen/main.go while keeping the new tests → 3 of the 4 new
tests failed (RED); restored the fix → all green. The 4th test (nullable
scalar response fields stay value types) passed in both states, confirming
it's a genuine regression guard rather than accidentally coupled to the fix.

isNullable only recognized the OpenAPI 3.1 type: ["T", "null"] union
form used for scalar fields; it silently ignored the OpenAPI 3.0
`nullable: true` boolean form, the only marker this org's spec can put
on a `$ref` or inline-object field (a `$ref`/object sibling can't
carry a `type` array naming a non-null member). Struct-shaped fields
were also never pointer-wrapped when nullable, even when detected: a
bare Go struct's zero value has no way to represent "the API said
null", unlike the pointers/maps/slices encoding/json already nils out
on decode. A `null` from the API for such a field silently decoded
into a zero-value struct indistinguishable from a legitimately empty
object — e.g. ToolCatalogResponse.target ("null when locator could not
be uniquely resolved") and ToolInvokeResponse[.results[]].error
("mutually exclusive with data") both lost their null/present
distinction entirely.

Add isStructSchema to resolve a property schema ($ref or inline,
following allOf) to whether it emits as a Go struct, then recognize
both nullable spellings and pointer-wrap any nullable field that
resolves to a struct, in both directions (unlike the pre-existing
scalar rule, which stays request-only by deliberate, unchanged
policy — see the comment above it).

Regenerated: 5 fields flip from value to pointer type —
ToolCatalogResponse.{Target,Error}, ToolInvokeResponse.{Target,Error},
and ToolInvokeResponseResultsItem.Error — the only fields in the
vendored spec currently marked `nullable: true` on a struct-shaped
schema. All spec-example round-trip tests still pass unchanged.

Two other known-nullable $ref fields, ScheduleItem.{cur_oncall,
next_oncall} and SessionItem.bound_environment, are unaffected: the
vendored spec documents their nullability in prose only ("or null when
nobody is on-call", "Null until the first message") with no formal
marker for a generator to act on. Verified end-to-end against a
locally patched copy of the spec that adds the marker to those three
fields: regeneration correctly produces *ScheduleOncallGroup and
*EnvironmentBinding pointer fields, confirming the mechanism is
correct and ready for whenever the spec gains that marker.
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.

1 participant