Story-1241 · pramaan-docs ADR-100 (catalog composition)

Firm roles catalog — decouple role from capability

The distinction

Before this story, identity.firm_memberships.role (firm_admin / firm_editor / firm_member) did double duty: it was both the permission level and the only title the UI could show — so a Partner read as "Admin". This story splits the two:

ConceptMeaningThis story
CapabilityWhat a member may DO (admin / editor / member). Authz.Untouched. ADR-037/055/097 intact.
RoleProfessional designation (Partner / Senior Advocate / Member …). Firm-defined, renameable, human-facing.New first-class catalog.

A member now carries BOTH, orthogonally: a firm_member capability could be a "Senior Advocate" role; a firm_admin could be a "Partner".

What changed

PieceChange
Migration 0094New business.firm_roles table: id, firm_id, name, display_order, availability ('active'|'deleted'), is_system_default, created_at. Firm-scoped RLS (mirrors participant_labels). Seeds Partner / Senior Advocate / Member on firm provisioning and back-seeds every existing firm (same shape migration 0091 used for participant labels).
Migration 0095identity.firm_memberships.role_id → nullable FK to business.firm_roles. Backfills every existing membership to its firm's "Member" default. Additive — pramaan-agents reflects without code change.
src/domains/firm_role/New domain: entity, repository, service. CRUD plus a last-role guard (cannot soft-delete the final non-deleted role) and member reassign/null on delete.
Endpoints under /app/firm/GET /roles (active, ordered) · POST /roles · PATCH /roles/{id} (rename/reorder) · DELETE /roles/{id} (soft-delete, refuse last). The member update path gains role_id.
FirmMemberListItemMembers response now carries role: {id, name} | null — so the app + add-matter wizard show the ROLE, not the capability.

Why a catalog, not a new shape

This is the same pattern as business.participant_labels (seeded defaults, firm-scoped, soft-delete, display_order) and pramaan-docs ADR-100 (matter templates as catalog composition). The role↔capability decoupling was judged not ADR-grade — it extends an established family rather than inventing a new concept — so ADR-100 + participant-labels govern as precedent.

Verification

Integration: full CRUD + last-role guard + member assignment against isolated test DB.
Unit: migration round-trip (upgrade/downgrade/upgrade).
Contract: OpenAPI operation-ids + response schemas for the new routes.
Audit coverage registry updated for the new mutations.
784 tests pass. 0 new mypy errors in firm_role/.
Capability labels (admin/editor/member) are deliberately NOT renamed here — that is display-only and separate. The app-side settings page, assignment UI, and wizard wiring are the paired pramaan-app Story-1242, which depends on these endpoints.