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
| Input | Stored 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)
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.
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 item | Proof |
|---|---|
| Admin-capability team role creates admin membership | tests/services/test_story_1961_membership_team_role.py |
| First-run seeded roles present | Same test creates a fresh firm through FirmService.create_firm |
| Missing key logs contract violation and leaves no designation | test_missing_team_role_id_logs_contract_violation_without_designation |
| Backfill realigns divergent row | test_backfill_migration_realigns_divergent_membership_role_id |