organizations 0.8.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cee8b956413b0859d592b794078046a1f5ed95fea6fd391e8b3a6aee83dbc14d
4
- data.tar.gz: 3f0ef38dd0d343ffbba6c7f9a28f1943f55f2610d90b77b36306908b79d18e64
3
+ metadata.gz: e3c621c2b6bb7bd32be611858d49af9cb12cf9fa80bc0309d1227eafc819f8d8
4
+ data.tar.gz: c6028a9e424ab92432c44ae45bcba367d2a70eab56e3901314b1d5ff9b9b102f
5
5
  SHA512:
6
- metadata.gz: fa931ef4f5a12370ae4f86ceb15bc9d601a35ef042db467cc9c4c4ff125022f16760fbfe1c9a140c0a3001862b61386d7a2529e1e2074be2b98576760983747f
7
- data.tar.gz: a33b77589dc25b53c5a0783899d4056ac8a0fcb73e578f089eb3fece8565a3ae9c2f098c3c64babf43e752907807064ad3e78a0855b1e5a858631749bcce8461
6
+ metadata.gz: 267e7126501538f4461616e2be708f1b3696a016b428135337193dc8eaf24532a446a9c651b1989610862025298cbb4b9ab38b7f1d901b0fcb3f49f68d28ec56
7
+ data.tar.gz: 98d2dbd1b5369c247c6b8147c7472f0b53e74aa25c0c000b8458834ddff6e81f0ec6f3e599611f2ba8ef10b2dd88ddb5775fe15816cef13d57fb48a393ddce6a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.8.1] - 2026-10-25
4
+
5
+ **The joins take the lock order of the destroys.** A patch release with one fix. No migration is needed.
6
+
7
+ ### Fixed
8
+
9
+ - **A join could deadlock with a deletion on PostgreSQL.** 0.8.0 made every destroy lock the user row, then the organization rows, then their child rows. Several joins locked their own row first, and the organization row (or a user row) only later, when they wrote. So a join and a deletion could each hold a row that the other waited for. PostgreSQL then aborted one of them with `ActiveRecord::Deadlocked`. These paths now take a guard on the user rows that their writes name (in id order), then lock the organization row, then their own row:
10
+ - `Invitation#accept!` (the accepting user and the inviter). Before, an acceptance deadlocked with the inviter's account deletion or with a destroy of the organization.
11
+ - `Invitation#revoke!` (no user row). Before, the revoke of an invitation with grants deadlocked with a destroy of its organization or with the inviter's account deletion.
12
+ - `JoinRequest#approve!` and the automatic approval after a verified email (the requester and the decider). Before, an approval deadlocked with the requester's account deletion.
13
+ - `JoinRequest#verify_email_code!` (the requester). Before, the check of an emailed code deadlocked with a destroy of the organization, which deletes the domain before the request. The requester's guard comes first, so the automatic approval inside a host transaction asks for no user row while it holds the organization row.
14
+ - `JoinCode#redeem!` (the redeeming user). Before, a redemption deadlocked with the account deletion of the code's creator or with a destroy of the organization.
15
+ - **The user guard is a key share lock** (`FOR KEY SHARE` on PostgreSQL, a shared lock on MySQL, nothing on SQLite). It conflicts with the deletion's `FOR UPDATE`, and with nothing else: an update of the user and the foreign key checks of other writers pass. So `reject!` (which names its decider), `withdraw!`, `resend!` and a grant on a pending invitation (which names its granter) keep their single row lock and do not wait for the guards.
16
+ - **An acceptance from a stale copy locks the current inviter.** A reissue of an expired invitation (`send_invite_to!`) names the new inviter. A loaded copy can still name the old one. The acceptance reads the row again under its lock. When the row names other ids than the ones locked, the acceptance releases its locks (a savepoint) and tries again with the current ids, up to three times. Then it raises `Invitation::InvitationChanged`, an `InvitationError`. `accept_pending_organization_invitation!` returns it as the failure `:invitation_changed` and keeps the session token. The engine's accept action answers with the localized message: HTML goes back to the invitation page, JSON gets a 409. Before, the acceptance could insert a membership that named an unlocked inviter, and deadlock with an ownership transfer to that inviter.
17
+ - Inside a host transaction, the order holds only when the locks that the host took earlier in that transaction keep it too.
18
+ - README: "Row-level locking" states the one lock order. The steps of the ownership transfer now start with the new owner's row, as the code has done since 0.7.0.
19
+
3
20
  ## [0.8.0] - 2026-10-24
