# Field Editability Simplification Design

## Status: DRAFT
## Date: 2026-04-01
## Author: Ian Zhai / Agent Team

---

## 1. What the User Sees

Operations edits Campaign's Detail Layout in Setup — adds fields, rearranges sections, saves successfully. Then navigates to New Campaign. Some fields (e.g., Name) don't appear, even though they're visible in the Layout Editor.

The user expectation: **what I see in the Layout Editor is what appears on the Create form.**

---

## 2. What the System Actually Does

New Campaign loads the Detail Layout, then **filters fields at render time** before displaying them. The filtering happens in 5 separate code paths, each with different rules for deciding "can this field appear on a Create form":

| # | Filter Point | File | Logic | Filters Campaign.Name? |
|---|-------------|------|-------|----------------------|
| 1 | Describe API `createable` | `sobject_describe.go:126` | `canCreate && fieldEditable && !fieldIntrinsicRO` | **No** — `CheckFieldEditabilityWithUser` does not block Name; `permissions/service.go:314` explicitly states "IsSystem means standard field, NOT read-only" |
| 2 | UI-API ObjectInfo `createable` | `primitives.go:76` | `!isSystemAuditField && (!IsSystem \|\| IsNameField) && !intrinsicRO` | **No** — has `IsNameField` exemption |
| 3 | Layout `editableForNew` (UI-API) | `primitives.go:299` | `isSystemAuditField \|\| intrinsicRO` | **No** — already fixed (PR #587) |
| **4** | **Describe Layout `editableForNew`** | **`sobject_describe.go:453`** | **`!f.IsSystem`** | **Yes — direct cause. If IsSystem=true, Name is marked non-editable for Create** |
| 5 | Frontend `isSystemField()` | `CommonFields.ts:41` | Hardcoded name list (Id, CreatedDate, etc.) | **No** — Name not in the list |

**Filter point 4 is the direct source of the current symptom.** It uses `!f.IsSystem` with no `IsNameField` exemption, unlike filter point 2 which has one.

There is a unit test (`service_methods_test.go:180`) that explicitly requires Name to remain writable when `IsSystem=true && IsNameField=true`. But that test covers filter point 2, not filter point 4. The two filter points are inconsistent.

### Where does `IsSystem=true` on Campaign.Name come from?

The exact source is still under investigation. What is confirmed:
- `_System_Field` in the database has `IsSystem=1` for Campaign.Name
- `registerFieldsForDiscoveredTables` (`discover_tables.go:289`) inserts fields without IsSystem (defaults to 0)
- `IsSystemColumn()` (`schema_repository.go:37-45`) returns false for "Name"
- `reconcileDiscoveredFieldOverrides` (`discover_tables.go:681-685`) tries to fix it, but only runs when new tables are first discovered (early return gate at line 82-96)
- `BatchSyncSystemFields` (`field_batch.go:29`) sets `IsSystem=true` on all input fields, but appears to be dead code (no production callers)
- The value may be residual from data import or a bootstrap step that has since been modified

Regardless of source, **the root issue is that filter point 4 uses `IsSystem` to determine editability, and the 5 filter points are inconsistent.**

---

## 3. The Inconsistency Problem

The 5 filter points don't agree with each other on what "non-editable" means:

| Question | Filter 1 (describe) | Filter 2 (UI-API) | Filter 3 (layout UI-API) | Filter 4 (layout describe) | Filter 5 (frontend) |
|----------|---------------------|-------------------|------------------------|--------------------------|-------------------|
| Uses IsSystem? | Indirectly (via permissions) | Yes, with IsNameField exemption | No | **Yes, no exemption** | No |
| Uses field type (formula/auto)? | Yes | Yes | Yes | No | No |
| Uses audit field name list? | Yes (via permissions) | Yes | Yes | No | Yes |
| Checks permissions? | Yes | No | No | No | No |

A field like Campaign.Name with `IsSystem=true, IsNameField=true`:
- Filter 1: createable=true ✓
- Filter 2: createable=true ✓ (IsNameField exemption)
- Filter 3: editableForNew=true ✓
- **Filter 4: editableForNew=false ✗** (no exemption)
- Filter 5: not filtered ✓

**The user gets different behavior depending on which API path renders the page.**

---

## 4. How Salesforce Handles This

Salesforce has a canonical object/field access model:

- **`getObjectInfo`** returns unified `createable`/`updateable` per field, incorporating user context ([LWC Wire Adapter: Object Info](https://developer.salesforce.com/docs/platform/lwc/guide/reference-wire-adapters-object-info))
- **`generateRecordInputForCreate`** filters fields by `createable=true` ([LWC: Generate Record Input for Create](https://developer.salesforce.com/docs/platform/lwc/guide/reference-generate-record-input-create.html))
- **Layout `editableForNew`/`editableForUpdate`** reflects layout-level + intrinsic behavior, does NOT reflect user-level or profile-level access ([GraphQL: Query Record Layouts](https://developer.salesforce.com/docs/platform/graphql/guide/query-record-layouts.html))

The key difference: **Salesforce has one source of truth for field capabilities.** NexusCRM has no `_System_Field.Createable/Updateable` persistent columns, so 5 different code paths each re-derive the answer with inconsistent logic.

---

## 5. Summary of Findings

1. **User-visible problem**: Layout Editor shows fields that Create page doesn't render
2. **Direct cause**: Filter point 4 (`sobject_describe.go:453`) uses `!f.IsSystem` without exemptions
3. **Root cause**: 5 independent filter points with inconsistent logic, no single source of truth
4. **Why it's hard to fix**: Each filter point was added/modified independently; fixing one doesn't fix the others; bandaid fixes (like `reconcileDiscoveredFieldOverrides`) only address symptoms
5. **Salesforce model**: One canonical access model, not 5 divergent computations

---

## 6. Design: Two-Layer Architecture

### 6.1 Layer 1 — Intrinsic Field Capability (Persistent)

`_System_Field` stores the field's **inherent** writability — independent of who is asking:

```
_System_Field:
  + Createable   TINYINT(1) NOT NULL DEFAULT 1   -- can this field ever be set on create?
  + Updateable   TINYINT(1) NOT NULL DEFAULT 1   -- can this field ever be modified on edit?
```

```go
// FieldMetadata struct:
Createable  bool `json:"createable"`
Updateable  bool `json:"updateable"`
```

These values are computed and persisted when field metadata is written — during bootstrap, field creation, field mutation, or metadata sync. Not computed at API request time.

| Condition | Createable | Updateable | Reason |
|-----------|-----------|------------|--------|
| Audit fields (Id, CreatedDate, CreatedById, LastModifiedDate, LastModifiedById, SystemModstamp, IsDeleted) | false | false | System-managed identity/audit trail |
| Formula fields | false | false | Computed, not user-writable |
| AutoNumber fields | false | false | Server-generated sequence |
| RollupSummary fields | false | false | Aggregated from child records |
| System-maintained dates (LastActivityDate, LastViewedDate, LastReferencedDate) | false | false | Updated by platform, not user |
| Object-specific aggregates (Campaign: NumberOfLeads, AmountAllOpportunities, etc.) | false | false | System-computed statistics |
| Standard business fields (Name, OwnerId, Status, Phone, Email, etc.) | **true** | **true** | User-writable |
| Custom fields | **true** | **true** | User-writable |

Optional: add `ReadOnlyReason` column (`audit` / `formula` / `autonumber` / `rollup` / `system_managed` / `null`) for debugging — know not just that it's false, but **why** it's false.

### 6.2 Layer 2 — Effective Field Capability (Runtime)

A single `FieldAccessResolver` computes the **effective** capability by combining intrinsic capability with user permissions:

```go
// The ONE place where permissions are applied to field capabilities.
// No other code path is allowed to re-derive this.
func (r *FieldAccessResolver) EffectiveCreateable(field FieldMetadata, objPerm ObjectPermission, fieldPerm FieldPermission) bool {
    return field.Createable && objPerm.PermissionsCreate && fieldPerm.PermissionsEdit
}

func (r *FieldAccessResolver) EffectiveUpdateable(field FieldMetadata, objPerm ObjectPermission, fieldPerm FieldPermission) bool {
    return field.Updateable && objPerm.PermissionsEdit && fieldPerm.PermissionsEdit
}
```

### 6.3 Two Projections for Consumers

All consumers read from the resolver. No consumer re-derives field capabilities:

**Access projection** (for Describe API / UI-API ObjectInfo):
```
effectiveCreateable = field.Createable && objectPerm && fieldPerm
effectiveUpdateable = field.Updateable && objectPerm && fieldPerm
```
This is what `createable`/`updateable` mean in the API response — user-context-aware.

**Layout projection** (for editableForNew / editableForUpdate):
```
editableForNew    = field.Createable    // intrinsic only, no permissions
editableForUpdate = field.Updateable    // intrinsic only, no permissions
```
Matches Salesforce: layout `editableForNew`/`editableForUpdate` reflects field intrinsic behavior, **not** user-level access.

> **Assumption**: This holds under the current design where layout metadata does not support per-item read-only or required overrides. If we later add layout-level field behavior overrides (e.g., admin marks a field as read-only on a specific layout), the projection becomes: `editableForNew = field.Createable && !layoutItem.ReadOnly`. This design should be extended at that point, not worked around.

### 6.4 Final Architecture

```
┌──────────────────────────────────────────────────────┐
│  _System_Field.Createable / .Updateable              │
│  (Persistent — written on bootstrap / field mutate)  │
│  THE SINGLE SOURCE OF TRUTH                          │
└──────────────┬───────────────────────────────────────┘
               │
       ┌───────┴────────┐
       │                │
       ▼                ▼
 FieldAccessResolver   Layout Projection
 (intrinsic + perms)   (intrinsic only)
       │                │
       ▼                ▼
 ┌─────────────┐  ┌──────────────────────┐
 │ REST Describe│  │ Layout editableForNew│
 │ UI-API ObjInfo│ │ Layout editableForUpdate│
 │ Create/Update │ │                      │
 │  validation  │  │                      │
 └─────────────┘  └──────────────────────┘
       │                │
       ▼                ▼
    Frontend          Frontend
    form filter       section filter
```

- **1 metadata source** — `_System_Field.Createable/Updateable`
- **1 runtime resolver** — `FieldAccessResolver` (permissions projection)
- **1 layout projection** — intrinsic only, no permissions
- **N read-only consumers** — all just read, never re-derive

### 6.5 When to Compute Intrinsic Capabilities

Not just at bootstrap — any time field metadata changes:
- **Bootstrap**: initial population + reconciliation
- **Field creation**: `BatchCreateFields` sets Createable/Updateable based on field type
- **Field mutation**: type change, formula change → recompute
- **Metadata sync**: import/reconcile → recompute
- Bootstrap is the **fallback**, not the only path

### 6.6 What `IsSystem` Becomes

`IsSystem` stays on `FieldMetadata` but **only** means `custom=false` (standard field badge in UI). It is:
- **Used for**: `Custom: !f.IsSystem` in describe response
- **Never used for**: createable, updateable, editableForNew, editableForUpdate, field visibility, form filtering

---

## 7. What Changes (Summary)

> **Note**: Section 8 contains the detailed per-file implementation. This section is the overview.

### Delete

| Code | File | Why |
|------|------|-----|
| `!f.IsSystem` for editableForNew | `sobject_describe.go:453` | Layout projection reads `f.Createable` / `f.Updateable` (intrinsic) |
| `(!f.IsSystem \|\| f.IsNameField)` for createable | `primitives.go:76-77` | Access projection goes through `FieldAccessResolver` |
| `IsFieldSystemReadOnly()` metadata lookup | `permissions/service.go:327-334` | Resolver checks `f.Createable` directly |
| `BatchSyncSystemFields()` | `field_batch.go:14-45` | Dead code |
| `reconcileDiscoveredFieldOverrides()` | `discover_tables.go:673-706` | **Partial**: remove IsSystem editability overrides; keep label/required fixes |
| `isSystemAuditField()` in primitives | `primitives.go:14-18` | Replaced by stored `f.Createable` |
| `isIntrinsicallyReadOnly()` in primitives | `primitives.go:23-27` | Replaced by stored `f.Createable` |
| `fieldIntrinsicRO` local computation | `sobject_describe.go:112-114` | Replaced by resolver |

### Add

| Code | File | Description |
|------|------|-------------|
| `Createable` / `Updateable` columns | `system_tables.json` | `TINYINT(1) NOT NULL DEFAULT 1` |
| `Createable` / `Updateable` fields | `models/metadata.go` | On `FieldMetadata` struct |
| `ComputeIntrinsic()` | `shared/pkg/fieldcapabilities/capabilities.go` | Two-layer intrinsic logic; called at repository save layer |
| `FieldAccessResolver` | `permissions/field_access_resolver.go` | Effective = intrinsic + permissions; explicit Create vs Edit perm |
| DB scan for new columns | `metadata_scan_schema.go` | Read stored values |
| Migration SQL | Bootstrap | Populate columns for existing data |
| ObjectInfo cache key with user ID | `primitives.go` | Per-user ObjectInfo cache (permissions vary by user) |
| Record write enforcement | `persistence_insert.go`, `persistence_service.go` | Drop non-createable/non-updateable fields |

### Keep (unchanged)

| Code | File | Why |
|------|------|-----|
| `Custom: !f.IsSystem` | `sobject_describe.go:127` | Correct use of IsSystem |
| Object-level `IsSystem` | `primitives.go:50` | System tables should not be user-writable |
| `IsSystemColumn()` | `schema_repository.go:37-45` | Keep for bootstrap — used to set `IsSystem` flag (display), NOT for editability |

### Change (frontend)

| Code | File | Change |
|------|------|--------|
| `isSystemField()` usage in form filtering | `MetadataRecordForm.tsx:428,465`, `InlineEditField.tsx:56` | **Stop using `isSystemField()` for editability decisions.** Frontend reads `createable`/`updateable` from describe response. `isSystemField()` demoted to display-only. |
| `isSystemField()` definition | `CommonFields.ts:41` | Keep the function, remove from all form/edit behavior gates. |

---

## 8. Detailed Implementation Design

### 8.1 Schema Change: `_System_Field` Table

Add two columns to `_System_Field` in `backend/internal/bootstrap/system_tables.json`:

```json
{ "name": "Createable", "type": "TINYINT(1)", "nullable": false, "default": "1" },
{ "name": "Updateable", "type": "TINYINT(1)", "nullable": false, "default": "1" }
```

Default `1` (true) — most fields are createable/updateable. Non-createable fields are explicitly set to `0` during bootstrap.

### 8.2 Model Change: `FieldMetadata` Struct

In `shared/pkg/models/metadata.go`, add after line 113 (`IsSystem`):

```go
Createable         bool                `json:"createable"`
Updateable         bool                `json:"updateable"`
```

### 8.3 DB Scan Change: `metadata_scan_schema.go`

In `scanField()` (line 76-114), add `Createable` and `Updateable` to the column list (`ColsSystemField`) and `Scan()` call. After line 114 (`field.IsSystem = isSystem.Bool`):

```go
field.Createable = createable.Bool
field.Updateable = updateable.Bool
```

### 8.4 Core Logic: `computeIntrinsicFieldCapabilities()`

New file: `backend/internal/features/metadata/field_capabilities.go`

```go
package metadata

import (
    "strings"
    "github.com/nexuscrm/shared/pkg/constants"
    "github.com/nexuscrm/shared/pkg/models"
)

// computeIntrinsicFieldCapabilities determines whether a field is inherently
// createable/updateable, independent of user permissions.
//
// Two layers:
//   Layer 1: Universal rules (all objects)
//   Layer 2: Object-specific overrides (Campaign aggregates, Opportunity computed fields)
//
// This is the SINGLE place where intrinsic capability is decided.
// All consumers read the stored result; none re-derive.
func computeIntrinsicFieldCapabilities(objectAPIName string, f *models.FieldMetadata) (createable bool, updateable bool) {
    // Layer 1: Universal intrinsic deny
    
    // 1a. Audit fields — always non-editable
    switch f.APIName {
    case constants.FieldID,
         constants.FieldCreatedDate, constants.FieldCreatedByID,
         constants.FieldLastModifiedDate, constants.FieldLastModifiedByID,
         constants.FieldSystemModstamp, constants.FieldIsDeleted:
        return false, false
    }

    // 1b. Type-based — formula, autoNumber, rollupSummary
    normalizedType := constants.NormalizeFieldType(string(f.Type))
    if (f.Formula != nil && *f.Formula != "") ||
        normalizedType == constants.FieldTypeAutonumber ||
        normalizedType == constants.FieldTypeRollupSummary {
        return false, false
    }

    // 1c. System-maintained date fields
    switch f.APIName {
    case "LastActivityDate", "LastViewedDate", "LastReferencedDate":
        return false, false
    }

    // Layer 2: Object-specific overrides
    if deny, ok := objectFieldDenylist[strings.ToLower(objectAPIName)]; ok {
        if deny[f.APIName] {
            return false, false
        }
    }

    // Default: createable and updateable
    return true, true
}

// objectFieldDenylist maps object API name (lowercase) to a set of field API names
// that are non-createable/non-updateable despite having normal column types.
// These are system-computed aggregate or derived fields that cannot be inferred
// from field type alone.
//
// Source: Salesforce Object Reference PDF — fields without Create/Update properties.
var objectFieldDenylist = map[string]map[string]bool{
    "campaign": {
        "AmountAllOpportunities":    true,
        "AmountWonOpportunities":    true,
        "NumberOfContacts":          true,
        "NumberOfConvertedLeads":    true,
        "NumberOfLeads":             true,
        "NumberOfOpportunities":     true,
        "NumberOfWonOpportunities":  true,
        "NumberOfResponses":         true,
    },
    "opportunity": {
        "ExpectedRevenue":           true, // Amount * Probability, read-only
        "HasOpportunityLineItem":    true, // system-maintained boolean
        "IsClosed":                  true, // derived from StageName
        "HasOpenActivity":           true, // system-maintained
    },
}
```

### 8.5 Write Path: Centralized at Repository Save Layer

> All field metadata writes flow through `SaveFieldMetadataWithIDs` or `BatchSaveFieldMetadata` in `schema_metadata_persist.go`. Capability computation happens at this central point — no per-path scatter.

**Problem**: `SaveFieldMetadataWithIDs` (line 111) takes `objectID string`, not `objectAPIName`. But `ComputeIntrinsic()` needs `objectAPIName` for the object-specific denylist.

**Solution**: Both paths get changed:

- **`SaveFieldMetadataWithIDs`**: Add `objectAPIName string` to signature. All 3 callers already have it:
  - `field_update.go:102` — has `obj.APIName`
  - `schema_column_ops.go:477` (`AddColumn`) — has `tableName` parameter
  - `schema_manager.go:135` — transparent pass-through, callers can pass it
- **`BatchSaveFieldMetadata`**: Add `ObjectAPIName` to the `FieldWithContext` struct

```go
// In schema/definitions.go (or wherever FieldWithContext is defined):
type FieldWithContext struct {
    ObjectID      string
    ObjectAPIName string  // NEW: needed for object-specific capability denylist
    FieldID       string
    Field         *models.FieldMetadata
}
```

Then in `BatchSaveFieldMetadata` and `SaveFieldMetadataWithIDs`, before INSERT/UPDATE:

```go
import "github.com/nexuscrm/shared/pkg/fieldcapabilities"

// Compute intrinsic capabilities before persisting
f.Createable, f.Updateable = fieldcapabilities.ComputeIntrinsic(objectAPIName, f)
```

**All write paths are automatically covered** because they all end at this repository layer:

| Write Path | Entry Point | Ends At |
|-----------|-------------|---------|
| Bootstrap: system table fields | `schema.go` → `BatchSaveFieldMetadata` | ✅ Covered |
| Bootstrap: discovered table fields | `discover_tables.go` → `registerFieldsForDiscoveredTables` → INSERT IGNORE | ⚠️ Uses raw SQL, not repository. Add a follow-up UPDATE or refactor to use `BatchSaveFieldMetadata` |
| API: create field | `metadata_field_handler.go:242` → `CreateField` → repository save | ✅ Covered |
| API: update field | `field_update.go:102` → repository save | ✅ Covered |
| Tooling API: create field | `tooling_metadata_handler.go:277` → `schema_column_ops.go:477` → repository save | ✅ Covered |

**Special case**: `registerFieldsForDiscoveredTables` uses raw `INSERT IGNORE` SQL (line 289-304) that bypasses the repository. Two options:
- Refactor it to use `BatchSaveFieldMetadata` (cleaner but larger change)
- Add a post-INSERT pass that computes and UPDATEs Createable/Updateable (simpler, matches migration pattern)

Recommend: post-INSERT pass for now, refactor later.

### 8.6 Migration SQL (One-Time for Existing Data)

Run during bootstrap if columns are newly added (idempotent):

```sql
-- Default: everything is createable/updateable (column default is 1)
-- Then deny specific categories:

-- Audit fields
UPDATE `_System_Field` SET `Createable` = 0, `Updateable` = 0
WHERE `ApiName` IN ('Id','CreatedDate','CreatedById','LastModifiedDate','LastModifiedById','SystemModstamp','IsDeleted');

-- System-maintained dates
UPDATE `_System_Field` SET `Createable` = 0, `Updateable` = 0
WHERE `ApiName` IN ('LastActivityDate','LastReferencedDate','LastViewedDate');

-- Formula / AutoNumber / RollupSummary (by type)
UPDATE `_System_Field` SET `Createable` = 0, `Updateable` = 0
WHERE `Type` IN ('formula','autonumber','rollup_summary');

-- Campaign aggregates
UPDATE `_System_Field` f JOIN `_System_Object` o ON o.Id = f.ObjectId
SET f.Createable = 0, f.Updateable = 0
WHERE LOWER(o.ApiName) = 'campaign'
AND f.ApiName IN ('AmountAllOpportunities','AmountWonOpportunities','NumberOfContacts',
    'NumberOfConvertedLeads','NumberOfLeads','NumberOfOpportunities',
    'NumberOfWonOpportunities','NumberOfResponses');

-- Opportunity computed fields
UPDATE `_System_Field` f JOIN `_System_Object` o ON o.Id = f.ObjectId
SET f.Createable = 0, f.Updateable = 0
WHERE LOWER(o.ApiName) = 'opportunity'
AND f.ApiName IN ('ExpectedRevenue','HasOpportunityLineItem','IsClosed','HasOpenActivity');
```

### 8.7 FieldAccessResolver

New file: `backend/internal/features/permissions/field_access_resolver.go`

```go
package permissions

import (
    "context"
    "github.com/nexuscrm/shared/pkg/models"
)

// FieldAccessResolver computes effective field capability by combining
// intrinsic capability (from _System_Field) with runtime permissions.
//
// This is the ONE place where permissions are applied to field capabilities.
type FieldAccessResolver struct {
    permSvc *PermissionService
}

func NewFieldAccessResolver(permSvc *PermissionService) *FieldAccessResolver {
    return &FieldAccessResolver{permSvc: permSvc}
}

// EffectiveCreateable = intrinsic AND object create perm AND field edit perm
func (r *FieldAccessResolver) EffectiveCreateable(
    ctx context.Context, f *models.FieldMetadata,
    objectAPIName string, user *models.UserSession,
) bool {
    if !f.Createable {
        return false
    }
    // Object-level create permission
    if !r.permSvc.CheckObjectPermissionWithUser(ctx, objectAPIName, "Create", user) {
        return false
    }
    // Field-level edit permission
    return r.permSvc.CheckFieldEditabilityWithUser(ctx, objectAPIName, f.APIName, user)
}

// EffectiveUpdateable = intrinsic AND object edit perm AND field edit perm
func (r *FieldAccessResolver) EffectiveUpdateable(
    ctx context.Context, f *models.FieldMetadata,
    objectAPIName string, user *models.UserSession,
) bool {
    if !f.Updateable {
        return false
    }
    // Object-level edit permission
    if !r.permSvc.CheckObjectPermissionWithUser(ctx, objectAPIName, "Edit", user) {
        return false
    }
    // Field-level edit permission
    return r.permSvc.CheckFieldEditabilityWithUser(ctx, objectAPIName, f.APIName, user)
}
```

> **Finding 3 fix**: `EffectiveCreateable` now explicitly checks object Create permission, not object Edit. `EffectiveUpdateable` checks object Edit permission. Each operation checks the correct object-level permission rather than reusing `CheckFieldEditabilityWithUser`'s fallback (which defaults to PermEdit for both).

### 8.8 Replace 5 Filter Points

**Filter 1** (`sobject_describe.go:126,162`) — access projection via resolver:
```go
// BEFORE:
Createable: canCreate && fieldEditable && !fieldIntrinsicRO,
Updateable: canEdit && fieldEditable && !fieldIntrinsicRO,

// AFTER:
Createable: resolver.EffectiveCreateable(ctx, &f, objectName, user),
Updateable: resolver.EffectiveUpdateable(ctx, &f, objectName, user),
```

**Filter 2** (`primitives.go:76-77`) — access projection via resolver (NOT raw intrinsic):
```go
// BEFORE:
createable := !isSystemAuditField(f.APIName) && (!f.IsSystem || f.IsNameField) && !isIntrinsicallyReadOnly(&f)
updateable := !isSystemAuditField(f.APIName) && (!f.IsSystem || f.IsNameField) && !isIntrinsicallyReadOnly(&f)

// AFTER:
createable := resolver.EffectiveCreateable(ctx, &f, objectType, user)
updateable := resolver.EffectiveUpdateable(ctx, &f, objectType, user)
```

> ObjectInfo is an access projection (user-context-aware), not intrinsic. Both Filter 1 and Filter 2 go through the resolver. Because ObjectInfo is now user-context-aware, the cache must include user ID (see Section 8.13).

**Filter 3** (`primitives.go:293-301`) — layout projection (intrinsic only, no permissions):
```go
// BEFORE:
editableForNew := true
editableForUpdate := true
if isSystemAuditField(fMeta.APIName) || isIntrinsicallyReadOnly(fMeta) {
    editableForNew = false
    editableForUpdate = false
}

// AFTER:
editableForNew := fMeta.Createable     // intrinsic only — layout projection
editableForUpdate := fMeta.Updateable  // intrinsic only — layout projection
```

**Filter 4** (`sobject_describe.go:453-454`) — layout projection (intrinsic only):
```go
// BEFORE:
"editableForNew":    !f.IsSystem,
"editableForUpdate": !f.IsSystem,

// AFTER:
"editableForNew":    f.Createable,    // intrinsic only — layout projection
"editableForUpdate": f.Updateable,    // intrinsic only — layout projection
```

**Filter 5** (`MetadataRecordForm.tsx:374,428,465`) — frontend reads backend values:
```tsx
// BEFORE:
if (isSystemField(f.name)) return false;
// ...
return canWrite && !isSystemField(name) && f.type !== 'formula' && !f.calculated;

// AFTER:
// Remove isSystemField() from editability checks entirely.
// The backend already provides createable/updateable on the describe response.
// Frontend only checks: isEdit ? f.updateable : f.createable
// No local re-derivation of "is this field editable".
```

Note: `isSystemField()` remains in the codebase for display-only use (badges, palette hints), but is removed from all form/edit behavior gates.

### 8.9 Centralize Capability Computation on Write Paths

> **Finding 4 fix**: Instead of scattering `computeIntrinsicFieldCapabilities()` calls across every write path, centralize in the repository save layer.

> **Finding 6 fix**: `computeIntrinsicFieldCapabilities()` must NOT live in `backend/internal/features/metadata` (circular dependency with `persistence`). It must live in a lower-level package.

**Package placement**: `shared/pkg/fieldcapabilities/capabilities.go`

This package has no dependencies on `metadata` or `persistence` — it only depends on `shared/pkg/constants` and `shared/pkg/models`. Both `metadata` and `persistence` can safely import it.

**Central save point**: `SaveFieldMetadataWithIDs` / `BatchSaveFieldMetadata` in `schema_metadata_persist.go`

Before persisting, always recompute and set `Createable`/`Updateable`:
```go
import "github.com/nexuscrm/shared/pkg/fieldcapabilities"

// In SaveFieldMetadataWithIDs / BatchSaveFieldMetadata, before INSERT/UPDATE:
f.Createable, f.Updateable = fieldcapabilities.ComputeIntrinsic(objectAPIName, f)
```

This covers ALL write paths automatically:
- `discover_tables.go` → eventually calls `SaveFieldMetadata` or `BatchSaveFieldMetadata`
- `BatchCreateFields` → calls `BatchSaveFieldMetadata`
- `CreateField` (`metadata_field_handler.go:242`) → calls repository save
- `field_update.go:102` → calls repository save
- `tooling_metadata_handler.go:277` → calls repository save

No write path can skip the computation because it happens at the persistence layer.

### 8.10 Record Write Path Enforcement

> **Finding 5 fix**: UI display rules and actual write validation must use the same source.

Current record write paths that need field-level createability enforcement:

| Path | File | Current State | Change |
|------|------|--------------|--------|
| Record create | `persistence_insert.go:33` | No field-level createability check | Add: reject fields where `f.Createable=false` |
| Bulk create | `persistence_bulk_insert.go:50` | No field-level createability check | Add: reject fields where `f.Createable=false` |
| Record update | `persistence_service.go:450` | Checks `CheckFieldEditabilityWithUser` | Change: also check `f.Updateable` (intrinsic) before permission check |

**Enforcement strategy decision**: The current update path returns 400 for non-editable fields (`persistence_service.go:445`). The create path has no field-level check. We need a unified strategy.

Options:
- **A: Silently drop** — matches Salesforce client-side behavior (`generateRecordInputForCreate` pre-filters)
- **B: Return 400** — matches current update path; explicit error for the caller
- **C: Silently drop for intrinsic, 400 for permissions** — intrinsic violations are client bugs (field shouldn't have been submitted); permission violations are access errors

**Decision: Option C** — this is the most informative:
- If a field is `Createable=false` (intrinsic), the client should never have submitted it → silently drop (the UI already hides these fields)
- If a field is `Createable=true` but user lacks permission → return 400 (real access error the user/admin needs to know about)

Implementation:
```go
// In both create and update paths, BEFORE the existing permission check:
if fieldMeta != nil {
    if isCreate && !fieldMeta.Createable {
        continue  // silently drop — field is intrinsically non-createable
    }
    if !isCreate && !fieldMeta.Updateable {
        continue  // silently drop — field is intrinsically non-updateable
    }
}
// ... existing permission check (returns 400 if user lacks access)
```

### 8.11 Migration SQL Correction

> **Finding 7 fix**: Migration must also catch formula fields by `Formula IS NOT NULL`, not just by `Type`.

```sql
-- Default: everything is createable/updateable (column default is 1)
-- Then deny specific categories:

-- Audit fields
UPDATE `_System_Field` SET `Createable` = 0, `Updateable` = 0
WHERE `ApiName` IN ('Id','CreatedDate','CreatedById','LastModifiedDate','LastModifiedById','SystemModstamp','IsDeleted');

-- System-maintained dates
UPDATE `_System_Field` SET `Createable` = 0, `Updateable` = 0
WHERE `ApiName` IN ('LastActivityDate','LastReferencedDate','LastViewedDate');

-- Formula / AutoNumber / RollupSummary (by type AND by formula content)
UPDATE `_System_Field` SET `Createable` = 0, `Updateable` = 0
WHERE `Type` IN ('formula','autonumber','rollup_summary')
   OR (`Formula` IS NOT NULL AND `Formula` != '');

-- Campaign aggregates
UPDATE `_System_Field` f JOIN `_System_Object` o ON o.Id = f.ObjectId
SET f.Createable = 0, f.Updateable = 0
WHERE LOWER(o.ApiName) = 'campaign'
AND f.ApiName IN ('AmountAllOpportunities','AmountWonOpportunities','NumberOfContacts',
    'NumberOfConvertedLeads','NumberOfLeads','NumberOfOpportunities',
    'NumberOfWonOpportunities','NumberOfResponses');

-- Opportunity computed fields
UPDATE `_System_Field` f JOIN `_System_Object` o ON o.Id = f.ObjectId
SET f.Createable = 0, f.Updateable = 0
WHERE LOWER(o.ApiName) = 'opportunity'
AND f.ApiName IN ('ExpectedRevenue','HasOpportunityLineItem','IsClosed','HasOpenActivity');
```

### 8.12 Delete Dead Code

> **Finding 8 fix**: `reconcileDiscoveredFieldOverrides()` cannot be fully deleted yet — it also fixes label/required/IsSystem data beyond editability. Only remove the Createable-related overrides; keep label/required fixes until the import root cause is addressed separately.

| Code | File | Action |
|------|------|--------|
| `isSystemAuditField()` | `primitives.go:14-18` | Delete — replaced by `f.Createable` |
| `isIntrinsicallyReadOnly()` | `primitives.go:23-27` | Delete — replaced by `f.Createable` |
| `IsFieldSystemReadOnly()` metadata lookup branch | `permissions/service.go:327-334` | Simplify — resolver checks `f.Createable` directly |
| `BatchSyncSystemFields()` | `field_batch.go:14-45` | Delete — dead code, no production callers |
| `reconcileDiscoveredFieldOverrides()` | `discover_tables.go:673-706` | **Partial**: remove IsSystem=0 overrides for Campaign.Name/OwnerId (root cause fixed). Keep label/required overrides until import root cause addressed |
| `fieldIntrinsicRO` local computation | `sobject_describe.go:112-114` | Delete — replaced by resolver |

### 8.13 ObjectInfo Cache Key Change

> ObjectInfo cache must become user-context-aware. Profile alone is insufficient — permissions also vary by Permission Sets (see `permissions/service.go:57,63`).

In `primitives.go`, change ObjectInfo cache:

```go
// BEFORE (primitives.go:37,152):
cacheKey := objectType  // same ObjectInfo for all users

// AFTER:
userKey := ""
if user != nil {
    userKey = user.ID  // per-user, not per-profile (Permission Sets differ per user)
}
cacheKey := objectType + ":" + userKey
```

**Why user.ID not user.ProfileID**: Two users with the same Profile can have different Permission Sets, resulting in different effective `createable`/`updateable` values. Caching by Profile would serve one user's permissions to another. Caching by `user.ID` is safe — at the cost of lower cache hit rate. If this becomes a performance issue, a permission fingerprint (hash of profile + permission set IDs) can be used instead.

Note: `UserSession` field names differ between packages: `jwt.go` uses `ProfileId`, `models/auth.go` uses `ProfileID`. The `user.ID` field is consistent across both.

### 8.14 Test Changes

| Test Category | Files | What to Test |
|--------------|-------|-------------|
| ObjectInfo user-aware cache | `service_methods_test.go` | Two users with different profiles get different `createable` values for the same field |
| Field create/update capability recompute | `metadata_service_unit_test.go` | After `CreateField` or field type change, `Createable`/`Updateable` are correctly recomputed |
| Migration backfill | Integration test | After migration SQL, audit fields have `Createable=0`, normal fields have `Createable=1` |
| Record create enforcement | `persistence_insert_test.go` | Submitting a non-createable field in create payload → field silently dropped |
| Record update enforcement | `persistence_service_test.go` | Submitting a non-updateable field in update payload → field silently dropped |
| Resolver correctness | `field_access_resolver_test.go` | `EffectiveCreateable` = false when intrinsic false; true when intrinsic true + perms granted; false when intrinsic true + perms denied |
| Existing assertions | `service_methods_test.go:180,215` | OwnerId with `IsSystem=true` should have `Createable=true` (no longer derived from IsSystem) |
| Layout editableForNew | `layout_create_edit_test.js` | `editableForNew` matches `f.Createable` for all field categories |

---

## 9. Effort Estimate

| Task | Files | Time |
|------|-------|------|
| Add Createable/Updateable to schema + model + scan | 4 | 1.5h |
| Implement `computeIntrinsicCreateable()` | 1 | 1h |
| Integrate into bootstrap + field mutation paths | 3 | 1.5h |
| Implement `FieldAccessResolver` | 1 | 1h |
| Replace all 5 filter points with resolver/projection reads | 4 | 1.5h |
| Delete dead code + bandaids | 4 | 30m |
| Migration SQL for existing data | 1 | 30m |
| Update tests | 4 | 1.5h |
| **Total** | ~20 files | **~9h** |

---

## 9. Phased Delivery

### Phase 1: Single-Layout Correctness

**Goal**: One layout per object works correctly across View/Edit/Create. Admin edits the layout, all three modes reflect the change predictably.

**Definition of done**:
- Same Detail Layout renders correctly in View, Edit, and Create modes
- Field capability (`createable`/`updateable`) not scattered across 5 filter points — one metadata source, one resolver
- Layout Editor changes are immediately reflected on Create/Edit pages
- System section auto-hidden on Create (editableForNew derived from stored intrinsic capability)
- Compact layout / related lists / section rendering work end-to-end
- Drag-and-drop in Layout Editor works (PR #594)

**What this phase does NOT include**:
- Multiple layouts per object
- New Layout / Clone Layout
- Layout Assignment matrix
- Per-field Required/Read-Only overrides in Layout Editor

**Relevant work**:
| Item | Relationship |
|------|-------------|
| PR #590 (single layout UI) | Part of Phase 1 — correct UI for single-layout model |
| This design (field-editability) | Part of Phase 1 — correct field capability source of truth |
| PR #594 (drag-and-drop fix) | Part of Phase 1 — Layout Editor usability |
| PR #587 (IsSystem fix in primitives) | Partial fix — this design replaces it with the full solution |
| Issue #591 (editableForNew gap) | This design resolves it completely |

### Phase 2: Multi-Layout Assignment Workflow

**Goal**: 千人千面 foundation — different audiences can see different layouts, starting with Salesforce-level `Profile × RecordType` assignment and then layering Role-based overrides on the same resolver if needed.

**This is NOT "add a New Layout button".** The core capability is the full workflow:
```
clone existing → edit cloned layout → assign audience → resolve at runtime → verify
```

Each step serves a distinct purpose:
- **clone** solves "how to quickly create a second layout" (not from blank — SF admins almost never build from scratch)
- **edit** solves "what this layout looks like"
- **assign** solves "who sees this layout"
- **resolve** solves "which layout does this user get at runtime"

**Clone does NOT copy assignments.** A cloned layout starts as unassigned. Admin explicitly assigns it to an audience via the assignment matrix. Reason: if clone copies assignments, the old and new layout would compete for the same audience, causing implicit override conflicts. An advanced "Copy assignments from source" option may be offered later, but must not be the default.

**Minimum definition of done**:
- Admin can **clone** an existing layout (creates a copy with a new name, content identical, no assignments)
- Admin can **edit** each layout independently
- Admin can **assign** layouts via a matrix UI: rows = Profiles, columns = RecordTypes, cells = which layout
- System **resolves** the correct layout per user at runtime
- Different users **verify** they see different pages

**Minimum Salesforce parity**: Profile × RecordType → Layout

#### Infrastructure Readiness Assessment (Verified 2026-04-01)

**Profile / Role / RecordType backend foundations: ⚠️ Mostly ready**

| Infrastructure | Status | Evidence |
|---------------|--------|---------|
| `LayoutAssignment` model (RoleID + ProfileID + RecordTypeID) | ✅ | `shared/pkg/models/metadata.go:194` — three nullable dimensions |
| `_System_LayoutAssignment` table | ✅ | `system_tables.json` — three dimension columns + specificity |
| `ResolveLayout()` (roleAncestors + profileID + recordTypeID) | ✅ | `metadata_layouts.go:54` — specificity-based resolution with role hierarchy |
| `UserSession` (ProfileId + RoleId) | ✅ | `jwt.go:18` — both dimensions available at runtime |
| Assignment CRUD API | ✅ | `routes.go` — POST/DELETE/GET endpoints registered |
| `LayoutAssignmentEditor` frontend component | ⚠️ | `layout/LayoutAssignmentEditor.tsx` — preserved CRUD prototype, not a matrix UI |
| Main layout fetch path passes `recordTypeId` through to resolver | ❌ | `ui_api_handler.go:201` passes `\"\"`; `useLayout()` has no `recordTypeId` param |
| Role hierarchy traversal | ✅ | `ResolveLayout` walks role ancestors for specificity matching |

**Main remaining gaps for Phase 2**:
- Clone layout UI (no clone endpoint or UI exists)
- Assignment matrix UI (the preserved component is only CRUD, not a matrix)
- RecordType-aware layout fetch path in the primary UI workflow
- E2E verification of the full clone → edit → assign → resolve chain

**Group / Team / Region dimensions: ❌ Not ready**

| Missing | Where |
|---------|-------|
| `GroupId` / `TeamId` / `RegionId` on `UserSession` | `jwt.go` — no group/team/region fields |
| `GroupId` / `TeamId` / `RegionId` on `LayoutAssignment` | `models/metadata.go` — no corresponding columns |
| `_System_LayoutAssignment` table columns | `system_tables.json` — only Role/Profile/RecordType |
| Resolver dimension support | `ResolveLayout` — no group/team/region in signature |

**Phase 2 scope decision**: Phase 2 MVP is Salesforce-level `Profile × RecordType`. Role-based overrides are a Phase 2b Nexus extension on the same resolver. Group/Team/Region extension is a separate later phase after the base workflow is proven.

**What was wrong with the previous attempt** (`8ee2b06a`):
- Had New Layout but no Clone from existing (SF admin workflow is clone → customize, not blank → build)
- Had layout list + CRUD but no Assignment matrix UI (multiple layouts existed in DB but no way to assign them to audiences)
- Mixed in Create Layout tab (confused "mode projection" with "audience assignment")
- Result: CRUD shell without the workflow that makes multiple layouts useful

**Why not do Phase 2 now**:
- Phase 1 (single-layout correctness) is not yet done — field capability still broken
- Exposing multi-layout UI on a broken foundation creates more confusion
- Backend capabilities for Phase 2 are already built (#566) and preserved — only frontend workflow UI is deferred

---

## 10. Salesforce Layout Model Reference

### One layout, three modes

Salesforce `getLayout` ([docs](https://developer.salesforce.com/docs/platform/lwc/guide/reference-get-layout.html)) returns a layout with:
- `layoutType`: `Full` or `Compact` (density, not interaction mode)
- `mode`: `Create`, `Edit`, or `View` (rendering context, not separate layout definitions)

There is no "Create Layout" or "Edit Layout" as a separate entity. One PageLayout handles all three modes.

### Multiple layouts for different audiences

Salesforce allows multiple page layouts per object ([Page Layout Considerations](https://help.salesforce.com/s/articleView?id=platform.layouts_considerations.htm&language=en_US&type=5)):
- Enterprise/Unlimited editions: "create different page layouts for use by different profiles and record types"
- Layout Assignment maps (Profile × RecordType) → Layout ([Assignment](https://help.salesforce.com/s/articleView?id=analytics.bi_explorer_apply_action_to_page_layout.htm&language=en_US&type=5))

The purpose of multiple layouts is **not** Create vs Edit vs View. It is **different audiences seeing different field sets**.

### Layout vs Access

Layout `editableForNew`/`editableForUpdate` reflects layout-level + intrinsic field behavior. It does **not** reflect user-level or profile-level access ([GraphQL: query-record-layouts](https://developer.salesforce.com/docs/platform/graphql/guide/query-record-layouts.html)).

User-level access is a separate concern, resolved at runtime by the access model (`getObjectInfo` returns user-context-aware `createable`/`updateable`).

---

## 11. References

- `backend/internal/features/uiapi/primitives.go:14-18,76-77,293-301` — current scattered checks
- `backend/internal/interfaces/rest/sobject_describe.go:126,162,453` — divergent createable paths
- `backend/internal/features/permissions/service.go:139-168,298-334` — permission-based editability
- `backend/internal/infrastructure/persistence/schema_repository.go:37-45` — IsSystemColumn()
- `backend/internal/features/metadata/field_batch.go:14-45` — dead code BatchSyncSystemFields
- `backend/internal/bootstrap/discover_tables.go:673-706` — bandaid reconcileDiscoveredFieldOverrides
- `backend/internal/features/uiapi/service_methods_test.go:180,215` — tests encoding current behavior
- `tests/contract/ui_api/ui_api.postman_collection.json` — Salesforce contract evidence
- Salesforce docs: [getLayout](https://developer.salesforce.com/docs/platform/lwc/guide/reference-get-layout.html), [getObjectInfo](https://developer.salesforce.com/docs/platform/lwc/guide/reference-wire-adapters-object-info), [generateRecordInputForCreate](https://developer.salesforce.com/docs/platform/lwc/guide/reference-generate-record-input-create.html), [query-record-layouts](https://developer.salesforce.com/docs/platform/graphql/guide/query-record-layouts.html), [Page Layout Considerations](https://help.salesforce.com/s/articleView?id=platform.layouts_considerations.htm&language=en_US&type=5)
