Migrate registration policies from JSONB to ActiveRecord (#11238)#11819
Open
nbudin wants to merge 8 commits into
Open
Migrate registration policies from JSONB to ActiveRecord (#11238)#11819nbudin wants to merge 8 commits into
nbudin wants to merge 8 commits into
Conversation
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
force-pushed
the
nbudin/issue11238
branch
from
July 15, 2026 18:30
f3767ab to
fc09d8e
Compare
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
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>
nbudin
commented
Jul 16, 2026
…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>
Contributor
Code Coverage Report: Only Changed Files listed
Minimum allowed coverage is |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Purpose
Registration policies and their buckets have been stored as a JSONB blob on
events/event_proposals, which meansbucket_key/requested_bucket_keyon 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 realregistration_policies/registration_policy_bucketstables 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/RegistrationPolicyBucketare now plainApplicationRecordmodels instead of anActiveModel::Modelvalue object backed by jsonb.EventProposalis migrated in the same pass asEvent(previously untouched), sharing aHasRegistrationPolicyconcern for the "build a detached proposed policy, diff against current, apply" flow.key, never renamed) instead of replaced wholesale, so row identity stays stable for anything that didn't change — deliberately compatible with a futurebucket_key→ FK conversion.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.anything/not_counted, aliased at the model layer over the newflex/countedcolumns, so the frontend needs zero changes.RegistrationPolicyBucket.positionuses thepositioninggem (already used the same way bySignupRankedChoice) rather than hand-rolled ordering.🗄 Database Migrations
registration_policies/registration_policy_bucketstables, backfills them from the existing jsonb on botheventsandevent_proposals, addsevent_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:
idleak inRegistrationPolicy#as_jsonthat let a detached policy alias a real row's id, corrupting comparisons.registration_policydefault inAcceptEventProposalServicenow that the column isNOT NULLon events (defaults to an empty, fail-closed policy — no buckets means no signups accepted until someone configures one — rather than defaulting to unlimited).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/.tsxfiles touched, so no frontend changes are expected;tsc --noEmitcurrently OOMs on this branch, but that's a pre-existing issue on this branch's base (already fixed onmainvia #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