rubygems/rubygems.org·PR #6699·nevinera

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.

Revision
nev/6534/historical-ownership-foundation → master
4199bcb → e9b5a3e
Shape
feature
Commits
7
Files
11
By kind
6 prod · 4 test · 1 generated
What changed

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.

Context

What the judgments below rely on

Disowning a gem
When someone pushes to a gem name with no indexed versions, Pusher#update calls Rubygem#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
Attention

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.

Checkpoint A

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.

  1. added already confirmedapp/models/ownership.rb:19
  2. invitation acceptedapp/models/ownership.rb:21
  3. ownership removedapp/models/ownership.rb:26
  4. gem disownedownerships deleted without callbacksapp/models/rubygem.rb:345-347
  5. accounts mergedownership re-pointed; no history callback firesscript/merge_users:44-51
one open history row per confirmed owner and gemmust hold on every path
Which paths must keep the open history row in step with the ownership.
  • 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 only user_id, then other_user.destroy from 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 disown or 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.

Checkpoint B

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_all that now runs close_historical_ownership for 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
    
GAPNo test runs onboarding or transfer against a gem with history rows.
Checkpoint C

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.

Checkpoint D

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_discard destroys 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: :destroy that a hard destroy of a user now fires app/models/user.rb:37
  • The foreign keys both references are null: false with 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
    
Where to begin

Read the code in this order

  1. 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 →
  2. 02 app/models/historical_ownership.rb:3-17 Role ordering and the current scope the writers below lean on. Checkpoint A →
  3. 03 app/models/ownership.rb:139-159 The three writers; read them beside the callback conditions at :18-26. Checkpoint A →
  4. 04 app/models/organization_onboarding.rb:126-130 Where ownerships end through a flow outside the owners pages from unchanged code. Checkpoint B →
  5. 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 →
  6. 06 app/models/user.rb:36-37 The associations that decide what a hard destroy takes with it. Checkpoint D →
Beyond the diff

Impact outside the diff

Impact paths
A · Pushing to an abandoned gem name closes its owners' history

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

In this PR's filesOutside the diff
  1. Rubygem#disowncloses current rowsapp/models/rubygem.rb:347
  2. called byPusher#updateno indexed versionsapp/models/pusher.rb:236
  3. causesprevious owners recorded as removedon the next push
B · Organization members recorded as former owners

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

In this PR's filesOutside the diff
  1. close_historical_ownershipstamps removed_atapp/models/ownership.rb:156-159
  2. called byremove_ownerships_for_joining_membersdestroy_allapp/models/organization_onboarding.rb:126-130
  3. causesmember listed as a former owneraccess unchanged
In this PR's files Affected, not changed Behaviour

Found by name: an ownership changed from a console or by SQL outside this repository is out of its reach.

Affected, not changed

▸ 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 config writer search: rubygem.rb:345 deletes without callbacks and compensates; user.rb:376 runs them
  • rg -n 'remove_ownerships_for_joining_members|demote_outside' app onboarding and transfer: destroy_all runs callbacks; update_all(role: :maintainer) is a demotion the ratchet ignores
  • rg -n 'confirmed_at' app lib Ownership's column written only at ownership.rb:75 and avo/actions/add_owner.rb:42
  • rg -n 'Ownership\.(create|new)|ownerships[a-z_]*\.(create|new|build)' app lib five create sites, all saving through callbacks
  • rg -n 'disown' app lib one caller, pusher.rb:236
  • rg -n '\.destroy\b' script script/merge_users:51, the one hard destroy of a user
  • rg -n '(user|rubygem|@user|@rubygem|owner)\.(destroy|delete)\b|Rubygem\.[a-z_.()]*(destroy|delete)' app lib no hits
  • rg -n 'HistoricalOwnership|historical_ownerships' app lib config script only 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