Domain model
Entity map
erDiagram
Subscription ||--o{ Seller : owns
Subscription ||--o{ Contact : owns
Subscription ||--o{ SalesRep : owns
Subscription ||--o{ Campaign : owns
Subscription ||--o{ Configs : owns
Seller ||--o{ Contact : "employs"
Contact ||--o{ TargetPhoneNumbers : "reachable at"
Contact ||--o{ TargetEmails : "reachable at"
Contact ||--o{ CampaignTarget : "enrolled as"
Contact ||--o{ EmailCampaignTarget : "enrolled as"
Contact ||--o{ Activity : "has"
Contact ||--o{ FollowUpTask : "subject of"
Campaign ||--o{ CampaignTarget : contains
Campaign ||--o{ EmailCampaignTarget : contains
Campaign ||--o{ EmailVariation : has
CampaignTarget ||--o{ Call : produced
CampaignTarget ||--o{ FollowUpTask : triggered
SalesRep ||--o{ FollowUpTask : "assigned"
SalesRep ||--o{ Call : made
EmailCampaignTarget ||--o{ Emails : "messages"
FollowUpTask ||--o{ Activity : logged
The entities that matter
- Subscription — the tenant
-
Every domain row carries
subscriptionId. Production runs one:b191a905-5d19-4fa0-941b-d8eb81c0c477. Thesubscriptionstable itself is not managed bymorpheus-data-layer, which is why no ORM model declares a foreign key to it. - Seller — a marketplace business
-
Maps to a HubSpot Company, not a Contact. Keyed by
sellerId, the external marketplace identifier (ATVPDKIKX0DER). Carries marketplace metrics —estimateSales,numberAsins,percentFba, growth rates — plus enrichment output (website,linkedinUrl,hubspotCompanyId) andpayoutPainScore.platformisamazon,walmart, orshopify.datasourceissmartscoutormanual. - Contact — a person
-
The single source of truth shared between campaign targets and HubSpot. Optionally linked to a
Seller. Carries identity, address, lifecycle and lead status, calling context (disposition,stopCalling,nextAction), HubSpot sync fields, and supplier fields (supplierKey,supplierStatus).Four unique constraints, all per subscription:
email,phone,hubspotContactId,supplierKey. Nullable-unique — many rows may haveNULL, but a non-null value must be unique. - TargetPhoneNumbers / TargetEmails — reachable endpoints
-
A contact may have several. These carry the channel state that does not belong on the person: line type and risk level for phones, opt-out flags and send spacing for emails. This is where suppression actually lives.
- CampaignTarget — one phone number in a calling campaign
-
Its own state machine plus a cache of business-logic answers (
isAmazonSeller,meetsMonthlyThreshold,interestedInDailyPayments,stopCalling,disposition) and scheduling fields (nextEarliestAttemptAt,leaseExpiresAt). - Call — one call attempt
-
Written by both dialer paths. Holds identifiers, status and timing, cost and latency, AI analysis (sentiment, disposition, intelligence flags, extracted business logic), actionable next steps, and HubSpot correlation.
providerCallIdis unique when set, which is what makes the Five9 upsert idempotent. - Activity — a timeline entry
-
sms,email,call,task,meeting,note, orreminder. Unique on(subscriptionId, externalId), which is how the BigQuery engagement sync upserts HubSpot engagements idempotently. - FollowUpTask — a commitment by a rep
-
Mirrored into HubSpot and optionally Nooks. Carries
escalationStagefor the Slack ladder. See Follow-up tasks and escalation. - SalesRep — a human who calls
-
Subscription-scoped, so the same person can be a rep in several workspaces with different CRM ids. Holds
hubspotOwnerId,nooksUserId,activeListId, andhubspotCallSegmentId.isActivegates every selector. - Configs — runtime state
-
A key/value table used for watermarks, pagination cursors, round-robin position, and kill switches.
keyis globally unique, not unique per subscription — which is why the round-robin key embeds the subscription id. See Runtime state inconfigs.
Identity and matching
The hardest problem in the platform is deciding that two records are the same person.
| Entity | Natural key | HubSpot correlation |
|---|---|---|
Seller |
|
|
Contact |
|
|
Call |
|
|
Activity |
|
|
FollowUpTask |
|
|
SalesRep |
|
|
Three mechanisms keep identity converging:
- Phone-first resolution
-
resolve_hubspot_contact_id_phone_first()tries phone, then email, then mobile phone, and reports which step matched plus whether there were multiple matches. Phone first because a dialer knows a number and may not know an email. - Contact backfill
-
ensure_local_contact_row()guarantees a local row exists for a HubSpot contact id, short-circuiting through: already loaded → local lookup by HubSpot id → fetch from the CRM and upsert → repair the campaign target’s foreign keys. - Scheduled id resolution
-
Hubspot.ContactResolvewidens the search to every relatedtarget_phone_numbersandtarget_emailsrow, and stampshubspotLastSyncedAteven on a miss so the batch keeps moving through fresh candidates.
|
The email-click BigQuery view deliberately resolves HubSpot contacts by email rather than by
|
Two lifecycle ladders
Seller.lifeCycleStage— the internal enrichment pipeline-
new→raw_lead→enriched→in_sequence→engaged→qualified→bdr_handoff→demo_scheduled→opportunity→customer, plusdisqualified. Morpheus writes onlynewandenriched. Contact.lifecycleStage— a mirror of HubSpot’slifecyclestage-
subscriber→lead→marketingqualifiedlead→salesqualifiedlead→opportunity→customer→evangelist, plusother. Morpheus only ever promotes, via a rank guard. See Outbound funnel.
Alongside them, two columns act as pipeline gates rather than stages: enrichedAt (enrichment
has run) and payoutPainScore (scoring has run). Both are IS NULL checks in the backfill
queries.
Heat status
Contact.heatStatus is a coarse engagement temperature:
| Value | Meaning |
|---|---|
|
Active follow-up in progress |
|
A rep acted but the deal did not close |
|
The task failed; an automated email track is running |
|
30 days of silence, deprioritised |
Morpheus writes hot in one place: after the dialer sync creates a follow-up task. The other
transitions belong to the web application.