4
21
 
5
22
  **Hand a personal organization over, and keep organizations that hold records with a retention period.** An invoicing app on the fleet built both by hand and runs them in production. This release moves them into the gem, with their tests. Both are opt-in, and no migration is needed.
data/README.md CHANGED
@@ -722,6 +722,10 @@ invitation.grant!(record, capabilities: %i[read])
722
722
  # Actions
723
723
  invitation.accept! # Accept (auto-infers Current.user)
724
724
  invitation.accept!(user) # Accept with explicit user
725
+ # accept! raises Organizations::InvitationExpired, Organizations::InvitationAlreadyAccepted,
726
+ # Invitation::EmailMismatch, Invitation::CannotAcceptAsOwner, or Invitation::InvitationChanged:
727
+ # a reissue named another inviter at each of three tries to lock the rows. Nothing is
728
+ # written then, and the person can try again (organizations.errors.invitation_changed).
725
729
  invitation.resend!(resent_by: current_user) # Send invitation email again
726
730
  invitation.revoke!(revoked_by: current_user) # Delete it, so the link stops working
727
731
  ```
@@ -1325,6 +1329,8 @@ else
1325
1329
  end
1326
1330
  ```
1327
1331
 
1332
+ The reasons: `:missing_user`, `:missing_token`, `:invitation_not_found`, `:invitation_expired`, `:email_mismatch`, `:already_accepted_without_membership` and `:invitation_changed`. `:invitation_changed` means that a reissue changed the invitation during the acceptance (`Invitation::InvitationChanged`). Nothing was written, and the session keeps the token, so the person can try again. The engine's accept action answers it with the localized message (`organizations.errors.invitation_changed`): HTML goes back to the invitation page, JSON gets a 409 with `{ "error": message }`.
1333
+
1328
1334
  Configure redirects in your initializer:
1329
1335
 
1330
1336
  ```ruby
@@ -1604,7 +1610,7 @@ What the gem guarantees:
1604
1610
 
1605
1611
  - It checks the complete owned set before it deletes anything. One blocker blocks the whole deletion.
1606
1612
  - The organization deletion and the user deletion are one transaction, with its own savepoint, also inside a transaction of yours. A host `before_destroy` veto on the organization, or any later failure, rolls back everything, and the veto message is on `user.errors`.
1607
- - Before its first write, `user.destroy` locks the user row, then the row of every organization in which it writes (memberships, sent invitations and their grants, join requests and the other records it nullifies), in id order. An organization's destroy also locks its row before its cascades, so the two cannot deadlock. A concurrent join or organization creation for that user, a join, transfer or destroy in one of those organizations, and an ownership transfer to that user (`transfer_ownership_to!` locks the new owner's row) wait for the deletion, or finish first and are seen by it. The guard reads the database under its locks, not the query cache.
1613
+ - Before its first write, `user.destroy` locks the user row, then the row of every organization in which it writes (memberships, sent invitations and their grants, join requests and the other records it nullifies), in id order. An organization's destroy also locks its row before its cascades, so the two cannot deadlock. The joins take the same order (see "Row-level locking"). A concurrent join or organization creation for that user, a join, transfer or destroy in one of those organizations, and an ownership transfer to that user (`transfer_ownership_to!` locks the new owner's row) wait for the deletion, or finish first and are seen by it. The guard reads the database under its locks, not the query cache.
1608
1614
  - The user's other memberships are deleted (`dependent: :destroy`), as before. Invitations they sent, join requests they decided, join codes they created and allowlist entries they claimed keep the record and lose the user reference.
1609
1615
 
1610
1616
  ### Organizations that must be kept
@@ -2705,7 +2711,9 @@ These constraints prevent duplicate data at the database level:
2705
2711
 
2706
2712
  ### Row-level locking
2707
2713
 
2708
- The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race conditions:
2714
+ The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race conditions.
2715
+
2716
+ The gem takes its row locks in one order: the user rows that a write names (in id order), then the organization row, then the rows of the organization. A user's deletion locks its user row `FOR UPDATE`. The joins take a key share guard on their user rows (`FOR KEY SHARE` on PostgreSQL): only a deletion of those users waits for it, and ordinary updates and foreign key checks pass. A user's deletion, an organization's destroy, an invitation acceptance or revoke, a join request approval, the check of an emailed code and a join code redemption all keep this order. So they wait for each other, and do not deadlock on PostgreSQL. Inside your own transaction, the order holds only when your earlier locks in that transaction keep it too. Keep the same order in your own code that writes in an organization.
2709
2717
 
2710
2718
  **Invitation acceptance:**
2711
2719
  ```ruby
