ORM models
SQLAlchemy 2.0 declarative models in
libs/morpheus-data-layer/morpheus_data_layer/models/, one per file, all on a shared Base,
all exported from models/init.py.
The models mirror a schema owned elsewhere — they do not define it. Every model docstring says so: "Read/write only — schema modifications are not permitted via this layer."
The models
| Model | Table | Role |
|---|---|---|
|
|
A marketplace business; maps to a HubSpot Company |
|
|
A person; the single source of truth shared with HubSpot |
|
|
A timeline entry — sms, email, call, task, meeting, note, reminder |
|
|
One call attempt, AI or human |
|
|
One phone number in a calling campaign, with its state machine |
|
|
Key/value runtime state |
|
|
An individual message, inbound or outbound |
|
|
One (campaign, contact) pair in an email campaign |
|
|
Content variant for an email campaign |
|
|
A commitment assigned to a rep |
|
|
A human who calls |
|
|
An email address plus its suppression state |
|
|
A phone number plus its line intelligence |
Note the table naming is inconsistent — sellers and contacts are lowercase plural, while
Activity, Call, and CampaignTarget are PascalCase singular. That reflects the upstream
Prisma schema, where some models declare @@map and some do not. The ORM matches whatever the
database actually has.
Conventions
camelCase attributes, no overrides
class Seller(Base):
__tablename__ = "sellers"
id: Mapped[str] = mapped_column(String, primary_key=True,
default=lambda: str(uuid.uuid4()))
subscriptionId: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
lifeCycleStage: Mapped[SellerLifeCycleStage] = mapped_column(
_seller_lifecycle_stage_pg,
server_default=text("'new'::\"SellerLifeCycleStage\""),
nullable=False,
)
hubspotCompanyId: Mapped[str | None] = mapped_column(String, nullable=True)
The Python attribute name is the column name. No mapped_column(name=…) anywhere.
This is unusual for SQLAlchemy — the idiomatic choice is snake_case attributes mapped onto whatever the column is called. Doing it this way means:
-
conflict_columns=["sellerId"]andupdate_columns=[…]in an upsert are literal column names, and grep finds every use of a column across Python and SQL alike. -
Raw SQL, BigQuery view columns, and ORM attributes all read the same.
-
The cost is that Python code is full of camelCase identifiers, which no linter will like.
No subscriptions foreign key
ForeignKey("subscriptions.id") is deliberately not declared on any model. That table is not
managed by this layer; the constraint exists at the database level only. Adding it would make
the ORM claim ownership of a table it does not own.
Other foreign keys are declared — Contact.sellerId references sellers.id, for example.
PostgreSQL enums with create_type=False
_seller_platform_pg = ENUM(
*[e.value for e in SellerPlatform],
name="SellerPlatform", # must match the existing DB type name exactly
create_type=False, # never attempt to CREATE TYPE
)
Each model builds its ENUM types from the corresponding StrEnum in
morpheus_data_layer/enums.py, so the Python enum and the database type cannot drift in
their values. The name must match the database type exactly, and create_type=False
guarantees the ORM never tries to create or alter a type — a migration concern that belongs to
Prisma.
Adding a value to a database enum therefore requires: change the Prisma schema, migrate, then
add the member to the StrEnum here.
Defaults
Server-side where the database has one:
lifeCycleStage: Mapped[SellerLifeCycleStage] = mapped_column(
_seller_lifecycle_stage_pg,
server_default=text("'new'::\"SellerLifeCycleStage\""),
)
createdAt: Mapped[datetime] = mapped_column(DateTime(timezone=True),
server_default=func.now())
Client-side only for generated primary keys (default=lambda: str(uuid.uuid4()) or
uuid.uuid4). Note the primary-key types differ by table: Seller.id is a String,
Contact.id is a UUID(as_uuid=True) — again mirroring Prisma, where Seller.id lacks
@db.Uuid.
Nullable-unique columns
Contact.email and Contact.phone are nullable-unique: many rows may hold NULL, but a
non-null value must be unique per subscription. PostgreSQL treats NULL as distinct in a
unique index, so this works without a partial index.
The practical consequence appears in the BigQuery contact sync, which drops an email or phone from an update when it would collide with a different contact — see enrichment-scraper.
Indexes and relationships
Indexes are declared with Index(…) in table_args, mirroring the Prisma @@index
declarations. Relationships are declared with relationship() where a handler traverses them —
FollowUpTask.salesRep and FollowUpTask.contact are used by the escalation handler,
Call.target by the dialer sync. Most models declare few relationships, because most access
goes through explicit repository queries instead.
|
|
Removed columns
Two columns that used to exist on Seller and no longer do — both are listed in AGENTS.md
as common mistakes:
-
confirmed_email -
hubspotContactId— a seller maps to a HubSpot Company;hubspotCompanyIdis the correct field. Contact ids belong onContact.
Adding a model
-
Add the table to the web application’s Prisma schema and migrate it there first.
-
Create
models/<name>.pywith camelCase attributes matching the columns exactly. -
Build any
ENUMtypes frommorpheus_data_layer/enums.pywithcreate_type=False. -
Do not declare a
subscriptionsforeign key. -
Export the model from
models/init.py. -
Add a repository — see Repositories.
-
Update Prisma schema reference and this page.
Full schema
The authoritative definition, including every table Morpheus does not model: Prisma schema reference.