Ownership and round-robin

Ownership answers "whose list does this appear on?". Getting it wrong either buries a lead with an inactive rep or floods one rep while others idle.

Implementation: libs/morpheus-data-layer/morpheus_data_layer/repositories/sales_rep_round_robin.py, plus the ownership block in each re-engagement handler.

The two properties

HubSpot contact property Meaning

hubspot_owner_id

The record’s actual owner. Written only when Morpheus deliberately reassigns.

call_assigned_to

Who this call is routed to. Can differ from the record owner — for example a deal’s BDR handling a no-show while the record stays with its original owner.

Both hold a CRM owner id from GET /crm/v3/owners, mirrored locally on sales_reps.hubspotOwnerId.

The reassignment test

Every re-engagement handler runs the same three-part test. Ownership is sticky unless it is not real:

needs_owner = not current_owner or current_owner == DIALER_FALLBACK_HUBSPOT_OWNER_ID
if not needs_owner:
    needs_owner = _rep_repo.get_active_by_hubspot_owner_id(
        session, current_owner, sub_id
    ) is None
Case Interpretation

No owner

Nobody is working this contact — assign one

Owner is 86817215

The AI fallback owner. A record "owned" by the dialer is effectively unowned by a human.

Owner is not an active sales_reps row

A departed or deactivated rep. Their leads must not be stranded.

Otherwise

A real, active rep owns this. Leave it alone.

When reassignment happens, both hubspot_owner_id and call_assigned_to are set to the new rep. When it does not, hubspot_owner_id is not written at all — this is the guard that stops automation from stealing a human’s contacts.

The AI fallback owner

DIALER_FALLBACK_HUBSPOT_OWNER_ID = "86817215" (lambdas/morpheus_hubspot/utils/constants.py, duplicated as AI_OWNER_ID in lambdas/phone_agent/utils/constants.py).

It plays three roles:

  1. Owner of last resort when the dialer sync cannot find any qualifying rep.

  2. Owner of every AI call engagement — always, even when the contact and follow-up task belong to a human rep, so the CRM records who actually made the call.

  3. A sentinel that means "not a human". Every re-engagement segment treats it as unowned, and the email-click segment uses it to decide that a prior call does not suppress a human follow-up.

That third role is why the constant appears in five different handlers. Changing it requires changing every reference, and the HubSpot portal’s owner list.

Round-robin selection

select_next_sales_rep_round_robin(session, subscription_id, *, require_nooks):

flowchart TD
  A["list active reps\nrequire_hubspot=True\nrequire_nooks=?"] --> B{"any reps?"}
  B -->|no| B1["log warning, return None"]
  B -->|yes| C["pg_advisory_xact_lock(subscription hash)"]
  C --> D["read cursor from configs\ndialer_follow_up_rep_rr:<subscriptionId>"]
  D --> E["chosen = reps[cursor % len(reps)]"]
  E --> F["write cursor + 1"]
  F --> G["return chosen\n(lock released at commit)"]

Concurrency safety

The cursor is a row in configs, so two Lambdas running the same handler could read it simultaneously and pick the same rep. A PostgreSQL transaction-scoped advisory lock prevents that:

key = int.from_bytes(subscription_id.bytes[0:8], "big", signed=False) % (2**63)
session.execute(text("SELECT pg_advisory_xact_lock(:k)"), {"k": key})

The lock key is derived from the first eight bytes of the subscription UUID, so different tenants never block each other. Being transaction-scoped, the lock is released automatically when the caller’s get_session() block commits — there is no unlock call to forget.

The cursor is advanced in the same transaction as the read, so the increment is atomic with the selection.

Eligibility

list_active_for_subscription() filters on:

  • isActive = true

  • require_hubspot=True — always, since the point is to assign a HubSpot owner, so hubspotOwnerId must be present

  • require_nooks — when true, also requires nooksUserId

require_nooks=True is used only by the dialer sync, and only for Nooks sequence enrolment. That handler tries require_nooks=True first and falls back to require_nooks=False, so a missing Nooks configuration degrades to HubSpot-only assignment rather than failing.

Every re-engagement handler passes require_nooks=False.

No reps available

An empty pool logs at WARNING and returns None. Handlers diverge on what happens next:

Handler Behaviour with no rep

Email click, supplier, no-show, lost deal

Log a warning naming the contact and continue. The contact is still enrolled on a list — with no assignee — so the enrolment is not lost.

Phone.CampaignTargetReEngagement

Log at ERROR and abort the entire batch. Every remaining row would be enrolled unassigned, which is worse than enrolling none.

Crm.DialerCallEnded

Retry without the Nooks requirement, then fall back to owner 86817215.

Cursor state

SELECT key, value FROM configs WHERE key LIKE 'dialer_follow_up_rep_rr%';

The key embeds the subscription id (dialer_follow_up_rep_rr:<uuid>) because configs.key is globally unique, not unique per subscription. Resetting the value is harmless — it only changes who is next.

Fairness is over selections, not over workload. The cursor advances once per assignment regardless of how much work each contact represents, and adding or removing an active rep changes len(reps) and therefore shifts the modulo mapping. Neither matters at this scale.

Deal-owner routing

Two segments prefer the deal’s owner over round-robin, because a deal already has a human accountable for it:

Segment Routing

No-show

The deal’s BDR owner (hubspot_owner_id on the view row) goes to call_assigned_to, leaving hubspot_owner_id untouched — the call is routed without transferring the record.

Lost deal

Same pattern, using the deal’s owner.

In both cases the deal owner still has to pass the active-rep test. A deal owned by a departed rep falls through to round-robin.

Sales rep record

sales_reps column Purpose

subscriptionId, email

Unique together — the same person can be a rep in multiple workspaces with different CRM ids

profileId

Optional link to a Supabase user; a rep can exist before they have an account

hubspotOwnerId

CRM owner id — assigns contacts and tasks

nooksUserId

Nooks user id — assigns Nooks tasks and sequences

activeListId

HubSpot list id used for the rep’s sequences

hubspotCallSegmentId

Today’s dynamic call list, rewritten daily by Hubspot.SalesRepCallListRefresh

isActive

The eligibility flag for every selector on this page

Deactivating a rep (isActive = false) has three immediate effects: they drop out of round-robin, their existing contacts become reassignable on the next re-engagement run, and they stop receiving a daily call list.