@@ -2713,11 +2721,14 @@ The gem uses `SELECT ... FOR UPDATE` (row-level locks) to prevent race condition
2713
2721
  invitation.accept!
2714
2722
 
2715
2723
  # Internally:
2716
- # 1. Lock invitation row
2717
- # 2. Check accepted_at is nil
2718
- # 3. Create membership
2719
- # 4. Set accepted_at
2720
- # 5. Release lock
2724
+ # 1. Take the key share guard on the accepting user and the inviter
2725
+ # 2. Lock organization row
2726
+ # 3. Lock invitation row (when it now names another inviter, release the
2727
+ # locks and start again with the current one, up to three times)
2728
+ # 4. Check accepted_at is nil
2729
+ # 5. Create membership
2730
+ # 6. Set accepted_at
2731
+ # 7. Release locks
2721
2732
  # Second request sees accepted_at is set, returns existing membership
2722
2733
  ```
2723
2734
 
@@ -2726,13 +2737,14 @@ invitation.accept!
2726
2737
  org.transfer_ownership_to!(new_owner)
2727
2738
 
2728
2739
  # Internally:
2729
- # 1. Lock organization row
2730
- # 2. Lock old owner's membership
2731
- # 3. Lock new owner's membership
2732
- # 4. Verify new owner is a member
2733
- # 5. Demote old owner to admin
2734
- # 6. Promote new owner to owner
2735
- # 7. Release locks
2740
+ # 1. Lock the new owner's user row
2741
+ # 2. Lock organization row
2742
+ # 3. Lock old owner's membership
2743
+ # 4. Lock new owner's membership
2744
+ # 5. Verify new owner is a member
2745
+ # 6. Demote old owner to admin
2746
+ # 7. Promote new owner to owner
2747
+ # 8. Release locks
2736
2748
  ```
2737
2749
 
2738
2750
  **Last admin/owner protection:**
@@ -66,18 +66,13 @@ module Organizations
66
66
 
67
67
  def respond_invitation_acceptance_failure(failure)
68
68
  case failure.failure_reason
69
- when :email_mismatch
70
- return respond_invitation_email_mismatch
71
- when :invitation_expired
72
- return respond_invitation_expired
69
+ when :email_mismatch then respond_invitation_email_mismatch
70
+ when :invitation_expired then respond_invitation_expired
71
+ when :invitation_changed then respond_invitation_changed(failure.invitation || @invitation)
72
+ else
73
+ respond_invitation_error(html_path: main_app.root_path, alert: "Unable to accept this invitation.",
74
+ json_error: "Acceptance failed", status: 422)
73
75
  end
74
-
75
- respond_invitation_error(
76
- html_path: main_app.root_path,
77
- alert: "Unable to accept this invitation.",
78
- json_error: "Acceptance failed",
79
- status: 422
80
- )
81
76
  end
82
77
 
83
78
  def respond_invitation_email_mismatch
@@ -98,6 +93,14 @@ module Organizations
98
93
  )
99
94
  end
100
95
 
96
+ # A reissue changed the invitation during the acceptance, and nothing was
97
+ # written. Send the person back to it (its current link) to try again.
98
+ def respond_invitation_changed(invitation)
99
+ message = Organizations.t(:"errors.invitation_changed", organization: invitation.organization)
100
+ respond_invitation_error(html_path: invitation_path(invitation.token), alert: message, json_error: message,
101
+ status: 409)
102
+ end
103
+
101
104
  def respond_invitation_acceptance_success(result)
