## Problem
Self-hosted upgrades that jump versions (e.g. `2.4 → 2.7/2.9`) abort
with:
```
TypeError: Cannot read properties of undefined (reading 'universalIdentifier')
at WorkspaceRolesPermissionsCacheService.hasSettingsGatedObjectPermissions
at WorkspaceRolesPermissionsCacheService.computeForCache
at WorkspaceCacheService.recomputeDataFromProvider
```
Reported in #20841 (Failure #2). The sequence aborts mid-upgrade and
leaves the DB in a half-migrated state.
## Root cause
The per-workspace **cache recompute runs at a `2.5.0` workspace step —
before the `2.6` schema migrations apply**. At that cursor:
- `RolePermissionFlagEntity.permissionFlag` is
`@WasIntroducedInUpgrade('2.6.0_LinkRolePermissionFlagToPermissionFlag…')`,
so `UpgradeAwareRepositoryProxy` **strips the relation**
(`[upgrade-proxy] strip relation
RolePermissionFlagEntity.permissionFlag` in the logs) → `permissionFlag`
is `undefined`.
- `hasSettingsGatedObjectPermissions()` then does an **unguarded**
`rolePermissionFlag.permissionFlag.universalIdentifier` → throws.
The crash only manifests when a workspace has **≥1 `rolePermissionFlag`
row** (custom roles with gated settings perms / SDK `defineRole`). A
vanilla seed has an empty table, so `.find()` over `[]` never
dereferences anything — which is why it didn't reproduce on a clean
instance.
A null-safe fallback to the legacy `flag` column used to exist here; it
was dropped in #20730.
## Fix
Resolve the flag's universal identifier through a small helper that
falls back to the legacy `flag` column (only removed in `2.7.0`) when
the relation is unavailable:
```ts
private getRolePermissionFlagUniversalIdentifier(
rolePermissionFlag: RolePermissionFlagEntity,
): string {
// The `permissionFlag` relation is stripped during upgrades until the 2.6.0
// cursor (@WasIntroducedInUpgrade), so fall back to the legacy `flag` column.
return (
rolePermissionFlag.permissionFlag?.universalIdentifier ??
SystemPermissionFlag[rolePermissionFlag.flag]
);
}
```
`SystemPermissionFlag[flag]` yields the same UUID the relation would, so
the comparison stays in a single space and the computed permission is
exact (not an over-grant). Correct at every transitional cursor:
pre-`2.6` (relation stripped → use `flag`), `2.6` (both present →
relation wins), post-`2.7` (`flag` removed → relation wins).
## Reproduction & validation
Locally jumped a real `2.4.0` DB → `v2.9.0` build via `yarn command:prod
upgrade`:
| Scenario | Result |
| --- | --- |
| Empty `permissionFlag` (vanilla seed) | passes (no crash) |
| **+1 flag row**, current code | `TypeError … universalIdentifier` →
**3 succeeded, 1 failed** |
| Same fixture, **this fix** | **16 succeeded, 0 failed**, DB fully
migrated to 2.9.0 |
`nx typecheck twenty-server` clean; existing cache-service unit tests
pass; app boots on the upgraded DB.
## Scope / follow-up
This fixes **Failure #2**. **Failure #1** in the same issue
(`viewFilter.relationTargetFieldMetadataId` selected before its column
exists) is a separate instance of the same theme — cache recompute
reading "future" schema before migrations run — and is worth a
follow-up. A more durable systemic fix would defer the workspace cache
recompute until after all schema-adding migrations; this PR is the
low-risk, backport-friendly fix for the immediate breakage.
> Note: an earlier bot branch
(`sonarly-39738-fixupgrade-guard-role-permission-flag-relation`)
proposed the same fallback inline. This PR supersedes it with a named
helper + a focused comment.
Fixes#20841
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>