Segmentation models
The segmentation tables turn re-engagement from an unrecorded, hardcoded selection into something
measurable. Before them, a producer wrote call_list_date / call_priority / call_assigned_to
straight to HubSpot and the run ended — yesterday’s cohort was unrecoverable and nothing joined the
enrolled list back to what the rep actually did.
|
Manual refresh is wired: The daily list is a later pick from those open members: |
Three design decisions
Membership is the source of truth, not an executable segment definition. Segments are registry rows; membership is materialised by whichever producer wrote it. Nothing is re-evaluated after the fact, so point-in-time correctness comes for free and historical numbers never change retroactively. It also means each producer keeps its own query instead of being normalised into a DSL first.
Membership and the daily list are different things — different grains, different lifetimes. Membership is slow-moving and concurrent (a contact sits in several segments at once); the daily list is one contact, one day, one reason.
Rollups are not stored. There is deliberately no aggregate table. ReengagementDailyListEntry is
the fact table, and per (segment, strategy, arm, day) summaries are expected to come from SQL
views over it. A materialised rollup would have to be recomputed as its attribution window matured,
which a view gets for free by always reading the current facts.
Segment
Table segments. The reusable audience registry.
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
Stable machine name, e.g. |
|
|
|
|
|
|
|
|
Reserved for a future cross-segment tiebreak; nothing enforces it yet |
|
|
Disabling a segment is a flag flip, not a deploy |
purpose is what keeps the table general — the same registry can hold the email campaign audiences
that currently exist only informally.
Unique: (subscriptionId, key). Relations: memberships, strategies, runs,
dailyListEntries.
SegmentMembership
Table segment_memberships. Slow-moving and concurrent, not a daily snapshot.
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
Required — producers hold the HubSpot id, not the local one |
|
|
Nullable FK to |
|
|
default |
|
|
default |
|
|
|
|
|
Soft close |
Soft-closing with removedAt preserves "was this contact in this segment in June" without writing a
row per contact per day. Partial unique indexes enforce at most one open row per (segmentId, hubspotContactId)
and per (segmentId, contactId) when the local contact is set — see Constraints outside the schema.
Refresh removals only close open rows with source = strategy:<that strategy’s id>. Legacy
producer rows and members enrolled by another strategy are left alone. Stayers keep the same
row and addedAt; sql_view refreshes update priorityScore in place. Qualifying
identities for the run are staged in segment_refresh_qualifying
(@@unique([runId, hubspotContactId]), with priorityScore) and deleted on finalize
or run failure. Email import and the members list order open members by
priorityScore DESC. Score updates stay scoped to source = strategy:<id> — a later
sql_view strategy does not overwrite another strategy’s members.
SegmentStrategy
Table segment_strategies. The methodology layer.
Separating who (segment) from how we treat them (strategy) is what gives the control/treatment arms something meaningful to vary. Without the split, "arm" has nothing to test.
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
Unique together per segment |
|
|
|
|
|
Result / catalog field copied onto membership when |
|
|
Only meaningful when |
|
|
Applied on daily-list pick: |
|
|
|
|
|
|
Share routed to the treatment arm, |
|
|
|
|
|
|
|
|
react-querybuilder rule group. Outreach only |
|
|
BigQuery SQL or HubSpot filter text. Empty drafts are allowed |
|
|
Ordered BigQuery column → identity field fallbacks. Empty drafts are allowed |
|
|
dataSource and sourceTable are paired in Zod, not the database: Outreach uses one of the four app tables plus filterRules; BigQuery and HubSpot require sourceTable = custom_query and store customQuery. BigQuery also stores contactMappings (source_column + contact_field). Manual Outreach refresh compiles filterRules in lib/segments/compile-filter-rules.ts. BigQuery refresh runs SQL via querySegmentRows and resolves rows in lib/segments/resolve-contact-mappings.ts. Empty filters, empty SQL, or empty mappings fail the run so they never enroll the whole tenant. A HubSpot-id mapping can enroll with contactId = null; email, phone, and contact id only enroll when that Contact already has a HubSpot id.
The filterable Outreach columns live in lib/segments/source-table-fields.ts. The query builder cannot invent a column the catalog does not list.
prioritySource exists because the five producers do not agree. For sql_view, the named
priorityColumn is the score: BigQuery uses the result column (leaf-name fallback, same as
contact mappings); Outreach uses a type: "number" catalog field, including contact
leadScore / hubspotSyncPriority even when the filter is on a child table. Refresh
coerces with Math.round into Int. strategy_rules scoring is not implemented —
inserts stay at 0 and scores are not updated.
lost_deal and supplier_account
compute a weighted priority_score in their BigQuery view and the producer only rounds it:
-- lib/bigquery/v_lost_deal_reeng.sql
ROUND(
LEAST(4.0,
CASE
WHEN lc.last_call_date IS NULL THEN 4.0
ELSE CASE lc.disposition_id ... END
* LEAST(1.3, 1.0 + (TIMESTAMP_DIFF(CURRENT_TIMESTAMP(), lc.last_call_date, DAY) / 30.0))
* (1.0 / (1.0 + 0.25 * lc.total_calls))
END
)
, 1) AS priority_score
The rest compute priority in producer code. A single priorityRules column would have misdescribed
two of the five.
treatmentPercent lives here rather than on Segment so it versions alongside the methodology it
varies.
SegmentRun
Table segment_runs. Provenance for one producer run.
Because no executable definition is stored, the criteria in effect are frozen in
criteriaSnapshot at write time — query name, thresholds, params. Descriptive, never re-evaluated.
| Field | Type | Notes |
|---|---|---|
|
|
|
|
|
|
|
|
Handler that wrote the rows |
|
|
default |
|
|
|
|
|
ReengagementDailyListEntry
Table reengagement_daily_list_entries. The daily list plus arm tracking.
| Field | Type | Notes |
|---|---|---|
|
|
Which segment sourced the pick |
|
|
Required — every summary groups by strategy, and a NULL would drop the row out of those aggregates |
|
|
The producer run that wrote the pick |
|
|
|
|
|
Required |
|
nullable |
|
|
|
HubSpot ILS list/segment id for the assigned rep’s daily list |
|
|
|
Copied from |
|
|
|
US-Eastern calendar day |
|
|
Rounded 1–4, as written to HubSpot |
|
|
Unrounded score when the source view computes one |
|
|
Which rule fired |
|
Unique: (segmentId, hubspotContactId, listDate).
That is deliberately not (contactId, listDate). Logging every qualifying pick keeps producer
wiring behaviour-neutral, and makes cross-segment overlap measurable before any precedence rule is
legislated:
SELECT "hubspotContactId", "listDate", count(DISTINCT "segmentId") AS segments_claiming
FROM reengagement_daily_list_entries
GROUP BY 1, 2
HAVING count(DISTINCT "segmentId") > 1;
A winner view or a Segment.precedence tiebreak can be layered on later with no data migration.
The native picker (not the Python producers) sets reason = open_member and
qualifyingContext = { source, priority_score } — no PII. hubspotOwnerId and
salesRepId are copied from the local contact when present, for display only.
reason records which rule fired; qualifyingContext records what the contact looked like when it
fired (lifecycle stage, deal stage, days since last call, click timestamp). Contact properties
drift, so without the frozen context later analysis cannot ask questions nobody thought to log for.
|
|
Constraints outside the schema
prisma/sql/segmentation_constraints.sql holds things Prisma cannot express. The open-membership
uniques also ship in prisma/migrations/20260902100000_segment_membership_active_unique.
CREATE UNIQUE INDEX IF NOT EXISTS "segment_memberships_active_unique"
ON "segment_memberships" ("segmentId", "hubspotContactId")
WHERE "removedAt" IS NULL;
CREATE UNIQUE INDEX IF NOT EXISTS "segment_memberships_active_contact_unique"
ON "segment_memberships" ("segmentId", "contactId")
WHERE "removedAt" IS NULL AND "contactId" IS NOT NULL;
ALTER TABLE "segment_strategies"
ADD CONSTRAINT "segment_strategies_treatment_percent_range"
CHECK ("treatmentPercent" BETWEEN 0 AND 100);
The partial unique indexes are what make the soft-close design safe. A plain unique constraint would
reject a contact who legitimately re-enters a segment later; without any constraint, a contact can
silently hold several open membership rows (same HubSpot id, or same local contactId with a
different HubSpot id). Closed rows may repeat.
|
Identifiers are quoted camelCase. These models use |
Seeding
scripts/seed-segments.ts inserts the five existing re-engagement channels as Segment rows, each
with one baseline strategy at treatmentPercent = 0 — 100% control, so logging can ship before any
challenger strategy exists and nothing about who gets called changes.
npx tsx --env-file=.env scripts/seed-segments.ts # dry-run
npx tsx --env-file=.env scripts/seed-segments.ts --confirm # apply
It is a script rather than a migration because it needs a real subscriptionId. Re-running is safe.
| Key | Producer | prioritySource |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
staleDays is left null on the seeded strategies. The current thresholds live as constants in the
producer repository, and guessing them here would put wrong numbers into the baseline that later
strategy versions get compared against.