Story 1961: membership creation derives designation and capability from the invited team role

The defect was a split source of truth. Clerk carried the invited access capability, while identity.firm_memberships.role_id was hard-defaulted to the seeded "Member" team role. A user invited as an admin could therefore display as "Member" while still being protected by admin demotion guards.

Runtime contract

InputStored membership
organizationMembership.created.data.public_metadata.teamRoleId role_id = teamRoleId
business.firm_roles.capability for that team role role = capability
Clerk invitation publicMetadata.teamRoleId
  -> Clerk membership webhook public_metadata.teamRoleId
  -> business.firm_roles(id, firm_id, capability)
  -> identity.firm_memberships(role_id, role)
The key is defined in this repo as teamRoleId. Clerk's API documents invitation publicMetadata as membership publicMetadata after accept, so Story 1962 must write that key on the organization invitation.

Contract violation path

Missing or malformed teamRoleId is not repaired by guessing a designation from a capability. For client-firm memberships the webhook logs event="api_contract_violation" with code pramaan.webhook.team_role_id_contract_violation, creates the membership with role_id = NULL, and keeps the Clerk capability only as the last-resort authorization value.

Support memberships are excluded from the team-role contract because business.firm_roles.capability intentionally has no support tier.

Legacy data repair

The Alembic migration realigns existing client-firm memberships where role_id is null or points to a team role whose capability disagrees with firm_memberships.role. It chooses the firm's active system-default team role for the current capability, in one set-based update scoped by firm_id.

Verification map

Acceptance itemProof
Admin-capability team role creates admin membershiptests/services/test_story_1961_membership_team_role.py
First-run seeded roles presentSame test creates a fresh firm through FirmService.create_firm
Missing key logs contract violation and leaves no designationtest_missing_team_role_id_logs_contract_violation_without_designation
Backfill realigns divergent rowtest_backfill_migration_realigns_divergent_membership_role_id