102
105
  after_path = redirect_path_after_invitation_accepted(result.invitation, user: current_user)
103
106
  payload, status = invitation_acceptance_json_response(result)
@@ -72,6 +72,7 @@ en:
72
72
  # — Invitations —
73
73
  invitation_expired: "This invitation has expired"
74
74
  invitation_already_accepted: "This invitation has already been accepted"
75
+ invitation_changed: "This invitation changed while it was being accepted. Try again."
75
76
  invitation_cannot_resend_accepted: "Cannot resend an accepted invitation"
76
77
  invitation_email_mismatch: "This invitation was sent to a different email address"
77
78
  invitation_accept_as_owner: "Cannot accept invitation as owner. Invite as admin, then use transfer_ownership_to! after joining."
@@ -54,6 +54,7 @@ es:
54
54
 
55
55
  invitation_expired: "Esta invitación ha caducado"
56
56
  invitation_already_accepted: "Esta invitación ya ha sido aceptada"
57
+ invitation_changed: "Esta invitación cambió mientras se aceptaba. Inténtalo de nuevo."
57
58
  invitation_cannot_resend_accepted: "No se puede reenviar una invitación ya aceptada"
58
59
  invitation_email_mismatch: "Esta invitación se envió a otra dirección de email"
59
60
  invitation_accept_as_owner: "No se puede aceptar una invitación como propietario. Invita como administrador y transfiere la propiedad después."
@@ -290,6 +290,14 @@ module Organizations
290
290
  return_failure: return_failure,
291
291
  invitation: invitation
292
292
  )
293
+ rescue Organizations::Invitation::InvitationChanged
294
+ # A reissue changed the invitation at each try to lock it. Nothing
295
+ # was written, and the person can try again: keep the session token.
296
+ return invitation_acceptance_failure(
297
+ :invitation_changed,
298
+ return_failure: return_failure,
299
+ invitation: invitation
300
+ )
293
301
  rescue Organizations::InvitationAlreadyAccepted
294
302
  # Check if user is actually a member
295
303
  membership = Organizations::Membership.find_by(
@@ -10,6 +10,7 @@ module Organizations
10
10
  invitation_expired
11
11
  email_mismatch
12
12
  already_accepted_without_membership
13
+ invitation_changed
13
14
  ].freeze
14
15
 
15
16
  attr_reader :reason, :invitation
@@ -124,6 +124,12 @@ module Organizations
124
124
  # Error raised when trying to accept an invitation with owner role
125
125
  class CannotAcceptAsOwner < Organizations::InvitationError; end
126
126
 
127
+ # The invitation named another inviter at each of ACCEPT_LOCK_ATTEMPTS
128
+ # tries to lock its rows (it was reissued meanwhile).
129
+ class InvitationChanged < Organizations::InvitationError; end
130
+
131
+ ACCEPT_LOCK_ATTEMPTS = 3
132
+
127
133
  # Accept the invitation and create membership
128
134
  # Uses row-level locking to prevent race conditions
129
135
  # @param user [User, nil] The user accepting (uses Current.user if not provided)
@@ -149,8 +155,7 @@ module Organizations
149
155
  membership = nil
150
156
 
151
157
  ActiveRecord::Base.transaction do
152
- # Lock the invitation row to prevent race conditions
153
- lock!
158
+ lock_rows_for_acceptance!(accepting_user)
154
159
 
155
160
  # Re-check status after acquiring lock
156
161
  if accepted?
@@ -231,6 +236,10 @@ module Organizations
231
236
  # @raise [Organizations::NotAuthorized] if the role ranks above revoked_by's own
232
237
  def revoke!(revoked_by: nil)
233
238
  ActiveRecord::Base.transaction do
239
+ # The organization row first (RowLocks), as an organization destroy
240
+ # and the inviter's deletion lock it before this invitation and its
241
+ # grants.
242
+ RowLocks.lock_organization!(organization_id)
234
243
  lock!
235
244
  authorize_management!(revoked_by)
236
245
  destroy!
@@ -257,6 +266,31 @@ module Organizations
257
266
 
258
267
  private
259
268
 
