Story-1244 · pramaan-docs ADR-112 (amends ADR-037, ADR-055)

Firm role carries capability — designation is the only thing you assign

The problem ADR-112 fixes

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.

What changed (backend half)

PieceChange
Migration 0096Adds 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 / serviceFirmRoleCreateRequest requires capability; FirmRoleUpdateRequest accepts an optional one; FirmRoleView exposes it (for app #1245).
PropagationPATCH /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 retiredPATCH /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 protectionSystem 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.

The backfill we deliberately did NOT do

ADR-112's reconciliation note suggests re-deriving every member's capability from their designation. We did not: Story-1241/0095 backfilled every member to the seeded "Member" designation, so a naive re-derivation would demote every existing firm admin to firm_member — a firm-wide lockout. Migration 0096 reconciles only the catalog capability; member capability keeps its current value, and only future assignments propagate.

Verification

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/.
Paired app work is pramaan-spec #1245 (pramaan-app): read-only/derived capability on Members, capability selector in the role sheet, Firm-configuration → Team roles page. It consumes this contract.