Skip to content

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:

ruby
# 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:

ruby
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:

ruby
# 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) }
  # ...
end

Every 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:

ruby
# 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

  1. Add the field's key to CLEARABLE_NULLABLE_FIELDS in the resolver.
  2. Add a given?-guarded re-add line in build_update_attrs, right beside the existing two.
  3. Write mutation-level (not interaction-level) specs for the explicit-clear path — an interaction-only spec calling GoalTracking::UpdateGoal.call directly 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.

  • rails_api/app/graphql/mutations/update_goal.rbCLEARABLE_NULLABLE_FIELDS, resolve
  • rails_api/app/interactions/goal_tracking/update_goal.rbbuild_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 through RailsServerSchema.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)

Loading…