# Phase 2: Multi-Layout Assignment Workflow (千人千面)

## Status: DRAFT
## Date: 2026-04-02
## Prerequisite: Phase 1 complete (PR #590, #594, #597, #599)

---

## 1. Goal

Different users see different page layouts based on their Profile and RecordType.

This is Salesforce's standard capability: **admin creates multiple layouts per object, assigns them via a (Profile × RecordType) matrix, and the system resolves the correct layout per user at runtime.**

## 2. Known Gaps (Must Fix Before / During Phase 2)

Phase 1 left three runtime gaps that will cause "matrix works in Setup but layout doesn't change on the record page." These are **prerequisites**, not nice-to-haves.

### Gap 1: Detail View Does Not Pass RecordTypeId

**Problem**: The record-ui aggregator (`aggregator.go:76`) calls `GetLayout(ctx, typeHint, user)` which delegates to `GetLayoutWithOptions(ctx, objectType, user, "", "")` — empty `recordTypeID`. The record's `RecordTypeId` is available in the fetched record data but never extracted and passed to layout resolution.

Create/Edit already pass `recordTypeId` (`ObjectView.tsx:143`), but the standard Detail view reads `uiData.layouts` (`ObjectView.tsx:177`) which comes from the aggregator path — no RecordType context.

**Impact**: Matrix assignments keyed on RecordType (specificity 1-3) will never match for Detail view. All Detail pages will fall back to the wildcard assignment regardless of the record's RecordType.

**Fix**: After records are fetched in the aggregator, extract `RecordTypeId` from the first record and call `GetLayoutWithRecordType(ctx, typeHint, user, recordTypeID)` instead of `GetLayout`. The `GetLayoutWithRecordType` function already exists (`primitives.go:163`).

**Scope**: Phase 2 guarantees the single-record Detail/Create/Edit path (which is what `ObjectView` uses). The `record-ui` API technically accepts multiple record IDs, and those records could have different RecordTypes — mixed-RecordType multi-record resolution is **not** a Phase 2 goal. If needed later, the aggregator would need to resolve one layout per distinct RecordTypeId in the batch.

### Gap 2: SetDefaultLayout Does Not Sync Wildcard Assignment

**Problem**: `SetDefaultLayout` (`repo_layout.go:132`) flips `IsDefault` on the `_System_PageLayout` row but does **not** update the specificity-0 (all-NULL) row in `_System_LayoutAssignment`. Runtime resolution uses `FindBestAssignment` which reads `_System_LayoutAssignment`, not `IsDefault`.

**Impact**: Admin clicks "Set Default" in UI → `IsDefault` flag changes → but the wildcard assignment still points to the old layout → users without specific assignments still see the old layout.

**Fix**: `SetDefaultLayout` must also upsert the wildcard assignment row (`ProfileId=NULL, RoleId=NULL, RecordTypeId=NULL`) to point to the new default layout's ID. Wrap both operations in the same transaction.

### Gap 3: Delete Safety — Orphaned Assignments

**Problem**: `DeleteLayout` (`metadata_layouts.go:417`) soft-deletes the layout but does **not** clean up `_System_LayoutAssignment` rows pointing to it. `DeleteAssignmentsByLayoutID` exists in the repo (`repo_layout.go:405`) but is never called.

**Impact**: After deleting a layout, assignments still reference it. `FindBestAssignment` returns the deleted layout's assignment, then `GetLayout` returns nil for the soft-deleted layout, triggering auto-generation of a new default — but the orphaned assignment row persists and causes repeated misses.

**Fix**: Call `DeleteAssignmentsByLayoutID` inside `DeleteLayout` before the soft-delete. Also add handler validation: block delete if layout has active non-wildcard assignments (force admin to reassign first).

## 3. Scope

### In Scope (Salesforce parity)
- **Fix Gap 1-3 above** (prerequisite for anything below to work)
- Clone existing layout (primary creation workflow)
- Layout list with multiple layouts per object (object-level manager page)
- Assignment matrix UI (Profile rows × RecordType columns)
- Runtime resolution: user logs in → (Profile, RecordType) → correct layout
- Verify: different profiles see different layouts

