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

Seller

sellers

A marketplace business; maps to a HubSpot Company

Contact

contacts

A person; the single source of truth shared with HubSpot

Activity

Activity

A timeline entry — sms, email, call, task, meeting, note, reminder

Call

Call

One call attempt, AI or human

CampaignTarget

CampaignTarget

One phone number in a calling campaign, with its state machine

Configs

configs

Key/value runtime state

Email

emails

An individual message, inbound or outbound

EmailCampaignTarget

email_campaign_targets

One (campaign, contact) pair in an email campaign

EmailVariation

email_variations

Content variant for an email campaign

FollowUpTask

follow_up_tasks

A commitment assigned to a rep

SalesRep

sales_reps

A human who calls

TargetEmails

target_emails

An email address plus its suppression state

TargetPhoneNumber

target_phone_numbers

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"] and update_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.

expire_on_commit=False on the session factory keeps scalar attributes readable after a commit, but a relationship or an unloaded column raises when accessed on a detached instance. That is why _build_crm_sync_event() in enrichment-scorer runs inside the session, and why _upsert_seller() explicitly touches enrichedAt, payoutPainScore, and note before the block ends.

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; hubspotCompanyId is the correct field. Contact ids belong on Contact.

Adding a model

  1. Add the table to the web application’s Prisma schema and migrate it there first.

  2. Create models/<name>.py with camelCase attributes matching the columns exactly.

  3. Build any ENUM types from morpheus_data_layer/enums.py with create_type=False.

  4. Do not declare a subscriptions foreign key.

  5. Export the model from models/init.py.

  6. Add a repository — see Repositories.

  7. Update Prisma schema reference and this page.

Full schema

The authoritative definition, including every table Morpheus does not model: Prisma schema reference.