No structural rule can capture intent. The union base landed in #57 and generalized in #59 is inferred: when the variants of a oneOf inline the same properties, the generator intersects what they declare and synthesizes <Union>Base from the result. That is the only thing available for producers that flatten composition, and it works, but the analyzer cannot tell a property the variants share on purpose from one they happen to agree on, because in the spec those are the same thing.
The cost
Against a 793-schema spec the inference synthesizes four bases. One of them shows the problem:
| synthesized |
fields |
verdict |
AttachedStorageBase |
mountPoint, readOnly |
genuinely shared |
AttachedStorageWriteBase |
mountPoint, readOnly |
genuinely shared |
ClusterCreateBase |
7 |
6 deliberate, 1 coincidental |
ClusterDefinitionBase |
desktopSession only |
pure noise |
ClusterCreate's six variants intersect on {description, displayName, duplicatedFrom, name, runTimeAlert, tags}, which is a real base type in the producer, plus desktopSession, which lands there only because all six variable structs happen to declare it.
So an exported SDK type is a function of incidental spec structure. Dropping desktopSession from one cluster type removes a field from ClusterCreateBase, and the person making that edit has no reason to look at the union. Consumers get a compile error, which is the right outcome for them and no help at all to whoever caused it.
At 0.2.x that is a recompile. At 1.0.0 it would be a semver hazard: an unrelated edit to a single variant forces a major version.
Superseded on the timing: no 1.0 is planned, and breaking consumers is not the concern driving this. The case for the extension is now quality, that a base should mean something, rather than release mechanics. See the comments below.
Ruled out: a coverage threshold
Suppressing bases below N shared fields is anti-correlated with the cost. It removes ClusterDefinitionBase, which is inert, and keeps ClusterCreateBase, which is the one with six real fields depending on it and a coincidental seventh that moves. It would also kill legitimate one-field bases, such as a union whose variants genuinely share only id. It cleans up what costs nothing and preserves what costs something.
Proposal: let the producer name the base
An extension on the union schema that names a schema, not a property set:
Pet:
oneOf: [...]
discriminator: { propertyName: kind, ... }
x-go-union-base: "#/components/schemas/CreateClusterMetadata"
The generator then builds Base() *CreateClusterMetadata from the named schema's properties instead of from an intersection.
Two things follow from naming a schema rather than enabling inference:
- The coincidence disappears rather than being mitigated.
desktopSession is not a property of CreateClusterMetadata, so it is not in the base, and no edit to a variant can put it there.
- The declaration can be verified. Every variant must declare every property of the named base identically, or generation fails. That turns the extension from a hint into checked intent, which is what makes it worth spending an extension on.
Mechanically this is closer to the synthesis path than to the allOf path: a flattening producer's branches do not embed the base, so Base() still constructs it field by field. Only the source of the field list changes.
An extension that merely enables inference is not worth shipping. ClusterCreateBase would still gain and lose desktopSession with unrelated edits, and the extension would have bought nothing.
The producer side is confirmed
huma can attach this at exactly the right site, with no upstream change:
huma.Schema has Extensions map[string]any (schema.go:141), marshaled inline by Schema.MarshalJSON (schema.go:177-243), so it lands on any schema object.
- Unions built through
Schema(r huma.Registry) *huma.Schema return the oneOf wrapper that carries the discriminator, which is the object the extension goes on.
- The named base is registered with
r.Schema(reflect.TypeFor[CreateClusterMetadata](), true, "CreateClusterMetadata"), which both registers the component and returns the ref to put in the extension.
Open question
Ref or property list. A ref carries intent and gives the generated Base() a name the producer already uses, at the cost of requiring the base to exist as a component schema. A property list works when no such schema exists, but it restates the properties in the spec and has no name of its own to return.
Scope
Inference stays. Making the base opt-in would hand back the v0.2.7 behavior for every spec without the extension, which is the case the feature exists for. The extension takes precedence where it appears.
No structural rule can capture intent. The union base landed in #57 and generalized in #59 is inferred: when the variants of a
oneOfinline the same properties, the generator intersects what they declare and synthesizes<Union>Basefrom the result. That is the only thing available for producers that flatten composition, and it works, but the analyzer cannot tell a property the variants share on purpose from one they happen to agree on, because in the spec those are the same thing.The cost
Against a 793-schema spec the inference synthesizes four bases. One of them shows the problem:
AttachedStorageBaseAttachedStorageWriteBaseClusterCreateBaseClusterDefinitionBaseClusterCreate's six variants intersect on{description, displayName, duplicatedFrom, name, runTimeAlert, tags}, which is a real base type in the producer, plusdesktopSession, which lands there only because all six variable structs happen to declare it.So an exported SDK type is a function of incidental spec structure. Dropping
desktopSessionfrom one cluster type removes a field fromClusterCreateBase, and the person making that edit has no reason to look at the union. Consumers get a compile error, which is the right outcome for them and no help at all to whoever caused it.At 0.2.x that is a recompile. At 1.0.0 it would be a semver hazard: an unrelated edit to a single variant forces a major version.
Superseded on the timing: no 1.0 is planned, and breaking consumers is not the concern driving this. The case for the extension is now quality, that a base should mean something, rather than release mechanics. See the comments below.
Ruled out: a coverage threshold
Suppressing bases below N shared fields is anti-correlated with the cost. It removes
ClusterDefinitionBase, which is inert, and keepsClusterCreateBase, which is the one with six real fields depending on it and a coincidental seventh that moves. It would also kill legitimate one-field bases, such as a union whose variants genuinely share onlyid. It cleans up what costs nothing and preserves what costs something.Proposal: let the producer name the base
An extension on the union schema that names a schema, not a property set:
The generator then builds
Base() *CreateClusterMetadatafrom the named schema's properties instead of from an intersection.Two things follow from naming a schema rather than enabling inference:
desktopSessionis not a property ofCreateClusterMetadata, so it is not in the base, and no edit to a variant can put it there.Mechanically this is closer to the synthesis path than to the
allOfpath: a flattening producer's branches do not embed the base, soBase()still constructs it field by field. Only the source of the field list changes.An extension that merely enables inference is not worth shipping.
ClusterCreateBasewould still gain and losedesktopSessionwith unrelated edits, and the extension would have bought nothing.The producer side is confirmed
huma can attach this at exactly the right site, with no upstream change:
huma.SchemahasExtensions map[string]any(schema.go:141), marshaled inline bySchema.MarshalJSON(schema.go:177-243), so it lands on any schema object.Schema(r huma.Registry) *huma.Schemareturn theoneOfwrapper that carries the discriminator, which is the object the extension goes on.r.Schema(reflect.TypeFor[CreateClusterMetadata](), true, "CreateClusterMetadata"), which both registers the component and returns the ref to put in the extension.Open question
Ref or property list. A ref carries intent and gives the generated
Base()a name the producer already uses, at the cost of requiring the base to exist as a component schema. A property list works when no such schema exists, but it restates the properties in the spec and has no name of its own to return.Scope
Inference stays. Making the base opt-in would hand back the v0.2.7 behavior for every spec without the extension, which is the case the feature exists for. The extension takes precedence where it appears.