### Out of Scope (Future phases)
- Region/Team/Group dimensions (requires new UserSession fields, LayoutAssignment columns, resolver changes)
- Layout-level per-field Required/Read-Only overrides (separate feature)
- Blank "New Layout" from scratch (Salesforce's workflow is clone, not blank)

## 4. The Full Workflow

```
clone existing → edit cloned layout → assign to audience → resolve at runtime → verify
```

Each step is distinct:
- **clone** — "how to quickly create a second layout"
- **edit** — "what this layout looks like" (already works: Layout Editor)
- **assign** — "who sees this layout" (the matrix)
- **resolve** — "which layout does this user get" (already works: ResolveLayout)
- **verify** — "confirm different users see different pages"

## 5. Current Infrastructure

### Backend — ✅ Ready

| Component | Status | Evidence |
|-----------|--------|---------|
| `LayoutAssignment` model (RoleID + ProfileID + RecordTypeID) | ✅ | `shared/pkg/models/metadata.go` |
| `_System_LayoutAssignment` table | ✅ | DB verified: 10+ rows, all specificity=0 |
| `ResolveLayout(roleAncestors, profileID, recordTypeID)` | ✅ | `metadata_layouts.go` — specificity-based |
| `FindBestAssignment` with role hierarchy | ✅ | `repo_layout.go` — ORDER BY Specificity DESC |
| Assignment CRUD API (POST/DELETE/GET) | ✅ | `routes.go` — 3 endpoints registered |
| `UserSession.ProfileId` + `UserSession.RoleId` | ✅ | `jwt.go` — both available at runtime |
| Layout CRUD API (save/delete/set-default) | ✅ | Existing endpoints |

### Frontend — Partially Ready

| Component | Status | Notes |
|-----------|--------|-------|
| `LayoutAssignmentEditor` (240 lines) | ⚠️ Prototype | Exists but removed from editor tabs in PR #590. CRUD list, not matrix UI |
| Layout list page | ❌ | Removed in PR #590 (was a table with New/Delete/SetDefault) |
| Clone layout UI | ❌ | Never existed |
| Assignment matrix UI | ❌ | `LayoutAssignmentEditor` is a CRUD list, not a (Profile × RecordType) matrix |

### Data

| Data | Current State |
|------|--------------|
| Profiles | 5 (system_admin, sales_user, deal_user, legal_user, standard_user) |
| Roles | 45 |
| RecordTypes | Exist per object (via `_System_RecordType` table with ObjectId) |
| Layouts per object | 2 each (1 Detail default + 1 stale Create from PR #580) |
| Assignments | All specificity=0 (wildcard, no targeting) |

## 6. What to Build

### 6.0 Backend: Fix Gaps 1-3 (Prerequisites)

**Gap 1 fix** — `aggregator.go`: After records goroutine completes, extract `RecordTypeId` from the first record and call `GetLayoutWithRecordType` instead of `GetLayout`.

**Gap 2 fix** — `repo_layout.go` `SetDefaultLayout`: Inside the existing transaction, upsert the wildcard assignment row (`ObjectApiName=X, ProfileId=NULL, RoleId=NULL, RecordTypeId=NULL`) to point to the new default layout ID. If no wildcard row exists, create one with `Specificity=0`.

**Gap 3 fix** — `metadata_layouts.go` `DeleteLayout`: Call `DeleteAssignmentsByLayoutID` before soft-delete. In handler (`ui_api_handler.go` `HandleDeleteLayout`): after the existing `IsDefault` check, query assignments for this layout — if any non-wildcard assignments exist, return 409 with "Remove assignments before deleting."

### 6.1 Backend: Clone Layout Endpoint

New endpoint: `POST /ui-api/layout/:layoutId/clone`

```
Request:  { "layoutName": "APAC Sales Layout" }
Response: { ...cloned layout with new ID and name... }
```

Logic:
- Load source layout by ID
- Deep copy: sections, fields, compactLayout, relatedLists, headerActions
- New ID (UUID), new name from request, `Source: "admin_created"`
- **No assignment copied** — cloned layout starts unassigned
- Save and return

### 6.2 Frontend: Information Architecture — Separate Manager from Editor

**Problem**: Currently `/setup/objects/:objectApiName/layout` and `/setup/objects/:objectApiName/layout/:layoutId` both route to the same `LayoutEditorRoute` component (`Setup.tsx:171-172`). There is no object-level layout management page — Phase 2 needs one.

**Decision**: `/layout` = object-level layout manager (list + assignments). `/layout/:layoutId` = per-layout editor (sections, fields, compact, related lists). The assignment matrix belongs on the object-level manager, NOT inside the per-layout editor.

**Route structure**:
```
/setup/objects/:objectApiName           → ObjectDetail (has "Page Layouts" link)
/setup/objects/:objectApiName/layout    → LayoutManager (NEW — list + matrix)
/setup/objects/:objectApiName/layout/:layoutId → LayoutEditor (existing — sections/fields)
```

**LayoutManager page** (`/layout` without `:layoutId`):

```
┌─────────────────────────────────────────────────────┐
│  Page Layouts for Account            [Clone Layout]  │
├──────────────────┬──────────┬───────────────────────┤
│  Layout Name     │  Default │  Actions              │
├──────────────────┼──────────┼───────────────────────┤
│  Default Layout  │  ✓      │  [Edit] [Delete]      │
│  APAC Sales      │          │  [Edit] [Delete]      │
│  Legal Review    │          │  [Edit] [Delete]      │
└──────────────────┴──────────┴───────────────────────┘

┌──────────────────────────────────────────────────────┐
│  Page Layout Assignment                    [Save]    │
│              │ Master RT │ New Business │ Renewal     │
│  ────────────┼───────────┼──────────────┼─────────── │
│  System Admin│ Default ▼ │ Default   ▼  │ Default ▼  │
│  Sales User  │ Default ▼ │ APAC Sales▼  │ Default ▼  │
│  ...                                                 │
└──────────────────────────────────────────────────────┘
```

Key points:
- **"Clone Layout" button** instead of "New Layout" (SF workflow is clone, not blank)
- Click Clone → modal asks for name + which layout to clone from
- [Edit] navigates to `/layout/:layoutId` (the existing LayoutEditor)
- Assignment matrix is on this page, NOT inside the per-layout editor
- `ObjectDetail.tsx` "Page Layouts" tab links here instead of directly to the editor

### 6.3 Frontend: Assignment Matrix (on LayoutManager page)

The assignment matrix lives on the LayoutManager page (section 6.2), NOT inside the per-layout editor.

Each cell is a dropdown of available layouts for this object. **For objects with no RecordTypes**: matrix simplifies to a single column (Profile → Layout).

#### "Default" Selection Semantics

When a cell is set to "Default":
- This **deletes** the explicit assignment for that (Profile × RecordType), falling back to the wildcard (specificity-0) row.
- It does **NOT** create an explicit assignment pointing to the current default layout's ID.

Why: If an admin later changes which layout is the default (via "Set Default"), all cells set to "Default" automatically follow — because they have no explicit assignment and inherit the wildcard. If we instead wrote explicit assignments pointing to the old default's ID, changing the default would not propagate to those cells.

#### Handling Existing Role-Based Assignments

The backend supports three dimensions: `RoleId`, `ProfileId`, `RecordTypeId`. Phase 2 MVP only exposes the Profile × RecordType matrix. But the DB may already contain Role-based assignments (from the prototype CRUD editor or direct API calls).

**Rule**: If any assignment for this object has a non-NULL `RoleId`:
1. Show a warning banner above the matrix: "This object has role-based assignments that are not shown in this view. Use the API to manage them."
2. List the role-based assignments below the matrix as **read-only rows** (RoleId | ProfileId | RecordTypeId | Layout — no dropdowns).
3. The matrix only manages `RoleId=NULL` assignments. It does not create, modify, or delete any assignment where `RoleId IS NOT NULL`.
4. Save button only saves matrix (Profile × RecordType) changes.

This prevents the matrix from silently overwriting or hiding advanced assignments while keeping the MVP simple.

### 6.4 Data Cleanup: Soft-Delete Stale Create Layouts (One-Time Migration)

PR #580 created `Type='Create'` layout rows. These are stale (no longer used after PR #590/#599).

**This is a one-time migration, not a bootstrap step.** Bootstrap runs on every startup — a cleanup that targets specific historical data should execute once and be idempotent.

Migration file: `data-specs/phase2-cleanup-create-layouts.md`

```sql
-- Scope: only Type='Create' layouts from PR #580 era
-- Uses existing soft-delete pattern (IsDeleted flag), not hard DELETE
-- Also cleans up any LayoutAssignment rows pointing to these layouts

-- Step 1: Find stale layout IDs
-- SELECT Id, ObjectApiName, LayoutName FROM _System_PageLayout
--   WHERE Type = 'Create' AND IsDeleted = false;

-- Step 2: Soft-delete the layouts
UPDATE `_System_PageLayout`
SET `IsDeleted` = true, `LastModifiedDate` = CURRENT_TIMESTAMP
WHERE `Type` = 'Create' AND `IsDeleted` = false;

-- Step 3: Clean up orphaned assignments
DELETE FROM `_System_LayoutAssignment`
WHERE `LayoutId` IN (
    SELECT `Id` FROM `_System_PageLayout`
    WHERE `Type` = 'Create' AND `IsDeleted` = true
);
```

**Idempotency**: Re-running is safe — Step 2 matches `IsDeleted = false` only, Step 3 cleans any remaining orphans.
**Rollback**: `UPDATE _System_PageLayout SET IsDeleted = false WHERE Type = 'Create';`

## 7. Definition of Done

> **A Phase 2 change is not complete unless a saved assignment changes the actual layout rendered on Detail, Edit, and Create for the targeted audience.**

### Minimum Acceptance Criteria (all 4 required)

| # | Criterion | Verification Method |
|---|-----------|-------------------|
| 1 | Clone produces an independent layout that can be edited without affecting the source | Clone → edit cloned → verify source unchanged |
| 2 | Matrix assignment change is reflected on **Create, Edit, AND Detail** for the targeted Profile × RecordType | Assign layout to Profile X → login as X → verify all three modes show the assigned layout |
| 3 | "Set Default" updates the actual fallback resolution (wildcard assignment), not just the `IsDefault` flag | Set Default → login as unassigned profile → verify they see the new default |
| 4 | Two different profiles see different layouts end-to-end | Login as profile A → see Layout A on Detail; login as profile B → see Layout B on Detail |

**Criterion 2 is the hard one.** It requires Gap 1 (Detail view RecordTypeId passthrough) to be fixed. Without it, Detail pages ignore assignment changes — the feature looks configured but doesn't work where users spend most of their time.

## 8. Implementation Plan

### Step 1: Runtime Fixes — Gaps 1-3 (~3h) ⚠️ MUST BE FIRST

These are prerequisites. Without them, everything else is cosmetic.

| File | Change |
|------|--------|
| `aggregator.go` | Extract `RecordTypeId` from fetched record, call `GetLayoutWithRecordType` instead of `GetLayout` |
| `repo_layout.go` | `SetDefaultLayout`: upsert wildcard assignment row in same transaction |
| `metadata_layouts.go` | `DeleteLayout`: call `DeleteAssignmentsByLayoutID` before soft-delete |
| `ui_api_handler.go` | `HandleDeleteLayout`: block delete if layout has non-wildcard assignments |

**Verify**: After Step 1, existing default layout resolution still works. Record Detail pages with different RecordTypes resolve correctly (even with only one layout — the wildcard).

### Step 2: Backend Clone Endpoint (~2h)

| File | Change |
|------|--------|
| `ui_api_handler.go` | New `HandleCloneLayout` handler |
| `routes.go` | Register `POST /ui-api/layout/:layoutId/clone` |
| `service.go` or `primitives.go` | `CloneLayout(ctx, layoutID, newName)` service method |

### Step 3: Frontend LayoutManager Page (~4h)

| File | Change |
|------|--------|
| New: `LayoutManager.tsx` | Object-level layout manager (list + assignment matrix) |
| `Setup.tsx` | Route `/layout` (no `:layoutId`) → `LayoutManager`, `/layout/:layoutId` → `LayoutEditor` |
| `ObjectDetail.tsx` | "Page Layouts" links to `/layout` manager page |
| `schemas.ts` | Add `cloneLayout(layoutId, name)` API call |

### Step 4: Frontend Assignment Matrix (~4h)

| File | Change |
|------|--------|
| New: `AssignmentMatrix.tsx` | Matrix grid component (Profile rows × RecordType columns) |
| `LayoutManager.tsx` | Embed `AssignmentMatrix` below layout list |
| `schemas.ts` | Add batch assignment save (or N individual calls as MVP) |

Role-based assignment handling: if any assignment has non-NULL `RoleId`, show read-only warning section below matrix.

### Step 5: Stale Data Cleanup (~30m)

| File | Change |
|------|--------|
| `data-specs/phase2-cleanup-create-layouts.md` | One-time migration spec (soft-delete `Type='Create'` + orphaned assignments) |

### Step 6: E2E Verification Against All 4 Acceptance Criteria (~2h)

| AC | Test |
|----|------|
| AC1 | Clone "Default Layout" → "Sales Layout" → edit Sales Layout → verify Default unchanged |
| AC2 | Assign "Sales Layout" to sales_user + Master RT → login as sales_user → verify **Detail**, **Edit**, **Create** all show Sales Layout |
| AC3 | Set "Sales Layout" as default → login as unassigned profile → verify they see Sales Layout (not old default) |
| AC4 | Login as sales_user → see Sales Layout; login as system_admin → see Default Layout |

## 9. How Resolution Works (Already Built)

```
User logs in → JWT contains ProfileId + RoleId
User views record → record has RecordTypeId
System calls ResolveLayout(roleAncestors, profileID, recordTypeID)

FindBestAssignment query:
  SELECT ... FROM _System_LayoutAssignment
  WHERE ObjectApiName = ?
    AND (RoleId IS NULL OR RoleId IN (?ancestors))
    AND (ProfileId IS NULL OR ProfileId = ?)
    AND (RecordTypeId IS NULL OR RecordTypeId = ?)
  ORDER BY Specificity DESC, ...
  LIMIT 1

Specificity scoring:
  0 = wildcard (all NULL)     → fallback
  1 = one dimension set       → e.g., ProfileId only
  2 = two dimensions set      → e.g., ProfileId + RecordTypeId
  3 = all three dimensions    → most specific match wins
```

This is already implemented and tested. Phase 2 just needs to create the assignments that feed it.

## 10. Design Decisions

### Clone vs New Layout
Salesforce admins almost never build layouts from scratch. The workflow is: clone existing → customize. We follow this pattern. No "blank New Layout" button.

### Clone does NOT copy assignments
A cloned layout starts unassigned. Admin explicitly assigns it via the matrix. This prevents two layouts competing for the same audience.

### Matrix vs CRUD list
The existing `LayoutAssignmentEditor` (240 lines) is a CRUD list: add assignment by typing ProfileId/RoleId. This is unusable for non-technical admins. The matrix UI (Profile rows × RecordType columns) is how Salesforce does it — visual, intuitive, no typing.

### Assignments on LayoutManager, not inside LayoutEditor
Salesforce puts "Page Layout Assignment" as a standalone button in Object Manager, not inside individual layout editors. We follow this: assignments live on the object-level LayoutManager page (`/layout`), not inside the per-layout editor (`/layout/:layoutId`). This avoids the conceptual mismatch of editing object-wide assignment routing from within a single layout's editor.

### Default layout as fallback
When no assignment matches (specificity=0 wildcard), the layout marked `IsDefault=true` is used. Every object must have exactly one default layout.

## 11. Risks

| Risk | Mitigation |
|------|-----------|
| Admin deletes the only layout | Prevent delete if it's the default and the only one |
| Admin unassigns all layouts from a profile | Fallback to default (specificity=0 always exists) |
| Clone creates name collision | Backend validates unique name per object |
| Matrix doesn't load profiles/RecordTypes | API already returns profiles list and RecordType info |
| Stale Create layouts confuse the list | Cleanup migration removes them |
| Existing Role-based assignments conflict with matrix | Matrix only manages RoleId=NULL rows; role-based shown read-only with warning |
| Detail view ignores assignment (Gap 1 not fixed) | Step 1 is prerequisite; AC2 blocks completion |
| "Set Default" doesn't change fallback (Gap 2 not fixed) | Step 1 syncs wildcard assignment; AC3 blocks completion |

## 12. Effort Estimate

| Step | Time |
|------|------|
| **Runtime fixes (Gaps 1-3)** | **3h** |
| Backend: Clone endpoint | 2h |
| Frontend: LayoutManager page | 4h |
| Frontend: Assignment matrix | 4h |
| Data cleanup | 30m |
| E2E verification (4 ACs) | 2h |
| **Total** | **~16h** |

## 13. What Comes After Phase 2

- **Phase 2b**: Add RoleId dimension to the matrix (NexusCRM extension beyond SF)
- **Phase 3**: Region/Team/Group dimensions (requires UserSession changes, LayoutAssignment schema changes, resolver updates)
- **Per-field Required/Read-Only**: Layout Editor field properties panel (separate from assignment)
