Terraform detects a change with custom PlanModifier

Hello,

I’m working on a provider using the Plugin Framework. I have a specific resource that stores a date as a string in ISO8601 (format accepted and returned by the API), with the following schema:

			"expiration_date": schema.StringAttribute{
				Computed: true,
				Optional: true,
				Validators: []validator.String{
					fwvalidators.DateValidator(),
				},
				PlanModifiers: []planmodifier.String{
					fwmodifyplan.CheckExpirationDate(),
				},
			},

For this reason, I’ve made a custom plan modifier to not detect a difference when the date is semantically equivalent:

func (m datePlanModify) PlanModifyString(ctx context.Context, req planmodifier.StringRequest, resp *planmodifier.StringResponse) {
	if req.StateValue.IsNull() || req.StateValue.ValueString() == "" || req.ConfigValue.IsUnknown() ||
		req.ConfigValue.IsNull() || req.ConfigValue.ValueString() == "" {
		return
	}

	configDate, err := iso8601.Parse([]byte(req.ConfigValue.ValueString()))
	if err != nil {
		resp.Diagnostics.AddError(
			m.Description(ctx),
			err.Error(),
		)
	}
	stateDate, err := iso8601.Parse([]byte(req.StateValue.ValueString()))
	if err != nil {
		resp.Diagnostics.AddError(
			m.Description(ctx),
			err.Error(),
		)
	}
	if resp.Diagnostics.HasError() {
		return
	}

	if configDate.Equal(stateDate) {
		resp.PlanValue = req.StateValue
	} else if configDate.Before(stateDate) {
		resp.Diagnostics.AddError(
			m.Description(ctx),
			"...",
		)
	}
}

During debugging, I observed that my test correctly triggers the equality check and updates the PlanValue to match the StateValue. However, terraform still detects a difference and forces an update. After the update is applied, terraform continues to detect a difference because the state value remains unchanged and the config value hasn’t changed either, resulting in an infinite update loop.
If I’ve understood correctly the Plan Modifier, this case should not trigger an update since the attribute PlanValue is set to the StateValue.
The log shows that terraform is detecting a change for the expiration_date attribute:

