Summary
When the OpenAPI spec defines a nullable nested struct whose primitive fields are flattened to top-level Computed attributes on a Terraform resource, Speakeasy's generated RefreshFromShared* SDK conversion code populates the fields when the parent struct is non-nil but omits the else branch that nulls them when the parent is nil. The Terraform attributes stay at their zero types.Bool{} value (Unknown), which terraform-plugin-framework rejects as Provider returned invalid result object after apply.
This is structurally the same bug class as #1770 (use attribute-defined plan modifier for x-speakeasy-terraform-plan-only) — both are nullable-handling regressions in the Terraform target's generated conversion code. The plan-only side was fixed there; the flatten/null side remains.
Reproduction
Speakeasy version: 1.761.10. Confirmed present on every release we've tried in the 1.7xx series.
Minimal OpenAPI (openapi.yaml):
openapi: 3.0.3
info:
title: minimal-repro
version: 0.1.0
paths:
/resources/{id}:
get:
operationId: getResource
parameters:
- name: id
in: path
required: true
schema: { type: string }
responses:
"200":
description: ok
content:
application/json:
schema:
$ref: "#/components/schemas/ResourceView"
components:
schemas:
ResourceView:
type: object
x-speakeasy-entity: Resource
properties:
resource:
$ref: "#/components/schemas/Resource"
permissions:
$ref: "#/components/schemas/Permissions"
Resource:
type: object
properties:
id: { type: string }
displayName: { type: string }
Permissions:
type: object
nullable: true
properties:
delete: { type: boolean }
edit: { type: boolean }
read: { type: boolean }
gen.yaml:
configVersion: 2.0.0
generation:
sdkClassName: minimalAPI
terraform:
packageName: minimal
version: 0.1.0
Run speakeasy generate sdk -s openapi.yaml -o . -l terraform.
What Speakeasy generates (broken)
// internal/provider/resource_resource_sdk.go
func (r *ResourceResourceModel) RefreshFromSharedResourceView(ctx context.Context, resp *shared.ResourceView) diag.Diagnostics {
var diags diag.Diagnostics
if resp != nil {
if resp.Permissions != nil {
r.Delete = types.BoolPointerValue(resp.Permissions.Delete)
r.Edit = types.BoolPointerValue(resp.Permissions.Edit)
r.Read = types.BoolPointerValue(resp.Permissions.Read)
}
// ... other fields
}
return diags
}
When the API returns permissions: null (legal per nullable: true), the inner block is skipped. r.Delete / r.Edit / r.Read stay at their zero value (types.Bool{} = Unknown, not Null).
What it should generate
if resp.Permissions != nil {
r.Delete = types.BoolPointerValue(resp.Permissions.Delete)
r.Edit = types.BoolPointerValue(resp.Permissions.Edit)
r.Read = types.BoolPointerValue(resp.Permissions.Read)
} else {
r.Delete = types.BoolNull()
r.Edit = types.BoolNull()
r.Read = types.BoolNull()
}
Runtime failure
Error: Provider returned invalid result object after apply
After the apply operation, the provider still indicated an unknown value for
example_resource.test.delete. All values must be known after apply, so this is
always a bug in the provider and should be reported to the provider developers.
terraform-plugin-framework rejects Unknown values after Refresh / Read — the attribute must be either a known value or Null. The same flatten-with-no-else pattern appears in 6 SDK files in our generated provider. The flatten variant where the create response uses a different shape that doesn't include the nullable nested struct at all (in our case, CreateManuallyManagedAppResource returns the bare Resource, not ResourceView) also needs unconditional BoolNull() at the end of the populate block.
Things we tried that did NOT fix it
- Setting
terraform.respectRequiredFields: true in gen.yaml. This DID improve nullable handling for ordinary nested struct fields (flipping to a cleaner if X == nil { r.X = nil } else { r.X = &tfTypes.X{}; ... } form), but the flatten-promotion case where bools are promoted to siblings on r was unchanged — the bad pattern persisted.
Our workaround
We hand-patch the 6 affected files post-regen on every Speakeasy bot PR. The patch shape is the } else { r.X = types.BoolNull() ... } block above. References:
We have a TestNullableNestedStructsHaveElseBranch regression-tripwire test that fails CI when a regen ships without the patch; happy to share if useful for adding to your own integration suite.
Why we think it's the generator template
The pattern is deterministic across regens and identical across all 6 of our affected files:
- Always
if resp.X != nil { ... } with no else.
- Always for nullable nested structs whose primitive fields are promoted to siblings of
r.
This strongly suggests a templating omission in the Terraform target's RefreshFromShared* generator (presumably in your private openapi-generation repo). Filing here since the public PR #1770 thread is the only public artifact for the related plan-only fix.
Environment
- Speakeasy CLI: 1.761.10
- terraform-plugin-framework: 1.19.0
- terraform-plugin-go: 0.31.0
- Go: 1.25.8
- gen.yaml
fixes: nameResolutionDec2023: true, nameResolutionFeb2025: false, parameterOrderingFeb2024: true, requestResponseComponentNamesFeb2024: true, securityFeb2025: false, sharedErrorComponentsApr2025: false, sharedNestedComponentsJan2026: false, nameOverrideFeb2026: false
- gen.yaml
terraform.respectRequiredFields: false (true did not fix this issue)
Summary
When the OpenAPI spec defines a nullable nested struct whose primitive fields are flattened to top-level Computed attributes on a Terraform resource, Speakeasy's generated
RefreshFromShared*SDK conversion code populates the fields when the parent struct is non-nil but omits theelsebranch that nulls them when the parent is nil. The Terraform attributes stay at their zerotypes.Bool{}value (Unknown), whichterraform-plugin-frameworkrejects asProvider returned invalid result object after apply.This is structurally the same bug class as #1770 (use attribute-defined plan modifier for x-speakeasy-terraform-plan-only) — both are nullable-handling regressions in the Terraform target's generated conversion code. The plan-only side was fixed there; the flatten/null side remains.
Reproduction
Speakeasy version:
1.761.10. Confirmed present on every release we've tried in the1.7xxseries.Minimal OpenAPI (
openapi.yaml):gen.yaml:Run
speakeasy generate sdk -s openapi.yaml -o . -l terraform.What Speakeasy generates (broken)
When the API returns
permissions: null(legal pernullable: true), the inner block is skipped.r.Delete/r.Edit/r.Readstay at their zero value (types.Bool{}= Unknown, not Null).What it should generate
Runtime failure
terraform-plugin-frameworkrejects Unknown values after Refresh / Read — the attribute must be either a known value orNull. The same flatten-with-no-else pattern appears in 6 SDK files in our generated provider. The flatten variant where the create response uses a different shape that doesn't include the nullable nested struct at all (in our case,CreateManuallyManagedAppResourcereturns the bareResource, notResourceView) also needs unconditionalBoolNull()at the end of the populate block.Things we tried that did NOT fix it
terraform.respectRequiredFields: trueingen.yaml. This DID improve nullable handling for ordinary nested struct fields (flipping to a cleanerif X == nil { r.X = nil } else { r.X = &tfTypes.X{}; ... }form), but the flatten-promotion case where bools are promoted to siblings onrwas unchanged — the bad pattern persisted.Our workaround
We hand-patch the 6 affected files post-regen on every Speakeasy bot PR. The patch shape is the
} else { r.X = types.BoolNull() ... }block above. References:We have a
TestNullableNestedStructsHaveElseBranchregression-tripwire test that fails CI when a regen ships without the patch; happy to share if useful for adding to your own integration suite.Why we think it's the generator template
The pattern is deterministic across regens and identical across all 6 of our affected files:
if resp.X != nil { ... }with no else.r.This strongly suggests a templating omission in the Terraform target's
RefreshFromShared*generator (presumably in your privateopenapi-generationrepo). Filing here since the public PR #1770 thread is the only public artifact for the related plan-only fix.Environment
fixes:nameResolutionDec2023: true,nameResolutionFeb2025: false,parameterOrderingFeb2024: true,requestResponseComponentNamesFeb2024: true,securityFeb2025: false,sharedErrorComponentsApr2025: false,sharedNestedComponentsJan2026: false,nameOverrideFeb2026: falseterraform.respectRequiredFields:false(true did not fix this issue)