/** * lib/fk-allowlist.ts — Identity/audit columns intentionally NOT foreign keys. * * RULE (non-negotiable): every column that references another table is a foreign * key constraint. The ONLY exceptions are the identity/audit columns below — * aligned with SmartStack.app, which deliberately leaves these UN-constrained * (index only, no FK) because the referenced user may be: * - soft-deleted (audit rows must survive the user's removal), or * - synced from an external identity provider (Entra / Graph), so a hard FK * would break inserts/deletes and external sync. * * `TenantId` is deliberately NOT here — it MUST be a real FK to `core.tenant_Tenants` * (SmartStack.app configures `HasOne().WithMany().HasForeignKey(x => x.TenantId)`). * * Evidence (SmartStack.app, schema `core`): TicketConfiguration leaves * `CreatedByUserId` / `AssignedToUserId` as indexed scalars (no FK); * TicketActivityConfiguration leaves `ChangedByUserId`; TicketAssignee leaves the * join `UserId`. Contrast: ExternalApplicationUserAccess DOES declare a real FK to * User via `OwnerUserId`-style navigation — so generic `*UserId` is NOT blanket-exempt; * only the explicit audit-action columns + the bare join `UserId` are. * * Single source of truth — imported by the entity-audit CLI and the (prose) audit * rules DEV-DAT-008 / DM-013 reference this list by name. Override per-run via the * audit `--spec` `allowlist` field. */ export const FK_AUDIT_ALLOWLIST: readonly RegExp[] = [ /^CreatedByUserId$/i, /^UpdatedByUserId$/i, /^ModifiedByUserId$/i, /^DeletedByUserId$/i, /^ChangedByUserId$/i, /^ApprovedByUserId$/i, /^AssignedToUserId$/i, /^UserId$/i, ] /** Default allowlist as plain strings (for docs / spec defaults / display). */ export const FK_AUDIT_ALLOWLIST_NAMES: readonly string[] = [ 'CreatedByUserId', 'UpdatedByUserId', 'ModifiedByUserId', 'DeletedByUserId', 'ChangedByUserId', 'ApprovedByUserId', 'AssignedToUserId', 'UserId', ] /** * True when a column is an intentionally-non-FK identity/audit column. * `patterns` lets a caller pass a custom allowlist (e.g. from the audit `--spec`); * entries may be RegExp or string (string = exact, case-insensitive match). */ export function isAuditAllowlisted( column: string, patterns: ReadonlyArray = FK_AUDIT_ALLOWLIST, ): boolean { return patterns.some((p) => typeof p === 'string' ? p.toLowerCase() === column.toLowerCase() : p.test(column), ) }