Skip to content

Check the structs packages against encoding/json - #6469

Draft
denik wants to merge 12 commits into
denik/structaccess-embed-ambiguityfrom
denik/structs-json-agreement
Draft

Check the structs packages against encoding/json#6469
denik wants to merge 12 commits into
denik/structaccess-embed-ambiguityfrom
denik/structs-json-agreement

Conversation

@denik

@denik denik commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Stacked on #6468.

encoding/json decides which fields exist on the wire, under which names, at which paths. Every libs/structs package claims to speak that vocabulary — structwalk enumerates it, structaccess reads and writes it, structdiff reports changes in it — so a disagreement is a bug in one of them, or in the type. Nothing checked that until now.

Three layers:

  • bundle/config/structstest fills every field of a type, marshals it, and compares the result against structwalk's leaves and structaccess Get/ValidatePath. Driven off config.Resources by reflection, so a new resource is covered automatically.
  • bundle/direct/dresources/structs_test.go does the same for StateType and RemoteType — the types the plan actually reads fields out of.
  • Each libs/structs package is checked against a shared corpus of shapes that are easy to get wrong (two levels of embedding, a shadowed name, a same-depth collision, a diamond, a cyclic embed, an embed behind a nil pointer). The corpus asserts its own expectations against json.Marshal, so it cannot teach every consumer the same wrong answer.

What it found, all recorded rather than fixed here, each as a ratchet that fails when the bug is fixed:

  • Eleven resource types embed a struct that declares MarshalJSON and declare none of their own, so the embedded marshaler takes over and id, url, lifecycle and permissions never reach the wire. Latent: bundle validate -o json marshals the dyn tree.
  • structwalk visits every declaration of a shadowed embedded field, reporting it twice.
  • structwalk and structdiff both expose an ambiguous embedded field that encoding/json refuses to serialize.
  • structaccess.Set will not descend through a nil embedded pointer, so a field json.Unmarshal reaches by allocating it cannot write.
  • Free-form any fields and self-marshaling scalars (duration.Duration, the SDK time wrapper) are never visited by structwalk.

Reverting any commit from #6467 or #6468 turns these tests red.

This pull request and its description were written by Isaac.

@denik
denik force-pushed the denik/structs-json-agreement branch 5 times, most recently from 552548a to bf4147b Compare September 1, 2026 20:20
denik added 11 commits September 1, 2026 22:28
encoding/json decides which fields exist on the wire, under which names, at which paths.
Every libs/structs package claims to speak that vocabulary -- structwalk enumerates it,
structaccess reads and writes it, structdiff reports changes in it -- so a disagreement is
a bug in one of them, or in the type.

The new package fills every field of a type with a non-zero value, marshals it, and
compares the result against structwalk's leaves and structaccess.Get/ValidatePath. The
test drives it off config.Resources by reflection, so a newly added resource is covered
without touching the test.

It finds two things today, both enumerated rather than fixed here:

  - Eleven resource types embed a struct that declares MarshalJSON and declare none of
    their own, so the embedded marshaler takes over and id, url, lifecycle and permissions
    never reach the wire. Nothing marshals a config type today (bundle validate -o json
    marshals the dyn tree), so this is latent.
  - Free-form any fields and types that marshal themselves as a scalar (duration.Duration,
    the SDK time wrapper) are never visited by structwalk, so structdiff cannot report
    drift on them.

Co-authored-by: Isaac
These are the types the direct engine actually reads fields out of: the plan resolves a
${resources...} reference by walking them, the state file is the JSON encoding of
StateType, and a refresh decodes into RemoteType. A disagreement here is not latent the
way it is for the config types -- it is a field the plan cannot see or the state file
cannot carry.

assertJSONRoundTrip already covers whether a wrapper loses fields across Marshal ->
Unmarshal. This covers whether the packages and encoding/json name and reach the same
fields at all.

Both flavours pass for every registered resource once the EmbeddedSlice convention is
accounted for: __embed__ is transparent to the walkers by design, so the check follows
them rather than the literal wire key.

Co-authored-by: Isaac
The repo forbids sort.Strings in favour of the standard library's generic version.
…ape corpus

The corpus in libs/structs/internal/jsonshapes pairs a struct shape whose JSON behaviour is
easy to get wrong -- two levels of embedding, a shadowed name, a same-depth collision, a
diamond, a cyclic embed, an embed behind a nil pointer -- with the fields encoding/json
actually serializes for it. Its own test asserts those expectations against json.Marshal,
so the corpus cannot teach every consumer the same wrong answer.

Each package is then checked against it on its own terms: structaccess must read, write and
validate exactly what the wire carries, and the write assertion goes through json.Marshal so
a Set into a field the wire format ignores fails; structwalk must visit exactly those paths;
structdiff must report a change to each of them and none to a field encoding/json drops.
structpath and structtag have no corpus to check against, so they are pinned directly:
a rendered path must survive being parsed again (a map key with dots is the case that
matters), and a json tag must resolve to the name encoding/json chose for it.