269
+ # The gem's lock order (RowLocks): the users that the membership names
270
+ # (the accepting user and the inviter), then the organization row, then
271
+ # the invitation row. A deletion of the inviter or of the organization
272
+ # takes the same order, so it waits for this acceptance, or this one for
273
+ # it. This copy can name a stale inviter: a reissue of the expired row
274
+ # (send_invite_to!) names the new one. The row lock reads the row again.
275
+ # When it names other ids than the ones locked, the savepoint rolls back,
276
+ # which releases its locks, and the next attempt locks the current ids.
277
+ def lock_rows_for_acceptance!(accepting_user)
278
+ ACCEPT_LOCK_ATTEMPTS.times do
279
+ locked = [invited_by_id, organization_id]
280
+ held = ActiveRecord::Base.transaction(requires_new: true) do
281
+ RowLocks.lock_users!(accepting_user.id, invited_by_id)
282
+ RowLocks.lock_organization!(organization_id)
283
+ lock!
284
+ raise ActiveRecord::Rollback unless [invited_by_id, organization_id] == locked
285
+
286
+ true
287
+ end
288
+ return if held
289
+ end
290
+
291
+ raise InvitationChanged, Organizations.t(:"errors.invitation_changed", organization: organization)
292
+ end
293
+
260
294
  # The invitation's grants become the membership's (a grant on the same
261
295
  # resource takes the invitation's capabilities). The inviter was
262
296
  # checked when the invitation was sent.
@@ -152,10 +152,7 @@ module Organizations
152
152
  outcome = nil
153
153
 
154
154
  ActiveRecord::Base.transaction do
155
- # Lock the code row: uses_count accounting and the max_uses cap must
156
- # be race-safe (two concurrent redemptions of a max_uses: 1 code must
157
- # yield exactly one success).
158
- lock!
155
+ lock_rows_for_redemption!(user)
159
156
  ensure_redeemable!
160
157
 
161
158
  # Already a member — idempotent no-op that does NOT consume a use.
@@ -188,6 +185,18 @@ module Organizations
188
185
 
189
186
  private
190
187
 
188
+ # The gem's lock order (RowLocks): the redeeming user, then the
189
+ # organization row, then the code row. The deletion of the code's
190
+ # creator and an organization destroy take the organization row before
191
+ # the code row. The code row lock makes the uses_count accounting and
192
+ # the max_uses cap race-safe (two concurrent redemptions of a max_uses: 1
193
+ # code must yield exactly one success).
194
+ def lock_rows_for_redemption!(user)
195
+ RowLocks.lock_users!(user.id)
196
+ RowLocks.lock_organization!(organization_id)
197
+ lock!
198
+ end
199
+
191
200
  def ensure_redeemable!
192
201
  raise JoinCodeInvalid, Organizations.t(:"errors.join_code_invalid") if revoked? || expired?
193
202
  raise JoinCodeExhausted, Organizations.t(:"errors.join_code_exhausted") if exhausted?
@@ -180,7 +180,7 @@ module Organizations
180
180
  failure = nil
181
181
 
182
182
  ActiveRecord::Base.transaction do
183
- lock!
183
+ lock_rows_for_verification!
184
184
  ensure_open!
185
185
  ensure_active_challenge!
186
186
  # The domain or roster entry may have been removed, revoked or
@@ -319,11 +319,30 @@ module Organizations
319
319
 
320
320
  private
321
321
 
322
+ # The gem's lock order (RowLocks): the requester, then the organization
323
+ # row, then this request. An organization destroy deletes the domains
324
+ # and allowlist entries before this request, and the check locks one of
325
+ # them after this request. The automatic approval takes the requester's
326
+ # guard again: inside a host transaction, this one already holds it, so
327
+ # the approval does not ask for a user row while it holds the
328
+ # organization row.
329
+ def lock_rows_for_verification!
330
+ RowLocks.lock_users!(user_id)
331
+ RowLocks.lock_organization!(organization_id)
332
+ lock!
333
+ end
334
+
322
335
  def perform_approval!(decided_by:, recheck_instruments:)
323
336
  membership = nil
324
337
  reused_existing = false
325
338
 
326
339
  ActiveRecord::Base.transaction do
