diff --git a/apps/docs/content/docs/platform/enterprise/forks.mdx b/apps/docs/content/docs/platform/enterprise/forks.mdx
index 4c0851c53a0..94334615944 100644
--- a/apps/docs/content/docs/platform/enterprise/forks.mdx
+++ b/apps/docs/content/docs/platform/enterprise/forks.mdx
@@ -57,7 +57,7 @@ Everything under **Copy resources** starts **selected**. That is usually what yo
Click **Fork**. The child workspace is created immediately. Deployed workflows land as **drafts** in the child. Large content (table rows, knowledge base files, file blobs) may finish copying in the background — watch **Activity** on the source workspace.
- Only **deployed** workflows are forked. Drafts and undeployed work stay in the parent. If the parent has nothing deployed, the child starts with a blank starter workflow.
+ Only **deployed** workflows are forked, and only the ones that are [synced](#synced-workflows). Drafts and undeployed work stay in the parent. If there is nothing to copy, the child starts with a blank starter workflow.
### 3. Open the parent edge (from the child)
@@ -117,16 +117,35 @@ On success you will see a toast such as **Pushed to "…"** or **Pulled from "
---
-## Excluded workflows
+## Synced workflows
-The **Excluded workflows** section on the Forks page lists this workspace's deployed workflows in their sidebar folder structure. Check a workflow — or a whole folder at once — to keep it out of forking entirely. Think of it as a `.gitignore` for syncs:
+The **Synced workflows** section on the Forks page lists this workspace's deployed workflows in their sidebar folder structure, each with a checkbox. **Checked means the workflow syncs.** Uncheck one — or a whole folder at once — to keep it out of forking entirely. Think of an unchecked workflow as `.gitignore`d:
- **Never sent** — pushes from this workspace do not carry it, the other side pulling from this workspace does not receive it, and creating a new fork does not copy it
-- **Never touched** — a sync into this workspace will not overwrite or archive it, even if its counterpart was deleted on the other side
+- **Never touched** — a sync into this workspace will not overwrite or archive it, even if its counterpart was deleted on the other side. It stays deployed and keeps serving, and a previously-synced counterpart on the other side keeps running on its last deployed version.
-The setting belongs to **this workspace's copy** only. Excluding a workflow here does not exclude its counterpart in the parent or a fork — each workspace manages its own list. If the pair has synced before, the link between them is kept, so un-excluding later resumes updating the same counterpart instead of creating a duplicate.
+The checkbox list belongs to **this workspace's copy** only. Unchecking a workflow here does not unsync its counterpart in the parent or a fork — each workspace manages its own list. If the pair has synced before, the link between them is kept, so re-checking later resumes updating the same counterpart instead of creating a duplicate.
-**Example:** a staging fork excludes `Scratch experiment` so it can never reach production, and production excludes `Billing hotfix` so no push from staging can ever overwrite it.
+On the sync page, unsynced workflows still appear in the **Deployed workflows** list, greyed out, with a tooltip naming which workspace they are unsynced in. The sync will not touch them.
+
+**Example:** a staging fork leaves `Scratch experiment` unchecked so it can never reach production, and production leaves `Billing hotfix` unchecked so no push from staging can ever overwrite it.
+
+### Sync new workflows by default
+
+Above the list, **Sync new workflows by default** decides where a **newly created** workflow starts:
+
+| Setting | A new workflow… |
+|---------|-----------------|
+| **On** (default) | joins fork sync — it arrives checked and syncs as soon as you deploy it |
+| **Off** | starts outside fork sync — it arrives unchecked and only syncs after you check it |
+
+Three things to know:
+
+- **It applies to the whole fork lineage.** The toggle writes every workspace in the lineage — the root, every ancestor, every descendant — so a parent and its forks can never disagree about what "new" means. Any workspace admin in the lineage can change it, and each member gets its own audit entry naming the workspace the change came from. A new fork inherits the value at creation.
+- **It is forward-only.** Flipping it never moves an existing workflow in or out of sync. The checkbox list above stays the record of what syncs.
+- **"New" means genuinely new.** Creating, duplicating, or importing a workflow takes this setting, as does the blank starter workflow a fork gets when there is nothing to copy. A workflow that arrives as a **copy** — from a fork, or from a push or pull — inherits its source's own checkbox instead, so a workflow you deliberately synced never lands unsynced in the child.
+
+**Example:** a template workspace turns this off so every scratch workflow the team creates stays local, then checks only the handful meant to reach the forks.
---
@@ -155,6 +174,8 @@ Expand a row for names of workflows and resources that were created, updated, or
|--------|-----|
| See Forks / create a fork | Admin on this workspace (+ feature available) |
| Sync / edit mappings | Admin on **both** sides of the edge |
+| Check / uncheck **Synced workflows** | Admin on the workspace those workflows live in |
+| Change **Sync new workflows by default** | Admin on any one workspace in the lineage — the change applies to every member |
| Rollback | Admin on the workspace the sync landed in |
| Disconnect | Admin on **this** side only (you can disconnect even without access to the other workspace) |
| Open the other workspace | You must be a member of that workspace |
@@ -169,9 +190,9 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a
| Resource | Fork | Sync |
|----------|------|------|
-| Deployed workflows | Copied as drafts (unless excluded) | Updated / created / archived (force overwrite) |
+| Deployed workflows | Copied as drafts when [synced](#synced-workflows) | Updated / created / archived (force overwrite) |
| Undeployed workflows | Not copied | Not synced |
-| [Excluded workflows](#excluded-workflows) | Never | Never — not sent, not overwritten, not archived |
+| [Unsynced workflows](#synced-workflows) | Never | Never — not sent, not overwritten, not archived |
| Files | Optional copy (default on) | Map or copy |
| File folders referenced by workflows | Mirrored with their ancestor folders, even when empty | Map by canonical path |
| Tables | Optional copy (default on) | Map or copy |
@@ -191,11 +212,11 @@ How each resource behaves at **fork** time vs **sync** time. Use this when you a
### Workflows
-Only **deployed** workflows move. Deploy is the commit; sync is the force push/pull of those commits. Workflows marked [excluded](#excluded-workflows) never move in either direction.
+Only **deployed** workflows move, and only the ones checked under [Synced workflows](#synced-workflows). An unsynced workflow never moves in either direction.
| Feature | Behavior |
|---|----------|
-| **Fork** | Each deployed workflow becomes a **draft** in the child. Run history is not copied. Only folders that contain a copied workflow are kept. |
+| **Fork** | Each synced deployed workflow becomes a **draft** in the child. Run history is not copied. Only folders that contain a copied workflow are kept. |
| **Sync** | The change list shows what will be updated, created, or archived. The target is overwritten for those workflows. |
**Example:** Parent has `Support triage` deployed and `WIP experiment` as a draft. The fork gets only `Support triage` as a draft. A later push updates the child from the parent’s latest deploy of `Support triage`.
@@ -370,13 +391,16 @@ Schedules, webhooks, and triggers are not live in the child until you **deploy**
- **Rollback ≠ undo copies** — Workflow versions roll back; copied resources can remain as orphans.
- **Disconnect is permanent** — You cannot “reconnect” the same edge; you would fork again into a new workspace.
- **No grandparent sync** — Only the direct parent↔child pair.
+- **The sync default is lineage-wide** — **Sync new workflows by default** is one shared setting for the whole lineage, so changing it from a fork also changes it in the parent and every sibling fork.
---
---
diff --git a/apps/sim/app/api/superuser/import-workflow/route.ts b/apps/sim/app/api/superuser/import-workflow/route.ts
index 7ec0ceb17bf..f84d160f6e5 100644
--- a/apps/sim/app/api/superuser/import-workflow/route.ts
+++ b/apps/sim/app/api/superuser/import-workflow/route.ts
@@ -18,6 +18,7 @@ import {
} from '@/lib/workflows/persistence/utils'
import { sanitizeForExport } from '@/lib/workflows/sanitization/json-sanitizer'
import { deduplicateWorkflowName } from '@/lib/workflows/utils'
+import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
const logger = createLogger('SuperUserImportWorkflow')
@@ -149,6 +150,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
isDeployed: false, // Never copy deployment status
runCount: 0,
variables: sourceWorkflow.variables || {},
+ // An imported workflow is a NEW workflow in the target workspace, so it takes that
+ // workspace's fork-sync policy rather than the column default.
+ forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, targetWorkspaceId),
})
// Save using existing persistence logic
diff --git a/apps/sim/app/api/v1/admin/workflows/import/route.ts b/apps/sim/app/api/v1/admin/workflows/import/route.ts
index 3f4290a8b0d..5e0b85791bc 100644
--- a/apps/sim/app/api/v1/admin/workflows/import/route.ts
+++ b/apps/sim/app/api/v1/admin/workflows/import/route.ts
@@ -41,6 +41,7 @@ import {
notFoundResponse,
} from '@/app/api/v1/admin/responses'
import { extractWorkflowMetadata, type WorkflowImportRequest } from '@/app/api/v1/admin/types'
+import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
const logger = createLogger('AdminWorkflowImportAPI')
@@ -128,6 +129,10 @@ export const POST = withRouteHandler(
isDeployed: false,
runCount: 0,
variables: {},
+ // An imported workflow is a NEW workflow in this workspace, so it takes the
+ // workspace's fork-sync policy. Without this it lands on the column default and
+ // silently joins fork sync in a workspace that opted out.
+ forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId),
})
/**
diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
index 33f94c17a08..12b26db0c28 100644
--- a/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
+++ b/apps/sim/app/api/v1/admin/workspaces/[id]/import/route.ts
@@ -62,6 +62,7 @@ import type {
WorkspaceImportRequest,
WorkspaceImportResponse,
} from '@/app/api/v1/admin/types'
+import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
const logger = createLogger('AdminWorkspaceImportAPI')
@@ -363,6 +364,10 @@ async function importSingleWorkflow(
isDeployed: false,
runCount: 0,
variables: {},
+ // An imported workflow is a NEW workflow in this workspace, so it takes the
+ // workspace's fork-sync policy. Without this it lands on the column default and
+ // silently joins fork sync in a workspace that opted out.
+ forkSyncExcluded: await resolveForkSyncExclusionForNewWorkflow(db, workspaceId),
})
/**
diff --git a/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts b/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts
new file mode 100644
index 00000000000..1f9cdaf9d27
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/sync-default/route.ts
@@ -0,0 +1,29 @@
+import { updateForkSyncDefaultContract } from '@/lib/api/contracts/workspace-fork'
+import {
+ defineInternalJsonRoute,
+ internalRateLimits,
+ internalSessionAuth,
+} from '@/lib/api/server/routes'
+import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies'
+import { forkOperations } from '@/ee/workspace-forking/application/operations'
+import { setForkSyncDefault } from '@/ee/workspace-forking/application/sync-default'
+
+export const PUT = defineInternalJsonRoute({
+ contract: updateForkSyncDefaultContract,
+ auth: internalSessionAuth,
+ operation: forkOperations.syncDefault,
+ /**
+ * Rated, unlike its sibling fork routes. This is the one that writes workspaces the
+ * caller may not administer, under the feature's coarsest advisory lock, so an admin of
+ * any single lineage member could otherwise loop it and starve fork creation across the
+ * whole lineage.
+ */
+ rateLimit: internalRateLimits.user({ bucketName: 'workspace-fork-sync-default' }),
+ errorPolicy: internalForkErrorPolicy,
+ mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
+ present: ({ excludeNewWorkflows, changedWorkspaces }) => ({
+ excludeNewWorkflows,
+ workspacesUpdated: changedWorkspaces.length,
+ }),
+ useCase: setForkSyncDefault,
+})
diff --git a/apps/sim/ee/workspace-forking/application/lineage-details.ts b/apps/sim/ee/workspace-forking/application/lineage-details.ts
index d7656da859c..a2143b28b91 100644
--- a/apps/sim/ee/workspace-forking/application/lineage-details.ts
+++ b/apps/sim/ee/workspace-forking/application/lineage-details.ts
@@ -4,6 +4,7 @@ import { eq } from 'drizzle-orm'
import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils'
import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage'
import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store'
+import { resolveForkSyncExclusionForNewWorkflow } from '@/ee/workspace-forking/lib/sync-default'
/**
* Annotates a lineage node with whether the viewer holds any access to it (explicit
@@ -34,10 +35,12 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({
context: { userId: string }
}) {
const { workspaceId } = input
- const [rawParent, rawChildren, run] = await Promise.all([
+ const [rawParent, rawChildren, run, forkSyncNewWorkflowsExcluded] = await Promise.all([
getForkParent(workspaceId),
getForkChildren(workspaceId),
getUndoableRunForTarget(db, workspaceId),
+ // Lineage-uniform, so this workspace's own value is the lineage's value.
+ resolveForkSyncExclusionForNewWorkflow(db, workspaceId),
])
const [parent, children] = await Promise.all([
@@ -71,6 +74,7 @@ export const getWorkspaceForkLineageDetails = defineForkUseCase({
createdAt: child.createdAt.toISOString(),
})),
undoableRun,
+ forkSyncNewWorkflowsExcluded,
}
},
})
diff --git a/apps/sim/ee/workspace-forking/application/operations.ts b/apps/sim/ee/workspace-forking/application/operations.ts
index bdc5871fe89..27759c9e9b5 100644
--- a/apps/sim/ee/workspace-forking/application/operations.ts
+++ b/apps/sim/ee/workspace-forking/application/operations.ts
@@ -98,4 +98,20 @@ export const forkOperations = {
id: 'workspaces.fork.exclusions',
oauthScope: 'api:write',
}),
+ /**
+ * Admin on the CALLING workspace is sufficient, and the write then fans out to every
+ * ancestor and descendant, because the default is meaningless unless it is uniform
+ * across a lineage. Flipping it to "sync new workflows" restores the historical
+ * behaviour rather than granting anything new, and it never moves an existing workflow
+ * in or out of sync - so each member records its own audit entry rather than the write
+ * being restricted to one workspace.
+ *
+ * permission-group-exempt: the new-workflow fork-sync default is workspace configuration governed by the admin role.
+ */
+ syncDefault: defineWorkspaceOperation({
+ ...adminPolicy,
+ capability: 'none',
+ id: 'workspaces.fork.sync_default',
+ oauthScope: 'api:write',
+ }),
} as const
diff --git a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts
index 0b8a5898a79..eb519e299bc 100644
--- a/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts
+++ b/apps/sim/ee/workspace-forking/application/recovery-and-mappings.ts
@@ -52,6 +52,10 @@ export const updateWorkspaceForkMappings = defineForkUseCase<
input.direction === 'push' ? input.otherWorkspaceId : input.workspaceId
return db.transaction(async (tx) => {
await setForkLockTimeout(tx)
+ // Rank 4 - see the rank table on `acquireForkLineageLock`. Unlike promote and
+ // rollback this takes no rank-3 target lock: it rewrites only this edge's mapping
+ // rows, never the target's workflows, so nothing contends with a sync into the
+ // target. Skipping a higher rank is not an ordering violation.
await acquireForkEdgeLock(tx, edge.childWorkspaceId)
const [currentEdge] = await tx
.select({ parentId: workspace.forkedFromWorkspaceId })
diff --git a/apps/sim/ee/workspace-forking/application/revision.ts b/apps/sim/ee/workspace-forking/application/revision.ts
index cb3fb69beeb..4ca0fb3b96d 100644
--- a/apps/sim/ee/workspace-forking/application/revision.ts
+++ b/apps/sim/ee/workspace-forking/application/revision.ts
@@ -140,7 +140,15 @@ export async function loadForkPreviewRevision(
}
}
-/** Locks normalized graph rows as well as workflow metadata, including realtime-only writes. */
+/**
+ * Locks normalized graph rows as well as workflow metadata, including realtime-only writes.
+ *
+ * Rank 5 - the heaviest acquirer in the fork module, and the one the rank table on
+ * `acquireForkLineageLock` exists for. It takes `FOR UPDATE` on the `workspace` rows, so
+ * any caller that also needs the rank-2 lineage lock must take that one FIRST; doing it
+ * the other way round deadlocks against `unlinkForkEdge`, which holds the lineage key and
+ * then updates the same `workspace` row.
+ */
export async function lockForkRevision(tx: DbTransaction, scope: ForkRevisionScope): Promise {
const workspaceIds = [
...new Set([
@@ -199,11 +207,21 @@ export async function assertForkSourceVersions(
sourceWorkspaceId: string,
expected: ReadonlyMap
): Promise {
+ // Verify exactly the workflows that were ADMITTED, rather than re-deriving the source
+ // predicate here. Re-deriving it duplicated `listDeployedWorkflows`'s filter, so the day
+ // a caller admitted a different set - "Copy unsynced workflows" admits sync-excluded
+ // workflows - this query returned fewer rows and every such fork failed on a phantom
+ // size mismatch. Keying off `expected` cannot drift from the admitted set by construction.
+ if (expected.size === 0) return
+ const admittedIds = sql.join(
+ [...expected.keys()].map((id) => sql`${id}`),
+ sql`, `
+ )
const rows = await tx.execute<{ workflowId: string; id: string; digest: string }>(sql`
SELECT w.id AS "workflowId", d.id, md5(d.state::text) AS digest FROM ${workflow} w
JOIN ${workflowDeploymentVersion} d ON d.workflow_id = w.id AND d.is_active = true
WHERE w.workspace_id = ${sourceWorkspaceId} AND w.is_deployed = true
- AND w.archived_at IS NULL AND w.fork_sync_excluded = false
+ AND w.archived_at IS NULL AND w.id IN (${admittedIds})
`)
if (
rows.length !== expected.size ||
diff --git a/apps/sim/ee/workspace-forking/application/sync-default.test.ts b/apps/sim/ee/workspace-forking/application/sync-default.test.ts
new file mode 100644
index 00000000000..953001acd26
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/application/sync-default.test.ts
@@ -0,0 +1,137 @@
+/**
+ * @vitest-environment node
+ */
+import { createSessionPrincipal } from '@sim/testing/factories/principal.factory'
+import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock'
+import { dbChainMockFns, resetDbChainMock } from '@sim/testing/mocks/database.mock'
+import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
+import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock'
+import {
+ workspaceAuthorizationMock,
+ workspaceAuthorizationMockFns,
+} from '@sim/testing/mocks/workspace-authorization.mock'
+import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock'
+import { workspaceForkingLineageMock } from '@sim/testing/mocks/workspace-forking-lineage.mock'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const hoisted = vi.hoisted(() => ({
+ resolveRootId: vi.fn(),
+ resolveLineage: vi.fn(),
+}))
+
+vi.mock('@sim/audit', () => auditMock)
+vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock)
+vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
+vi.mock('@/lib/posthog/server', () => posthogServerMock)
+vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock)
+vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock)
+vi.mock('@/ee/workspace-forking/lib/sync-default', () => ({
+ resolveForkLineageRootId: hoisted.resolveRootId,
+ resolveForkLineageWorkspaceIds: hoisted.resolveLineage,
+}))
+
+import { setForkSyncDefault } from '@/ee/workspace-forking/application/sync-default'
+
+const principal = createSessionPrincipal({ userId: 'actor-1' })
+const LINEAGE = ['root-ws', 'fork-a', 'fork-b', 'grandchild']
+
+beforeEach(() => {
+ resetDbChainMock()
+ vi.clearAllMocks()
+ permissionsMockFns.mockGetWorkspaceWithOwner.mockResolvedValue({
+ id: 'fork-a',
+ name: 'Fork A',
+ organizationId: null,
+ allowPersonalApiKeys: true,
+ })
+ workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined)
+ hoisted.resolveRootId.mockResolvedValue('root-ws')
+ hoisted.resolveLineage.mockResolvedValue(LINEAGE)
+ // The use case's `UPDATE ... RETURNING` over the global @sim/db mock: the rows it returns
+ // are the lineage members whose value actually changed, each with the name the audit
+ // entry is filed under.
+ dbChainMockFns.returning.mockResolvedValue(LINEAGE.map((id) => ({ id, name: `Name of ${id}` })))
+})
+
+const run = (excludeNewWorkflows: boolean) =>
+ setForkSyncDefault.execute({
+ principal,
+ input: { workspaceId: 'fork-a', excludeNewWorkflows },
+ })
+
+describe('setForkSyncDefault', () => {
+ it('writes every lineage member when issued from a mid-lineage fork', async () => {
+ await expect(run(true)).resolves.toMatchObject({
+ excludeNewWorkflows: true,
+ changedWorkspaces: LINEAGE.map((id) => ({ id, name: `Name of ${id}` })),
+ })
+ })
+
+ /**
+ * One entry per member, because the default genuinely changed for all of them. A single
+ * entry on the calling workspace would leave the other members' admins with no record.
+ */
+ it('records one entry per CHANGED member, naming where the change was issued from', async () => {
+ await run(true)
+ const audited = auditMockFns.mockRecordAudit.mock.calls.map(([entry]) => entry)
+ expect(audited).toHaveLength(LINEAGE.length)
+ expect(audited.map((entry) => entry.resourceId).sort()).toEqual([...LINEAGE].sort())
+ for (const entry of audited) {
+ expect(entry.action).toBe('workspace.fork_sync_default_changed')
+ // Filed in the workspace it describes. Without an explicit workspaceId the wrapper
+ // defaults it to the caller's workspace, so every entry would pile into one log and
+ // the other members' admins would see nothing.
+ expect(entry.workspaceId).toBe(entry.resourceId)
+ // The member's OWN name, not a raw id and not the caller's name. A lineage-wide
+ // change that named only the calling workspace left every other member's audit
+ // entry reading as an opaque identifier.
+ expect(entry.resourceName).toBe(`Name of ${entry.resourceId}`)
+ expect(entry.metadata).toMatchObject({
+ forkSyncNewWorkflowsExcluded: true,
+ originWorkspaceId: 'fork-a',
+ originWorkspaceName: 'Fork A',
+ })
+ }
+ })
+
+ /**
+ * `projectAudit` maps over `changedWorkspaces` and `afterSuccess` returns early when it
+ * is empty, so an empty result IS "no audit, no analytics". Asserting the result rather
+ * than the mocks' call counts keeps this pinned to the value the fan-out reads.
+ */
+ it('reports no changed members when the value already matched everywhere', async () => {
+ dbChainMockFns.returning.mockResolvedValue([])
+ await expect(run(true)).resolves.toMatchObject({ changedWorkspaces: [] })
+ })
+
+ /**
+ * The update only touches members whose value differs, so auditing the whole lineage
+ * would file a change record against a workspace that already held the requested value.
+ */
+ it('records nothing for a member that already held the requested value', async () => {
+ dbChainMockFns.returning.mockResolvedValue([{ id: 'fork-b', name: 'Fork B' }])
+ await expect(run(true)).resolves.toMatchObject({
+ changedWorkspaces: [{ id: 'fork-b', name: 'Fork B' }],
+ })
+ const audited = auditMockFns.mockRecordAudit.mock.calls.map(([entry]) => entry)
+ expect(audited.map((entry) => entry.resourceId)).toEqual(['fork-b'])
+ })
+
+ /**
+ * The fan-out reaches workspaces the caller may not administer, so the admission check
+ * is the only thing standing between a non-admin and a lineage-wide write. Assert it
+ * rejects rather than trusting that the wrapper was wired up.
+ */
+ it('rejects a caller who fails workspace admission', async () => {
+ workspaceAuthorizationMockFns.mockAuthorizeWorkspaceOperation.mockRejectedValue(
+ new Error('forbidden')
+ )
+ await expect(run(true)).rejects.toThrow('forbidden')
+ })
+
+ it('carries the chosen value through, so turning the default back on is symmetric', async () => {
+ await expect(run(false)).resolves.toMatchObject({ excludeNewWorkflows: false })
+ const [entry] = auditMockFns.mockRecordAudit.mock.calls[0]
+ expect(entry.metadata).toMatchObject({ forkSyncNewWorkflowsExcluded: false })
+ })
+})
diff --git a/apps/sim/ee/workspace-forking/application/sync-default.ts b/apps/sim/ee/workspace-forking/application/sync-default.ts
new file mode 100644
index 00000000000..1b8ea739670
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/application/sync-default.ts
@@ -0,0 +1,163 @@
+import { AuditAction, AuditResourceType } from '@sim/audit'
+import { db } from '@sim/db'
+import { workspace } from '@sim/db/schema'
+import { compareStrings } from '@sim/utils/string'
+import { and, inArray, isNull, ne, sql } from 'drizzle-orm'
+import { captureServerEvent } from '@/lib/posthog/server'
+import { defineForkUseCase } from '@/ee/workspace-forking/application/authorized-fork-use-case'
+import { forkOperations } from '@/ee/workspace-forking/application/operations'
+import { ForkError } from '@/ee/workspace-forking/lib/lineage/authz'
+import {
+ acquireForkLineageLock,
+ setForkLockTimeout,
+} from '@/ee/workspace-forking/lib/lineage/lineage'
+import {
+ resolveForkLineageRootId,
+ resolveForkLineageWorkspaceIds,
+} from '@/ee/workspace-forking/lib/sync-default'
+
+export interface SetForkSyncDefaultInput {
+ workspaceId: string
+ excludeNewWorkflows: boolean
+}
+
+export interface SetForkSyncDefaultResult {
+ excludeNewWorkflows: boolean
+ /**
+ * Exactly the lineage members whose value changed, empty when it already matched
+ * everywhere. The audit fan-out projects one entry per element, and the surface derives
+ * its `workspacesUpdated` count from the length - one source of truth, so the count can
+ * never disagree with the entries actually filed.
+ */
+ changedWorkspaces: Array<{ id: string; name: string }>
+}
+
+/**
+ * Set whether newly created workflows start OUTSIDE fork sync, for an entire fork lineage.
+ *
+ * Admin on the calling workspace is enough: the default is meaningless unless it is
+ * uniform across a lineage, so the write fans out to every ancestor and descendant. It is
+ * forward-only - no existing workflow's `forkSyncExcluded` is touched, so flipping it can
+ * never move a workflow in or out of sync behind an admin's back.
+ *
+ * Serialized on the lineage-root advisory lock, which fork creation also takes, so a fork
+ * created concurrently cannot inherit a stale value.
+ */
+export const setForkSyncDefault = defineForkUseCase<
+ typeof forkOperations.syncDefault,
+ SetForkSyncDefaultInput,
+ SetForkSyncDefaultResult
+>({
+ operation: forkOperations.syncDefault,
+ async execute({ input }) {
+ return db.transaction(async (tx) => {
+ await setForkLockTimeout(tx)
+ const rootId = await resolveForkLineageRootId(tx, input.workspaceId)
+ // Rank 2, and the only fork lock this transaction takes before its rank-6 row
+ // locks - see the rank table on `acquireForkLineageLock`.
+ await acquireForkLineageLock(tx, rootId)
+ // Re-resolve under the lock and refuse if the root moved. An unlink committing
+ // between the read and the lock would leave us holding the OLD lineage's key while
+ // writing the new one's members, so a second write rooted at the new lineage could
+ // run concurrently over the same rows. Mirrors the organization re-check `createFork`
+ // performs under its own lock.
+ if ((await resolveForkLineageRootId(tx, input.workspaceId)) !== rootId) {
+ throw new ForkError(
+ 'The fork lineage changed while this request was being applied. Try again.',
+ 409
+ )
+ }
+ // Resolve the membership AFTER the lock: a fork created a moment ago must be
+ // included, and one being created right now is blocked on the same key.
+ const lineageWorkspaceIds = await resolveForkLineageWorkspaceIds(tx, input.workspaceId)
+ if (lineageWorkspaceIds.length === 0) {
+ return { excludeNewWorkflows: input.excludeNewWorkflows, changedWorkspaces: [] }
+ }
+ // Rank 6: take the member row locks in sorted id order BEFORE the update. A bare
+ // multi-row `UPDATE ... WHERE id IN (...)` acquires its row locks in whatever order
+ // the plan produces, so it is unordered against any other multi-workspace writer
+ // that takes no lineage lock - `lockWorkspaceRowsForPayerChanges` on the
+ // organization-attach path is one today. `revision.ts` locks this same table with an
+ // explicit `ORDER BY id FOR UPDATE` for exactly this reason; Drizzle's
+ // `.update().where(inArray(...))` cannot express ORDER BY, so this is the same
+ // lock-then-update shape.
+ const memberIds = sql.join(
+ [...lineageWorkspaceIds].sort(compareStrings).map((id) => sql`${id}`),
+ sql`, `
+ )
+ await tx.execute(
+ sql`SELECT id FROM ${workspace} WHERE id IN (${memberIds}) ORDER BY id FOR UPDATE`
+ )
+ const changed = await tx
+ .update(workspace)
+ .set({ forkSyncNewWorkflowsExcluded: input.excludeNewWorkflows, updatedAt: new Date() })
+ .where(
+ and(
+ inArray(workspace.id, lineageWorkspaceIds),
+ // Re-assert liveness at write time: `resolveForkLineageWorkspaceIds` filtered
+ // archived members when it read, but a workspace can be archived between that
+ // read and this update, and a policy write must never touch one.
+ isNull(workspace.archivedAt),
+ ne(workspace.forkSyncNewWorkflowsExcluded, input.excludeNewWorkflows)
+ )
+ )
+ // Return the NAME too, so the audit fan-out can identify each member the way a
+ // reader knows it. Projecting a bare id as `resourceName` made every entry but the
+ // caller's read as a raw identifier in the audit log.
+ .returning({ id: workspace.id, name: workspace.name })
+ return {
+ excludeNewWorkflows: input.excludeNewWorkflows,
+ // The members whose value ACTUALLY changed, not every member considered. Auditing
+ // the whole lineage would file a change record against a workspace that already
+ // held the requested value, making its history claim something that did not happen.
+ changedWorkspaces: changed,
+ }
+ })
+ },
+ /**
+ * One entry per workspace whose value actually changed. Every such member gets its own
+ * record, because the default genuinely moved for each of them and a single entry on the
+ * calling workspace would leave the others' admins with no trace. A member that already
+ * held the requested value gets nothing - it did not change.
+ */
+ projectAudit: ({ input, context, result }) =>
+ result.changedWorkspaces.map((member) => ({
+ action: AuditAction.WORKSPACE_FORK_SYNC_DEFAULT_CHANGED,
+ // File each entry in the workspace it describes, not the caller's. The audit
+ // wrapper defaults `workspaceId` to the initiating workspace, which would land
+ // every entry in one log and leave the other members' admins with no record of
+ // their own workspace changing - the exact gap this per-member fan-out exists
+ // to close.
+ workspaceId: member.id,
+ resourceType: AuditResourceType.WORKSPACE,
+ resourceId: member.id,
+ // The member's own name, read back from the UPDATE. Falling back to the id for
+ // every member but the caller made a lineage-wide change read as one named
+ // workspace and N opaque identifiers.
+ resourceName: member.name,
+ description: input.excludeNewWorkflows
+ ? 'New workflows no longer sync to forks by default'
+ : 'New workflows sync to forks by default',
+ metadata: {
+ forkSyncNewWorkflowsExcluded: input.excludeNewWorkflows,
+ // Which workspace in the lineage the admin changed it from, so a member's own
+ // log explains why its behaviour moved without an action taken on it.
+ originWorkspaceId: context.workspace.id,
+ originWorkspaceName: context.workspace.name,
+ workspacesChanged: result.changedWorkspaces.length,
+ },
+ })),
+ afterSuccess({ context, input, result }) {
+ if (result.changedWorkspaces.length === 0) return
+ captureServerEvent(
+ context.userId,
+ 'fork_sync_default_updated',
+ {
+ workspace_id: input.workspaceId,
+ fork_sync_new_workflows_excluded: input.excludeNewWorkflows,
+ workspaces_updated: result.changedWorkspaces.length,
+ },
+ { groups: { workspace: input.workspaceId } }
+ )
+ },
+})
diff --git a/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx b/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx
new file mode 100644
index 00000000000..7e3fa15cf97
--- /dev/null
+++ b/apps/sim/ee/workspace-forking/components/fork-sync-default-toggle/fork-sync-default-toggle.tsx
@@ -0,0 +1,81 @@
+'use client'
+
+import { ChipSwitch, Label, toast } from '@sim/emcn'
+import { getErrorMessage } from '@sim/utils/errors'
+import { useUpdateForkSyncDefault } from '@/ee/workspace-forking/hooks/workspace-fork'
+
+/** Both outcomes named, so "off" does not have to be inferred from the label. */
+const FORK_SYNC_DEFAULT_OPTIONS = [
+ { value: 'sync', label: 'Sync' },
+ { value: 'exclude', label: "Don't sync" },
+] as const
+
+interface ForkSyncDefaultToggleProps {
+ workspaceId: string
+ /** The lineage's stored policy: true means new workflows start outside fork sync. */
+ excludeNewWorkflows: boolean
+ /**
+ * True while the value on screen is not this workspace's own. Covers the first load AND
+ * the placeholder window after a workspace switch: `useForkLineage` sets
+ * `placeholderData: keepPreviousData`, so it serves the PREVIOUS workspace's policy with
+ * `isLoading: false`. Rendering then would show one workspace's value under another's
+ * name, and a click would write that stale value to the newly selected lineage.
+ */
+ loading: boolean
+}
+
+/**
+ * Whether a newly created workflow in this lineage joins fork sync automatically.
+ *
+ * Positive polarity, matching the checkbox list below it - on means new workflows sync,
+ * which is the historical default. The stored column is the negative
+ * `forkSyncNewWorkflowsExcluded`, so this component owns that inversion.
+ *
+ * The description is not decoration: this writes to every workspace in the lineage, and
+ * without it the control reads as a local preference. That is the "prevents a
+ * misunderstanding" case `sim-ui-copy.md` reserves supporting copy for.
+ *
+ * Forward-only - no existing workflow's checkbox moves, so flipping it can never silently
+ * pull a workflow into or out of sync.
+ */
+export function ForkSyncDefaultToggle({
+ workspaceId,
+ excludeNewWorkflows,
+ loading,
+}: ForkSyncDefaultToggleProps) {
+ const updateDefault = useUpdateForkSyncDefault()
+
+ // Render nothing until the lineage resolves, matching the workflow list below. A
+ // placeholder would have to guess a value, and guessing `false` selects "Sync" - the
+ // opposite of the truth for an opt-in lineage, which then visibly snaps once the real
+ // value lands. Disabled-but-wrong is worse than absent for a cross-workspace policy.
+ if (loading) return null
+
+ return (
+
+
+ {/* No `htmlFor`: `ChipSwitch` is a radio group and takes no id, so the group
+ carries its own `aria-label`. Matches `inbox-enable-toggle.tsx`. */}
+
+
+ Applies to every workspace in this fork lineage.
+