Estimate: 33m
The data model and the whole write path — no UI.
Migration: project_key_alias (id, workspaceId, projectId, identifier, createdAt; @@unique([workspaceId, identifier]), indexed by projectId) modelled as a Prisma @relation on BOTH sides with onDelete: Cascade from Project (deletion frees the keys — the verified mirror rule; the FK-drift rule applies). Project.avatarIcon + Project.avatarColor (nullable Strings — null = the shipped mono-identifier rendering, so existing rows need no backfill).
Service (projectsService): updateDetails — name (trimmed, non-empty; slug NOT regenerated — recorded decision) + avatar (icon key validated against the preset registry; colour against the swatch set). changeKey — ONE $transaction: FOR-UPDATE lock on the project row (the lock-before-read-derived-update rule); normalizeIdentifier format guard (3–5 uppercase A–Z/0–9 — rung 2); no-op guard (new == current → typed error); collision guard against live identifiers AND other projects' aliases (workspace-scoped); reclaiming the OWN previous key deletes that alias row; a SINGLE bulk UPDATE work_item SET identifier = <new> || '-' || key WHERE "projectId" = … via a repository $queryRaw method (one statement, index-maintained, NO per-row loop and NO revision rows — identifier is derived data, the key number is untouched); insert the alias for the old key; update project.identifier. releaseAlias — admin-gated delete of one alias row (the Jira Cloud "Previous project keys" remove). Create-path guard: the create-project identifier suffix loop ALSO checks the alias table inside its tx (a new project must not take a reserved key).
Route: PATCH /api/projects/[key] accepting { name?, avatarIcon?, avatarColor?, identifier? } + DELETE /api/projects/[key]/aliases/[alias], both project-admin-gated (the 6.4.3 capability, the /api/projects/[key]/access PATCH pattern); typed errors → 400/403/409. DTO growth: avatarIcon/avatarColor + previousKeys on the project DTO (the details card + switcher consumers).
changeKey is atomic: after PROD→NIF on the large seed, EVERY work_item identifier is NIF- with numbers preserved, the alias row exists, and a mid-tx failure (fault-injected) leaves NO partial state. The rewrite is one SQL statement (asserted via query log), bounded on the 10k-issue seed.allocateWorkItemNumber-backed issue creation never produces a stale-prefix identifier (both interleavings asserted against the FOR-UPDATE lock).releaseAlias deletes exactly one row; pnpm test:coverage ≥90% on the touched files.lib/services/projectsService.ts (normalizeIdentifier, the create suffix loop, allocateWorkItemNumber) + lib/repositories/projectRepository.ts (findByIdentifier)prisma/schema.prisma — Project (@@unique([workspaceId, identifier])), WorkItem.identifier (denormalized "PROD-42", @@unique([projectId, identifier]))/api/projects/[key]/access route patternmotir-core/CLAUDE.md (4-layer, required-tx, FK-as-@relation); the lock-before-read-derived-update rule; the verified mirror behaviour in the Story 6.8 description