340
+ # The gem's lock order (RowLocks): the requester and the decider,
341
+ # then the organization row, then this request. The requester's
342
+ # deletion locks the requester, then the organization, and deletes
343
+ # this request.
344
+ RowLocks.lock_users!(user_id, decided_by&.id)
345
+ RowLocks.lock_organization!(organization_id)
327
346
  lock!
328
347
 
329
348
  return approved_membership! if approved? # idempotent re-approval
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Organizations
4
+ # The one lock order of the gem: the user rows (in id order), then the
5
+ # organization row, then the rows of the organization. A user's deletion
6
+ # locks the user row, then the row of every organization in which it
7
+ # writes. An organization's destroy locks its row before its cascades. A
8
+ # path that writes in an organization takes the same order, or it can
9
+ # deadlock with them on PostgreSQL.
10
+ #
11
+ # The user rows are the ones that the path's writes reference: a row that
12
+ # names a user takes a key share lock on it when it is written, which
13
+ # waits for a deletion of that user. Lock them first, so that the wait
14
+ # comes before the organization row and not after it.
15
+ #
16
+ # The user guard is a key share lock (FOR KEY SHARE on PostgreSQL). It
17
+ # conflicts with the deletion's FOR UPDATE, which is its purpose, and with
18
+ # nothing else: an ordinary update of the user and the foreign key check
19
+ # of another writer pass. A FOR UPDATE guard would block those writers,
20
+ # and a writer that holds a row this path waits for would then close a
21
+ # cycle (a reject! that sets decided_by_id, a grant that names its
22
+ # granter).
23
+ #
24
+ # Inside a host transaction the order holds only when the host's earlier
25
+ # locks in that transaction keep it too.
26
+ #
27
+ # @api private
28
+ module RowLocks
29
+ module_function
30
+
31
+ # Take the key share guard on these users' rows, in id order. Nil ids
32
+ # and users that are gone are skipped.
33
+ # @param ids [Array<Object>]
34
+ def lock_users!(*ids)
35
+ ids = ids.flatten.compact.uniq
36
+ return if ids.empty?
37
+
38
+ user_class = Organizations.user_class
39
+ key = user_class.primary_key
40
+ user_class.unscoped.where(key => ids).order(key).lock(key_share_clause(user_class.connection)).pluck(key)
41
+ end
42
+
43
+ # PostgreSQL has a lock that only a delete or a key change conflicts
44
+ # with. MySQL has a shared lock. SQLite has no row locks: Rails drops
45
+ # the clause there.
46
+ def key_share_clause(connection)
47
+ adapter = connection.adapter_name.downcase
48
+ return "FOR KEY SHARE" if adapter.include?("postg")
49
+ return "LOCK IN SHARE MODE" if adapter.include?("mysql") || adapter.include?("trilogy")
50
+
51
+ true
52
+ end
53
+
54
+ # Lock the organization row. A destroyed organization is skipped: the
55
+ # caller's own row lock then finds its row gone.
56
+ # @param id [Object]
57
+ def lock_organization!(id)
58
+ Organizations::Organization.unscoped.where(id: id).lock.pick(:id)
59
+ end
60
+ end
61
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Organizations
4
- VERSION = "0.8.0"
4
+ VERSION = "0.8.1"
5
5
  end
data/lib/organizations.rb CHANGED
@@ -126,6 +126,7 @@ module Organizations
126
126
  autoload :GrantAccess, "organizations/grant_access"
127
127
  autoload :Grantable, "organizations/grantable"
128
128
  autoload :AccountDeletionPlan, "organizations/account_deletion_plan"
129
+ autoload :RowLocks, "organizations/row_locks"
129
130
 
130
131
  # Alias for README compatibility: `include Organizations::Controller`
131
132
  Controller = ControllerHelpers
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: organizations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.8.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - rameerez
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-24 00:00:00.000000000 Z
10
+ date: 2026-10-25 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: railties
@@ -180,6 +180,7 @@ files:
180
180
  - lib/organizations/public_email_domains.rb
181
181
  - lib/organizations/resource.rb
182
182
  - lib/organizations/roles.rb
183
+ - lib/organizations/row_locks.rb
183
184
  - lib/organizations/test_helpers.rb
184
185
  - lib/organizations/version.rb
185
186
  - lib/organizations/view_helpers.rb