diff --git a/graphql.schema.json b/graphql.schema.json
index 96b4716c4..f3ab9f88e 100644
--- a/graphql.schema.json
+++ b/graphql.schema.json
@@ -17990,6 +17990,50 @@
         ],
         "possibleTypes": null
       },
+      {
+        "kind": "INPUT_OBJECT",
+        "name": "EnrollmentRelationshipInput",
+        "description": null,
+        "isOneOf": false,
+        "fields": null,
+        "inputFields": [
+          {
+            "name": "enrollmentId",
+            "description": null,
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "SCALAR",
+                "name": "ID",
+                "ofType": null
+              }
+            },
+            "defaultValue": null,
+            "isDeprecated": false,
+            "deprecationReason": null
+          },
+          {
+            "name": "relationshipToHoh",
+            "description": null,
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "ENUM",
+                "name": "RelationshipToHoH",
+                "ofType": null
+              }
+            },
+            "defaultValue": null,
+            "isDeprecated": false,
+            "deprecationReason": null
+          }
+        ],
+        "interfaces": null,
+        "enumValues": null,
+        "possibleTypes": null
+      },
       {
         "kind": "ENUM",
         "name": "EnrollmentSortOption",
@@ -28017,6 +28061,70 @@
         "enumValues": null,
         "possibleTypes": null
       },
+      {
+        "kind": "OBJECT",
+        "name": "JoinHouseholdPayload",
+        "description": "Autogenerated return type of JoinHousehold.",
+        "isOneOf": null,
+        "fields": [
+          {
+            "name": "donorHousehold",
+            "description": null,
+            "args": [],
+            "type": {
+              "kind": "OBJECT",
+              "name": "Household",
+              "ofType": null
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          },
+          {
+            "name": "errors",
+            "description": null,
+            "args": [],
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "LIST",
+                "name": null,
+                "ofType": {
+                  "kind": "NON_NULL",
+                  "name": null,
+                  "ofType": {
+                    "kind": "OBJECT",
+                    "name": "ValidationError",
+                    "ofType": null
+                  }
+                }
+              }
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          },
+          {
+            "name": "receivingHousehold",
+            "description": null,
+            "args": [],
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "OBJECT",
+                "name": "Household",
+                "ofType": null
+              }
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          }
+        ],
+        "inputFields": null,
+        "interfaces": [],
+        "enumValues": null,
+        "possibleTypes": null
+      },
       {
         "kind": "SCALAR",
         "name": "JsonObject",
@@ -30465,6 +30573,59 @@
             "isDeprecated": false,
             "deprecationReason": null
           },