Full logs
2025-12-10T14:28:06.371Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: tf_rpc=ReadResource @module=sdk.framework tf_attribute_path=last_modification_date tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=397f05fd-d29a-395f-cc44-4227c5e96578 tf_resource_type=outscale_access_key @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwschemadata/value_semantic_equality.go:91 timestamp=2025-12-10T14:28:06.371Z
2025-12-10T14:28:06.371Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: @module=sdk.framework tf_attribute_path=access_key_id tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=397f05fd-d29a-395f-cc44-4227c5e96578 tf_resource_type=outscale_access_key tf_rpc=ReadResource @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwschemadata/value_semantic_equality.go:91 timestamp=2025-12-10T14:28:06.371Z
2025-12-10T14:28:06.372Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: tf_resource_type=outscale_access_key tf_rpc=ReadResource tf_provider_addr=registry.terraform.io/outscale/outscale @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwschemadata/value_semantic_equality.go:91 @module=sdk.framework tf_attribute_path=state tf_mux_provider="*proto6server.Server" tf_req_id=397f05fd-d29a-395f-cc44-4227c5e96578 timestamp=2025-12-10T14:28:06.371Z
2025-12-10T14:28:06.372Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: @module=sdk.framework tf_mux_provider="*proto6server.Server" tf_req_id=397f05fd-d29a-395f-cc44-4227c5e96578 tf_resource_type=outscale_access_key tf_rpc=ReadResource @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwschemadata/value_semantic_equality.go:91 tf_attribute_path=expiration_date tf_provider_addr=registry.terraform.io/outscale/outscale timestamp=2025-12-10T14:28:06.372Z
2025-12-10T14:28:06.372Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: @module=sdk.framework tf_mux_provider="*proto6server.Server" tf_req_id=397f05fd-d29a-395f-cc44-4227c5e96578 tf_resource_type=outscale_access_key @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwschemadata/value_semantic_equality.go:91 tf_attribute_path=id tf_provider_addr=registry.terraform.io/outscale/outscale tf_rpc=ReadResource timestamp=2025-12-10T14:28:06.372Z
2025-12-10T14:28:06.372Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwschemadata/value_semantic_equality.go:91 tf_attribute_path=creation_date tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=397f05fd-d29a-395f-cc44-4227c5e96578 tf_rpc=ReadResource @module=sdk.framework tf_resource_type=outscale_access_key timestamp=2025-12-10T14:28:06.372Z
2025-12-10T14:28:06.374Z [DEBUG] provider.terraform-provider-outscale: Detected value change between proposed new state and prior state: tf_req_id=d1ccd145-1124-39a9-6aae-b8cb995c11ac tf_resource_type=outscale_access_key @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwserver/server_planresourcechange.go:220 tf_attribute_path=expiration_date tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_rpc=PlanResourceChange @module=sdk.framework timestamp=2025-12-10T14:28:06.374Z
2025-12-10T14:28:06.374Z [DEBUG] provider.terraform-provider-outscale: Marking Computed attributes with null configuration values as unknown (known after apply) in the plan to prevent potential Terraform errors: tf_mux_provider="*proto6server.Server" tf_req_id=d1ccd145-1124-39a9-6aae-b8cb995c11ac @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwserver/server_planresourcechange.go:229 @module=sdk.framework tf_provider_addr=registry.terraform.io/outscale/outscale tf_resource_type=outscale_access_key tf_rpc=PlanResourceChange timestamp=2025-12-10T14:28:06.374Z
2025-12-10T14:28:06.374Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: tf_rpc=PlanResourceChange @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwserver/server_planresourcechange.go:480 @module=sdk.framework tf_attribute_path="AttributeName(\"secret_key\")" tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=d1ccd145-1124-39a9-6aae-b8cb995c11ac tf_resource_type=outscale_access_key timestamp=2025-12-10T14:28:06.374Z
2025-12-10T14:28:06.374Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: @module=sdk.framework tf_attribute_path="AttributeName(\"creation_date\")" tf_mux_provider="*proto6server.Server" tf_req_id=d1ccd145-1124-39a9-6aae-b8cb995c11ac tf_resource_type=outscale_access_key @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwserver/server_planresourcechange.go:480 tf_provider_addr=registry.terraform.io/outscale/outscale tf_rpc=PlanResourceChange timestamp=2025-12-10T14:28:06.374Z
2025-12-10T14:28:06.374Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: tf_req_id=d1ccd145-1124-39a9-6aae-b8cb995c11ac @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwserver/server_planresourcechange.go:480 @module=sdk.framework tf_resource_type=outscale_access_key tf_rpc=PlanResourceChange tf_attribute_path="AttributeName(\"access_key_id\")" tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale timestamp=2025-12-10T14:28:06.374Z
2025-12-10T14:28:06.374Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: tf_mux_provider="*proto6server.Server" tf_req_id=d1ccd145-1124-39a9-6aae-b8cb995c11ac @module=sdk.framework tf_attribute_path="AttributeName(\"request_id\")" tf_provider_addr=registry.terraform.io/outscale/outscale tf_resource_type=outscale_access_key tf_rpc=PlanResourceChange @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwserver/server_planresourcechange.go:480 timestamp=2025-12-10T14:28:06.374Z
2025-12-10T14:28:06.374Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=d1ccd145-1124-39a9-6aae-b8cb995c11ac tf_resource_type=outscale_access_key tf_rpc=PlanResourceChange @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwserver/server_planresourcechange.go:480 @module=sdk.framework tf_attribute_path="AttributeName(\"last_modification_date\")" timestamp=2025-12-10T14:28:06.374Z
2025-12-10T14:28:06.374Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: tf_req_id=d1ccd145-1124-39a9-6aae-b8cb995c11ac tf_resource_type=outscale_access_key @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.14.1/internal/fwserver/server_planresourcechange.go:480 tf_attribute_path="AttributeName(\"id\")" tf_mux_provider="*proto6server.Server" tf_rpc=PlanResourceChange @module=sdk.framework tf_provider_addr=registry.terraform.io/outscale/outscale timestamp=2025-12-10T14:28:06.374Z
2025-12-10T14:28:06.376Z [DEBUG] provider.stdio: received EOF, stopping recv loop: err="rpc error: code = Unavailable desc = error reading from server: EOF" 2025-12-10T14:28:06.377Z [INFO]  provider: plugin process exited: plugin=.terraform/providers/registry.terraform.io/outscale/outscale/1.0.0-dev/linux_amd64/terraform-provider-outscale id=627994
2025-12-10T14:28:06.377Z [DEBUG] provider: plugin exited
2025-12-10T14:28:06.377Z [DEBUG] building apply graph to check for errors
2025-12-10T14:28:06.377Z [DEBUG] ProviderTransformer: "outscale_access_key.access_key01" (*terraform.NodeApplyableResourceInstance) needs provider["registry.terraform.io/outscale/outscale"]
2025-12-10T14:28:06.377Z [DEBUG] ProviderTransformer: "outscale_access_key.access_key01 (expand)" (*terraform.nodeExpandApplyableResource) needs provider["registry.terraform.io/outscale/outscale"]
2025-12-10T14:28:06.377Z [DEBUG] pruning unused provider["registry.terraform.io/scottwinkler/shell"]
2025-12-10T14:28:06.377Z [DEBUG] ReferenceTransformer: "outscale_access_key.access_key01 (expand)" references: []
2025-12-10T14:28:06.377Z [DEBUG] ReferenceTransformer: "var.region" references: []
2025-12-10T14:28:06.377Z [DEBUG] ReferenceTransformer: "var.image_id" references: []
2025-12-10T14:28:06.377Z [DEBUG] ReferenceTransformer: "var.vm_type" references: []
2025-12-10T14:28:06.377Z [DEBUG] ReferenceTransformer: "outscale_access_key.access_key01" references: []
2025-12-10T14:28:06.377Z [DEBUG] ReferenceTransformer: "provider[\"registry.terraform.io/outscale/outscale\"]" references: []
2025-12-10T14:28:06.377Z [INFO]  backend/local: plan operation completed

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # outscale_access_key.access_key01 will be updated in-place
  ~ resource "outscale_access_key" "access_key01" {
      ~ access_key_id          = "..." -> (known after apply)
      ~ creation_date          = "2025-12-10T13:42:18.616+0000" -> (known after apply)
      ~ id                     = "..." -> (known after apply)
      ~ last_modification_date = "2025-12-10T13:52:27.167+0000" -> (known after apply)
      ~ request_id             = "4d12e358-607f-4ab5-a56a-eb76652c81de" -> (known after apply)
      ~ secret_key             = "..." -> (known after apply)
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

Hi @ryohkhn ,

For this problem we have semantic equality handling that you can read more about here and that may be a simpler approach. The challenge with using PlanModifiers for this is that it only handles the Plan part of the implementation and does not handle Read, which is likely where Terraform raised a few concerns.

Let me know if this helps.

Hello @rain.kwan ,

Thank you for your response. Following the documentation and the RFC3339 implementation from the timetypes package, I’ve implemented a custom ISO8601 type as such:

CustomType
// Ensure the implementation satisfies the expected interfaces
var _ basetypes.StringTypable = ISO8601Type{}

type ISO8601Type struct {
	basetypes.StringType
}

func (t ISO8601Type) Equal(o attr.Type) bool {
	other, ok := o.(ISO8601Type)

	if !ok {
		return false
	}

	return t.StringType.Equal(other.StringType)
}

func (t ISO8601Type) String() string {
	return "ISO8601Type"
}

func (t ISO8601Type) ValueFromString(ctx context.Context, in basetypes.StringValue) (basetypes.StringValuable, diag.Diagnostics) {
	value := ISO8601{
		StringValue: in,
	}

	return value, nil
}

func (t ISO8601Type) ValueFromTerraform(ctx context.Context, in tftypes.Value) (attr.Value, error) {
	attrValue, err := t.StringType.ValueFromTerraform(ctx, in)
	if err != nil {
		return nil, err
	}

	stringValue, ok := attrValue.(basetypes.StringValue)

	if !ok {
		return nil, fmt.Errorf("unexpected value type of %T", attrValue)
	}

	stringValuable, diags := t.ValueFromString(ctx, stringValue)

	if diags.HasError() {
		return nil, fmt.Errorf("unexpected error converting StringValue to StringValuable: %v", diags)
	}

	return stringValuable, nil
}

func (t ISO8601Type) ValueType(ctx context.Context) attr.Value {
	return ISO8601{}
}

and:

CustomValue
// Ensure the implementation satisfies the expected interfaces
var (
	_ basetypes.StringValuable                   = ISO8601{}
	_ basetypes.StringValuableWithSemanticEquals = ISO8601{}
)

type ISO8601 struct {
	basetypes.StringValue
}

func (v ISO8601) Equal(o attr.Value) bool {
	other, ok := o.(ISO8601)

	if !ok {
		return false
	}

	return v.StringValue.Equal(other.StringValue)
}

func (v ISO8601) Type(ctx context.Context) attr.Type {
	// ISO8601Type defined in the schema type section
	return ISO8601Type{}
}

func (v ISO8601) StringSemanticEquals(ctx context.Context, newValuable basetypes.StringValuable) (bool, diag.Diagnostics) {
	var diags diag.Diagnostics

	// The framework should always pass the correct value type, but always check
	newValue, ok := newValuable.(ISO8601)

	if !ok {
		diags.AddError(
			"Semantic Equality Check Error",
			"An unexpected value type was received while performing semantic equality checks. "+
				"Please report this to the provider developers.\n\n"+
				"Expected Value Type: "+fmt.Sprintf("%T", v)+"\n"+
				"Got Value Type: "+fmt.Sprintf("%T", newValuable),
		)

		return false, diags
	}

	if v.IsNull() || newValue.IsNull() || v.IsUnknown() || newValue.IsUnknown() {
		return false, diags
	}

	// Skipping error checking if ISO8601 already implemented ISO8601 validation
	priorTime, _ := iso8601.Parse([]byte(v.ValueString()))

	// Skipping error checking if ISO8601 already implemented ISO8601 validation
	newTime, _ := iso8601.Parse([]byte(newValue.ValueString()))

	// If the times are equivalent, keep the prior value
	return priorTime.Equal(newTime), diags
}

// ValidateAttribute implements attribute value validation. This type requires the value to be a String value that
// is valid ISO8601 format.
func (v ISO8601) ValidateAttribute(ctx context.Context, req xattr.ValidateAttributeRequest, resp *xattr.ValidateAttributeResponse) {
	if v.IsUnknown() || v.IsNull() {
		return
	}

	if _, err := iso8601.Parse([]byte(v.ValueString())); err != nil {
		resp.Diagnostics.Append(diag.WithPath(
			req.Path,
			iso8601InvalidStringDiagnostic(v.ValueString(), err),
		))

		return
	}
}

// ValidateParameter implements provider-defined function parameter value validation. This type requires the value to
// be a String value that is valid ISO8601 format.
func (v ISO8601) ValidateParameter(ctx context.Context, req function.ValidateParameterRequest, resp *function.ValidateParameterResponse) {
	if v.IsUnknown() || v.IsNull() {
		return
	}

	if _, err := iso8601.Parse([]byte(v.ValueString())); err != nil {
		resp.Error = function.NewArgumentFuncError(
			req.Position,
			"Invalid ISO8601 String Value: "+
				"A string value was provided that is not valid ISO8601 date string format.\n\n"+
				"Given Value: "+v.ValueString()+"\n"+
				"Error: "+err.Error(),
		)

		return
	}
}

// ValueISO8601Time creates a new time.Time instance with the ISO8601 StringValue. A null or unknown value will produce an error diagnostic.
func (v ISO8601) ValueISO8601Time() (time.Time, diag.Diagnostics) {
	var diags diag.Diagnostics

	if v.IsNull() {
		diags.Append(diag.NewErrorDiagnostic("ISO8601 ValueISO8601Time Error", "ISO8601 string value is null"))
		return time.Time{}, diags
	}

	if v.IsUnknown() {
		diags.Append(diag.NewErrorDiagnostic("ISO8601 ValueISO8601Time Error", "ISO8601 string value is unknown"))
		return time.Time{}, diags
	}

	ISO8601Time, err := iso8601.Parse([]byte(v.ValueString()))
	if err != nil {
		diags.Append(diag.NewErrorDiagnostic("ISO8601 ValueISO8601Time Error", err.Error()))
		return time.Time{}, diags
	}

	return ISO8601Time, nil
}

// NewISO8601Null creates an ISO8601 with a null value. Determine whether the value is null via IsNull method.
func NewISO8601Null() ISO8601 {
	return ISO8601{
		StringValue: basetypes.NewStringNull(),
	}
}

// NewISO8601Unknown creates an ISO8601 with an unknown value. Determine whether the value is unknown via IsUnknown method.
func NewISO8601Unknown() ISO8601 {
	return ISO8601{
		StringValue: basetypes.NewStringUnknown(),
	}
}

// NewISO8601Value creates an ISO8601 with a known value or raises an error
// diagnostic if the string is not ISO8601 format.
func NewISO8601Value(value string) (ISO8601, diag.Diagnostics) {
	if value == "" {
		return NewISO8601Null(), nil
	}
	_, err := iso8601.Parse([]byte(value))
	if err != nil {
		// Returning an unknown value will guarantee that, as a last resort,
		// Terraform will return an error if attempting to store into state.
		return NewISO8601Unknown(), diag.Diagnostics{iso8601InvalidStringDiagnostic(value, err)}
	}

	return ISO8601{
		StringValue: basetypes.NewStringValue(value),
	}, nil
}

// NewISO8601ValueMust creates an ISO8601 with a known value or raises a panic
// if the string is not ISO8601 format.
//
// This creation function is only recommended to create ISO8601 values which
// either will not potentially affect practitioners, such as testing, or within
// exhaustively tested provider logic.
func NewISO8601ValueMust(value string) ISO8601 {
	if value == "" {
		return NewISO8601Null()
	}
	_, err := iso8601.Parse([]byte(value))
	if err != nil {
		panic(fmt.Sprintf("Invalid ISO8601 String Value (%s): %s", value, err))
	}

	return ISO8601{
		StringValue: basetypes.NewStringValue(value),
	}
}

// NewISO8601PointerValue creates an ISO8601 with a null value if nil, a known
// value, or raises an error diagnostic if the string is not ISO8601 format.
func NewISO8601PointerValue(value *string) (ISO8601, diag.Diagnostics) {
	if value == nil || *value == "" {
		return NewISO8601Null(), nil
	}

	return NewISO8601Value(*value)
}

// NewISO8601PointerValueMust creates an ISO8601 with a null value if nil, a
// known value, or raises a panic if the string is not ISO8601 format.
//
// This creation function is only recommended to create ISO8601 values which
// either will not potentially affect practitioners, such as testing, or within
// exhaustively tested provider logic.
func NewISO8601PointerValueMust(value *string) ISO8601 {
	if value == nil || *value == "" {
		return NewISO8601Null()
	}

	return NewISO8601ValueMust(*value)
}

// iso8601InvalidStringDiagnostic returns an error diagnostic intended to report
// when a string is not ISO8601 format.
func iso8601InvalidStringDiagnostic(value string, err error) diag.Diagnostic {
	return diag.NewErrorDiagnostic(
		"Invalid ISO8601 String Value",
		"A string value was provided that is not valid ISO8601 string format.\n\n"+
			"Given Value: "+value+"\n"+
			"Error: "+err.Error(),
	)
}

But I’m still experiencing a plan comparison problem. Here’s the workflow:

  1. During Create: Resource is created with expiration_date: “2028-01-05” in configuration
  2. API Response: API returns the date in a different ISO8601 format (e.g., “2028-01-05T00:00:00.000+0000”)
  3. On Read: I call State.Set() with the API value
  4. Semantic Equality: My StringSemanticEquals() method correctly identifies both formats as equivalent and keeps the prior value (configuration format)
  5. Configuration Change: When I update the configuration to use a different ISO8601 format for the same date
  6. Problem: My Plan Modification verifies that the resource are equal on Plan and sets resp.PlanValue = req.StateValue, but Terraform still detects a value change on the attribute
Logs
2025-12-15T16:25:07.379Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwschemadata/value_semantic_equality.go:91 @module=sdk.framework tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=0343d78f-655a-f5b8-be3e-6f0ed6fa2ac4 tf_rpc=ReadResource tf_attribute_path=expiration_date tf_resource_type=outscale_access_key timestamp=2025-12-15T16:25:07.379Z
2025-12-15T16:25:07.379Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: tf_req_id=0343d78f-655a-f5b8-be3e-6f0ed6fa2ac4 tf_resource_type=outscale_access_key @module=sdk.framework tf_rpc=ReadResource @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwschemadata/value_semantic_equality.go:91 tf_attribute_path=id tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale timestamp=2025-12-15T16:25:07.379Z
2025-12-15T16:25:07.379Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: tf_attribute_path=access_key_id @module=sdk.framework tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=0343d78f-655a-f5b8-be3e-6f0ed6fa2ac4 tf_resource_type=outscale_access_key tf_rpc=ReadResource @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwschemadata/value_semantic_equality.go:91 timestamp=2025-12-15T16:25:07.379Z
2025-12-15T16:25:07.380Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=0343d78f-655a-f5b8-be3e-6f0ed6fa2ac4 tf_resource_type=outscale_access_key tf_rpc=ReadResource @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwschemadata/value_semantic_equality.go:91 @module=sdk.framework tf_attribute_path=creation_date tf_mux_provider="*proto6server.Server" timestamp=2025-12-15T16:25:07.379Z
2025-12-15T16:25:07.380Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: tf_provider_addr=registry.terraform.io/outscale/outscale @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwschemadata/value_semantic_equality.go:91 @module=sdk.framework tf_attribute_path=last_modification_date tf_req_id=0343d78f-655a-f5b8-be3e-6f0ed6fa2ac4 tf_resource_type=outscale_access_key tf_rpc=ReadResource tf_mux_provider="*proto6server.Server" timestamp=2025-12-15T16:25:07.379Z
2025-12-15T16:25:07.380Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwschemadata/value_semantic_equality.go:91 tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=0343d78f-655a-f5b8-be3e-6f0ed6fa2ac4 tf_resource_type=outscale_access_key tf_rpc=ReadResource @module=sdk.framework tf_attribute_path=secret_key timestamp=2025-12-15T16:25:07.380Z
2025-12-15T16:25:07.380Z [DEBUG] provider.terraform-provider-outscale: Value switched to prior value due to semantic equality logic: @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwschemadata/value_semantic_equality.go:91 @module=sdk.framework tf_attribute_path=state tf_mux_provider="*proto6server.Server" tf_rpc=ReadResource tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=0343d78f-655a-f5b8-be3e-6f0ed6fa2ac4 tf_resource_type=outscale_access_key timestamp=2025-12-15T16:25:07.380Z
2025-12-15T16:25:07.380Z [DEBUG] provider.terraform-provider-outscale: State updated due to semantic equality: tf_resource_type=outscale_access_key @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwserver/server_readresource.go:237 tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_rpc=ReadResource @module=sdk.framework tf_req_id=0343d78f-655a-f5b8-be3e-6f0ed6fa2ac4 timestamp=2025-12-15T16:25:07.380Z
2025-12-15T16:25:07.382Z [DEBUG] provider.terraform-provider-outscale: Detected value change between proposed new state and prior state: tf_req_id=618c80a8-dafd-921b-9897-b0397f1a85cf tf_resource_type=outscale_access_key tf_attribute_path=expiration_date tf_provider_addr=registry.terraform.io/outscale/outscale tf_rpc=PlanResourceChange @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwserver/server_planresourcechange.go:241 @module=sdk.framework tf_mux_provider="*proto6server.Server" timestamp=2025-12-15T16:25:07.382Z
2025-12-15T16:25:07.382Z [DEBUG] provider.terraform-provider-outscale: Marking Computed attributes with null configuration values as unknown (known after apply) in the plan to prevent potential Terraform errors: @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwserver/server_planresourcechange.go:250 @module=sdk.framework tf_mux_provider="*proto6server.Server" tf_rpc=PlanResourceChange tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=618c80a8-dafd-921b-9897-b0397f1a85cf tf_resource_type=outscale_access_key timestamp=2025-12-15T16:25:07.382Z
2025-12-15T16:25:07.382Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: @module=sdk.framework tf_attribute_path="AttributeName(\"last_modification_date\")" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=618c80a8-dafd-921b-9897-b0397f1a85cf tf_resource_type=outscale_access_key tf_rpc=PlanResourceChange @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwserver/server_planresourcechange.go:538 tf_mux_provider="*proto6server.Server" timestamp=2025-12-15T16:25:07.382Z
2025-12-15T16:25:07.382Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_rpc=PlanResourceChange @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwserver/server_planresourcechange.go:538 tf_attribute_path="AttributeName(\"secret_key\")" tf_req_id=618c80a8-dafd-921b-9897-b0397f1a85cf tf_resource_type=outscale_access_key @module=sdk.framework timestamp=2025-12-15T16:25:07.382Z
2025-12-15T16:25:07.382Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: @module=sdk.framework tf_provider_addr=registry.terraform.io/outscale/outscale tf_resource_type=outscale_access_key tf_rpc=PlanResourceChange @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwserver/server_planresourcechange.go:538 tf_attribute_path="AttributeName(\"request_id\")" tf_mux_provider="*proto6server.Server" tf_req_id=618c80a8-dafd-921b-9897-b0397f1a85cf timestamp=2025-12-15T16:25:07.382Z
2025-12-15T16:25:07.382Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: @module=sdk.framework tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=618c80a8-dafd-921b-9897-b0397f1a85cf tf_resource_type=outscale_access_key @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwserver/server_planresourcechange.go:538 tf_attribute_path="AttributeName(\"id\")" tf_mux_provider="*proto6server.Server" tf_rpc=PlanResourceChange timestamp=2025-12-15T16:25:07.382Z
2025-12-15T16:25:07.382Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: tf_attribute_path="AttributeName(\"access_key_id\")" tf_mux_provider="*proto6server.Server" tf_req_id=618c80a8-dafd-921b-9897-b0397f1a85cf @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwserver/server_planresourcechange.go:538 @module=sdk.framework tf_provider_addr=registry.terraform.io/outscale/outscale tf_resource_type=outscale_access_key tf_rpc=PlanResourceChange timestamp=2025-12-15T16:25:07.382Z
2025-12-15T16:25:07.382Z [DEBUG] provider.terraform-provider-outscale: marking computed attribute that is null in the config as unknown: tf_attribute_path="AttributeName(\"creation_date\")" tf_rpc=PlanResourceChange @module=sdk.framework tf_mux_provider="*proto6server.Server" tf_provider_addr=registry.terraform.io/outscale/outscale tf_req_id=618c80a8-dafd-921b-9897-b0397f1a85cf tf_resource_type=outscale_access_key @caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework@v1.17.0/internal/fwserver/server_planresourcechange.go:538 timestamp=2025-12-15T16:25:07.382Z
2025-12-15T16:25:07.383Z [DEBUG] provider.stdio: received EOF, stopping recv loop: err="rpc error: code = Unavailable desc = error reading from server: EOF"
2025-12-15T16:25:07.384Z [INFO]  provider: plugin process exited: plugin=.terraform/providers/registry.terraform.io/outscale/outscale/1.0.0-dev/linux_amd64/terraform-provider-outscale id=1532838
2025-12-15T16:25:07.384Z [DEBUG] provider: plugin exited
2025-12-15T16:25:07.385Z [DEBUG] building apply graph to check for errors
2025-12-15T16:25:07.385Z [DEBUG] ProviderTransformer: "outscale_access_key.access_key01 (expand)" (*terraform.nodeExpandApplyableResource) needs provider["registry.terraform.io/outscale/outscale"]
2025-12-15T16:25:07.385Z [DEBUG] ProviderTransformer: "outscale_access_key.access_key01" (*terraform.NodeApplyableResourceInstance) needs provider["registry.terraform.io/outscale/outscale"]
2025-12-15T16:25:07.385Z [DEBUG] pruning unused provider["registry.terraform.io/scottwinkler/shell"]
2025-12-15T16:25:07.385Z [DEBUG] ReferenceTransformer: "outscale_access_key.access_key01 (expand)" references: []
2025-12-15T16:25:07.385Z [DEBUG] ReferenceTransformer: "var.region" references: []
2025-12-15T16:25:07.385Z [DEBUG] ReferenceTransformer: "var.image_id" references: []
2025-12-15T16:25:07.385Z [DEBUG] ReferenceTransformer: "var.vm_type" references: []
2025-12-15T16:25:07.385Z [DEBUG] ReferenceTransformer: "outscale_access_key.access_key01" references: []
2025-12-15T16:25:07.385Z [DEBUG] ReferenceTransformer: "provider[\"registry.terraform.io/outscale/outscale\"]" references: []
2025-12-15T16:25:07.385Z [INFO]  backend/local: plan operation completed

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # outscale_access_key.access_key01 will be updated in-place
  ~ resource "outscale_access_key" "access_key01" {
      ~ access_key_id          = "..." -> (known after apply)
      ~ creation_date          = "2025-12-15T16:11:35.165+0000" -> (known after apply)
      ~ id                     = "..." -> (known after apply)
      ~ last_modification_date = "2025-12-15T16:11:35.165+0000" -> (known after apply)
      ~ request_id             = "88564639-eea9-430e-961d-822ff23077c4" -> (known after apply)
      ~ secret_key             = "..." -> (known after apply)
        # (2 unchanged attributes hidden)
    }

Plan: 0 to add, 1 to change, 0 to destroy.

I’m a bit confused because now both Plan and Read have a semantic equality implemented, but my problem is the same as before

There’s obviously still something not quite right here, but I wonder if you’re not looking at the wrong thing. You’re saying that Terraform detects a difference, but none of the output reflects that. There is no change shown for the expiration_date, and the only attributes with planned changes are computed. Because Terraform is not detecting any change in the value, that means it must be set correctly.

What are the rules for these other computed attributes? Are you ensuring that they retain their prior state when computing the plan?

When I update the configuration to use a different ISO8601 format for the same date

@jbardin:

There’s obviously still something not quite right here

I thought that I’d learned here (in some other discussion) that semantic equality wasn’t considered for configuration changes, but only when comparing the state read from the API against the state collected during the most recent apply phase.

Do I remember that incorrectly? I thought that this behavior is expected.

Maybe I’m not understanding the logs correctly, but in the following line Terraform detects a value change between the new and prior state:

What do you mean by:

They’re computed attributes that get refreshed at each Read, no specific rules on them.

yes, but I think that is what the desired result would be here, and it seems to be, because there is no change for that value. What it’s doing is retaining prior state if the remote API returns a semantically equal value, therefor maintaining the configured value.

That log line is coming from your provider (provider.terraform-provider-outscale), or more specifically the framework code being used by your provider (@caller=/home/outscale/go/pkg/mod/github.com/hashicorp/terraform-plugin-framework). Since the value returned to Terraform ends up being equal, it seems the plan modifier is working correctly.

They’re computed attributes that get refreshed at each Read, not specific rules on them.

I’m not super familiar with the framework, but here’s my best guess.

What I think might be happening is that the change in expiration_date triggers the entire resource to update, meaning all computed values will be unknown by default. You are then suppressing the change to expiration_date, but leaving the computed values unknown. You can use the UseStateForUnknown plan modifier to retain an existing value for computed attributes, and should always have that set for computed attributes which can never change (I’m assuming id might be that last case).

I do however see at least one complication to that based on only the attribute names, you have a last_modification_date. I think in the framework that’s going to require you to create a plan modifier based on the resource as a whole, so that you can manually check for other changes. The logic for something like that would be to set it as unknown only if there is any other change planned after all the plan modifiers are taken into account. Sometimes if you have an attribute like that, which is just informational and has no real use in the Terraform configuration, it’s easier to leave it out entirely.

Again, I’m not the most familiar with the framework code, there might be better pattern to use here, but this at least would explain the computed changes in the plan.