Skip to content

Migrate registration policies from JSONB to ActiveRecord (#11238)#11819

Open
nbudin wants to merge 8 commits into
mainfrom
nbudin/issue11238
Open

Migrate registration policies from JSONB to ActiveRecord (#11238)#11819
nbudin wants to merge 8 commits into
mainfrom
nbudin/issue11238

Conversation

@nbudin

@nbudin nbudin commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Purpose

Registration policies and their buckets have been stored as a JSONB blob on events/event_proposals, which means bucket_key/requested_bucket_key on signups, signup requests, and ranked-choice signups are just plain strings compared against whatever's sitting in that blob — no referential integrity at all. That's the root cause of #11229. This PR moves registration policies into real registration_policies/registration_policy_buckets tables so bucket references can eventually become real foreign keys (that FK conversion itself is deferred to a follow-up — see the plan doc linked on #11238).

This has to land as one atomic change: the migration drops the jsonb columns in the same pass it creates the new tables, so there's no bootable intermediate state between the schema change and the model/service/GraphQL code that depends on it.

Changes

💻 Engineer-facing

  • RegistrationPolicy/RegistrationPolicyBucket are now plain ApplicationRecord models instead of an ActiveModel::Model value object backed by jsonb. EventProposal is migrated in the same pass as Event (previously untouched), sharing a HasRegistrationPolicy concern for the "build a detached proposed policy, diff against current, apply" flow.
  • Buckets are updated in place on edit (matched by key, never renamed) instead of replaced wholesale, so row identity stays stable for anything that didn't change — deliberately compatible with a future bucket_key → FK conversion.
  • No accepts_nested_attributes_for, attributes= override, or == override. Explicit named methods (build_from_hash, equivalent_to?, update_from!) instead — this app doesn't lean on Rails form helpers, so the implicit machinery wasn't earning its keep, and it's easier to trace.
  • External API is unchanged: GraphQL/Liquid still expose anything/not_counted, aliased at the model layer over the new flex/counted columns, so the frontend needs zero changes.
  • RegistrationPolicyBucket.position uses the positioning gem (already used the same way by SignupRankedChoice) rather than hand-rolled ordering.

🗄 Database Migrations

  • Adds registration_policies/registration_policy_buckets tables, backfills them from the existing jsonb on both events and event_proposals, adds event_proposals.registration_policy_id, and drops both jsonb columns. Also drops ~18 orphaned SQL helper functions from an old jsonb-era migration that nothing calls anymore.

Risks

This is a real, irreversible-in-place data migration on two of the most heavily-used tables in the app (events, event_proposals). To de-risk it, I ran the migration end-to-end against a full, sanitized copy of production data (twice), which caught a few real bugs before they got anywhere near a shared database:

  • An id leak in RegistrationPolicy#as_json that let a detached policy alias a real row's id, corrupting comparisons.
  • A stale association cache in the bucket-sync logic that let a destroyed bucket reappear in an audit log read.
  • A missing registration_policy default in AcceptEventProposalService now that the column is NOT NULL on events (defaults to an empty, fail-closed policy — no buckets means no signups accepted until someone configures one — rather than defaulting to unlimited).
  • A dependent: option conflict that blocked destroying an event that owned its own policy.

Testing

Full Minitest suite (1102 tests) green with 0 failures/errors, run twice against a full copy of production data loaded locally. No .ts/.tsx files touched, so no frontend changes are expected; tsc --noEmit currently OOMs on this branch, but that's a pre-existing issue on this branch's base (already fixed on main via #11709's ancestor, just not yet rebased onto here) and unrelated to this change.

Release plan and notes

🚢 The bucket_key → FK conversion (the other half of #11238) is intentionally deferred to a follow-up PR — this PR's in-place bucket-update design is what makes that conversion viable later without needing to touch this persistence strategy again.

🤖 Generated with Claude Code

nbudin and others added 2 commits July 15, 2026 11:27
Registration policies and their buckets were stored as JSONB blobs on
events/event_proposals, making bucket_key references in signups,
signup_requests, and signup_ranked_choices plain strings with no
referential integrity (the cause of #11229). This moves them into real
registration_policies/registration_policy_buckets tables.

Key design points:
- Buckets are updated in place on edit (matched by key, never renamed)
  rather than replaced wholesale, so row identity is stable for
  anything that didn't change -- deliberately compatible with a future
  bucket_key -> FK conversion on signups (still deferred).
- External API (GraphQL field names, Liquid) is unchanged: anything/
  not_counted are preserved as aliases over the new flex/counted
  columns, since nothing in the frontend actually shows a bucket's key
  to users, only its name.
- No accepts_nested_attributes_for, attributes= override, or ==
  override -- this app doesn't use Rails form helpers, and explicit
  named methods (build_from_hash, equivalent_to?) are easier to trace
  than hooking into Rails' implicit machinery.
- EventProposal is migrated in this same pass; a shared
  HasRegistrationPolicy concern unifies what was previously duplicated
  apply-the-change logic between Event and EventProposal's write paths.

Verified against a full copy of production data end to end, which
caught a few real bugs before they went anywhere near a shared
database: an id leak in RegistrationPolicy#as_json that corrupted
detached-policy comparisons, a stale association cache in bucket sync
that let a destroyed bucket reappear in an audit log, a missing
registration_policy default in AcceptEventProposalService now that the
column is NOT NULL, and a dependent: option conflict that blocked
destroying an event that owned its own policy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nbudin
nbudin force-pushed the nbudin/issue11238 branch from f3767ab to fc09d8e Compare July 15, 2026 18:30
nbudin and others added 4 commits July 15, 2026 13:26
RegistrationPolicy::BucketDrop's `delegate :anything, :not_counted, to: :bucket`
calls the plain (non-predicate) method names, not anything?/not_counted?. These
were dropped during the JSONB->AR migration cleanup after a grep for direct
`.anything` call sites came up empty -- it missed this delegate's symbol
arguments, so any CMS template referencing bucket.anything/bucket.not_counted
crashed with "undefined method 'anything'".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Creating a new proposal via "copy from an old proposal" routed
registration_policy through the generic form-response clone path, which
returns the source's live RegistrationPolicy record and assigns it directly
-- pointing the new proposal's FK at the same row (and buckets) as the
source. Since EventProposal has dependent: :destroy on registration_policy,
destroying either proposal would destroy the other's policy out from under
it. Build an independent copy via RegistrationPolicy.build_from_hash instead,
same pattern already used in AcceptEventProposalService.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sals

DB-level backstop for the "one policy, one owner" invariant -- every
registration_policy association is belongs_to with dependent: :destroy on
the owning side, which only makes sense if a policy is never shared. Catches
the same bug class as the recent create_event_proposal clone fix even if a
future code path reintroduces it.

Fixed one pre-existing local-data violation (two event proposals sharing a
policy row, left over from the clone bug) by giving the newer proposal an
independent copy before adding the constraint. Updated two test files that
relied on `let`-memoized RegistrationPolicy instances shared across multiple
events within a single test -- harmless under the old JSONB-embedded design,
but a real collision now that policies are real rows with a unique FK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…on proposal import

createEvent and createFillerEvent had no registration_policy handling at all,
so any event category with a registration_policy form item -- which is the
normal case -- made both mutations unusable: submitting a value crashed with
ActiveRecord::AssociationTypeMismatch (assigning a raw Hash to a belongs_to,
with no accepts_nested_attributes_for), and omitting it failed the NOT NULL
constraint. Build a real RegistrationPolicy explicitly before the generic
form-response assignment, same as the other mutations; build_from_hash(nil)
already returns an empty, fail-closed policy when nothing is submitted.

Also fixed ImportConventionDataService#import_event_proposals, which never
read a registration_policy from import data at all (only import_events did),
silently dropping it on import. Since registration_policy_id is nullable for
proposals (unlike events), only build one when the source data actually
provides it, rather than fabricating a default that wasn't there.

Found while auditing every Event/EventProposal creation and update path for
registration_policy handling after the recent unique-index/clone-sharing fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@nbudin
nbudin marked this pull request as ready for review July 16, 2026 21:24
…alue

apply_registration_policy_change wrapped a caller-supplied block between
capturing the old policy's JSON and reading the new one, so the "how do we
actually apply this" logic (in-place update for EventProposal, full signup
simulation via EventChangeRegistrationPolicyService for Event) lived at the
call site while the comparison/snapshot bookkeeping lived in the concern.
Correct, but harder to read than necessary: understanding what a mutation
does required jumping into the concern to see when the block runs relative
to the snapshot.

Replaced with #registration_policy_change_for, which returns a
HasRegistrationPolicy::Change (or nil if there's nothing to apply) holding
the detached new policy and the old policy's JSON. Each mutation now applies
the change and builds its own diff hash inline, in linear top-to-bottom code,
at the cost of each mutation writing that one-line hash itself instead of
having the concern build it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread app/graphql/mutations/create_event.rb Outdated
Comment thread app/graphql/mutations/create_event_proposal.rb Outdated
Comment thread app/graphql/mutations/create_filler_event.rb Outdated
Comment thread app/models/concerns/has_registration_policy.rb Outdated
Comment thread app/models/concerns/has_registration_policy.rb
Comment thread app/models/registration_policy.rb Outdated
Comment thread app/models/registration_policy_bucket.rb Outdated
Comment thread app/models/registration_policy_bucket.rb Outdated
Comment thread app/services/accept_event_proposal_service.rb Outdated
Comment thread app/services/accept_event_proposal_service.rb Outdated
…rationPolicy::Change

Condensed several comments that had grown into decision logs rather than
usable documentation. Notably: moved the IGNORED_HASH_KEYS rationale out of
a standalone comment block and into a short inline note inside
build_from_hash (the only place a developer would actually look for it);
removed an unnecessary comment on `positioned on: :registration_policy`
(the method name already says what it does); and clarified that
sync_buckets_from_hash!'s key-based bucket matching is about this API's own
write-identity model, not signups' bucket_key -- so it isn't automatically
resolved by the deferred bucket_key->FK conversion on signups unless that
work also redesigns this API around ids.

Also replaced HasRegistrationPolicy::Change's prose comment and plain
attr_reader with YARD @!attribute/@param/@return annotations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

Code Coverage Report: Only Changed Files listed

Package Base Coverage New Coverage Difference
app/graphql/mutations/base_mutation.rb 🟢 75.86% 🟢 79.31% 🟢 3.45%
app/graphql/mutations/create_event.rb 🔴 40% 🟢 88.24% 🟢 48.24%
app/graphql/mutations/create_event_proposal.rb 🔴 28.13% 🟢 97.14% 🟢 69.01%
app/graphql/mutations/create_filler_event.rb 🔴 41.18% 🟢 89.47% 🟢 48.29%
app/graphql/mutations/update_event_proposal.rb 🟢 100% 🟢 93.75% 🔴 -6.25%
app/models/concerns/form_response.rb 🟢 93.18% 🟢 100% 🟢 6.82%
app/models/concerns/has_registration_policy.rb 🔴 0% 🟢 100% 🟢 100%
app/models/convention.rb 🟢 86.76% 🟢 86.13% 🔴 -0.63%
app/models/event.rb 🟢 78.89% 🟢 79.12% 🟢 0.23%
app/models/event_proposal.rb 🟢 92.86% 🟢 93.02% 🟢 0.16%
app/models/registration_policy.rb 🟢 93.83% 🟢 98.55% 🟢 4.72%
app/models/registration_policy_bucket.rb 🔴 0% 🟢 97.92% 🟢 97.92%
app/services/accept_event_proposal_service.rb 🟢 100% 🟢 97.87% 🔴 -2.13%
app/services/event_change_registration_policy_service.rb 🟢 88.34% 🟢 88.27% 🔴 -0.07%
app/services/execute_ranked_choice_signup_round_service.rb 🟢 81.65% 🟢 84.55% 🟢 2.9%
app/services/import_convention_data_service.rb 🟢 94.09% 🟢 97.08% 🟢 2.99%
test/factories/registration_policies.rb 🔴 0% 🟢 100% 🟢 100%
test/factories/registration_policy_buckets.rb 🔴 0% 🟢 100% 🟢 100%
test/graphql/mutations/create_event_proposal_test.rb 🔴 0% 🟢 100% 🟢 100%
test/graphql/mutations/create_event_test.rb 🔴 0% 🟢 100% 🟢 100%
test/graphql/mutations/create_filler_event_test.rb 🔴 0% 🟢 100% 🟢 100%
test/models/registration_policy_bucket_test.rb 🔴 0% 🟢 100% 🟢 100%
test/services/execute_ranked_choice_signup_round_service_test.rb 🟢 95.09% 🟢 95.27% 🟢 0.18%
Overall Coverage 🟢 54.27% 🟢 54.89% 🟢 0.62%

Minimum allowed coverage is 0%, this run produced 54.89%

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