feat(admin): move a workspace between organizations - #7254
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
Greptile SummaryThis PR enables administrators to move workspaces between organizations while preserving tenant, lineage, billing, entitlement, and audit invariants.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/workspaces/admin-move.ts | Orchestrates the cross-organization transfer with ordered locks, transactional blocker checks, payer fencing, cleanup, and audit recording. |
| apps/sim/lib/workspaces/admin-move-source-impact.ts | Computes source-organization impact and performs transaction-aware cleanup of organization-scoped artifacts. |
| apps/sim/ee/workspace-forking/lib/create-fork.ts | Re-reads the parent workspace under a conflicting row lock before inserting a fork, preventing stale organization policy from creating cross-organization lineage. |
| apps/sim/lib/workflows/custom-blocks/operations.ts | Serializes custom-block publication with organization mutation and supports transactional deletion during workspace moves. |
| apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts | Expands the admin transfer contract with source impact, credential, entitlement, blocker, notice, and truncation metadata. |
| apps/sim/lib/billing/core/subscription.ts | Exposes whether organization entitlement is subscription-backed so transfer checks respect deployment-configured entitlement modes. |
Sequence Diagram
sequenceDiagram
participant Admin
participant Move as Workspace move
participant Source as Source organization
participant Destination as Destination organization
participant Workspace as Workspace row
Admin->>Move: Request transfer
Move->>Move: Acquire invitation locks
Move->>Source: Lock source organization
Move->>Destination: Lock destination organization
Move->>Workspace: Lock and re-read current organization
Move->>Move: Re-check forks, invitations, and entitlements
alt Blocker exists
Move-->>Admin: Refuse without mutation
else Transfer allowed
Move->>Source: Remove source-scoped artifacts
Move->>Workspace: Change organization and storage payer
Move->>Source: Record source loss audit entry
Move->>Destination: Record destination move entry
Move-->>Admin: Return applied move summary
end
Reviews (5): Last reviewed commit: "feat(admin): move a workspace between or..." | Re-trigger Greptile
There was a problem hiding this comment.
3 issues found across 8 files
Confidence score: 2/5
apps/sim/ee/workspace-forking/lib/create-fork.tsonly makes concurrent organization attachment wait, so after the fork commitsattachOwnedWorkspacesToOrganizationTxcan attach the parent alone during a personal-parent fork. Coordinate the lock and post-fork attachment flow to preserve the intended workspace set.apps/sim/lib/workspaces/admin-move.tstreats Team as entitled viaisOrgPlan, allowing Enterprise-to-Team moves thatresolveMoveEntitlementsrejects. Align the transaction fence with the entitlement resolution logic.apps/sim/lib/workspaces/admin-move-source-impact.tsreports a branding change when one organization has missing whitelabel settings and the other has{}despite equivalent default branding. Normalize missing and empty settings before comparison.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/lib/workspaces/admin-move-source-impact.ts">
<violation number="1" location="apps/sim/lib/workspaces/admin-move-source-impact.ts:535">
P3: When one organization has no whitelabel settings and the other stores an empty settings object, this reports a branding change even though both use the default branding. Normalize missing and empty settings before comparing them.</violation>
</file>
<file name="apps/sim/lib/workspaces/admin-move.ts">
<violation number="1" location="apps/sim/lib/workspaces/admin-move.ts:1188">
P1: When the source is Enterprise and the destination is Team, this transaction fence treats both organizations as entitled because `isOrgPlan` includes Team, so it allows a move that `resolveMoveEntitlements` reports would lose Enterprise capabilities. It also incorrectly blocks Team→Free moves; use the same Enterprise-specific predicate as `resolveMoveEntitlements` for this re-check.</violation>
</file>
<file name="apps/sim/ee/workspace-forking/lib/create-fork.ts">
<violation number="1" location="apps/sim/ee/workspace-forking/lib/create-fork.ts:194">
P1: When a personal parent is forked by another admin, this lock only makes a concurrent organization attachment wait. After the fork commits, `attachOwnedWorkspacesToOrganizationTx` can attach the parent alone, leaving its child personal and violating the fork invariant; check the edge before attachment or move both atomically.</violation>
</file>
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
There was a problem hiding this comment.
6 issues found across 8 files
Confidence score: 2/5
apps/sim/ee/workspace-forking/lib/create-fork.tscan commit a child fork while an organization attach/detach batch is waiting on the row lock, after which the batch updates only its stale parent ID. Preserve the lock/transaction ordering so the batch cannot resume against stale workspace state.apps/sim/lib/workspaces/admin-move.tsmisclassifies Team and Enterprise plans for subscription-backed billing, allowing Enterprise→Team moves past the downgrade fence while blocking Team→free moves. Reuse the Enterprise-specific classification for the downgrade check.- The move preflight contract is inconsistent across
apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.tsandapps/sim/lib/workspaces/admin-move.ts: seat-capacity failures are exposed as warnings despite transaction rejection, and fork/credential truncation can omit the incomplete-review notice. Align blockers and notices with the conditions that can prevent or limit a move. apps/sim/lib/workspaces/admin-move-source-impact.tscan omit a source-organization cap when collaborator joins exceed 1,000 and can issue up to 2,000 enrichment queries concurrently for 500 custom blocks. Apply the usage-limit inner join and bounded-concurrency enrichment before relying on these preflight results.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/ee/workspace-forking/lib/create-fork.ts">
<violation number="1" location="apps/sim/ee/workspace-forking/lib/create-fork.ts:194">
P1: When organization attach or detach has already snapshotted workspace IDs and is waiting on this row lock, the fork can commit a child before that batch resumes. The batch then updates only its stale parent ID, leaving the parent and child in different organizations; synchronize these operations with fork creation or re-enumerate children after acquiring the locks.</violation>
</file>
<file name="apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts">
<violation number="1" location="apps/sim/lib/api/contracts/v1/admin/dashboard-workspaces.ts:205">
P2: When destination seat capacity is exceeded, preflight returns a non-null `warning` but leaves `blockers` empty. The move transaction then throws `seat-capacity-exceeded`, so a UI following this new field can offer a confirmation guaranteed to fail. Add that capacity message to `blockers`, or explicitly make the UI honor `warning`.</violation>
</file>
<file name="apps/sim/lib/workspaces/admin-move.ts">
<violation number="1" location="apps/sim/lib/workspaces/admin-move.ts:675">
P2: When only fork edges or credential/environment-variable keys are truncated, preflight returns nonzero truncation counts without the required incomplete-review notice. Pass the merged truncation state into notice generation and check credential truncation even when no source organization exists.</violation>
<violation number="2" location="apps/sim/lib/workspaces/admin-move.ts:1188">
P1: When billing is subscription-backed, this recheck treats Team plans as equivalent to Enterprise. An Enterprise→Team move bypasses the downgrade fence, while a Team→free move is incorrectly blocked; reuse the Enterprise-only predicate here.</violation>
</file>
<file name="apps/sim/lib/workspaces/admin-move-source-impact.ts">
<violation number="1" location="apps/sim/lib/workspaces/admin-move-source-impact.ts:231">
P2: For 500 source custom blocks, this starts up to 2,000 database queries concurrently and can saturate the pool or time out the admin preflight. Enrich blocks with bounded concurrency instead of one unbounded `Promise.all`.</violation>
<violation number="2" location="apps/sim/lib/workspaces/admin-move-source-impact.ts:410">
P2: When a workspace has more than 1,000 explicit collaborators, this join returns uncapped users too, so the bounded preflight list can omit a real source-org cap. Join the usage-limit table as an inner join before applying the limit.</violation>
</file>
Tip: instead of fixing issues one by one fix them all with cubic
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
8cf3b11 to
a5cead5
Compare
a5cead5 to
361d1b1
Compare
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
2 existing issues remain and 2 new issues found across 9 files
Confidence score: 2/5
apps/sim/lib/workspaces/admin-move-source-impact.tscan allow moves into apast_dueEnterprise subscription even thoughisOrganizationOnEnterprisePlanconsiders it unusable, creating an entitlement mismatch and potentially permitting an invalid move — align the query with the entitlement check.apps/sim/lib/workspaces/organization-workspaces.tscapturesworkspaceIdsbefore recursively created fork descendants are appended, so grandchildren can be omitted from the move and left behind — walk the complete descendant closure under locks or revalidate before applying changes.apps/sim/lib/workspaces/organization-workspaces.tsexcludes archived or admin-forked children while still moving their attached parent, which can split a fork hierarchy across organizations — include every fork descendant in the move validation.apps/sim/lib/workspaces/admin-move.tsmay report a stale downgrade in the successful move summary when Enterprise entitlement changes after the optimistic read, despite the fenced check allowing the move — derive the summary from the fenced entitlement result.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/lib/workspaces/admin-move-source-impact.ts">
<violation number="1" location="apps/sim/lib/workspaces/admin-move-source-impact.ts:402">
P1: When the destination Enterprise subscription is `past_due`, this query marks it as Enterprise and lets the move proceed, but `isOrganizationOnEnterprisePlan` treats that subscription as unusable and Enterprise-gated features become unavailable after the move. Use the same usable-status predicate as the feature gates when resolving destination entitlements.</violation>
</file>
<file name="apps/sim/lib/workspaces/admin-move.ts">
<violation number="1" location="apps/sim/lib/workspaces/admin-move.ts:1170">
P2: When the destination gains Enterprise entitlement after the optimistic read, the fenced check allows the move but the applied summary still reports the stale downgrade. Use the fenced entitlement result for the successful summary so the response matches the decision that allowed the move.</violation>
</file>
Requires human review: Auto-approval blocked because this review re-detected 2 unresolved issues already reported by Cubic.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
361d1b1 to
a47703a
Compare
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
1 existing issue remains and 3 new issues found across 9 files
Confidence score: 2/5
apps/sim/lib/workspaces/organization-workspaces.tscloses only one fork level, so a grandchild created after the snapshot can remain in the old organization; traverse and lock the full descendant closure before attaching the parent.apps/sim/lib/workspaces/organization-workspaces.tsrow-locks swept children that were absent from the initial workspace list without taking their workspace advisory locks, leaving a concurrency gap; include all swept children in the advisory-lock plan.apps/sim/lib/workspaces/organization-workspaces.tscan attach a fork created after disclosure validation without recheckingdisclosedWorkspaceIds, potentially bypassing invitation disclosure requirements; revalidate the final swept set before commit.apps/sim/lib/workspaces/organization-workspaces.tsexcludes fork children owned by another workspace admin from the attachability check, allowing a cross-organization fork edge after the parent moves; validate every fork child and reject or explicitly handle violations.
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/sim/lib/workspaces/organization-workspaces.ts">
<violation number="1" location="apps/sim/lib/workspaces/organization-workspaces.ts:280">
P1: The sweep only closes one fork level, so a child created after the snapshot can have a grandchild that remains in the old organization. Traverse and lock the full descendant closure before attaching the parent.</violation>
<violation number="2" location="apps/sim/lib/workspaces/organization-workspaces.ts:288">
P1: When a fork child is absent from the caller’s initial workspace list, this query row-locks it after the organization lock without acquiring its workspace advisory lock. Include swept children in the advisory-lock plan before taking organization locks, or restart when the lock set expands.</violation>
<violation number="3" location="apps/sim/lib/workspaces/organization-workspaces.ts:292">
P1: During invitation acceptance, a fork created after disclosure validation can be swept here and attached without updating or rechecking `disclosedWorkspaceIds`. Revalidate the final swept set before committing, or abort when the sweep expands the consented set.</violation>
</file>
Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.
Tip: instead of fixing issues one by one fix them all with cubic
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
a47703a to
654030f
Compare
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
All reported issues were addressed across 9 files
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Fix all with cubic | Re-trigger cubic
The admin workspace move was restricted to personal/grandfathered sources; `assertWorkspaceMovable` refused anything already owned by an organization, so support could only re-home a workspace with manual SQL. Relax that guard to a drift-only check and handle the source organization. `changeWorkspaceStoragePayerInTx` already accepted an arbitrary source payer, so the storage-ledger rebalance needed no change. Moving a workspace between organizations is the first operation capable of separating an artifact from the organization that owns it, so two invariants nothing has ever had to defend are enforced here: - A custom block and its bound workflow always share an organization. `getCustomBlockAuthority` resolves by the consumer's org and `admitCustomBlockChildExecution` skips its concurrency reservation on the strength of that, so a stranded row would run a foreign tenant's workflow under its owner's credentials, billed to the wrong payer. The move unpublishes those blocks through the product's own `deleteCustomBlock` and records the loss in the source organization's audit view. - A fork parent and child always share an organization. `resolveForkEdge` has no org check at all, so the move refuses while a cross-org edge would result. Move-time checks alone cannot hold either invariant, because the writers can commit after them. `publishCustomBlock` now validates and inserts under the organization mutation lock the move holds, and `createFork` row-locks its parent so the move and the organization-attach path — both of which take `FOR NO KEY UPDATE` on that row — serialize against it. Pending invitations block: re-stamping an org-scoped invitation would convert a pending membership in the source org into one in the destination, consuming a seat for an invitation the destination never issued. An entitlement downgrade blocks. When entitlement is subscription-backed, both organizations are re-evaluated under the locks rather than trusting a pre-transaction read that is stale in both directions. The two modes where `resolveOrganizationEnterprisePlan` grants entitlement by deployment configuration are excluded through a shared `isSubscriptionBackedEntitlement` predicate, so a missing subscription row is never misread as a lapse. Both organizations are locked, ascending by id, mirroring `acquireOrganizationUserMutationLocks`. The source id is read optimistically before the transaction and re-verified under the locks, retrying through the existing loop when it moved.
654030f to
7809360
Compare
|
@cubic review |
@mzxchandra I have started the AI code review. It will take a few minutes to complete. |
There was a problem hiding this comment.
No issues found across 9 files
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.
Re-trigger cubic
Summary
Lets the admin panel move a workspace from one organization to another. The move was restricted to personal/grandfathered sources —
assertWorkspaceMovablethrew "Inter-organization workspace transfers are not supported" — so support could only re-home a workspace with manual SQL.Most of the machinery already existed: durable operation IDs, seat-capacity checks, and
changeWorkspaceStoragePayerInTx, which already accepted an arbitrary source payer, so the storage-ledger rebalance between two orgs needed no change. The work is a guard relaxation plus handling what the source organization loses.Two invariants this feature could break, now enforced here
Moving a workspace between organizations is the first operation in the product capable of separating an artifact from the org that owns it. Two invariants nothing has ever had to defend:
A custom block and its bound workflow always share an organization.
publishCustomBlockrefuses a workflow outside the target org, so the pair has always been co-located.getCustomBlockAuthorityresolves by the consumer's org, andadmitCustomBlockChildExecutiondeliberately skips its concurrency reservation because "the consumer and source workspaces are always in the same organization". A stranded row would run a foreign tenant's workflow under its owner's credentials, billed to the wrong payer. The move unpublishes those blocks through the product's owndeleteCustomBlock, and recordsCUSTOM_BLOCK_DELETEDin the source org's audit view.A fork parent and child always share an organization.
assertCanForkpins the child to the source's org andresolveForkEdgehas no org check at all. The move refuses while a cross-org edge would result; the fork must be disconnected first.Three blockers
Each is evaluated in preflight and re-checked inside the transaction, because a subscription can lapse, a fork can be created, and an invitation can arrive in between.
fork-lineage-conflictpending-invitations-presentdestination-entitlement-downgradeBlocking on invitations deleted the most intricate part of the design — no cross-org invitation migration, no intent resolver, no sibling-invitation creation.
invitation-migration-plan.tsis untouched and the shipped personal→org invitation path is not modified at all.Concurrency
Both organizations are now locked, ascending by id, mirroring
acquireOrganizationUserMutationLocks. The source id is read optimistically before the transaction and re-verified under the locks, retrying through the existing loop viaSourceOrganizationChangedError. Lock order is unchanged: invitation/workspace advisory locks → org locks → workspace row.Audit
The workspace-scoped move entry resolves to the destination after the move, because
buildOrgScopeConditionscopes org audit reads by the org's current workspaces. Without more, the org that lost the workspace would have no record. Adds an org-level entry (workspaceId: null+metadata.organizationId, that condition's other branch) so the loss is diagnosable from the side that incurred it.Type of Change
Testing
Automated: full
apps/simsuite — 37,306 tests across 2,669 files, zero failures.turbo run type-checkclean across all 26 workspaces.check:api-validation:strict,check:boundaries,check:client-boundary,check:route-verbs,check:api-contract-routes,check:utils,check:import-specifiers, and the 39-auditcheck:auditsbundle all pass.11 new tests cover the org→org path: both org locks acquired in sorted order after the invitation locks and before the row lock; the payer transfer fenced on the source org read under those locks (the assertion that makes the whole move safe); each blocker refusing without mutating anything; custom blocks unpublished; the source-org audit entry landing in the source and not the destination; and the personal source path unchanged, as a regression fence.
End to end against a real database, two orgs and a workspace with content on both sides. Verified: org-owned workspaces appear as candidates with their source org named; every blocker refuses and mutates nothing; the happy path re-homes org, payer, and
organizationAssignedAt; the move is reversible; replaying anoperationIdis idempotent; and both invariant post-conditions hold (zero cross-org custom blocks, zero cross-org fork edges).E2E surfaced two defects the unit tests missed, both now with regression tests:
getMovedWorkspaceSummarytook anappliedContextparameter no call site passed, so every applied move reportedsourceOrganization: null; and the source-org audit entries were specified but never implemented.Reviewers should focus on the lock ordering in
moveWorkspaceToOrganizationand theexpectedCurrentPayerfence — that optimistic check is what makes the payer transfer safe under concurrency.Checklist
Notes for reviewers
Not verifiable on a local deployment.
resolveOrganizationEnterprisePlanshort-circuits onisAccessControlEnabled && !isHosted, so every org resolves as entitled locally and the downgrade blocker cannot fire. That is correct — the gates it protects also all-pass there, so nothing is lost — but it means the blocker needs a hosted deployment to exercise. Related:isOrganizationOnEnterprisePlanis really "is on a paid org plan" (isOrgPlan = isTeam || isEnterprise), so Team is not a downgrade from Enterprise for these capabilities. The blocker reuses that same predicate deliberately, so it can never disagree with the gates it protects.Companion PR in
simstudioai/adminadds the review modal that presents all of this before the admin confirms.Review history
Supersedes #7243, which is closed. That PR carried the same file state but had accumulated ~45 review threads across 10 rounds, and the bots had started re-reviewing the thread history rather than the diff — re-raising findings already fixed and regressing the score on unchanged code. This is a clean slate on identical code.
23 findings were fixed there before this PR was opened, every one a real bug except a single false positive that was pushed back on with the type definitions. The substantive ones:
publishCustomBlockand fork creation wrote without the lock the move holds, so either could commit after its scans. Both now serialize with it. The first fix for the fork was itself racy (the policy organization is captured before the transaction, and the lock is skipped entirely when it is null), so the parent is now row-locked instead, which also removed a lock-order inversion against invitation acceptance, a 5s timeout overwriting the fork's 10s, and an organization-wide lock held across the whole content copy.sourceOrganizationIdthen dropped it, so every reload would have claimed the source was not persisted.usageCountis a union of live and deployed placements, so subtracting a live-only count misattributed blocks; both figures are now measured with identical predicates.isSubscriptionBackedEntitlementpredicate exported alongside the short-circuits it mirrors.EMPTY_CREDENTIAL_SUMMARYomitted two newly required contract fields, which would have failed validation on every successful move.