Three disagreements are recorded rather than fixed, each as a ratchet that asserts the
disagreement is still present, so fixing one breaks the test and forces the entry out:

  - structwalk visits every declaration of a shadowed embedded field, so it reports the
    field twice while the wire format carries one value.
  - structwalk and structdiff both expose an ambiguous embedded field that encoding/json
    refuses to serialize, so the engine can plan an update that can never be sent.
  - structaccess.Set will not descend through a nil embedded pointer, so a field
    json.Unmarshal reaches by allocating it cannot be written.

Co-authored-by: Isaac
JSONLeaves had no caller once Check took the self-marshaling set from the internal helper,
and KnownDivergence was never used. task fmt rewrote the reflection loop to reflect.TypeFor
and Type.Fields.
From the adversarial review. Four ways the checks were weaker than they read:

Filter dropped SelfMarshalingScalars from the report it returned, so the callers' check on
that category could never fire and the category was silently ignored rather than reported.

A known-divergence entry only filtered; nothing noticed when the underlying bug was fixed
and the entry went stale. Filter now returns the entries that matched nothing and both
tests fail on them. That immediately found two stale entries.

Prefix coverage applied to every category, so an entry naming a field could absorb an
unrelated Get or value failure at a path beneath it. It now applies only to the two walk
categories, where a field lost wholesale really does take its leaves with it.

The per-shape gaps asserted merely that *some* disagreement remained, which a different
regression would satisfy. They now hold the exact current output, so any change in
behaviour fails and the entry has to be revisited.

Paths inside a free-form any field are a category of their own now, like self-marshaling
scalars: structwalk does not traverse an interface and structaccess cannot validate a path
through one, so the whole subtree is opaque and listing individual paths would only pin the
filler's choice of map key. With that, the state and remote types need no recorded
divergences at all.

Co-authored-by: Isaac
A json name on an anonymous field makes it a named field to encoding/json; a tag that sets
only an option leaves it flattened. Both shapes are in the corpus now, and Leaves flattens
a shape's JSON to leaf paths so the corpus's expectations stay comparable once a shape nests.

Also: coveredBy takes the longest matching entry rather than the first, so overlapping
known-divergence entries are each credited with what they alone cover instead of one
absorbing everything and leaving the other looking stale.
…gories

Three coverage holes from the final review round.

Check inventoried scalar leaves only, so a field encoding/json emits as {} or [] contributed
nothing to compare and a field the packages could not reach at all would have passed
unnoticed. Container paths are now collected too and each one has to resolve through
ValidatePath and Get. Every resource, state and remote type already satisfies that.

The free-form and self-marshaling categories were logged and cleared, so a newly introduced
blind spot passed silently. Both are ratcheted now, each at the level where the ratchet says
something:

  - Free-form any fields are listed by name per resource. Which resources have one is
    stable, so a new one has to be added here deliberately. That immediately caught a real
    difference: cluster policies have two in the config type and none in the state type,
    because the definition is normalized to the string the API takes before deploy.
  - Self-marshaling scalars are ratcheted on the Go *types* that behave this way, not the
    paths. A new timestamp field of duration.Duration or the SDK time wrapper says nothing;
    a new type that hides itself from the walkers is a finding, and fails.

Co-authored-by: Isaac
Check recorded the walk's leaves in a map, so two visits to one path became one entry and a
shadowed embedded field looked like agreement. That is the worst failure mode for a test
whose whole job is to notice disagreement, and it was hiding real cases.

Visits are counted now, and six resource types turn out to have one: apps (id, url,
lifecycle.prevent_destroy), pipelines and alerts (id), and job_runs, clusters and
sql_warehouses (lifecycle.prevent_destroy). Each embeds BaseResource alongside an SDK type
that declares the same json name, or two structs that each carry a Lifecycle. encoding/json
serializes the shallower field and nothing else, so the second visit is a field that cannot
reach the wire under that name -- and structdiff reports a change at that path twice.

Recorded per resource rather than fixed: making structwalk resolve a name the way
encoding/json does is a change to the walk itself, and it would move the refschema golden.
The state and remote types have none of these, so the duplication is confined to the config
types.

Co-authored-by: Isaac
FillNonZero skipped any field whose parsed json name was "-", which includes
json:"-,omitempty" -- a field encoding/json does serialize, under the name "-". Left at its
zero value it was then omitted from the marshal output, so the harness saw no disagreement
and passed: a false pass on exactly the shape structaccess could not resolve.

It uses structaccess.IsSkippedField now, and the corpus carries a dash-named field alongside
the genuinely skipped one so all four packages are held to the distinction.
@denik
denik force-pushed the denik/structs-json-agreement branch from bf4147b to 1f87973 Compare September 1, 2026 20:30
Two more false-pass paths in the harness itself.

render normalised every scalar to its text, so a JSON string and a JSON number of the same
digits compared equal. A field tagged json:",string" puts a number on the wire as "1" while
the packages expose the int behind it -- a real difference in what is at the path, reported as
agreement. The JSON side is decoded with UseNumber now and both sides render with their type,
so number:1 and string:1 no longer match while 1, 1.0 and 1e0 still do.

flatten silently rewrote the EmbeddedSlice convention: __embed__ carries the slice on the wire
while the walkers put its elements at the parent path. Following the walkers is right -- it is
how the state file and the engine's paths relate -- but doing it silently meant the harness
could not notice a change to the convention. The rename is reported now, and both tests assert
that __embed__ is the only key it ever applies to.

Co-authored-by: Isaac
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