Story-1241 made designation (Partner / Counsel / Member, the business.firm_roles catalog) and capability (firm_admin / firm_editor / firm_member) independent. A firm admin had to set two things, and nothing bound them — a "Partner" could be a firm_member. Capability is the security-bearing field, and it was being hand-set per member, independent of the title that should imply it. That is incoherent to counsel and a standing mis-grant risk.
ADR-112: a firm role carries a capability. The designation is the single thing assigned to a member; capability is derived from the assigned role, never set per-member.
| Piece | Change |
|---|---|
Migration 0096 | Adds business.firm_roles.capability (NOT NULL, CHECK firm_admin|firm_editor|firm_member). Backfills existing rows: system-default Partner→firm_admin, Senior Advocate/Counsel→firm_editor, Member→firm_member; every custom role→firm_member (least privilege, ADR-037). Renames the editor-tier seed default Senior Advocate → Counsel where no active "Counsel" collides. |
Seed (seed_default_firm_roles) | Provisioning seeds exactly 3 roles 1:1 with capability: Partner→firm_admin, Counsel→firm_editor, Member→firm_member. |
| Catalog DTOs / service | FirmRoleCreateRequest requires capability; FirmRoleUpdateRequest accepts an optional one; FirmRoleView exposes it (for app #1245). |
| Propagation | PATCH /app/firm/members/{id}/firm-role now sets the designation and propagates that role's capability to identity.firm_memberships.role. Clearing the designation reverts to the firm_member default. Atomic, in one transaction. |
| Standalone path retired | PATCH /app/firm/members/{id}/role (direct capability set), its service method, and FirmMemberRoleUpdateRequest are removed. No firm-admin-facing endpoint mutates capability directly. The self-demotion / admin-protection guards moved onto the assignment path so they are not lost. |
| System-default protection | System defaults are rename-only: DELETE is rejected (pramaan.firm_role.system_default_undeletable) and capability change is rejected (pramaan.firm_role.system_default_capability_locked). Mirrors pramaan-app commit 76a0115 server-side. |
firm_member — a firm-wide lockout. Migration 0096 reconciles only the catalog capability; member capability keeps its current value, and only future assignments propagate.Migration: upgrade head + 0096 down/up round-trip + `alembic check` clean.
Backfill: legacy Partner/Senior Advocate/Member/custom rows on a real DB →
Partner=firm_admin, Counsel(renamed)=firm_editor, Member=firm_member,
Paralegal=firm_member. CHECK rejects invalid capability; column is NOT NULL.
Integration: seed 1:1, capability propagation on assign + clear, self-demotion
block, system-default delete/capability rejection, firm scoping.
Contract: OpenAPI regenerated; FirmRoleView/Create/Update carry capability;
/members/{id}/role + FirmMemberRoleUpdateRequest gone from the spec.
785 tests pass. ruff clean. 0 new mypy errors in firm_role/.