Clearing a nullable field vs. omitting it — GraphQL can't tell the difference by default
Applies to: Any GraphQL mutation with an optional (required: false) string/nullable argument that a client should be able to explicitly clear to NULL, not just leave unset.
The trap
Ruby keyword args collapse two distinct client intents into the same value. When a GraphQL client either omits an argument entirely or sends it as an explicit null, resolve(**args) sees args[:key] == nil (or the key missing) in both cases — there is no built-in way to distinguish "the user didn't touch this field" from "the user emptied this field and wants it cleared."
update_goal.rb's resolver used to reject every nil-valued argument outright:
# BEFORE — collapses omit and explicit-clear into the same no-op
clean_args = args.reject { |_, v| v.nil? }And the interaction's build_update_attrs compounded it with .compact:
attrs = { name:, content:, identity_prompt:, past_attempt_context:, ... }.compact.compact drops every nil-valued key before goal.update! runs — including a key the client explicitly sent as null to clear it. Net effect: a user who deleted their identity prompt or past-attempt-context text and saved saw the old value silently persist. No error, no toast — the clear just didn't happen.
What went wrong (OBJ-1141, and again in Phase 115)
Flagged by Roy during review of past_attempt_context (Phase 114) and traced back to the exact same gap already shipped with identity_prompt (Phase 113). Neither field's RSpec coverage exercised the "explicit clear" path — only "omitted" — so the bug shipped twice, uncaught, before anyone tried to clear a saved value in production.
It shipped a third time with completion_reflection (Phase 115): the field was added to Goal, the interaction, and the GraphQL type, but CLEARABLE_NULLABLE_FIELDS wasn't updated to include it — so a stale reflection from a goal's prior completion couldn't be cleared on re-completion. Roy caught it again in review (MAJOR finding) before merge; fixed in the same PR (#1289) that shipped the field, with a dedicated mutation-level regression spec.
It shipped a fourth time with app_link (OBJ-1456, PR #1540): Codi's first-round implementation added the column, model validation, and GraphQL surface, but again left app_link out of CLEARABLE_NULLABLE_FIELDS and the interaction's re-add list — same gap, same shape, this time on a habit's external check-in redirect URL. Roy caught it in round-1 review (MAJOR finding) before merge; fixed in round 2 with mutation-level and interaction-level explicit-clear specs. Four fields, four near-misses on the same gap — this is exactly the pattern the "Reusing this for a new clearable field" checklist below exists to prevent; follow it explicitly rather than eyeballing the diff next time.
Correct pattern
Fix both layers together, or the field still can't be cleared:
1. Resolver — allowlist which nullable fields may pass an explicit null through:
# rails_api/app/graphql/mutations/update_goal.rb
CLEARABLE_NULLABLE_FIELDS = %i[identity_prompt past_attempt_context completion_reflection app_link].freeze
def resolve(**args)
require_auth!
clean_args = args.reject { |k, v| v.nil? && !CLEARABLE_NULLABLE_FIELDS.include?(k) }
# ...
endEvery other nullable argument (target_date, goal_type_id, etc.) keeps today's drop-on-nil behavior — omitting them still leaves the stored value untouched. Only the allowlisted keys forward an explicit null into the interaction.
2. Interaction — re-add the field after .compact, guarded by whether the key was actually sent:
# rails_api/app/interactions/goal_tracking/update_goal.rb
def build_update_attrs
attrs = { name:, content:, identity_prompt:, past_attempt_context:, completion_reflection:, ..., app_link: }.compact
%i[identity_prompt past_attempt_context completion_reflection app_link].each { |k| attrs[k] = public_send(k) if given?(k) }
# ...
end
def given?(key) = input.to_h.key?(key)Because the resolver now only forwards a clearable key when the client explicitly sent it, given?(key) (input.to_h.key?(key)) is true exactly on explicit-send (including explicit null) and false on omission — the same internal mechanism the vendored Interaction::Base gem's own generated accessors use to distinguish "given" from "falls back to default" (lib/interaction/input_dsl.rb). There is no per-key given? built into the gem; add this one-line helper locally rather than reaching for a whole-hash inputs_given? check.
This mirrors the pre-existing recurrence_type special-case a few lines below in the same method — switching a goal's kind away from Habit needs to null out recurrence_type/recurrence_days/recurrence_interval past .compact, for the same reason.
Reusing this for a new clearable field
- Add the field's key to
CLEARABLE_NULLABLE_FIELDSin the resolver. - Add a
given?-guarded re-add line inbuild_update_attrs, right beside the existing two. - Write mutation-level (not interaction-level) specs for the explicit-clear path — an interaction-only spec calling
GoalTracking::UpdateGoal.calldirectly bypasses the resolver's reject entirely and will pass even if the resolver still blanket-strips nulls.
The three v3.12 identity-journey fields (identity_prompt, past_attempt_context, completion_reflection) plus app_link (habit check-in redirect, OBJ-1456) now use this pattern.
Related
rails_api/app/graphql/mutations/update_goal.rb—CLEARABLE_NULLABLE_FIELDS,resolverails_api/app/interactions/goal_tracking/update_goal.rb—build_update_attrs,given?rails_api/spec/graphql/mutations/update_goal_identity_prompt_spec.rb,update_goal_past_attempt_context_spec.rb,update_goal_completion_reflection_spec.rb,update_goal_app_link_spec.rb— explicit-clear regression coverage, exercised throughRailsServerSchema.execute(mutation-level)- Habit Tracking § App-Link Check-In Redirect — user-facing feature that consumes this pattern for
app_link
Last updated: 2026-07-15 (OBJ-1456/PR #1540: app_link added to CLEARABLE_NULLABLE_FIELDS, closing the same gap for the fourth time; Phase 115/PR #1289: completion_reflection added, closing it for the third time; originally OBJ-1141: identityPrompt/pastAttemptContext explicit-clear fix, PR #1279)