Add HistoricalOwnership foundation for tracking gem ownership history
A new table remembers every stint a user spent owning or maintaining a gem, kept in step by Ownership callbacks and seeded by a maintenance task.
Ending an ownership now leaves a history row behind
rubygems.org starts recording who has ever owned or maintained a gem. The new
historical_ownerships table holds one row per stint: Ownership callbacks open a
row when an ownership is confirmed (an invitation counts only once accepted), raise its role on
promotion but never lower it, and stamp removed_at when the ownership is
destroyed. Rubygem#disown, which deletes ownerships without callbacks, closes the
rows itself. A maintenance task opens rows for every ownership confirmed today; the description
says stints that ended before deploy are deliberately not reconstructed, and that this backfill
must finish before the two follow-up PRs are safe to merge. No code in this PR reads the table;
the description says the follow-ups will.
What the judgments below rely on
- Disowning a gem
- When someone pushes to a gem name with no indexed versions,
Pusher#updatecallsRubygem#disown, which deletes the existing ownership rows without running Ownership callbacks; the pusher then becomes the owner. app/models/pusher.rb:236 Used by Checkpoint A - Organization onboarding and gem transfer
- The two flows that move gems into an organization. Members who join have their direct Ownership rows destroyed, because organization membership already grants access to the organization's gems. app/models/organization_onboarding.rb:126-130 Used by Checkpoint B
script/merge_users- An operator script that merges two accounts: it re-points the discarded account's ownerships
to the kept one with
ownership.user = chosen_user; ownership.save!, then hard-destroys the discarded user. script/merge_users:44-51 Used by Checkpoint A, Checkpoint D
What needs your attention
Four judgments, all about whether the history table stays true to the ownerships it mirrors. The order is the order to think about them.
Does every path that starts or ends an ownership keep exactly one open history row?
The history follows ownerships only through writes that run Ownership's callbacks. Every
destroy caller runs them, including account deletion at
app/models/user.rb:376 and the
organization flows
(app/models/organization_onboarding.rb:130)
from unchanged code, and disown skips them but closes the rows itself.
script/merge_users
(script/merge_users:44-51) does neither: changing ownership.user touches no
column a history callback watches, so the open row stays on the discarded account and is
deleted with it, leaving the kept account with a confirmed ownership and no open row
from unchanged code. Decide whether that path, and any
future write that re-points an ownership, needs its own handling.
- added already confirmedapp/models/ownership.rb:19
- invitation acceptedapp/models/ownership.rb:21
- ownership removedapp/models/ownership.rb:26
- gem disownedownerships deleted without callbacksapp/models/rubygem.rb:345-347
- accounts mergedownership re-pointed; no history callback firesscript/merge_users:44-51
-
The lifecycle callbacks
the conditions that decide which writes open, raise or close a row; none watches
user_id▸ app/models/ownership.rb:18-26 At head
after_create :record_create_event after_create :create_historical_ownership, if: :confirmed? after_update :record_confirmation_event, if: :saved_change_to_confirmed_at? after_update :create_historical_ownership, if: :saved_change_to_confirmed_at? after_update :record_role_updated_event, if: :saved_change_to_role? after_update :notify_user_role_of_role_change, if: :saved_change_to_role? after_update :upgrade_historical_ownership_role, if: :saved_change_to_role? after_destroy :record_destroy_event after_destroy :close_historical_ownership
-
Disown's own close
the one bulk delete of ownerships, compensated two lines later
▸ app/models/rubygem.rb:344-348 At head
def disown ownerships_including_unconfirmed.find_each(&:delete) ownerships_including_unconfirmed.clear historical_ownerships.current.update_all(removed_at: Time.current)
-
The account-merge re-point
a
save!that changes onlyuser_id, thenother_user.destroyfrom unchanged code▸ script/merge_users:44-51 Unchanged
(other_user.rubygems - chosen_user.rubygems).each do |rubygem| puts "Merging ownership for #{rubygem.name}..." ownership = rubygem.ownerships_including_unconfirmed.find_by_user_id(other_user.id) ownership.user = chosen_user ownership.save! end other_user.destroy
-
Lifecycle specs
pin create, confirm, promote, demote and destroy; none runs
disownor re-points an ownership test/models/ownership_test.rb:163-263
Open question Whether script/merge_users is still run, and if
so whether it should carry the open history row across to the kept account.
When members' gems move into an organization, should their history rows close?
Onboarding and gem transfer destroy the direct ownerships of members who join
(app/models/organization_onboarding.rb:126-130), so each
destroy now stamps removed_at on that member's row
from unchanged code. Those members keep access through the
organization, so the table will record them as former owners of gems they still publish. The
description says later slices will show prior owners on gem pages; decide whether
removed_at means the Ownership row went or the access did, because the follow-ups
will read it as one of the two. The path is drawn as
Impact path B.
-
Joining members' ownerships
the
destroy_allthat now runsclose_historical_ownershipfor each member from unchanged code▸ app/models/organization_onboarding.rb:126-131 Unchanged
def remove_ownerships_for_joining_members onboarded_users = invites.reject { it.role.nil? || it.outside_contributor? }.map(&:user) onboarded_users << created_by Ownership.includes(:rubygem, :user, api_key_rubygem_scopes: :api_key).where(user: onboarded_users, rubygem: selected_rubygems).destroy_all end
- The transfer twin the same destroy when gems are transferred to an existing organization from unchanged code app/models/rubygem_transfer.rb:76-84
-
Access through membership
owned_by?stays true for an organization member whose Ownership row is gone▸ app/models/rubygem.rb:226-229 At head
def owned_by?(user) return false unless user ownerships.exists?(user_id: user.id) || (owned_by_organization? && user_authorized_for_organization?(user)) end
Can the backfill open a history row for an ownership that is already gone?
process creates from the ownership the task loaded, without reloading it. If that
ownership is destroyed after its batch loads, the destroy callback finds no row to close and the
task then opens one for a user who no longer owns the gem, which the partial unique index
cannot refuse because no other open row exists inferred.
This repository's maintenance-task guide asks tasks to handle that case
(doc/maintenance-tasks.md:9-11)
from unchanged code. Removals that land before the task
reaches an ownership leave no trace at all, and backfilled rows carry today's role rather than
the highest held. Decide whether that window matters before the follow-up PRs rely on the data.
-
The backfill's write
no reload and no existence check before the create
▸ app/tasks/maintenance/backfill_historical_ownerships_task.rb:14-19 Added
def process(ownership) HistoricalOwnership.find_or_create_by!(rubygem_id: ownership.rubygem_id, user_id: ownership.user_id, removed_at: nil) do |historical| historical.role = ownership.role historical.first_owned_at = ownership.confirmed_at end end
-
The repository's rule for tasks
records may change or be deleted after loading; reload them or skip the deleted ones
from unchanged code
▸ doc/maintenance-tasks.md:9-11 Unchanged
The application may change a record after the task loads it. When that matters, make the database update conditional on the task's change still being needed, or lock and reload the record in a transaction and check before updating. Preserve newer application writes and make reruns safe. Decide whether to include deleted and soft-deleted records. Use the same rule in `collection` and `process`. If excluding them, also skip records deleted after loading.
-
The tolerant close
closes nothing when no open row exists yet
▸ app/models/ownership.rb:155-159 At head
# Intentionally tolerant of not finding a record to update def close_historical_ownership HistoricalOwnership.where(rubygem_id:, user_id:, removed_at: nil) .update_all(removed_at: Time.current) end
- Backfill specs pin the id range, the unconfirmed exclusion and a rerun; none deletes an ownership between load and process test/tasks/maintenance/backfill_historical_ownerships_task_test.rb:6-50
Open question Whether the follow-up PRs need any stint that ended before the backfill finished, which neither this task nor the callbacks can supply.
Should history rows be destroyed with their user?
User and Rubygem now declare
has_many :historical_ownerships with
dependent: :destroy.
Account deletion is a soft discard that destroys ownerships but leaves the user row, so the
ordinary path keeps the history. A hard destroy deletes it; the one found is
script/merge_users:51, which erases the
merged account's rows from unchanged code. Destroying a
Rubygem would delete its rows the same way
(app/models/rubygem.rb:15). Decide whether rows in a
history table are meant to disappear with the account or gem they describe.
-
Account deletion is a discard
before_discarddestroys ownerships; the user row and its history stay▸ app/models/user.rb:25-28 At head
before_discard :yank_gems, unless: :keep_gems_published? before_discard :expire_all_api_keys before_discard :destroy_associations_for_discard before_discard :clear_personal_attributes
-
The user association
the
dependent: :destroythat a hard destroy of a user now fires app/models/user.rb:37 -
The foreign keys
both references are
null: falsewith a foreign key, so a row cannot outlive its user or gem as the schema stands▸ db/migrate/20260716041502_create_historical_ownerships.rb:5-7 Added
create_table :historical_ownerships do |t| t.references :rubygem, null: false, foreign_key: true t.references :user, null: false, foreign_key: true
Read the code in this order
- 01 db/migrate/20260716041502_create_historical_ownerships.rb:5-16 The table and its partial unique index on open rows, which every later write assumes. Checkpoint A →
-
02
app/models/historical_ownership.rb:3-17
Role ordering and the
currentscope the writers below lean on. Checkpoint A → -
03
app/models/ownership.rb:139-159
The three writers; read them beside the callback conditions at
:18-26. Checkpoint A → - 04 app/models/organization_onboarding.rb:126-130 Where ownerships end through a flow outside the owners pages from unchanged code. Checkpoint B →
- 05 app/tasks/maintenance/backfill_historical_ownerships_task.rb:6-19 The backfill, read against the callbacks that are already live when it runs. Checkpoint C →
- 06 app/models/user.rb:36-37 The associations that decide what a hard destroy takes with it. Checkpoint D →
Impact outside the diff
Any push to a gem with no indexed versions closes every current owner's history row, even when the pusher is one of them, so an owner re-pushing a fully yanked gem starts a second stint from unchanged code. Checkpoint A
- Rubygem#disowncloses current rowsapp/models/rubygem.rb:347
- called byPusher#updateno indexed versionsapp/models/pusher.rb:236
- causesprevious owners recorded as removedon the next push
Each member who joins an organization through onboarding gets a closed history row for every onboarded gem, while still publishing it through the organization from unchanged code. Checkpoint B
- close_historical_ownershipstamps removed_atapp/models/ownership.rb:156-159
- called byremove_ownerships_for_joining_membersdestroy_allapp/models/organization_onboarding.rb:126-130
- causesmember listed as a former owneraccess unchanged
Found by name: an ownership changed from a console or by SQL outside this repository is out of its reach.
Affected, not changed
- app/models/pusher.rb:236 — its call to
disownnow closes history rows. Checkpoint A - app/models/organization_onboarding.rb:126-130 — its
destroy_allnow closes each joining member's row. Checkpoint B - app/models/rubygem_transfer.rb:76-84 — the same, for a gem transfer. Checkpoint B
▸ Evidence & diff coverage · all 11 changed paths
▸ Searches run, and what they could not reach
rg -n 'ownerships[a-z_]*\b.*\.(delete_all|update_all|delete\b|update_column|insert_all|upsert_all|destroy_all)|&:delete\)' app lib configwriter search:rubygem.rb:345deletes without callbacks and compensates;user.rb:376runs themrg -n 'remove_ownerships_for_joining_members|demote_outside' apponboarding and transfer:destroy_allruns callbacks;update_all(role: :maintainer)is a demotion the ratchet ignoresrg -n 'confirmed_at' app libOwnership's column written only atownership.rb:75andavo/actions/add_owner.rb:42rg -n 'Ownership\.(create|new)|ownerships[a-z_]*\.(create|new|build)' app libfive create sites, all saving through callbacksrg -n 'disown' app libone caller,pusher.rb:236rg -n '\.destroy\b' scriptscript/merge_users:51, the one hard destroy of a userrg -n '(user|rubygem|@user|@rubygem|owner)\.(destroy|delete)\b|Rubygem\.[a-z_.()]*(destroy|delete)' app libno hitsrg -n 'HistoricalOwnership|historical_ownerships' app lib config scriptonly the writers and associations in this diff; no reader
Out of reach. Writes from a console, a one-off SQL statement, or any repository other than this one; whether the Avo resources for users and gems allow a destroy was not established.
Affected, not changed
- script/merge_users:44-51 — re-pointed ownerships keep their open row on the discarded user, which the destroy then deletes.
- app/avo/actions/add_owner.rb:42 — an admin-added owner is created confirmed, so the create callback opens a row.