6.8.1 Schema (project_key_alias + avatar columns) + the details/rename service core (locked atomic key-change tx, alias reservation, release) + admin-gated PATCH
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).
Acceptance criteria
- Migration applies cleanly; re-run reports "No difference detected" (no FK drift); existing rows read back with null avatar.
changeKeyis atomic: after PROD→NIF on the large seed, EVERY work_item identifier is NIF-<key> 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.- Collision matrix: live identifier of another project → 409; alias of another project → 409; own alias → reclaim (alias row swapped); format violations → 400; unchanged key → typed no-op error. Create-project can no longer mint a reserved key (suffix loop skips aliases).
- Concurrency: a rename racing
allocateWorkItemNumber-backed issue creation never produces a stale-prefix identifier (both interleavings asserted against the FOR-UPDATE lock). - Non-admin PATCH/DELETE → typed 403;
releaseAliasdeletes exactly one row;pnpm test:coverage≥90% on the touched files.
Context refs
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]))- The 6.4.3 project-admin gate + the
/api/projects/[key]/accessroute pattern motir-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