Skip to content

fix: stop coercing server-side false/0 to Null on read - #13

Open
ysyneu wants to merge 2 commits into
mainfrom
fix/read-path-zero-value-drift
Open

fix: stop coercing server-side false/0 to Null on read#13
ysyneu wants to merge 2 commits into
mainfrom
fix/read-path-zero-value-drift

Conversation

@ysyneu

@ysyneu ysyneu commented Aug 12, 2026

Copy link
Copy Markdown

Problem

types.Bool and types.Int64 in terraform-plugin-framework are Null at their zero value, not false/0. Several read/mapping helpers in this provider used the shape:

if g.AllEqualsRequired { result.AllEqualsRequired = types.BoolValue(true) }   // no else
if g.TimeWindow != 0   { result.TimeWindow = types.Int64Value(...) }          // no else

so whenever the server's value was the zero value, the attribute was left Null. A practitioner who wrote all_equals_required = false (or any other zero value) in HCL would then see state disagree with config on every subsequent plan — a diff that never converges.

Fix

For each attribute where the server's zero value is a real, documented, distinct value, the mapping helper now writes a concrete value unconditionally instead of guarding with if.

The remedy depends on how the attribute is declared in the schema:

Attribute Schema flags (before) Remedy Why
flashduty_channel.group.all_equals_required Optional only Optional+Computed+Default(false), write unconditionally false is the common/default case a practitioner would set explicitly
flashduty_channel.group.time_window Optional only Optional+Computed+Default(0), write unconditionally Documented: "0 means merge until incident closes" — a real, distinct value
flashduty_channel.group.i_score_threshold Optional only No change — kept guarded Documented valid range is 0.5–1.0, so 0 is never a legal configured value; it only ever means "not applicable"
flashduty_route.cases[].fallthrough Optional only Optional+Computed+Default(false), write unconditionally false is the common case
flashduty_schedule.layers[].fair_rotation Optional only Optional+Computed+Default(false), write unconditionally false is the common case
flashduty_schedule.layers[].mask_continuous_enabled Optional only Optional+Computed+Default(false), write unconditionally false is the common case
flashduty_schedule.layers[].restrict_mode Optional only Optional+Computed+Default(0), write unconditionally Documented: "0 = none" — a real, distinct value
flashduty_schedule.layers[].handoff_time Optional only Optional+Computed+Default(0), write unconditionally It's a duration in seconds, not a sentinel/timestamp — 0 (no handoff buffer) is a legitimate, distinguishable operational choice, the same way 0 is meaningful for the sibling duration fields in this same struct
flashduty_schedule.layers[].layer_end Optional only No change — kept guarded It's a Unix timestamp, not a duration. 0 (1970-01-01) is never a real end time a practitioner would configure — it only ever functions as a "no end configured" sentinel, which is operationally identical to omitting the attribute. There's no distinct value being lost by collapsing it to Null
flashduty_schedule.notify.advance_in_time Optional only Optional+Computed+Default(0), write unconditionally 0 = no advance notice — a real, distinct value

Where an attribute needed a concrete zero value, always writing into a plain Optional attribute would trade the drift bug for a worse failure mode: "Provider produced inconsistent result after apply", whenever a config omits the attribute but the provider now writes a concrete false/0 into state. So each of those attributes is changed to Optional+Computed with a static default, following the pattern already used elsewhere in this provider (e.g. channel.is_private), and the mapping helper writes unconditionally only after that schema change.

One extra wrinkle: flashduty_schedule's readNotify()

That function also collapses to a nil notify block when the server returns an all-zero-value struct for a resource that was never configured with a notify block (this avoids drift in the opposite direction — a notify block appearing in state that isn't in config). That check previously tested the mapped model's AdvanceInTime.IsNull(), which becomes permanently false once AdvanceInTime is written unconditionally. It's rewritten to test the raw response struct instead, so the "fully empty notify" case still collapses to nil correctly. Covered by a dedicated test (TestScheduleReadNotifyFullyEmptyCollapsesToNil).

Testing

Added internal/provider/flashduty_read_path_zero_value_test.go — unit tests for the affected mapping helpers, asserting a server-side false/0 produces a concrete BoolValue(false)/Int64Value(0) rather than Null (confirmed red before the fix, green after), plus the notify-collapse regression test above. No TF_ACC needed.

go build ./...    # clean
go vet ./...       # clean
gofmt -l .         # clean
golangci-lint run  # 0 issues
go test -v -cover -timeout=120s -parallel=10 ./...

Full suite output: all TestAcc* tests SKIP (require TF_ACC=1 or specific env vars — pre-existing, not run here per instructions not to exercise the live API), the 5 new unit tests PASS, no failures before or after this change.

ysyneu added 2 commits August 12, 2026 02:41
types.Bool and types.Int64 in terraform-plugin-framework are Null by
default, so `if cond { result.X = types.BoolValue(true) }` (no else)
silently leaves the attribute Null whenever the server's value is the
type's zero value. A config that explicitly sets the attribute to
false/0 then diffs against a Null state on every subsequent plan -
drift that never converges.

Fixed the read/mapping helpers for attributes where zero is a real,
documented, distinct value:

- flashduty_channel: group.all_equals_required, group.time_window
  ("0 means merge until incident closes")
- flashduty_route: cases[].fallthrough
- flashduty_schedule: layers[].fair_rotation, layers[].handoff_time,
  layers[].restrict_mode ("0 = none"),
  layers[].mask_continuous_enabled, notify.advance_in_time

Each of these was Optional-only (not Computed) in its schema, so
simply removing the `if` and always writing a concrete value would
trade the drift for a stricter failure: "Provider produced
inconsistent result after apply" whenever a config omits the
attribute and the provider now writes a concrete zero into it.
Instead each attribute schema is changed to Optional+Computed with a
static default of false/0, following the pattern already used
elsewhere in this provider (e.g. channel.is_private), and the mapping
helper now writes the value unconditionally.

group.i_score_threshold and layers[].layer_end keep their original
guarded behavior: i_score_threshold's documented valid range is
0.5-1.0, so 0 is never a legal configured value; layer_end is a Unix
timestamp where 0 has no meaning as a real end time distinct from "no
end configured", so collapsing it to Null loses nothing a user could
have intentionally set.

flashduty_schedule's readNotify() also collapses to a nil notify
block when the server returns an all-zero-value struct for a resource
that was never configured with `notify` (avoiding drift the other
direction). That check previously read the mapped model's
AdvanceInTime.IsNull(), which always becomes false now that
AdvanceInTime is written unconditionally; it's rewritten to check the
raw response struct instead, so the empty-notify case still collapses
correctly.

Added unit tests for the affected mapping helpers asserting that a
server-side false/0 produces a concrete BoolValue(false)/Int64Value(0)
rather than Null, and that a fully empty schedule notify still
collapses to nil.
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