+          {
+            "name": "joinHousehold",
+            "description": null,
+            "args": [
+              {
+                "name": "joiningEnrollmentInputs",
+                "description": null,
+                "type": {
+                  "kind": "NON_NULL",
+                  "name": null,
+                  "ofType": {
+                    "kind": "LIST",
+                    "name": null,
+                    "ofType": {
+                      "kind": "NON_NULL",
+                      "name": null,
+                      "ofType": {
+                        "kind": "INPUT_OBJECT",
+                        "name": "EnrollmentRelationshipInput",
+                        "ofType": null
+                      }
+                    }
+                  }
+                },
+                "defaultValue": null,
+                "isDeprecated": false,
+                "deprecationReason": null
+              },
+              {
+                "name": "receivingHouseholdId",
+                "description": null,
+                "type": {
+                  "kind": "NON_NULL",
+                  "name": null,
+                  "ofType": {
+                    "kind": "SCALAR",
+                    "name": "ID",
+                    "ofType": null
+                  }
+                },
+                "defaultValue": null,
+                "isDeprecated": false,
+                "deprecationReason": null
+              }
+            ],
+            "type": {
+              "kind": "OBJECT",
+              "name": "JoinHouseholdPayload",
+              "ofType": null
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          },
           {
             "name": "mergeClients",
             "description": null,
@@ -36101,6 +36262,22 @@
             "isDeprecated": false,
             "deprecationReason": null
           },
+          {
+            "name": "canSplitHouseholds",
+            "description": null,
+            "args": [],
+            "type": {
+              "kind": "NON_NULL",
+              "name": null,
+              "ofType": {
+                "kind": "SCALAR",
+                "name": "Boolean",
+                "ofType": null
+              }
+            },
+            "isDeprecated": false,
+            "deprecationReason": null
+          },
           {
             "name": "canViewDob",
             "description": null,
diff --git a/src/api/operations/access.fragments.graphql b/src/api/operations/access.fragments.graphql
index 2cbccc841..5820e7447 100644
--- a/src/api/operations/access.fragments.graphql
+++ b/src/api/operations/access.fragments.graphql
@@ -85,6 +85,7 @@ fragment ProjectAccessFields on ProjectAccess {
   canManageIncomingReferrals
   canManageOutgoingReferrals
   canManageExternalFormSubmissions
+  canSplitHouseholds
 }
 
 fragment OrganizationAccessFields on OrganizationAccess {
diff --git a/src/api/operations/enrollment.fragments.graphql b/src/api/operations/enrollment.fragments.graphql
index 60006c0c8..4f9d694b6 100644
--- a/src/api/operations/enrollment.fragments.graphql
+++ b/src/api/operations/enrollment.fragments.graphql
@@ -122,6 +122,7 @@ fragment AllEnrollmentDetails on Enrollment {
     hasUnits
     access {
       id
+      canSplitHouseholds
       canManageIncomingReferrals # determine whether to link to source referral
     }
     staffAssignmentsEnabled
@@ -269,3 +270,10 @@ fragment EnrollmentRangeFields on Enrollment {
   exitDate
   inProgress
 }
+
+fragment EnrollmentWithHouseholdFields on Enrollment {
+  ...EnrollmentFields
+  household {
+    ...HouseholdFields
+  }
+}
diff --git a/src/api/operations/enrollment.queries.graphql b/src/api/operations/enrollment.queries.graphql
index d13817050..149c6537f 100644
--- a/src/api/operations/enrollment.queries.graphql
+++ b/src/api/operations/enrollment.queries.graphql
@@ -15,14 +15,7 @@ query GetEnrollmentDetails($id: ID!) {
 
 query GetEnrollmentWithHousehold($id: ID!) {
   enrollment(id: $id) {
-    ...EnrollmentFields
-    household {
-      id
-      shortId
-      householdClients {
-        ...HouseholdClientFields
-      }
-    }
+    ...EnrollmentWithHouseholdFields
   }
 }
 
diff --git a/src/api/operations/household.operations.graphql b/src/api/operations/household.operations.graphql
new file mode 100644
index 000000000..391166b01
--- /dev/null
+++ b/src/api/operations/household.operations.graphql
@@ -0,0 +1,16 @@
+mutation JoinHousehold(
+  $receivingHouseholdId: ID!
+  $joiningEnrollmentInputs: [EnrollmentRelationshipInput!]!
+) {
+  joinHousehold(
+    receivingHouseholdId: $receivingHouseholdId
+    joiningEnrollmentInputs: $joiningEnrollmentInputs
+  ) {
+    receivingHousehold {
+      ...HouseholdFields
+    }
+    donorHousehold {
+      ...HouseholdFields
+    }
+  }
+}
diff --git a/src/components/elements/CommonDialog.tsx b/src/components/elements/CommonDialog.tsx
index 8480202eb..165cce72c 100644
--- a/src/components/elements/CommonDialog.tsx
+++ b/src/components/elements/CommonDialog.tsx
@@ -4,11 +4,11 @@ import { useCallback } from 'react';
 
 import SentryErrorBoundary from '@/modules/errors/components/SentryErrorBoundary';
 
-interface Props extends DialogProps {
+export interface CommonDialogProps extends DialogProps {
   enableBackdropClick?: boolean;
 }
 
-const CommonDialog: React.FC<Props> = ({
+const CommonDialog: React.FC<CommonDialogProps> = ({
   children,
   onClose,
   enableBackdropClick = false,
diff --git a/src/components/elements/DateWithRelativeTooltip.tsx b/src/components/elements/DateWithRelativeTooltip.tsx
index c63bf7130..fdfa7a43d 100644
--- a/src/components/elements/DateWithRelativeTooltip.tsx
+++ b/src/components/elements/DateWithRelativeTooltip.tsx
@@ -54,7 +54,10 @@ const DateWithRelativeTooltip = ({
       >
         {formattedDate}
         {/* Include the tooltip text as visually hidden for accessibility */}
-        <Box sx={visuallyHidden}>, {formattedDateRelative}</Box>
+        {/* Add position: fixed to address visual bug when visuallyHidden is used inside dialog */}
+        <Box sx={{ ...visuallyHidden, position: 'fixed' }}>
+          , {formattedDateRelative}
+        </Box>
       </Typography>
     </Tooltip>
   );
diff --git a/src/components/elements/StepDialog.stories.tsx b/src/components/elements/StepDialog.stories.tsx
new file mode 100644
index 000000000..f2e71edab
--- /dev/null
+++ b/src/components/elements/StepDialog.stories.tsx
@@ -0,0 +1,27 @@
+import { Meta, StoryObj } from '@storybook/react';
+import StepDialog from './StepDialog';
+
+export default {
+  component: StepDialog,
+} as Meta<typeof StepDialog>;
+
+type Story = StoryObj<typeof StepDialog>;
+
+export const Default: Story = {
+  args: {
+    open: true,
+    title: 'Stepper Dialog Demo',
+    stepDefinitions: [
+      {
+        key: 'one',
+        title: 'One',
+        content: 'hello first tab',
+      },
+      {
+        key: 'two',
+        title: 'Two',
+        content: 'this is the second tab',
+      },
+    ],
+  },
+};
diff --git a/src/components/elements/StepDialog.tsx b/src/components/elements/StepDialog.tsx
new file mode 100644
index 000000000..a756464a3
--- /dev/null
+++ b/src/components/elements/StepDialog.tsx
@@ -0,0 +1,191 @@
+import { LoadingButton } from '@mui/lab';
+import {
+  Box,
+  Button,
+  ButtonProps,
+  DialogActions,
+  DialogContent,
+  DialogTitle,
+  Stack,
+  Typography,
+} from '@mui/material';
+import { ArrowLeftIcon, ArrowRightIcon } from '@mui/x-date-pickers';
+import React, { ReactNode, useMemo, useState } from 'react';
+import ButtonTooltipContainer from '@/components/elements/ButtonTooltipContainer';
+import CommonDialog, {
+  CommonDialogProps,
+} from '@/components/elements/CommonDialog';
+import Loading from '@/components/elements/Loading';
+
+export type StepDefinition = {
+  key: string;
+  title?: string;
+  content: ReactNode;
+
+  // if onProceed is not provided, the default action is to just go to the next step
+  onProceed?: () => Promise<any> | VoidFunction; // after promise resolves, go to the next step if there is one
+  proceedButtonText?: string;
+  proceedButtonType?: 'submit' | 'button';
+  proceedLoading?: boolean;
+  ButtonProps?: ButtonProps;
+
+  // `disableProceed` can be used to disable the proceed action
+  disableProceed?: boolean;
+  disabledReason?: string;
+};
+
+interface Props extends Omit<CommonDialogProps, 'onSubmit' | 'onClose'> {
+  title: string;
+  stepDefinitions: StepDefinition[];
+  onClose: VoidFunction;
+  loading?: boolean;
+}
+
+/**
+ * StepDialog is a Dialog that guides the user through several steps (StepDefinitions).
+ * Each step may render a title, content, and buttons to cancel, go back, or proceed.
+ *
+ * If a step has an onSubmit action specified, the proceed button triggers that action,
+ * waits for the promise to resolve, and then renders the next step.
+ * Note that this means a step with an `onSubmit` action does not necessarily need
+ * to be the last step in the workflow. The last step can, for example, be a
+ * successful response/wayfinding step.
+ *
+ * If no onSubmit is specified, the proceed button just renders the next step
+ * (it is purely navigational and does not submit anything).
+ *
+ * StepDialog only manages the state of which step is currently selected.
+ * If Steps have inputs that update some shared pieces of state, that state
+ * should live in StepDialog's parent (or wherever the StepDefinitions are defined).
+ */
+const StepDialog = ({
+  title,
+  stepDefinitions,
+  onClose,
+  loading,
+  ...rest
+}: Props) => {
+  const [currentStepKey, setCurrentStepKey] = useState(stepDefinitions[0].key);
+
+  const currentStepIndex = useMemo(
+    () => stepDefinitions.findIndex((t) => t.key === currentStepKey),
+    [currentStepKey, stepDefinitions]
+  );
+
+  const [prevStep, thisStep, nextStep] = useMemo(() => {
+    return [
+      stepDefinitions[currentStepIndex - 1],
+      stepDefinitions[currentStepIndex],
+      stepDefinitions[currentStepIndex + 1],
+    ];
+  }, [stepDefinitions, currentStepIndex]);
+
+  const {
+    title: stepTitle,
+    content,
+    onProceed,
+    proceedButtonText,
+    proceedButtonType = 'button',
+    proceedLoading,
+    disableProceed,
+    disabledReason,
+    ButtonProps,
+  } = thisStep;
+
+  const nextButton = useMemo(() => {
+    if (!onProceed && !nextStep) return undefined;
+
+    const handleClick = async () => {
+      if (onProceed) await onProceed();
+      if (nextStep) setCurrentStepKey(nextStep.key);
+    };
+
+    const defaultButtonText = nextStep ? nextStep.title || 'Next' : 'Finish';
+    const buttonText = proceedButtonText || defaultButtonText;
+
+    return (
+      <LoadingButton
+        onClick={handleClick}
+        type={proceedButtonType}
+        loading={proceedLoading}
+        sx={{ minWidth: '120px' }}
+        disabled={disableProceed}
+        endIcon={nextStep ? <ArrowRightIcon /> : undefined}
+        {...ButtonProps}
+      >
+        {buttonText}
+      </LoadingButton>
+    );
+  }, [
+    ButtonProps,
+    disableProceed,
+    nextStep,
+    onProceed,
+    proceedButtonText,
+    proceedButtonType,
+    proceedLoading,
+  ]);
+
+  return (
+    <CommonDialog onClose={onClose} {...rest}>
+      <DialogTitle>{title}</DialogTitle>
+      <DialogContent>
+        {loading ? (
+          <Loading />
+        ) : (
+          <Stack sx={{ mt: 2 }} gap={2}>
+            {stepTitle && (
+              <Box>
+                <Typography variant='overline'>
+                  Step {currentStepIndex + 1}
+                </Typography>
+                <Typography variant='h3'>{stepTitle}</Typography>
+              </Box>
+            )}
+            {content}
+          </Stack>
+        )}
+      </DialogContent>
+      {!!nextButton && (
+        <DialogActions>
+          <Stack
+            direction='row'
+            justifyContent={'space-between'}
+            sx={{ width: '100%' }}
+          >
+            <Box flexGrow={1}>
+              {prevStep && (
+                <Button
+                  startIcon={<ArrowLeftIcon />}
+                  color='grayscale'
+                  onClick={() => setCurrentStepKey(prevStep.key)}
+                >
+                  Back
+                </Button>
+              )}
+            </Box>
+
+            <Stack gap={3} direction='row'>
+              <Button onClick={onClose} color='grayscale'>
+                Cancel
+              </Button>
+
+              {disableProceed && disabledReason ? (
+                <ButtonTooltipContainer
+                  title={disabledReason}
+                  placement='top-start'
+                >
+                  {nextButton}
+                </ButtonTooltipContainer>
+              ) : (
+                nextButton
+              )}
+            </Stack>
+          </Stack>
+        </DialogActions>
+      )}
+    </CommonDialog>
+  );
+};
+
+export default StepDialog;
diff --git a/src/components/elements/table/GenericTable.tsx b/src/components/elements/table/GenericTable.tsx
index 084f2985d..9590d105c 100644
--- a/src/components/elements/table/GenericTable.tsx
+++ b/src/components/elements/table/GenericTable.tsx
@@ -19,16 +19,8 @@ import {
 } from '@mui/material';
 import { SystemStyleObject } from '@mui/system';
 import { visuallyHidden } from '@mui/utils';
-import { compact, get, includes, isNil, without } from 'lodash-es';
-import {
-  ComponentType,
-  ReactNode,
-  SyntheticEvent,
-  useCallback,
-  useEffect,
-  useMemo,
-  useState,
-} from 'react';
+import { compact, get, includes, isNil } from 'lodash-es';
+import { ComponentType, ReactNode, SyntheticEvent, useMemo } from 'react';
 import { To } from 'react-router-dom';
 
 import Loading from '../Loading';
@@ -44,6 +36,7 @@ import {
 } from './types';
 import { CommonMenuItem } from '@/components/elements/CommonMenuButton';
 import RouterLink from '@/components/elements/RouterLink';
+import { useTableSelection } from '@/components/elements/table/hooks/useTableSelection';
 import TableRowActions from '@/components/elements/table/TableRowActions';
 import { LocationState } from '@/routes/routeUtil';
 
@@ -76,8 +69,9 @@ export interface Props<T> {
   renderVerticalHeaderCell?: RenderFunction<T>;
   rowSx?: (row: T) => SxProps<Theme>;
   selectable?: 'row' | 'checkbox'; // selectable by clicking row or by clicking checkbox
+  selected?: readonly string[]; // selection can optionally be controlled by the parent
   isRowSelectable?: (row: T) => boolean;
-  onChangeSelectedRowIds?: (ids: readonly string[]) => void;
+  onChangeSelectedRowIds?: (ids: readonly string[]) => void; // Used BOTH by parents that control selection, AND those with uncontrolled selection to know what rows are currently selected
   EnhancedTableToolbarProps?: Omit<
     EnhancedTableToolbarProps<T>,
     'selectedIds' | 'rows'
@@ -283,6 +277,7 @@ const GenericTable = <T extends { id: string }>({
   rowSx,
   selectable,
   isRowSelectable,
+  selected: selectedProp,
   onChangeSelectedRowIds,
   EnhancedTableToolbarProps,
   filterToolbar,
@@ -298,41 +293,14 @@ const GenericTable = <T extends { id: string }>({
   );
   const hasHeaders = columns.find((c) => !!c.header);
 
-  // initially undefined so we can early return and avoid state flicker
-  const [selected, setSelected] = useState<string[]>();
-
-  const selectableRowIds = useMemo(() => {
-    if (!selectable) return [];
-    if (!isRowSelectable) return rows.map((r) => r.id);
-    return rows.filter(isRowSelectable).map((r) => r.id);
-  }, [rows, selectable, isRowSelectable]);
-
-  const handleSelectAllClick = useCallback(
-    (event: React.ChangeEvent<HTMLInputElement>) => {
-      if (event.target.checked) {
-        setSelected(selectableRowIds);
-      } else {
-        setSelected([]);
-      }
-    },
-    [selectableRowIds]
-  );
-
-  const handleSelectRow = useCallback(
-    (row: T) =>
-      setSelected((old) => {
-        if (!old) return undefined;
-        return old.includes(row.id) ? without(old, row.id) : [...old, row.id];
-      }),
-    []
-  );
-
-  // Clear selection when data changes
-  useEffect(() => setSelected([]), [rows]);
-
-  useEffect(() => {
-    if (selected) onChangeSelectedRowIds?.(selected);
-  }, [selected, onChangeSelectedRowIds]);
+  const { selected, selectableRowIds, handleSelectAllClick, handleSelectRow } =
+    useTableSelection({
+      selectable: !!selectable,
+      isRowSelectable,
+      rows,
+      selectedControlled: selectedProp,
+      onChangeSelected: onChangeSelectedRowIds,
+    });
 
   // avoid state flicker due to state reset
   if (!selected) return <Loading />;
@@ -389,7 +357,8 @@ const GenericTable = <T extends { id: string }>({
                 }
                 checked={
                   selectableRowIds.length > 0 &&
-                  selected.length === selectableRowIds.length
+                  // >= instead of === accommodates rows that are selected but disabled
+                  selected.length >= selectableRowIds.length
                 }
                 disabled={selectableRowIds.length === 0}
                 onChange={handleSelectAllClick}
@@ -410,6 +379,7 @@ const GenericTable = <T extends { id: string }>({
                   textAlign: def.textAlign,
                   width: def.width,
                 }}
+                {...def.headerCellProps}
               >
                 {renderHeaderCellContents(def)}
               </HeaderCell>
@@ -479,6 +449,7 @@ const GenericTable = <T extends { id: string }>({
                     sx={{ ...verticalCellSx(1), width: '350px' }}
                     key={getColumnKey(def)}
                     role='rowheader'
+                    {...def.headerCellProps}
                   >
                     {renderHeaderCellContents(def)}
                   </HeaderCell>
@@ -590,10 +561,15 @@ const GenericTable = <T extends { id: string }>({
 
                       const isLinked = rowLink && !dontLink;
 
+                      const cellProps =
+                        typeof tableCellProps === 'function'
+                          ? tableCellProps(row)
+                          : tableCellProps;
+
                       return (
                         <TableCell
                           key={getColumnKey(def) || index}
-                          {...tableCellProps}
+                          {...cellProps}
                           sx={{
                             ...getStickyCellStyles({
                               sticky,
@@ -605,7 +581,7 @@ const GenericTable = <T extends { id: string }>({
                             ...(isLinked ? { p: 0 } : undefined),
                             textAlign,
                             whiteSpace: 'initial',
-                            ...tableCellProps?.sx,
+                            ...cellProps?.sx,
                           }}
                           role={sticky === 'left' ? 'rowheader' : undefined}
                           // Reuse `dontLink` to prevent click propagation if the row has a click handler, but the cell has a more specific click target
diff --git a/src/components/elements/table/hooks/useTableSelection.tsx b/src/components/elements/table/hooks/useTableSelection.tsx
new file mode 100644
index 000000000..624092066
--- /dev/null
+++ b/src/components/elements/table/hooks/useTableSelection.tsx
@@ -0,0 +1,72 @@
+import { without } from 'lodash-es';
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+export function useTableSelection<T extends { id: string }>({
+  selectable = false,
+  isRowSelectable,
+  rows,
+  selectedControlled,
+  onChangeSelected,
+}: {
+  selectable?: boolean;
+  isRowSelectable?: (row: T) => boolean;
+  rows: T[];
+  selectedControlled?: readonly string[];
+  onChangeSelected?: (ids: readonly string[]) => void;
+}) {
+  // Initially set selected to undefined, so we can early return and avoid state flicker
+  const [selectedState, setSelectedState] = useState<string[]>();
+
+  // Table row selection can be either controlled or uncontrolled:
+  // - controlled: `selectedCtrl` and `onChangeSelectedCtrl` props passed from parent
+  // - uncontrolled: managed internally in `selectedState`
+  const isSelectControlled = selectedControlled !== undefined;
+
+  const selected = useMemo(
+    () => (isSelectControlled ? selectedControlled : selectedState || []),
+    [isSelectControlled, selectedState, selectedControlled]
+  );
+
+  const selectableRowIds = useMemo(() => {
+    if (!selectable) return [];
+    if (!isRowSelectable) return rows.map((r) => r.id);
+    return rows.filter(isRowSelectable).map((r) => r.id);
+  }, [rows, selectable, isRowSelectable]);
+
+  const handleSelectAllClick = useCallback(
+    (event: React.ChangeEvent<HTMLInputElement>) => {
+      if (event.target.checked) {
+        // select all
+        if (!isSelectControlled) setSelectedState(selectableRowIds);
+        onChangeSelected?.(selectableRowIds);
+      } else {
+        // deselect all
+        if (!isSelectControlled) setSelectedState([]);
+        onChangeSelected?.([]);
+      }
+    },
+    [isSelectControlled, onChangeSelected, selectableRowIds]
+  );
+
+  const handleSelectRow = useCallback(
+    (row: T) => {
+      const newValue = selected.includes(row.id)
+        ? without(selected, row.id)
+        : [...selected, row.id];
+
+      if (!isSelectControlled) setSelectedState(newValue);
+      onChangeSelected?.(newValue);
+    },
+    [isSelectControlled, onChangeSelected, selected]
+  );
+
+  // Clear selection when data changes
+  useEffect(() => setSelectedState([]), [rows]);
+
+  return {
+    selected,
+    selectableRowIds,
+    handleSelectAllClick,
+    handleSelectRow,
+  };
+}
diff --git a/src/components/elements/table/types.tsx b/src/components/elements/table/types.tsx
index dbe0578c5..9418064d5 100644
--- a/src/components/elements/table/types.tsx
+++ b/src/components/elements/table/types.tsx
@@ -13,7 +13,8 @@ type BaseColumnDef<T> = {
   // whether to NOT link this cell even when the whole row is linked using rowLinkTo. Use if there are clickable elements in the cell.
   dontLink?: boolean;
   textAlign?: 'center' | 'end' | 'justify' | 'left' | 'right' | 'start';
-  tableCellProps?: TableCellProps;
+  tableCellProps?: TableCellProps | ((row: T) => TableCellProps);
+  headerCellProps?: TableCellProps;
   optional?: boolean;
   defaultHidden?: boolean;
   sticky?: 'left' | 'right';
diff --git a/src/modules/clientAlerts/hooks/useClientAlerts.tsx b/src/modules/clientAlerts/hooks/useClientAlerts.tsx
index cd03d9ec3..93492de9d 100644
--- a/src/modules/clientAlerts/hooks/useClientAlerts.tsx
+++ b/src/modules/clientAlerts/hooks/useClientAlerts.tsx
@@ -4,6 +4,7 @@ import { useHasRootPermissions } from '@/modules/permissions/useHasPermissionsHo
 import {
   ClientAccess,
   ClientWithAlertFieldsFragment,
+  GetHouseholdClientAlertsQuery,
   useGetHouseholdClientAlertsQuery,
 } from '@/types/gqlTypes';
 
@@ -15,6 +16,7 @@ enum AlertPriority {
 
 interface ClientAlertParams {
   householdId?: string;
+  household?: GetHouseholdClientAlertsQuery['household'];
   client?: Omit<ClientWithAlertFieldsFragment, 'access'> & {
     access?: Partial<ClientAccess>;
   };
@@ -26,14 +28,19 @@ export default function useClientAlerts(params: ClientAlertParams) {
   const [canViewClientAlerts] = useHasRootPermissions(['canViewClientAlerts']);
 
   const {
-    data: { household } = {},
+    data: { household: queriedHousehold } = {},
     loading,
     error,
   } = useGetHouseholdClientAlertsQuery({
     variables: { id: params.householdId || '' },
-    skip: !params.householdId || !canViewClientAlerts,
+    skip: !params.householdId || !!params.household || !canViewClientAlerts,
   });
 
+  const household = useMemo(
+    () => queriedHousehold || params.household,
+    [params.household, queriedHousehold]
+  );
+
   const canViewAlertsForAnyHouseholdMembers = useMemo(() => {
     if (!canViewClientAlerts) return false;
     if (!household) return true;
diff --git a/src/modules/errors/components/ErrorAlert.tsx b/src/modules/errors/components/ErrorAlert.tsx
index af38a8326..c352896de 100644
--- a/src/modules/errors/components/ErrorAlert.tsx
+++ b/src/modules/errors/components/ErrorAlert.tsx
@@ -2,6 +2,7 @@ import { Alert, AlertProps, AlertTitle } from '@mui/material';
 import { find, reject } from 'lodash-es';
 
 import {
+  ErrorFilterFn,
   ErrorRenderFn,
   FIXABLE_ERROR_HEADING,
   UNKNOWN_VALIDATION_ERROR_HEADING,
@@ -16,14 +17,18 @@ const ErrorAlert = ({
   fixable = false,
   AlertProps = {},
   renderError,
+  errorFilter,
 }: {
   errors: ValidationError[];
   fixable?: boolean;
   AlertProps?: AlertProps;
   renderError?: ErrorRenderFn;
+  errorFilter?: ErrorFilterFn; // Allows the caller to filter out error messages (e.g. to selectively render outside the ErrorAlert, as in Join Households)
 }) => {
-  const filtered = reject(errors, ['severity', 'warning']);
-  if (filtered.length === 0) return null;
+  const filteredErrors = errorFilter ? errors.filter(errorFilter) : errors;
+
+  const errorsWithoutWarnings = reject(filteredErrors, ['severity', 'warning']);
+  if (errorsWithoutWarnings.length === 0) return null;
 
   let title = fixable
     ? FIXABLE_ERROR_HEADING
@@ -41,7 +46,7 @@ const ErrorAlert = ({
       {...AlertProps}
     >
       <AlertTitle>{title}</AlertTitle>
-      <ValidationErrorList errors={errors} renderError={renderError} />
+      <ValidationErrorList errors={filteredErrors} renderError={renderError} />
     </Alert>
   );
 };
diff --git a/src/modules/errors/util.ts b/src/modules/errors/util.ts
index 3bbb000eb..dd8fc05e3 100644
--- a/src/modules/errors/util.ts
+++ b/src/modules/errors/util.ts
@@ -41,6 +41,9 @@ export type ErrorRenderFn = (
   args?: { attributeOnly?: boolean }
 ) => React.ReactNode;
 
+export type ErrorFilterFn = (error: ValidationError) => boolean;
+export type OnChangeErrorsFn = (errors: ErrorState) => void;
+
 export const hasAnyValue = (state: ErrorState): boolean =>
   !!state.apolloError || state.errors.length > 0 || state.warnings.length > 0;
 
diff --git a/src/modules/form/components/DynamicForm.tsx b/src/modules/form/components/DynamicForm.tsx
index ea459643c..4566b71a2 100644
--- a/src/modules/form/components/DynamicForm.tsx
+++ b/src/modules/form/components/DynamicForm.tsx
@@ -22,7 +22,7 @@ import ApolloErrorAlert from '@/modules/errors/components/ApolloErrorAlert';
 import ErrorAlert from '@/modules/errors/components/ErrorAlert';
 import { ValidationDialogProps } from '@/modules/errors/components/ValidationDialog';
 import { useValidationDialog } from '@/modules/errors/hooks/useValidationDialog';
-import { ErrorState, hasErrors } from '@/modules/errors/util';
+import { ErrorFilterFn, ErrorState, hasErrors } from '@/modules/errors/util';
 import { formAutoCompleteOff } from '@/modules/form/util/formUtil';
 import { FormDefinitionJson } from '@/types/gqlTypes';
 
@@ -79,6 +79,7 @@ export interface DynamicFormProps
   localConstants?: LocalConstants;
   errorRef?: RefObject<HTMLDivElement>;
   variant?: 'standard' | 'without_top_level_cards';
+  errorFilter?: ErrorFilterFn;
 }
 export interface DynamicFormRef {
   SaveIfDirty: VoidFunction;
@@ -110,6 +111,7 @@ const DynamicForm = forwardRef(
       errorRef,
       onDirty,
       variant = 'standard',
+      errorFilter,
     }: DynamicFormProps,
     ref: Ref<DynamicFormRef>
   ) => {
@@ -242,7 +244,11 @@ const DynamicForm = forwardRef(
             <Grid item>
               <Stack gap={2}>
                 <ApolloErrorAlert error={errorState.apolloError} />
-                <ErrorAlert errors={errorState.errors} fixable />
+                <ErrorAlert
+                  errors={errorState.errors}
+                  fixable
+                  errorFilter={errorFilter}
+                />
               </Stack>
             </Grid>
           )}
diff --git a/src/modules/form/hooks/useFormDialog.tsx b/src/modules/form/hooks/useFormDialog.tsx
index 16ef3e02c..38141050d 100644
--- a/src/modules/form/hooks/useFormDialog.tsx
+++ b/src/modules/form/hooks/useFormDialog.tsx
@@ -5,7 +5,14 @@ import {
   DialogTitle,
   Grid,
 } from '@mui/material';
-import { ReactNode, useCallback, useMemo, useRef, useState } from 'react';
+import {
+  ReactNode,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useState,
+} from 'react';
 
 import DynamicForm, {
   DynamicFormProps,
@@ -23,7 +30,11 @@ import useFormDefinition from './useFormDefinition';
 
 import CommonDialog from '@/components/elements/CommonDialog';
 import Loading from '@/components/elements/Loading';
-import { emptyErrorState } from '@/modules/errors/util';
+import {
+  emptyErrorState,
+  ErrorFilterFn,
+  OnChangeErrorsFn,
+} from '@/modules/errors/util';
 
 import {
   FormDefinitionFieldsFragment,
@@ -48,6 +59,8 @@ interface Args<T> extends Omit<DynamicFormHandlerArgs<T>, 'formDefinition'> {
   onClose?: VoidFunction;
   localDefinition?: FormDefinitionFieldsFragment;
   projectId?: string; // Project context for fetching form definition
+  onChangeErrors?: OnChangeErrorsFn;
+  errorFilter?: ErrorFilterFn;
 }
 export function useFormDialog<T extends SubmitFormAllowedTypes>({
   onCompleted,
@@ -59,6 +72,8 @@ export function useFormDialog<T extends SubmitFormAllowedTypes>({
   localDefinition,
   pickListArgs,
   projectId,
+  onChangeErrors,
+  errorFilter,
 }: Args<T>) {
   const errorRef = useRef<HTMLDivElement>(null);
   const [dialogOpen, setDialogOpen] = useState<boolean>(false);
@@ -116,6 +131,10 @@ export function useFormDialog<T extends SubmitFormAllowedTypes>({
   const { initialValues, errors, onSubmit, submitLoading, setErrors } =
     useDynamicFormHandlersForRecord(hookArgs);
 
+  useEffect(() => {
+    onChangeErrors?.(errors);
+  }, [errors, onChangeErrors]);
+
   const closeDialog = useCallback(() => {
     setDialogOpen(false);
     setErrors(emptyErrorState);
@@ -185,6 +204,7 @@ export function useFormDialog<T extends SubmitFormAllowedTypes>({
                     }}
                     variant={variant}
                     hideSubmit
+                    errorFilter={errorFilter}
                     {...props}
                     errorRef={errorRef}
                   />
@@ -210,6 +230,7 @@ export function useFormDialog<T extends SubmitFormAllowedTypes>({
       closeDialog,
       definitionLoading,
       dialogOpen,
+      errorFilter,
       errors,
       formDefinition,
       formRole,
diff --git a/src/modules/hmis/hmisUtil.ts b/src/modules/hmis/hmisUtil.ts
index 07b4f9c9c..e4ef98106 100644
--- a/src/modules/hmis/hmisUtil.ts
+++ b/src/modules/hmis/hmisUtil.ts
@@ -660,3 +660,32 @@ export const raceEthnicityDisplayString = (race?: Race[]) => {
 
   return race.map((r) => HmisEnums.Race[r]).join(', ');
 };
+
+export function stringifyArray(arr: string[]) {
+  if (arr.length === 1) return arr[0];
+  const firsts = arr.slice(0, arr.length - 1);
+  const last = arr[arr.length - 1];
+  const finalJoiner = arr.length === 2 ? ' and ' : ', and '; // oxford comma
+  return firsts.join(', ') + finalJoiner + last;
+}
+
+export const stringifyHousehold = (
+  householdClients: HouseholdClientFieldsFragment[]
+) => {
+  return stringifyArray(
+    householdClients.map((hc) => clientBriefName(hc.client))
+  );
+};
+
+export const findHohOrRep = (
+  householdClients: HouseholdClientFieldsFragment[]
+) => {
+  // It's invalid, but possible in case of bad data, to have no HoH. If so, return the first household member
+  const sorted = sortHouseholdMembers(householdClients);
+  return (
+    sorted.find(
+      ({ relationshipToHoH }) =>
+        relationshipToHoH === RelationshipToHoH.SelfHeadOfHousehold
+    ) || sorted[0]
+  );
+};
diff --git a/src/modules/household/components/CreateHouseholdPage.tsx b/src/modules/household/components/CreateHouseholdPage.tsx
index 25ca8aa97..da16e4ba1 100644
--- a/src/modules/household/components/CreateHouseholdPage.tsx
+++ b/src/modules/household/components/CreateHouseholdPage.tsx
@@ -27,7 +27,7 @@ const CreateHouseholdPage = () => {
     <>
       <PageTitle title={`Enroll Household in ${project.projectName}`} />
       <ManageHousehold
-        projectId={project.id}
+        project={project}
         renderBackButton={(householdId) => (
           <BackButton
             onClick={() => {
diff --git a/src/modules/household/components/EditHouseholdMemberTable.tsx b/src/modules/household/components/EditHouseholdMemberTable.tsx
index 71d16627b..af589c05d 100644
--- a/src/modules/household/components/EditHouseholdMemberTable.tsx
+++ b/src/modules/household/components/EditHouseholdMemberTable.tsx
@@ -238,6 +238,9 @@ const EditHouseholdMemberTable = ({
           })}
           loading={loading}
           loadingVariant='linear'
+          tableProps={{
+            'aria-label': 'Manage Household',
+          }}
         />
       </SsnDobShowContextProvider>
       {renderValidationDialog({
diff --git a/src/modules/household/components/EditHouseholdPage.tsx b/src/modules/household/components/EditHouseholdPage.tsx
index 5ddc867f4..b5c41ef7e 100644
--- a/src/modules/household/components/EditHouseholdPage.tsx
+++ b/src/modules/household/components/EditHouseholdPage.tsx
@@ -23,7 +23,7 @@ const EditHousehold = () => {
 
       <ManageHousehold
         householdId={enrollment.householdId}
-        projectId={enrollment.project.id}
+        project={enrollment.project}
         BackButton={
           <BackButtonLink
             to={generateSafePath(EnrollmentDashboardRoutes.HOUSEHOLD, {
diff --git a/src/modules/household/components/ManageHousehold.tsx b/src/modules/household/components/ManageHousehold.tsx
index 4cd54c0d6..5cfa45ed6 100644
--- a/src/modules/household/components/ManageHousehold.tsx
+++ b/src/modules/household/components/ManageHousehold.tsx
@@ -31,21 +31,30 @@ import {
   ClientSortOption,
   EnrollmentFieldsFragment,
   ExternalIdentifierType,
+  ProjectAccessFieldsFragment,
+  ProjectAllFieldsFragment,
   SearchClientsDocument,
   SearchClientsQuery,
   SearchClientsQueryVariables,
 } from '@/types/gqlTypes';
 
+export type ManageHouseholdProject = Pick<
+  ProjectAllFieldsFragment,
+  'id' | 'projectName'
+> & {
+  access: Pick<ProjectAccessFieldsFragment, 'canSplitHouseholds'>;
+};
+
 interface Props {
   householdId?: string;
-  projectId: string;
+  project: ManageHouseholdProject;
   BackButton?: ReactNode;
   renderBackButton?: (householdId?: string) => ReactNode;
 }
 
 const ManageHousehold = ({
   householdId: initialHouseholdId,
-  projectId,
+  project,
   BackButton,
   renderBackButton,
 }: Props) => {
@@ -64,7 +73,7 @@ const ManageHousehold = ({
     householdId,
   } = useAddToHouseholdColumns({
     householdId: initialHouseholdId,
-    projectId,
+    project,
   });
 
   // Fetch members to show in "previously associated" table
@@ -150,7 +159,7 @@ const ManageHousehold = ({
             currentDashboardEnrollmentId={currentDashboardEnrollmentId}
             refetchHousehold={refetchHousehold}
             loading={loading}
-            projectId={projectId}
+            projectId={project.id}
           />
         </TitleCard>
       )}
@@ -185,7 +194,7 @@ const ManageHousehold = ({
               {hasSearched && (
                 <RootPermissionsFilter permissions='canEditClients'>
                   <AddNewClientButton
-                    projectId={projectId}
+                    projectId={project.id}
                     householdId={householdId}
                     onCompleted={handleNewClientAdded}
                   />
@@ -214,6 +223,9 @@ const ManageHousehold = ({
                   rowSecondaryActionConfigs={(row) => [
                     getViewClientMenuItem(row),
                   ]}
+                  tableProps={{
+                    'aria-label': 'Search results',
+                  }}
                 />
               </Paper>
             </SsnDobShowContextProvider>
diff --git a/src/modules/household/components/elements/AddToHouseholdButton.tsx b/src/modules/household/components/elements/AddToHouseholdButton.tsx
index 3419f6b56..14540bb91 100644
--- a/src/modules/household/components/elements/AddToHouseholdButton.tsx
+++ b/src/modules/household/components/elements/AddToHouseholdButton.tsx
@@ -1,38 +1,47 @@
-import { Button } from '@mui/material';
-import { useEffect, useMemo, useState } from 'react';
+import { Button, Stack } from '@mui/material';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 
 import ButtonTooltipContainer from '@/components/elements/ButtonTooltipContainer';
 import usePrevious from '@/hooks/usePrevious';
 
 import ClientAlertStack from '@/modules/clientAlerts/components/ClientAlertStack';
 import useClientAlerts from '@/modules/clientAlerts/hooks/useClientAlerts';
+import { ErrorState } from '@/modules/errors/util';
 import { useFormDialog } from '@/modules/form/hooks/useFormDialog';
 import { clientBriefName } from '@/modules/hmis/hmisUtil';
+import ConflictingEnrollmentAlert from '@/modules/household/components/householdActions/ConflictingEnrollmentAlert';
+import JoinHouseholdDialog from '@/modules/household/components/householdActions/JoinHouseholdDialog';
+import { ManageHouseholdProject } from '@/modules/household/components/ManageHousehold';
 import { useProjectCocsCountFromCache } from '@/modules/projects/hooks/useProjectCocsCountFromCache';
 import {
   ClientSearchResultFieldsFragment,
+  HouseholdFieldsFragment,
   RecordFormRole,
   SubmittedEnrollmentResultFieldsFragment,
+  ValidationError,
 } from '@/types/gqlTypes';
 
 interface Props {
   client: ClientSearchResultFieldsFragment;
   isMember: boolean;
   householdId?: string; // if omitted, a new household will be created
-  projectId: string;
+  project: ManageHouseholdProject;
   onSuccess: (householdId: string) => void;
+  household?: HouseholdFieldsFragment;
+  disabled?: boolean;
 }
 
 const AddToHouseholdButton = ({
   client,
   isMember,
-  householdId,
   onSuccess,
-  projectId,
+  project,
+  household,
+  disabled,
 }: Props) => {
   const prevIsMember = usePrevious(isMember);
   const [added, setAdded] = useState(isMember);
-  const cocCount = useProjectCocsCountFromCache(projectId);
+  const cocCount = useProjectCocsCountFromCache(project.id);
 
   useEffect(() => {
     // If client was previously added but has since been removed
@@ -41,11 +50,15 @@ const AddToHouseholdButton = ({
     }
   }, [prevIsMember, isMember, setAdded]);
 
-  let text = householdId ? 'Add to Household' : 'Enroll Client';
+  let text = household ? 'Add to Household' : 'Enroll Client';
   const color: 'secondary' | 'error' = 'secondary';
   if (added) text = 'Added';
   const clientId = client.id;
 
+  const [conflictingEnrollmentId, setConflictingEnrollmentId] = useState<
+    string | undefined
+  >();
+
   const memoedArgs = useMemo(
     () => ({
       formRole: RecordFormRole.Enrollment,
@@ -53,25 +66,69 @@ const AddToHouseholdButton = ({
         setAdded(true);
         onSuccess(data.householdId);
       },
-      inputVariables: { projectId, clientId },
-      pickListArgs: { projectId, householdId },
-      localConstants: { householdId, projectCocCount: cocCount },
+      inputVariables: { projectId: project.id, clientId },
+      pickListArgs: { projectId: project.id, householdId: household?.id },
+      localConstants: { householdId: household?.id, projectCocCount: cocCount },
+      errorFilter: (error: ValidationError) => {
+        // If there's an error about a conflicting enrollment, and we're adding to an existing household,
+        // then we will show the ConflictingEnrollmentAlert (so filter it out from the ErrorAlert display)
+        return !(
+          household?.id && error.data?.hasOwnProperty('conflictingEnrollmentId')
+        );
+      },
+      onChangeErrors: (errors: ErrorState) => {
+        const error = errors.errors.find((e) =>
+          e.data?.hasOwnProperty('conflictingEnrollmentId')
+        );
+        if (error) {
+          setConflictingEnrollmentId(error.data.conflictingEnrollmentId);
+        }
+      },
     }),
-    [projectId, clientId, householdId, cocCount, onSuccess]
+    [project.id, clientId, cocCount, onSuccess, household?.id]
   );
 
-  const { openFormDialog, renderFormDialog } =
+  const { openFormDialog, renderFormDialog, closeDialog } =
     useFormDialog<SubmittedEnrollmentResultFieldsFragment>(memoedArgs);
 
+  const [joinHouseholdDialogOpen, setJoinHouseholdDialogOpen] = useState(false);
+
   const { clientAlerts } = useClientAlerts({ client: client });
 
+  const clientAlertsComponent = useMemo(
+    () =>
+      clientAlerts.length > 0 ? (
+        <ClientAlertStack clientAlerts={clientAlerts} />
+      ) : undefined,
+    [clientAlerts]
+  );
+
+  const onCloseJoinHousehold = useCallback(() => {
+    setJoinHouseholdDialogOpen(false);
+    setConflictingEnrollmentId(undefined);
+  }, []);
+
+  const onJoinHouseholdSuccess = useCallback(
+    (joinedHousehold: HouseholdFieldsFragment) => {
+      // This only updates the initiating client's button
+      if (
+        joinedHousehold.householdClients.find(
+          (hc) => hc.client.id === client.id
+        )
+      ) {
+        setAdded(true);
+      }
+    },
+    [client.id]
+  );
+
   return (
     <>
       <ButtonTooltipContainer
         title={added ? 'Client is already a member of this household' : null}
       >
         <Button
-          disabled={added}
+          disabled={added || disabled}
           color={color}
           fullWidth
           size='small'
@@ -84,10 +141,38 @@ const AddToHouseholdButton = ({
       {renderFormDialog({
         title: <>Enroll {clientBriefName(client)}</>,
         submitButtonText: `Enroll`,
-        preFormComponent: clientAlerts.length > 0 && (
-          <ClientAlertStack clientAlerts={clientAlerts} />
-        ),
+        // TODO(#7234) This wrapper is necessary around the preFormComponent Stack,
+        // otherwise the form dialog renders some awkward extra spacing.
+        // It could be fixed more systematically with some updates to renderFormDialog's internals.
+        preFormComponent:
+          !!clientAlertsComponent || (household && conflictingEnrollmentId) ? (
+            <Stack gap={2}>
+              {clientAlertsComponent}
+              {household && conflictingEnrollmentId && (
+                <ConflictingEnrollmentAlert
+                  project={project}
+                  joiningClient={client}
+                  receivingHousehold={household}
+                  conflictingEnrollmentId={conflictingEnrollmentId}
+                  onClickJoinEnrollment={() => {
+                    closeDialog();
+                    setJoinHouseholdDialogOpen(true);
+                  }}
+                />
+              )}
+            </Stack>
+          ) : undefined,
       })}
+      {household && conflictingEnrollmentId && (
+        <JoinHouseholdDialog
+          open={joinHouseholdDialogOpen}
+          initiatorEnrollmentId={conflictingEnrollmentId}
+          onClose={onCloseJoinHousehold}
+          onSuccess={onJoinHouseholdSuccess}
+          receivingHousehold={household}
+          project={project}
+        />
+      )}
     </>
   );
 };
diff --git a/src/modules/household/components/householdActions/AddRelationshipsStep.tsx b/src/modules/household/components/householdActions/AddRelationshipsStep.tsx
new file mode 100644
index 000000000..a01b0406d
--- /dev/null
+++ b/src/modules/household/components/householdActions/AddRelationshipsStep.tsx
@@ -0,0 +1,125 @@
+import { Chip, Paper, Stack } from '@mui/material';
+import React, { ReactNode, useId } from 'react';
+import GenericTable from '@/components/elements/table/GenericTable';
+import ClientName from '@/modules/client/components/ClientName';
+import RequiredLabel from '@/modules/form/components/RequiredLabel';
+import HmisEnum from '@/modules/hmis/components/HmisEnum';
+import RelationshipToHohSelect from '@/modules/household/components/elements/RelationshipToHohSelect';
+import { WITH_ENROLLMENT_COLUMNS } from '@/modules/projects/components/tables/ProjectClientEnrollmentsTable';
+import {
+  asClient,
+  CLIENT_COLUMNS,
+} from '@/modules/search/components/ClientSearch';
+import { HmisEnums } from '@/types/gqlEnums';
+import {
+  HouseholdClientFieldsFragment,
+  RelationshipToHoH,
+} from '@/types/gqlTypes';
+
+interface Props {
+  existingClients: HouseholdClientFieldsFragment[];
+  newClients: HouseholdClientFieldsFragment[];
+  relationships: Record<string, RelationshipToHoH | null>;
+  updateRelationship: (
+    enrollmentId: string,
+    relationship: RelationshipToHoH | null
+  ) => void;
+  showNewIndicator?: boolean;
+  children?: ReactNode;
+}
+
+const AddRelationshipsStep = ({
+  existingClients,
+  newClients,
+  relationships,
+  updateRelationship,
+  showNewIndicator,
+  children,
+}: Props) => {
+  const relationshipHeaderId = useId();
+
+  return (
+    <Stack gap={2}>
+      {children}
+      <Paper>
+        <GenericTable<HouseholdClientFieldsFragment>
+          rows={[...existingClients, ...newClients]}
+          columns={[
+            {
+              ...CLIENT_COLUMNS.name,
+              render: (client) => (
+                <Stack direction='row' gap={1}>
+                  <ClientName client={asClient(client)} />
+                  {showNewIndicator && newClients.includes(client) && (
+                    <Chip label='New' size='small' variant='outlined' />
+                  )}
+                </Stack>
+              ),
+              sticky: 'left',
+              tableCellProps: (client) => {
+                return {
+                  // enables using aria-labelledby on inputs in this row
+                  id: `client-${client.id}`,
+                };
+              },
+            },
+            CLIENT_COLUMNS.age,
+            {
+              header: (
+                <RequiredLabel
+                  text='Relationship'
+                  TypographyProps={{
+                    fontWeight: 'bold',
+                  }}
+                  required={true}
+                />
+              ),
+              headerCellProps: {
+                id: relationshipHeaderId,
+              },
+              key: 'relationship',
+              render: (hc: HouseholdClientFieldsFragment) => {
+                if (newClients.includes(hc)) {
+                  return (
+                    <RelationshipToHohSelect
+                      value={relationships[hc.enrollment.id] || null}
+                      onChange={(_event, selected) => {
+                        updateRelationship(
+                          hc.enrollment.id,
+                          selected?.value || null
+                        );
+                      }}
+                      textInputProps={{
+                        warnIfEmptyTreatment: !relationships[hc.enrollment.id],
+                        inputProps: {
+                          'aria-labelledby': `client-${hc.id} ${relationshipHeaderId}`,
+                        },
+                      }}
+                    />
+                  );
+                } else {
+                  return (
+                    <HmisEnum
+                      key={hc.id}
+                      value={hc.relationshipToHoH}
+                      enumMap={HmisEnums.RelationshipToHoH}
+                      whiteSpace='nowrap'
+                    />
+                  );
+                }
+              },
+              tableCellProps: { sx: { py: 0 } },
+            },
+            WITH_ENROLLMENT_COLUMNS.entryDate,
+            WITH_ENROLLMENT_COLUMNS.enrollmentStatus,
+          ]}
+          tableProps={{
+            'aria-label': 'Add Relationships',
+          }}
+        />
+      </Paper>
+    </Stack>
+  );
+};
+
+export default AddRelationshipsStep;
diff --git a/src/modules/household/components/householdActions/ConflictingEnrollmentAlert.tsx b/src/modules/household/components/householdActions/ConflictingEnrollmentAlert.tsx
new file mode 100644
index 000000000..a226c1ba1
--- /dev/null
+++ b/src/modules/household/components/householdActions/ConflictingEnrollmentAlert.tsx
@@ -0,0 +1,106 @@
+import {
+  Alert,
+  AlertTitle,
+  Box,
+  Button,
+  List,
+  ListItem,
+  Stack,
+} from '@mui/material';
+import { useMemo } from 'react';
+import { generatePath } from 'react-router-dom';
+import ButtonLink from '@/components/elements/ButtonLink';
+import ButtonTooltipContainer from '@/components/elements/ButtonTooltipContainer';
+import { clientBriefName } from '@/modules/hmis/hmisUtil';
+import { ManageHouseholdProject } from '@/modules/household/components/ManageHousehold';
+import { EnrollmentDashboardRoutes } from '@/routes/routes';
+import {
+  ClientSearchResultFieldsFragment,
+  HouseholdFieldsFragment,
+  RelationshipToHoH,
+} from '@/types/gqlTypes';
+
+interface Props {
+  project: ManageHouseholdProject;
+  joiningClient: ClientSearchResultFieldsFragment;
+  conflictingEnrollmentId: string;
+  receivingHousehold: HouseholdFieldsFragment;
+  onClickJoinEnrollment: VoidFunction;
+}
+
+const ConflictingEnrollmentAlert = ({
+  project,
+  joiningClient,
+  receivingHousehold,
+  conflictingEnrollmentId,
+  onClickJoinEnrollment,
+}: Props) => {
+  const canSplitHouseholds = project.access.canSplitHouseholds;
+
+  const joiningClientName = clientBriefName(joiningClient);
+
+  const hohName = useMemo(() => {
+    const hoh = receivingHousehold.householdClients.find(
+      (hc) => hc.relationshipToHoH === RelationshipToHoH.SelfHeadOfHousehold
+    );
+    if (!hoh) return '';
+    return clientBriefName(hoh.client);
+  }, [receivingHousehold.householdClients]);
+
+  return (
+    <Alert severity='warning'>
+      <AlertTitle>Conflicting Enrollment</AlertTitle>
+      <Stack direction='column' gap={1}>
+        <Box>
+          {joiningClientName} has a conflicting enrollment in this project that
+          overlaps with the selected entry date.{' '}
+          {canSplitHouseholds && (
+            <>
+              You have two options:
+              <List sx={{ listStyle: 'decimal', pl: 4 }} component='ol'>
+                <ListItem sx={{ display: 'list-item' }}>
+                  The conflicting enrollment can be joined with {hohName}’s
+                  household.
+                </ListItem>
+                <ListItem sx={{ display: 'list-item' }}>
+                  To retain the conflicting enrollment, update the entry and/or
+                  exit dates to not overlap with this new entry date before
+                  attempting again to enroll.
+                </ListItem>
+              </List>
+            </>
+          )}
+        </Box>
+        <Stack direction='row' gap={2}>
+          {canSplitHouseholds ? (
+            <Button color='warning' onClick={onClickJoinEnrollment}>
+              Join Enrollments
+            </Button>
+          ) : (
+            <ButtonTooltipContainer
+              title={
+                "You don't have permission to join enrollments. Request permission from your HMIS administrator."
+              }
+            >
+              <Button disabled color='warning'>
+                Join Enrollments
+              </Button>
+            </ButtonTooltipContainer>
+          )}
+          <ButtonLink
+            to={generatePath(EnrollmentDashboardRoutes.ENROLLMENT_OVERVIEW, {
+              clientId: joiningClient.id,
+              enrollmentId: conflictingEnrollmentId,
+            })}
+            variant='contained'
+            color='grayscale'
+          >
+            View Conflicting Enrollment
+          </ButtonLink>
+        </Stack>
+      </Stack>
+    </Alert>
+  );
+};
+
+export default ConflictingEnrollmentAlert;
diff --git a/src/modules/household/components/householdActions/JoinHouseholdDialog.tsx b/src/modules/household/components/householdActions/JoinHouseholdDialog.tsx
new file mode 100644
index 000000000..4f7e36fe9
--- /dev/null
+++ b/src/modules/household/components/householdActions/JoinHouseholdDialog.tsx
@@ -0,0 +1,245 @@
+import { MergeTypeRounded } from '@mui/icons-material';
+import { Typography } from '@mui/material';
+import React, { useCallback, useMemo, useState } from 'react';
+import Loading from '@/components/elements/Loading';
+import StepDialog, { StepDefinition } from '@/components/elements/StepDialog';
+import {
+  clientBriefName,
+  findHohOrRep,
+  sortHouseholdMembers,
+  stringifyHousehold,
+} from '@/modules/hmis/hmisUtil';
+import AddRelationshipsStep from '@/modules/household/components/householdActions/AddRelationshipsStep';
+import JoinHouseholdReview from '@/modules/household/components/householdActions/JoinHouseholdReview';
+import JoinHouseholdSelectClients from '@/modules/household/components/householdActions/JoinHouseholdSelectClients';
+import SuccessWayfindingStep from '@/modules/household/components/householdActions/SuccessWayfindingStep';
+import { usePerformJoinHousehold } from '@/modules/household/hooks/usePerformJoinHousehold';
+import {
+  HouseholdClientFieldsFragment,
+  HouseholdFieldsFragment,
+  ProjectAllFieldsFragment,
+  RelationshipToHoH,
+  useGetEnrollmentWithHouseholdQuery,
+} from '@/types/gqlTypes';
+
+interface Props {
+  open: boolean;
+  initiatorEnrollmentId: string;
+  receivingHousehold: HouseholdFieldsFragment;
+  project: Pick<ProjectAllFieldsFragment, 'id' | 'projectName'>;
+  onClose: VoidFunction;
+  onSuccess?: (joinedHousehold: HouseholdFieldsFragment) => void;
+}
+
+const JoinHouseholdDialog = ({
+  open,
+  onClose,
+  onSuccess,
+  initiatorEnrollmentId,
+  receivingHousehold,
+  project,
+}: Props) => {
+  // `joiningClients` and `relationships` are shared across steps, so they are hoisted up and managed here.
+  // These pieces of state are updated live as soon as a user makes a selection within a step.
+  // Except for the `onSubmit` (joinHousehold mutation), the buttons to navigate between steps
+  // are purely navigational and don't submit anything.
+  const [joiningClients, setJoiningClients] = useState<
+    HouseholdClientFieldsFragment[]
+  >([]);
+  const [relationships, setRelationships] = useState<
+    Record<string, RelationshipToHoH | null>
+  >({});
+
+  // Fetch the initiator's enrollment by ID since we need the full EnrollmentWithHousehold
+  const {
+    data: { enrollment: initiatorEnrollment } = {},
+    loading: fetchLoading,
+    error: fetchError,
+  } = useGetEnrollmentWithHouseholdQuery({
+    variables: { id: initiatorEnrollmentId },
+    onCompleted: (data) => {
+      const household = data?.enrollment?.household;
+
+      const initiator = household?.householdClients.find(
+        (hc) => hc.enrollment.id === initiatorEnrollmentId
+      );
+
+      if (!household || !initiator) {
+        throw new Error(`Enrollment ${initiatorEnrollmentId} not found`);
+      }
+
+      // Set the joining clients list to the initiator client, plus, if they are the HoH, all their household members.
+      if (
+        initiator.relationshipToHoH === RelationshipToHoH.SelfHeadOfHousehold
+      ) {
+        setJoiningClients(sortHouseholdMembers(household.householdClients));
+      } else {
+        setJoiningClients([initiator]);
+      }
+    },
+  });
+
+  const donorHousehold = useMemo(
+    () => initiatorEnrollment?.household,
+    [initiatorEnrollment]
+  );
+
+  const receivingHoh = useMemo(
+    () => findHohOrRep(receivingHousehold.householdClients),
+    [receivingHousehold.householdClients]
+  );
+
+  const receivingHohName = useMemo(() => {
+    return clientBriefName(receivingHoh.client);
+  }, [receivingHoh]);
+
+  const missingRelationshipsCount = useMemo(
+    () =>
+      joiningClients.filter((jc) => !relationships[jc.enrollment.id]).length,
+    [joiningClients, relationships]
+  );
+
+  const missingRelationshipsProps = useMemo(() => {
+    return {
+      disableProceed: missingRelationshipsCount > 0,
+      disabledReason:
+        missingRelationshipsCount > 0
+          ? `Required fields missing (${missingRelationshipsCount})`
+          : undefined,
+    };
+  }, [missingRelationshipsCount]);
+
+  const {
+    performJoinHousehold,
+    loading: joinLoading,
+    error: joinError,
+    remainingHousehold,
+  } = usePerformJoinHousehold({ onSuccess });
+
+  const onSubmit = useCallback(
+    () =>
+      performJoinHousehold({
+        receivingHouseholdId: receivingHousehold.id,
+        joiningClients,
+        relationships,
+      }),
+    [joiningClients, performJoinHousehold, receivingHousehold.id, relationships]
+  );
+
+  const stepDefinitions: StepDefinition[] = useMemo(
+    () => [
+      {
+        key: 'select',
+        title: 'Select Clients',
+        content: (
+          <>
+            {donorHousehold && (
+              <JoinHouseholdSelectClients
+                donorHousehold={donorHousehold}
+                selectedClients={joiningClients}
+                setSelectedClients={setJoiningClients}
+                receivingHohName={receivingHohName}
+              />
+            )}
+          </>
+        ),
+        disableProceed: joiningClients.length === 0,
+        disabledReason: 'Select a client',
+      },
+      {
+        key: 'relationships',
+        title: 'Add Relationships',
+        content: (
+          <AddRelationshipsStep
+            existingClients={sortHouseholdMembers(
+              receivingHousehold.householdClients
+            )}
+            newClients={joiningClients}
+            showNewIndicator={true}
+            relationships={relationships}
+            updateRelationship={(enrollmentId, relationship) => {
+              setRelationships((prev) => {
+                return {
+                  ...prev,
+                  [enrollmentId]: relationship,
+                };
+              });
+            }}
+          >
+            <Typography variant='body1'>
+              Update joining clients’ relationships{' '}
+              {receivingHohName && <>to {receivingHohName}</>}
+            </Typography>
+          </AddRelationshipsStep>
+        ),
+        ...missingRelationshipsProps,
+      },
+      {
+        key: 'review',
+        title: 'Review Join',
+        content: !donorHousehold ? (
+          <Loading />
+        ) : (
+          <JoinHouseholdReview
+            joiningClients={joiningClients}
+            receivingHousehold={receivingHousehold}
+            donorHousehold={donorHousehold}
+            relationships={relationships}
+          />
+        ),
+        ...missingRelationshipsProps,
+        onProceed: onSubmit,
+        proceedLoading: joinLoading,
+        proceedButtonText: 'Join Enrollments',
+        ButtonProps: {
+          endIcon: <MergeTypeRounded />,
+        },
+        disabled: joinLoading || missingRelationshipsCount > 0,
+      },
+      {
+        key: 'success',
+        content: (
+          <SuccessWayfindingStep
+            title={'Successful Join'}
+            description={`${stringifyHousehold(joiningClients)} ${joiningClients.length > 1 ? 'have' : 'has'} been successfully joined to ${receivingHohName}’s Enrollment at ${project.projectName}`}
+            primaryClientName={receivingHohName}
+            secondary={findHohOrRep(remainingHousehold?.householdClients || [])}
+            project={project}
+            onClose={onClose}
+          />
+        ),
+      },
+    ],
+    [
+      donorHousehold,
+      joinLoading,
+      joiningClients,
+      missingRelationshipsCount,
+      missingRelationshipsProps,
+      onClose,
+      onSubmit,
+      project,
+      receivingHohName,
+      receivingHousehold,
+      relationships,
+      remainingHousehold?.householdClients,
+    ]
+  );
+
+  if (fetchError) throw fetchError;
+  if (joinError) throw joinError;
+
+  return (
+    <StepDialog
+      title={'Join Enrollments'}
+      loading={fetchLoading}
+      open={open}
+      fullWidth
+      maxWidth='md'
+      onClose={onClose}
+      stepDefinitions={stepDefinitions}
+    />
+  );
+};
+
+export default JoinHouseholdDialog;
diff --git a/src/modules/household/components/householdActions/JoinHouseholdReview.tsx b/src/modules/household/components/householdActions/JoinHouseholdReview.tsx
new file mode 100644
index 000000000..b7588492d
--- /dev/null
+++ b/src/modules/household/components/householdActions/JoinHouseholdReview.tsx
@@ -0,0 +1,87 @@
+import { Typography } from '@mui/material';
+import { uniqBy } from 'lodash-es';
+import { useMemo } from 'react';
+import {
+  sortHouseholdMembers,
+  stringifyHousehold,
+} from '@/modules/hmis/hmisUtil';
+import ReviewHouseholdsStep from '@/modules/household/components/householdActions/ReviewHouseholdsStep';
+import {
+  HouseholdClientFieldsFragment,
+  HouseholdFieldsFragment,
+  RelationshipToHoH,
+} from '@/types/gqlTypes';
+
+interface Props {
+  joiningClients: HouseholdClientFieldsFragment[];
+  donorHousehold: HouseholdFieldsFragment;
+  receivingHousehold: HouseholdFieldsFragment;
+  relationships: Record<string, RelationshipToHoH | null>;
+}
+
+const JoinHouseholdReview = ({
+  joiningClients,
+  donorHousehold,
+  receivingHousehold,
+  relationships,
+}: Props) => {
+  const joinedHouseholdClients = useMemo(() => {
+    return uniqBy(
+      [
+        // use uniqBy to avoid console warning after the household has been updated in the cache
+        ...sortHouseholdMembers(receivingHousehold.householdClients),
+        ...joiningClients.map((jc) => {
+          return {
+            ...jc,
+            relationshipToHoH:
+              relationships[jc.enrollment.id] ||
+              RelationshipToHoH.DataNotCollected,
+          };
+        }),
+      ],
+      'id'
+    );
+  }, [joiningClients, receivingHousehold.householdClients, relationships]);
+
+  const remainingHouseholdClients = useMemo(() => {
+    return sortHouseholdMembers(
+      donorHousehold.householdClients.filter(
+        (hc) => !joiningClients.includes(hc)
+      )
+    );
+  }, [donorHousehold.householdClients, joiningClients]);
+
+  const joining = useMemo(
+    () => stringifyHousehold(joiningClients),
+    [joiningClients]
+  );
+
+  return (
+    <ReviewHouseholdsStep
+      reviewableHouseholds={[
+        {
+          title: 'Joining Household',
+          description: `The household that ${joining} will join`,
+          members: joinedHouseholdClients,
+        },
+        ...(remainingHouseholdClients
+          ? [
+              {
+                title: 'Remaining Household',
+                description: `The household that ${joining} will leave`,
+                members: remainingHouseholdClients,
+              },
+            ]
+          : []),
+      ]}
+    >
+      <Typography variant='body1'>
+        Check that the joined{' '}
+        {remainingHouseholdClients.length > 0 && 'and remaining '}household
+        members and details are correct
+      </Typography>
+    </ReviewHouseholdsStep>
+  );
+};
+
+export default JoinHouseholdReview;
diff --git a/src/modules/household/components/householdActions/JoinHouseholdSelectClients.tsx b/src/modules/household/components/householdActions/JoinHouseholdSelectClients.tsx
new file mode 100644
index 000000000..73fffde49
--- /dev/null
+++ b/src/modules/household/components/householdActions/JoinHouseholdSelectClients.tsx
@@ -0,0 +1,117 @@
+import { InfoOutlined } from '@mui/icons-material';
+import { Alert, AlertTitle, Typography } from '@mui/material';
+
+import { Dispatch, SetStateAction, useCallback, useMemo } from 'react';
+import { generatePath } from 'react-router-dom';
+import ButtonLink from '@/components/elements/ButtonLink';
+import ClientAlertStack from '@/modules/clientAlerts/components/ClientAlertStack';
+import useClientAlerts from '@/modules/clientAlerts/hooks/useClientAlerts';
+import { clientBriefName, sortHouseholdMembers } from '@/modules/hmis/hmisUtil';
+import SelectClientsStep from '@/modules/household/components/householdActions/SelectClientsStep';
+import { EnrollmentDashboardRoutes } from '@/routes/routes';
+import {
+  HouseholdClientFieldsFragment,
+  HouseholdFieldsFragment,
+  RelationshipToHoH,
+} from '@/types/gqlTypes';
+
+interface Props {
+  donorHousehold: HouseholdFieldsFragment;
+  selectedClients: HouseholdClientFieldsFragment[];
+  setSelectedClients: Dispatch<SetStateAction<HouseholdClientFieldsFragment[]>>;
+  receivingHohName?: string;
+}
+
+const JoinHouseholdSelectClients = ({
+  donorHousehold,
+  selectedClients,
+  setSelectedClients: setSelectedClientsProp,
+  receivingHohName,
+}: Props) => {
+  const donorHoh = useMemo(
+    () =>
+      donorHousehold.householdClients.find(
+        (hc) => hc.relationshipToHoH === RelationshipToHoH.SelfHeadOfHousehold
+      ),
+    [donorHousehold.householdClients]
+  );
+
+  const isHohSelected = useMemo(() => {
+    return donorHoh && selectedClients.includes(donorHoh);
+  }, [donorHoh, selectedClients]);
+
+  // This is a controlled component, so the selected clients state is stored a level above,
+  // but here hijack the selection logic in order to select *all* members when HoH is selected.
+  // (Can't leave behind a household without a HoH)
+  const setSelectedClients = useCallback(
+    (clients: HouseholdClientFieldsFragment[]) => {
+      // If the HoH from the donor household is selected, all other hh members must be selected.
+      if (donorHoh && clients.includes(donorHoh)) {
+        setSelectedClientsProp(
+          sortHouseholdMembers(donorHousehold.householdClients)
+        );
+      } else {
+        setSelectedClientsProp(clients);
+      }
+    },
+    [donorHoh, setSelectedClientsProp, donorHousehold.householdClients]
+  );
+
+  const isRowSelectable = useCallback(
+    (row: HouseholdClientFieldsFragment) => {
+      if (donorHoh && isHohSelected) {
+        // Visually disable de-selecting other clients if the HoH is selected
+        // (This is also functionally disabled by the logic in setSelectedClientIds above)
+        return row.id === donorHoh.id;
+      }
+
+      return true;
+    },
+    [donorHoh, isHohSelected]
+  );
+
+  const { clientAlerts } = useClientAlerts({
+    household: donorHousehold,
+    showClientName: true,
+  });
+
+  return (
+    <SelectClientsStep
+      donorHousehold={donorHousehold}
+      selectedClients={selectedClients}
+      setSelectedClients={setSelectedClients}
+      isRowSelectable={isRowSelectable}
+    >
+      <Typography variant='body1'>
+        Select which clients you would like to join{' '}
+        {receivingHohName ? `${receivingHohName}’s` : 'this'} household.
+      </Typography>
+      {<ClientAlertStack clientAlerts={clientAlerts} />}
+      {donorHoh && isHohSelected && donorHousehold.householdSize > 1 && (
+        <Alert
+          color='info'
+          icon={<InfoOutlined />}
+          action={
+            <ButtonLink
+              variant='contained'
+              color='grayscale'
+              to={generatePath(EnrollmentDashboardRoutes.EDIT_HOUSEHOLD, {
+                clientId: donorHoh.client.id,
+                enrollmentId: donorHoh.enrollment.id,
+              })}
+            >
+              Edit Household
+            </ButtonLink>
+          }
+        >
+          <AlertTitle>Head of Household Selected</AlertTitle>
+          If the current Head of Household is selected, all members must also
+          join. Edit {clientBriefName(donorHoh.client)}’s household and select a
+          different Head of Household if this is not your intention.
+        </Alert>
+      )}
+    </SelectClientsStep>
+  );
+};
+
+export default JoinHouseholdSelectClients;
diff --git a/src/modules/household/components/householdActions/ReviewHouseholdsStep.tsx b/src/modules/household/components/householdActions/ReviewHouseholdsStep.tsx
new file mode 100644
index 000000000..1515cae78
--- /dev/null
+++ b/src/modules/household/components/householdActions/ReviewHouseholdsStep.tsx
@@ -0,0 +1,43 @@
+import { Paper, Stack, Typography } from '@mui/material';
+import { Fragment, ReactNode } from 'react';
+import GenericTable from '@/components/elements/table/GenericTable';
+import { MANAGE_HOUSEHOLD_COLUMNS } from '@/modules/household/components/householdActions/SelectClientsStep';
+import { HouseholdClientFieldsFragment } from '@/types/gqlTypes';
+
+type ReviewableHousehold = {
+  title: string;
+  description: string;
+  members: HouseholdClientFieldsFragment[];
+};
+
+interface Props {
+  reviewableHouseholds: ReviewableHousehold[];
+  children?: ReactNode;
+}
+
+const ReviewHouseholdsStep = ({ reviewableHouseholds, children }: Props) => {
+  return (
+    <Stack gap={2}>
+      {children}
+      {reviewableHouseholds.map((household) => {
+        if (household.members.length === 0) return;
+
+        return (
+          <Fragment key={household.title}>
+            <Typography variant='h4'>{household.title}</Typography>
+            <Typography variant='body2'>{household.description}</Typography>
+            <Paper>
+              <GenericTable<HouseholdClientFieldsFragment>
+                rows={household.members}
+                columns={MANAGE_HOUSEHOLD_COLUMNS}
+                tableProps={{ 'aria-label': household.title }}
+              />
+            </Paper>
+          </Fragment>
+        );
+      })}
+    </Stack>
+  );
+};
+
+export default ReviewHouseholdsStep;
diff --git a/src/modules/household/components/householdActions/SelectClientsStep.tsx b/src/modules/household/components/householdActions/SelectClientsStep.tsx
new file mode 100644
index 000000000..3f602c211
--- /dev/null
+++ b/src/modules/household/components/householdActions/SelectClientsStep.tsx
@@ -0,0 +1,83 @@
+import { Paper, Stack } from '@mui/material';
+
+import { ReactNode, useCallback, useMemo } from 'react';
+import GenericTable from '@/components/elements/table/GenericTable';
+import { ColumnDef } from '@/components/elements/table/types';
+import { sortHouseholdMembers } from '@/modules/hmis/hmisUtil';
+import { WITH_ENROLLMENT_COLUMNS } from '@/modules/projects/components/tables/ProjectClientEnrollmentsTable';
+import { ENROLLMENT_RELATIONSHIP_COL } from '@/modules/projects/components/tables/ProjectHouseholdsTable';
+import { CLIENT_COLUMNS } from '@/modules/search/components/ClientSearch';
+import {
+  HouseholdClientFieldsFragment,
+  HouseholdFieldsFragment,
+} from '@/types/gqlTypes';
+
+export const MANAGE_HOUSEHOLD_COLUMNS: ColumnDef<HouseholdClientFieldsFragment>[] =
+  [
+    { ...CLIENT_COLUMNS.name, sticky: 'left' },
+    CLIENT_COLUMNS.age,
+    ENROLLMENT_RELATIONSHIP_COL,
+    WITH_ENROLLMENT_COLUMNS.entryDate,
+    WITH_ENROLLMENT_COLUMNS.enrollmentStatus,
+  ];
+
+interface Props {
+  donorHousehold: HouseholdFieldsFragment;
+  selectedClients: HouseholdClientFieldsFragment[];
+  setSelectedClients: (clients: HouseholdClientFieldsFragment[]) => void;
+  children?: ReactNode;
+  isRowSelectable?: (client: HouseholdClientFieldsFragment) => boolean;
+}
+
+const SelectClientsStep = ({
+  donorHousehold,
+  selectedClients,
+  setSelectedClients,
+  isRowSelectable,
+  children,
+}: Props) => {
+  const donorHouseholdMembers = useMemo(
+    () => sortHouseholdMembers(donorHousehold.householdClients),
+    [donorHousehold.householdClients]
+  );
+
+  // Selection is controlled, so the selection state is stored in the parent.
+  // Here, translate the list of selected HouseholdClients from the parent to a list of row IDs for GenericTable.
+  const selectedClientIds = useMemo(
+    () => selectedClients.map((hc) => hc.id),
+    [selectedClients]
+  );
+
+  // .. and here, translate back again
+  const setSelectedClientIds = useCallback(
+    (clientIds: readonly string[]) => {
+      setSelectedClients(
+        sortHouseholdMembers(
+          donorHousehold.householdClients.filter((hc) =>
+            clientIds.includes(hc.id)
+          )
+        )
+      );
+    },
+    [donorHousehold.householdClients, setSelectedClients]
+  );
+
+  return (
+    <Stack gap={2}>
+      {children}
+      <Paper>
+        <GenericTable<HouseholdClientFieldsFragment>
+          rows={donorHouseholdMembers}
+          columns={MANAGE_HOUSEHOLD_COLUMNS}
+          selectable={'checkbox'}
+          selected={selectedClientIds}
+          onChangeSelectedRowIds={setSelectedClientIds}
+          isRowSelectable={isRowSelectable}
+          tableProps={{ 'aria-label': 'Select Clients' }}
+        />
+      </Paper>
+    </Stack>
+  );
+};
+
+export default SelectClientsStep;
diff --git a/src/modules/household/components/householdActions/SuccessWayfindingStep.tsx b/src/modules/household/components/householdActions/SuccessWayfindingStep.tsx
new file mode 100644
index 000000000..a4f712a41
--- /dev/null
+++ b/src/modules/household/components/householdActions/SuccessWayfindingStep.tsx
@@ -0,0 +1,68 @@
+import { Alert, AlertTitle, Button, Stack } from '@mui/material';
+import React from 'react';
+import { generatePath } from 'react-router-dom';
+import ButtonLink from '@/components/elements/ButtonLink';
+import { clientBriefName } from '@/modules/hmis/hmisUtil';
+import {
+  EnrollmentDashboardRoutes,
+  ProjectDashboardRoutes,
+} from '@/routes/routes';
+import {
+  HouseholdClientFieldsFragment,
+  ProjectAllFieldsFragment,
+} from '@/types/gqlTypes';
+
+interface Props {
+  title: string;
+  description: string;
+  primaryClientName: string;
+  secondary?: HouseholdClientFieldsFragment;
+  project: Pick<ProjectAllFieldsFragment, 'id' | 'projectName'>;
+  onClose: VoidFunction;
+}
+
+const SuccessWayfindingStep = ({
+  title,
+  description,
+  primaryClientName,
+  secondary,
+  project,
+  onClose,
+}: Props) => {
+  const secondaryName = secondary
+    ? clientBriefName(secondary.client)
+    : undefined;
+
+  return (
+    <Stack gap={2}>
+      <Alert color='success'>
+        <AlertTitle>{title}</AlertTitle>
+        {description}
+      </Alert>
+      <Button onClick={onClose} variant='contained'>
+        Return to {primaryClientName}’s Enrollment
+      </Button>
+      {secondary && (
+        <ButtonLink
+          to={generatePath(EnrollmentDashboardRoutes.ENROLLMENT_OVERVIEW, {
+            clientId: secondary.client.id,
+            enrollmentId: secondary.enrollment.id,
+          })}
+          variant='outlined'
+        >
+          View {secondaryName}’s Enrollment
+        </ButtonLink>
+      )}
+      <ButtonLink
+        to={generatePath(ProjectDashboardRoutes.PROJECT_ENROLLMENTS, {
+          projectId: project.id,
+        })}
+        variant='outlined'
+      >
+        View Enrollments at {project.projectName}
+      </ButtonLink>
+    </Stack>
+  );
+};
+
+export default SuccessWayfindingStep;
diff --git a/src/modules/household/hooks/useAddToHouseholdColumns.tsx b/src/modules/household/hooks/useAddToHouseholdColumns.tsx
index 5a413d9be..9472eb3e9 100644
--- a/src/modules/household/hooks/useAddToHouseholdColumns.tsx
+++ b/src/modules/household/hooks/useAddToHouseholdColumns.tsx
@@ -4,6 +4,7 @@ import AddToHouseholdButton from '../components/elements/AddToHouseholdButton';
 import { isRecentHouseholdMember, RecentHouseholdMember } from '../types';
 
 import { ColumnDef } from '@/components/elements/table/types';
+import { ManageHouseholdProject } from '@/modules/household/components/ManageHousehold';
 import {
   ClientSearchResultFieldsFragment,
   useGetHouseholdLazyQuery,
@@ -11,12 +12,12 @@ import {
 
 interface Args {
   householdId?: string;
-  projectId: string;
+  project: ManageHouseholdProject;
 }
 
 export default function useAddToHouseholdColumns({
   householdId: initialHouseholdId,
-  projectId,
+  project,
 }: Args) {
   const [householdId, setHouseholdId] = useState(initialHouseholdId);
   const [getHousehold, { data, loading, error }] = useGetHouseholdLazyQuery({
@@ -76,16 +77,25 @@ export default function useAddToHouseholdColumns({
           return (
             <AddToHouseholdButton
               client={client}
-              householdId={householdId}
-              projectId={projectId}
+              project={project}
               isMember={currentMembersMap.has(client.id)}
               onSuccess={onSuccess}
+              household={data?.household || undefined}
+              // Disable button until `household` is fetched
+              disabled={loading && !!householdId && !data?.household}
             />
           );
         },
       },
     ];
-  }, [currentMembersMap, householdId, projectId, onSuccess]);
+  }, [
+    project,
+    currentMembersMap,
+    onSuccess,
+    data?.household,
+    loading,
+    householdId,
+  ]);
 
   if (error) throw error;
 
diff --git a/src/modules/household/hooks/usePerformJoinHousehold.tsx b/src/modules/household/hooks/usePerformJoinHousehold.tsx
new file mode 100644
index 000000000..6310d60d1
--- /dev/null
+++ b/src/modules/household/hooks/usePerformJoinHousehold.tsx
@@ -0,0 +1,80 @@
+import { useCallback, useState } from 'react';
+import { clientBriefName } from '@/modules/hmis/hmisUtil';
+import {
+  HouseholdClientFieldsFragment,
+  HouseholdFieldsFragment,
+  RelationshipToHoH,
+  useJoinHouseholdMutation,
+} from '@/types/gqlTypes';
+
+export function usePerformJoinHousehold({
+  onSuccess,
+}: {
+  onSuccess?: (joinedHousehold: HouseholdFieldsFragment) => void;
+}) {
+  const [joinedHousehold, setJoinedHousehold] = useState<
+    HouseholdFieldsFragment | undefined
+  >(undefined);
+
+  const [remainingHousehold, setRemainingHousehold] = useState<
+    HouseholdFieldsFragment | undefined
+  >(undefined);
+
+  const [joinHousehold, { loading, error }] = useJoinHouseholdMutation({
+    onCompleted: (data) => {
+      if (data.joinHousehold) {
+        setJoinedHousehold(data.joinHousehold.receivingHousehold);
+        setRemainingHousehold(data.joinHousehold.donorHousehold || undefined);
+        onSuccess?.(data.joinHousehold.receivingHousehold);
+      }
+    },
+  });
+
+  const performJoinHousehold = useCallback(
+    ({
+      receivingHouseholdId,
+      joiningClients,
+      relationships,
+    }: {
+      receivingHouseholdId: string;
+      joiningClients: HouseholdClientFieldsFragment[];
+      relationships: Record<string, RelationshipToHoH | null>;
+    }) => {
+      const joiningEnrollmentInputs = joiningClients.map((hc) => {
+        const relationship = relationships[hc.enrollment.id];
+
+        if (!relationship) {
+          // don't really need to aggregate failures here; expect the caller to guard against this by disabling the submit button
+          throw new Error(
+            `Select relationship for ${clientBriefName(hc.client)}`
+          );
+        }
+
+        return {
+          enrollmentId: hc.enrollment.id,
+          relationshipToHoh: relationship,
+        };
+      });
+
+      if (joiningEnrollmentInputs.length === 0) {
+        throw new Error('Select at least one client to join');
+      }
+
+      return joinHousehold({
+        variables: {
+          receivingHouseholdId,
+          joiningEnrollmentInputs,
+        },
+      });
+    },
+    [joinHousehold]
+  );
+
+  return {
+    performJoinHousehold,
+    loading,
+    error,
+    joinedHousehold,
+    remainingHousehold,
+  };
+}
diff --git a/src/modules/projects/components/tables/ProjectClientEnrollmentsTable.tsx b/src/modules/projects/components/tables/ProjectClientEnrollmentsTable.tsx
index b661da530..18768339d 100644
--- a/src/modules/projects/components/tables/ProjectClientEnrollmentsTable.tsx
+++ b/src/modules/projects/components/tables/ProjectClientEnrollmentsTable.tsx
@@ -1,5 +1,5 @@
 import { Box, Chip, Tooltip } from '@mui/material';
-import { useMemo } from 'react';
+import React, { useMemo } from 'react';
 import DateWithRelativeTooltip from '@/components/elements/DateWithRelativeTooltip';
 import {
   getViewClientMenuItem,
diff --git a/src/modules/projects/components/tables/ProjectHouseholdsTable.tsx b/src/modules/projects/components/tables/ProjectHouseholdsTable.tsx
index e6847c073..5b4ae6a91 100644
--- a/src/modules/projects/components/tables/ProjectHouseholdsTable.tsx
+++ b/src/modules/projects/components/tables/ProjectHouseholdsTable.tsx
@@ -171,11 +171,12 @@ const ProjectHouseholdsTable: React.FC<Props> = ({ projectId, searchTerm }) => {
   const columns: ColumnDef<HouseholdFields>[] = useMemo(() => {
     return [
       ...BASE_COLUMNS,
-      ...(staffAssignmentsEnabled ? [{ ...ASSIGNED_STAFF_COL }] : []), // typescript appeasement
+      ...(staffAssignmentsEnabled ? [{ ...ASSIGNED_STAFF_COL }] : []),
       ACTION_COL,
     ].map(({ render, ...rest }) => ({
       ...rest,
       render: () => null,
+      tableCellProps: undefined, // typescript appeasement
     }));
   }, [staffAssignmentsEnabled]);
 
diff --git a/src/modules/search/components/ClientSearch.tsx b/src/modules/search/components/ClientSearch.tsx
index b4270a487..d0fb05889 100644
--- a/src/modules/search/components/ClientSearch.tsx
+++ b/src/modules/search/components/ClientSearch.tsx
@@ -48,7 +48,7 @@ import {
   SearchClientsQueryVariables,
 } from '@/types/gqlTypes';
 
-function asClient(
+export function asClient(
   record:
     | ClientSearchResultFieldsFragment
     | HouseholdClientFieldsFragment
diff --git a/src/types/gqlObjects.ts b/src/types/gqlObjects.ts
index b9950c79e..d52d55d3b 100644
--- a/src/types/gqlObjects.ts
+++ b/src/types/gqlObjects.ts
@@ -4892,6 +4892,14 @@ export const HmisObjectSchemas: GqlSchema[] = [
           ofType: { kind: 'SCALAR', name: 'Boolean', ofType: null },
         },
       },
+      {
+        name: 'canSplitHouseholds',
+        type: {
+          kind: 'NON_NULL',
+          name: null,
+          ofType: { kind: 'SCALAR', name: 'Boolean', ofType: null },
+        },
+      },
       {
         name: 'canViewDob',
         type: {
@@ -7051,6 +7059,27 @@ export const HmisInputObjectSchemas: GqlInputObjectSchema[] = [
       },
     ],
   },
+  {
+    name: 'EnrollmentRelationshipInput',
+    args: [
+      {
+        name: 'enrollmentId',
+        type: {
+          kind: 'NON_NULL',
+          name: null,
+          ofType: { kind: 'SCALAR', name: 'ID', ofType: null },
+        },
+      },
+      {
+        name: 'relationshipToHoh',
+        type: {
+          kind: 'NON_NULL',
+          name: null,
+          ofType: { kind: 'ENUM', name: 'RelationshipToHoH', ofType: null },
+        },
+      },
+    ],
+  },
   {
     name: 'EnrollmentsForClientFilterOptions',
     args: [
diff --git a/src/types/gqlTypes.ts b/src/types/gqlTypes.ts
index 74ff58fa9..cf7003a41 100644
--- a/src/types/gqlTypes.ts
+++ b/src/types/gqlTypes.ts
@@ -2573,6 +2573,11 @@ export enum EnrollmentFilterOptionStatus {
   Incomplete = 'INCOMPLETE',
 }
 
+export type EnrollmentRelationshipInput = {
+  enrollmentId: Scalars['ID']['input'];
+  relationshipToHoh: RelationshipToHoH;
+};
+
 /** HUD Enrollment Sorting Options */
 export enum EnrollmentSortOption {
   /** Age: Oldest to Youngest */
@@ -3934,6 +3939,14 @@ export enum ItemType {
   TimeOfDay = 'TIME_OF_DAY',
 }
 
+/** Autogenerated return type of JoinHousehold. */
+export type JoinHouseholdPayload = {
+  __typename?: 'JoinHouseholdPayload';
+  donorHousehold?: Maybe<Household>;
+  errors: Array<ValidationError>;
+  receivingHousehold: Household;
+};
+
 export type KeyValue = {
   __typename?: 'KeyValue';
   key: Scalars['String']['output'];
@@ -4194,6 +4207,7 @@ export type Mutation = {
   deleteService?: Maybe<DeleteServicePayload>;
   deleteServiceType?: Maybe<DeleteServiceTypePayload>;
   deleteUnits?: Maybe<DeleteUnitsPayload>;
+  joinHousehold?: Maybe<JoinHouseholdPayload>;
   mergeClients?: Maybe<MergeClientsPayload>;
   publishFormDefinition?: Maybe<PublishFormDefinitionPayload>;
   refreshExternalSubmissions?: Maybe<RefreshExternalSubmissionsPayload>;
@@ -4396,6 +4410,11 @@ export type MutationDeleteUnitsArgs = {
   input: DeleteUnitsInput;
 };
 
+export type MutationJoinHouseholdArgs = {
+  joiningEnrollmentInputs: Array<EnrollmentRelationshipInput>;
+  receivingHouseholdId: Scalars['ID']['input'];
+};
+
 export type MutationMergeClientsArgs = {
   input: MergeClientsInput;
 };
@@ -5640,6 +5659,7 @@ export type ProjectAccess = {
   canManageIncomingReferrals: Scalars['Boolean']['output'];
   canManageOutgoingReferrals: Scalars['Boolean']['output'];
   canManageUnits: Scalars['Boolean']['output'];
+  canSplitHouseholds: Scalars['Boolean']['output'];
   canViewDob: Scalars['Boolean']['output'];
   canViewEnrollmentDetails: Scalars['Boolean']['output'];
   canViewFullSsn: Scalars['Boolean']['output'];
@@ -8095,6 +8115,7 @@ export type ProjectAccessFieldsFragment = {
   canManageIncomingReferrals: boolean;
   canManageOutgoingReferrals: boolean;
   canManageExternalFormSubmissions: boolean;
+  canSplitHouseholds: boolean;
 };
 
 export type OrganizationAccessFieldsFragment = {
@@ -21059,6 +21080,7 @@ export type AllEnrollmentDetailsFragment = {
     access: {
       __typename?: 'ProjectAccess';
       id: string;
+      canSplitHouseholds: boolean;
       canManageIncomingReferrals: boolean;
     };
     projectCocs: { __typename?: 'ProjectCocsPaginated'; nodesCount: number };
@@ -21428,6 +21450,131 @@ export type EnrollmentRangeFieldsFragment = {
   inProgress: boolean;
 };
 
+export type EnrollmentWithHouseholdFieldsFragment = {
+  __typename?: 'Enrollment';
+  id: string;
+  lockVersion: number;
+  entryDate: string;
+  exitDate?: string | null;
+  exitDestination?: Destination | null;
+  autoExited: boolean;
+  inProgress: boolean;
+  relationshipToHoH: RelationshipToHoH;
+  enrollmentCoc?: string | null;
+  householdId: string;
+  householdShortId: string;
+  householdSize: number;
+  household: {
+    __typename?: 'Household';
+    id: string;
+    householdSize: number;
+    shortId: string;
+    householdClients: Array<{
+      __typename?: 'HouseholdClient';
+      id: string;
+      relationshipToHoH: RelationshipToHoH;
+      client: {
+        __typename?: 'Client';
+        id: string;
+        lockVersion: number;
+        firstName?: string | null;
+        middleName?: string | null;
+        lastName?: string | null;
+        nameSuffix?: string | null;
+        dob?: string | null;
+        age?: number | null;
+        gender: Array<Gender>;
+        pronouns: Array<string>;
+        ssn?: string | null;
+        race: Array<Race>;
+        veteranStatus: NoYesReasonsForMissingData;
+        access: {
+          __typename?: 'ClientAccess';
+          id: string;
+          canEditClient: boolean;
+          canDeleteClient: boolean;
+          canViewDob: boolean;
+          canViewFullSsn: boolean;
+          canViewPartialSsn: boolean;
+          canViewClientName: boolean;
+          canViewClientPhoto: boolean;
+          canViewClientAlerts: boolean;
+          canManageClientAlerts: boolean;
+          canViewEnrollmentDetails: boolean;
+          canAuditClients: boolean;
+          canManageScanCards: boolean;
+          canMergeClients: boolean;
+          canViewAnyFiles: boolean;
+          canManageAnyClientFiles: boolean;
+          canManageOwnClientFiles: boolean;
+          canUploadClientFiles: boolean;
+        };
+        externalIds: Array<{
+          __typename?: 'ExternalIdentifier';
+          id: string;
+          identifier?: string | null;
+          url?: string | null;
+          label: string;
+          type: ExternalIdentifierType;
+        }>;
+        alerts: Array<{
+          __typename?: 'ClientAlert';
+          id: string;
+          note: string;
+          expirationDate?: string | null;
+          createdAt: string;
+          priority: ClientAlertPriorityLevel;
+          createdBy?: {
+            __typename: 'ApplicationUser';
+            id: string;
+            name: string;
+            firstName?: string | null;
+            lastName?: string | null;
+            email: string;
+          } | null;
+        }>;
+      };
+      enrollment: {
+        __typename?: 'Enrollment';
+        id: string;
+        lockVersion: number;
+        relationshipToHoH: RelationshipToHoH;
+        autoExited: boolean;
+        entryDate: string;
+        exitDate?: string | null;
+        inProgress: boolean;
+        currentUnit?: { __typename?: 'Unit'; id: string; name: string } | null;
+      };
+    }>;
+  };
+  project: {
+    __typename?: 'Project';
+    id: string;
+    projectName: string;
+    projectType?: ProjectType | null;
+  };
+  client: {
+    __typename?: 'Client';
+    dob?: string | null;
+    veteranStatus: NoYesReasonsForMissingData;
+    id: string;
+    lockVersion: number;
+    firstName?: string | null;
+    middleName?: string | null;
+    lastName?: string | null;
+    nameSuffix?: string | null;
+  };
+  access: {
+    __typename?: 'EnrollmentAccess';
+    id: string;
+    canEditEnrollments: boolean;
+    canDeleteEnrollments: boolean;
+    canAuditEnrollments: boolean;
+    canViewEnrollmentLocationMap: boolean;
+  };
+  currentUnit?: { __typename?: 'Unit'; id: string; name: string } | null;
+};
+
 export type GetEnrollmentQueryVariables = Exact<{
   id: Scalars['ID']['input'];
 }>;
@@ -22369,6 +22516,7 @@ export type GetEnrollmentDetailsQuery = {
       access: {
         __typename?: 'ProjectAccess';
         id: string;
+        canSplitHouseholds: boolean;
         canManageIncomingReferrals: boolean;
       };
       projectCocs: { __typename?: 'ProjectCocsPaginated'; nodesCount: number };
@@ -22430,6 +22578,7 @@ export type GetEnrollmentWithHouseholdQuery = {
     household: {
       __typename?: 'Household';
       id: string;
+      householdSize: number;
       shortId: string;
       householdClients: Array<{
         __typename?: 'HouseholdClient';
@@ -31001,6 +31150,7 @@ export type SubmitFormMutation = {
             canManageIncomingReferrals: boolean;
             canManageOutgoingReferrals: boolean;
             canManageExternalFormSubmissions: boolean;
+            canSplitHouseholds: boolean;
           };
           user?: {
             __typename: 'ApplicationUser';
@@ -33229,6 +33379,194 @@ export type ProjectEnrollmentsHouseholdClientFieldsFragment = {
   };
 };
 
+export type JoinHouseholdMutationVariables = Exact<{
+  receivingHouseholdId: Scalars['ID']['input'];
+  joiningEnrollmentInputs:
+    | Array<EnrollmentRelationshipInput>
+    | EnrollmentRelationshipInput;
+}>;
+
+export type JoinHouseholdMutation = {
+  __typename?: 'Mutation';
+  joinHousehold?: {
+    __typename?: 'JoinHouseholdPayload';
+    receivingHousehold: {
+      __typename?: 'Household';
+      id: string;
+      householdSize: number;
+      shortId: string;
+      householdClients: Array<{
+        __typename?: 'HouseholdClient';
+        id: string;
+        relationshipToHoH: RelationshipToHoH;
+        client: {
+          __typename?: 'Client';
+          id: string;
+          lockVersion: number;
+          firstName?: string | null;
+          middleName?: string | null;
+          lastName?: string | null;
+          nameSuffix?: string | null;
+          dob?: string | null;
+          age?: number | null;
+          gender: Array<Gender>;
+          pronouns: Array<string>;
+          ssn?: string | null;
+          race: Array<Race>;
+          veteranStatus: NoYesReasonsForMissingData;
+          access: {
+            __typename?: 'ClientAccess';
+            id: string;
+            canEditClient: boolean;
+            canDeleteClient: boolean;
+            canViewDob: boolean;
+            canViewFullSsn: boolean;
+            canViewPartialSsn: boolean;
+            canViewClientName: boolean;
+            canViewClientPhoto: boolean;
+            canViewClientAlerts: boolean;
+            canManageClientAlerts: boolean;
+            canViewEnrollmentDetails: boolean;
+            canAuditClients: boolean;
+            canManageScanCards: boolean;
+            canMergeClients: boolean;
+            canViewAnyFiles: boolean;
+            canManageAnyClientFiles: boolean;
+            canManageOwnClientFiles: boolean;
+            canUploadClientFiles: boolean;
+          };
+          externalIds: Array<{
+            __typename?: 'ExternalIdentifier';
+            id: string;
+            identifier?: string | null;
+            url?: string | null;
+            label: string;
+            type: ExternalIdentifierType;
+          }>;
+          alerts: Array<{
+            __typename?: 'ClientAlert';
+            id: string;
+            note: string;
+            expirationDate?: string | null;
+            createdAt: string;
+            priority: ClientAlertPriorityLevel;
+            createdBy?: {
+              __typename: 'ApplicationUser';
+              id: string;
+              name: string;
+              firstName?: string | null;
+              lastName?: string | null;
+              email: string;
+            } | null;
+          }>;
+        };
+        enrollment: {
+          __typename?: 'Enrollment';
+          id: string;
+          lockVersion: number;
+          relationshipToHoH: RelationshipToHoH;
+          autoExited: boolean;
+          entryDate: string;
+          exitDate?: string | null;
+          inProgress: boolean;
+          currentUnit?: {
+            __typename?: 'Unit';
+            id: string;
+            name: string;
+          } | null;
+        };
+      }>;
+    };
+    donorHousehold?: {
+      __typename?: 'Household';
+      id: string;
+      householdSize: number;
+      shortId: string;
+      householdClients: Array<{
+        __typename?: 'HouseholdClient';
+        id: string;
+        relationshipToHoH: RelationshipToHoH;
+        client: {
+          __typename?: 'Client';
+          id: string;
+          lockVersion: number;
+          firstName?: string | null;
+          middleName?: string | null;
+          lastName?: string | null;
+          nameSuffix?: string | null;
+          dob?: string | null;
+          age?: number | null;
+          gender: Array<Gender>;
+          pronouns: Array<string>;
+          ssn?: string | null;
+          race: Array<Race>;
+          veteranStatus: NoYesReasonsForMissingData;
+          access: {
+            __typename?: 'ClientAccess';
+            id: string;
+            canEditClient: boolean;
+            canDeleteClient: boolean;
+            canViewDob: boolean;
+            canViewFullSsn: boolean;
+            canViewPartialSsn: boolean;
+            canViewClientName: boolean;
+            canViewClientPhoto: boolean;
+            canViewClientAlerts: boolean;
+            canManageClientAlerts: boolean;
+            canViewEnrollmentDetails: boolean;
+            canAuditClients: boolean;
+            canManageScanCards: boolean;
+            canMergeClients: boolean;
+            canViewAnyFiles: boolean;
+            canManageAnyClientFiles: boolean;
+            canManageOwnClientFiles: boolean;
+            canUploadClientFiles: boolean;
+          };
+          externalIds: Array<{
+            __typename?: 'ExternalIdentifier';
+            id: string;
+            identifier?: string | null;
+            url?: string | null;
+            label: string;
+            type: ExternalIdentifierType;
+          }>;
+          alerts: Array<{
+            __typename?: 'ClientAlert';
+            id: string;
+            note: string;
+            expirationDate?: string | null;
+            createdAt: string;
+            priority: ClientAlertPriorityLevel;
+            createdBy?: {
+              __typename: 'ApplicationUser';
+              id: string;
+              name: string;
+              firstName?: string | null;
+              lastName?: string | null;
+              email: string;
+            } | null;
+          }>;
+        };
+        enrollment: {
+          __typename?: 'Enrollment';
+          id: string;
+          lockVersion: number;
+          relationshipToHoH: RelationshipToHoH;
+          autoExited: boolean;
+          entryDate: string;
+          exitDate?: string | null;
+          inProgress: boolean;
+          currentUnit?: {
+            __typename?: 'Unit';
+            id: string;
+            name: string;
+          } | null;
+        };
+      }>;
+    } | null;
+  } | null;
+};
+
 export type GetHouseholdQueryVariables = Exact<{
   id: Scalars['ID']['input'];
 }>;
@@ -34297,6 +34635,7 @@ export type ProjectAllFieldsFragment = {
     canManageIncomingReferrals: boolean;
     canManageOutgoingReferrals: boolean;
     canManageExternalFormSubmissions: boolean;
+    canSplitHouseholds: boolean;
   };
   user?: {
     __typename: 'ApplicationUser';
@@ -35202,6 +35541,7 @@ export type GetProjectQuery = {
       canManageIncomingReferrals: boolean;
       canManageOutgoingReferrals: boolean;
       canManageExternalFormSubmissions: boolean;
+      canSplitHouseholds: boolean;
     };
     user?: {
       __typename: 'ApplicationUser';
@@ -35381,6 +35721,7 @@ export type GetProjectPermissionsQuery = {
       canManageIncomingReferrals: boolean;
       canManageOutgoingReferrals: boolean;
       canManageExternalFormSubmissions: boolean;
+      canSplitHouseholds: boolean;
     };
   } | null;
 };
@@ -41000,6 +41341,7 @@ export const AllEnrollmentDetailsFragmentDoc = gql`
       hasUnits
       access {
         id
+        canSplitHouseholds
         canManageIncomingReferrals
       }
       staffAssignmentsEnabled
@@ -41032,6 +41374,66 @@ export const SubmittedEnrollmentResultFieldsFragmentDoc = gql`
   ${EnrollmentOccurrencePointFieldsFragmentDoc}
   ${CustomDataElementFieldsFragmentDoc}
 `;
+export const HouseholdClientFieldsFragmentDoc = gql`
+  fragment HouseholdClientFields on HouseholdClient {
+    id
+    relationshipToHoH
+    client {
+      id
+      ...ClientName
+      ...ClientIdentificationFields
+      ...AssessedClientFields
+      access {
+        ...ClientAccessFields
+      }
+      externalIds {
+        ...ClientIdentifierFields
+      }
+      alerts {
+        ...ClientAlertFields
+      }
+    }
+    enrollment {
+      id
+      lockVersion
+      relationshipToHoH
+      ...EnrollmentRangeFields
+      autoExited
+      currentUnit {
+        id
+        name
+      }
+    }
+  }
+  ${ClientNameFragmentDoc}
+  ${ClientIdentificationFieldsFragmentDoc}
+  ${AssessedClientFieldsFragmentDoc}
+  ${ClientAccessFieldsFragmentDoc}
+  ${ClientIdentifierFieldsFragmentDoc}
+  ${ClientAlertFieldsFragmentDoc}
+  ${EnrollmentRangeFieldsFragmentDoc}
+`;
+export const HouseholdFieldsFragmentDoc = gql`
+  fragment HouseholdFields on Household {
+    id
+    householdSize
+    shortId
+    householdClients {
+      ...HouseholdClientFields
+    }
+  }
+  ${HouseholdClientFieldsFragmentDoc}
+`;
+export const EnrollmentWithHouseholdFieldsFragmentDoc = gql`
+  fragment EnrollmentWithHouseholdFields on Enrollment {
+    ...EnrollmentFields
+    household {
+      ...HouseholdFields
+    }
+  }
+  ${EnrollmentFieldsFragmentDoc}
+  ${HouseholdFieldsFragmentDoc}
+`;
 export const ExternalFormSubmissionSummaryFragmentDoc = gql`
   fragment ExternalFormSubmissionSummary on ExternalFormSubmission {
     id
@@ -41118,56 +41520,6 @@ export const GeolocationFieldsWithMetadataFragmentDoc = gql`
   }
   ${UserFieldsFragmentDoc}
 `;
-export const HouseholdClientFieldsFragmentDoc = gql`
-  fragment HouseholdClientFields on HouseholdClient {
-    id
-    relationshipToHoH
-    client {
-      id
-      ...ClientName
-      ...ClientIdentificationFields
-      ...AssessedClientFields
-      access {
-        ...ClientAccessFields
-      }
-      externalIds {
-        ...ClientIdentifierFields
-      }
-      alerts {
-        ...ClientAlertFields
-      }
-    }
-    enrollment {
-      id
-      lockVersion
-      relationshipToHoH
-      ...EnrollmentRangeFields
-      autoExited
-      currentUnit {
-        id
-        name
-      }
-    }
-  }
-  ${ClientNameFragmentDoc}
-  ${ClientIdentificationFieldsFragmentDoc}
-  ${AssessedClientFieldsFragmentDoc}
-  ${ClientAccessFieldsFragmentDoc}
-  ${ClientIdentifierFieldsFragmentDoc}
-  ${ClientAlertFieldsFragmentDoc}
-  ${EnrollmentRangeFieldsFragmentDoc}
-`;
-export const HouseholdFieldsFragmentDoc = gql`
-  fragment HouseholdFields on Household {
-    id
-    householdSize
-    shortId
-    householdClients {
-      ...HouseholdClientFields
-    }
-  }
-  ${HouseholdClientFieldsFragmentDoc}
-`;
 export const ProjectEnrollmentsHouseholdClientFieldsFragmentDoc = gql`
   fragment ProjectEnrollmentsHouseholdClientFields on HouseholdClient {
     id
@@ -41333,6 +41685,7 @@ export const ProjectAccessFieldsFragmentDoc = gql`
     canManageIncomingReferrals
     canManageOutgoingReferrals
     canManageExternalFormSubmissions
+    canSplitHouseholds
   }
 `;
 export const ProjectAllFieldsFragmentDoc = gql`
@@ -46520,18 +46873,10 @@ export type GetEnrollmentDetailsQueryResult = Apollo.QueryResult<
 export const GetEnrollmentWithHouseholdDocument = gql`
   query GetEnrollmentWithHousehold($id: ID!) {
     enrollment(id: $id) {
-      ...EnrollmentFields
-      household {
-        id
-        shortId
-        householdClients {
-          ...HouseholdClientFields
-        }
-      }
+      ...EnrollmentWithHouseholdFields
     }
   }
-  ${EnrollmentFieldsFragmentDoc}
-  ${HouseholdClientFieldsFragmentDoc}
+  ${EnrollmentWithHouseholdFieldsFragmentDoc}
 `;
 
 /**
@@ -49210,6 +49555,69 @@ export type GetEnrollmentGeolocationsQueryResult = Apollo.QueryResult<
   GetEnrollmentGeolocationsQuery,
   GetEnrollmentGeolocationsQueryVariables
 >;
+export const JoinHouseholdDocument = gql`
+  mutation JoinHousehold(
+    $receivingHouseholdId: ID!
+    $joiningEnrollmentInputs: [EnrollmentRelationshipInput!]!
+  ) {
+    joinHousehold(
+      receivingHouseholdId: $receivingHouseholdId
+      joiningEnrollmentInputs: $joiningEnrollmentInputs
+    ) {
+      receivingHousehold {
+        ...HouseholdFields
+      }
+      donorHousehold {
+        ...HouseholdFields
+      }
+    }
+  }
+  ${HouseholdFieldsFragmentDoc}
+`;
+export type JoinHouseholdMutationFn = Apollo.MutationFunction<
+  JoinHouseholdMutation,
+  JoinHouseholdMutationVariables
+>;
+
+/**
+ * __useJoinHouseholdMutation__
+ *
+ * To run a mutation, you first call `useJoinHouseholdMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useJoinHouseholdMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [joinHouseholdMutation, { data, loading, error }] = useJoinHouseholdMutation({
+ *   variables: {
+ *      receivingHouseholdId: // value for 'receivingHouseholdId'
+ *      joiningEnrollmentInputs: // value for 'joiningEnrollmentInputs'
+ *   },
+ * });
+ */
+export function useJoinHouseholdMutation(
+  baseOptions?: Apollo.MutationHookOptions<
+    JoinHouseholdMutation,
+    JoinHouseholdMutationVariables
+  >
+) {
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<
+    JoinHouseholdMutation,
+    JoinHouseholdMutationVariables
+  >(JoinHouseholdDocument, options);
+}
+export type JoinHouseholdMutationHookResult = ReturnType<
+  typeof useJoinHouseholdMutation
+>;
+export type JoinHouseholdMutationResult =
+  Apollo.MutationResult<JoinHouseholdMutation>;
+export type JoinHouseholdMutationOptions = Apollo.BaseMutationOptions<
+  JoinHouseholdMutation,
+  JoinHouseholdMutationVariables
+>;
 export const GetHouseholdDocument = gql`
   query GetHousehold($id: ID!) {
     household(id: $id) {