Customer State Model
One row per person or account carrying consent, lifecycle stage, eligibility flags, and contact history. The foundation every audience, experiment, and policy reads from.
The decision it encodes
What do we know about this customer right now, and what are we permitted to do?
The executive dashboard reports 2.1 million active customers. The lifecycle audience for the reactivation campaign contains 1.6 million. The finance model assumes 1.9 million.
Nobody is wrong. There are three definitions of active, each written by someone
sensible for a good reason, and none of them lives anywhere the other two can
read. The definitions are not documented because each one is a WHERE clause
inside a different query, and the queries were never meant to be a shared
vocabulary.
Everything downstream inherits that. An audience cannot be compared to another audience, a test result cannot be compared to last quarter’s, and a policy trained on one definition gets deployed against another.
The decision it encodes
What do we know about this customer right now, and what are we permitted to do?
The word doing the work is “know,” and the second word doing the work is “now.” Most state models answer the first and fail the second, and the second is the one every experiment depends on.
The hard part is time
A table with one current row per customer is straightforward and it cannot support measurement.
Six weeks after a campaign, a readout needs to know who was eligible at assignment, what lifecycle stage they were in then, and how many contacts they had already received at that moment. A current-state table has been overwritten several times since. The audience can be re-run, and it returns a different population, so the test cannot be reconstructed and its result cannot be defended.
What a row carries
| Field group | Why it belongs here rather than in a query |
|---|---|
| Identity keys | One canonical id per grain, plus the aliases that resolve to it. Assignment hashes this, so it cannot be allowed to drift. |
| Lifecycle stage | The single definition of active, lapsed, and churned. Named fields, not thresholds each team picks. |
| Tenure and value | Stable inputs to eligibility and to variance reduction in readouts. |
| Engagement recency | Predicts both deliverability and outcome, which is why it belongs in balance checks. |
| Contact history | Counts by channel and window, so fatigue caps are enforced against a fact rather than an estimate. |
| Eligibility flags | Derived, named, and reusable. The place a campaign says what it means by eligible. |
| Consent pointers | The raw permission and opt-out events. Resolving them into an answer is a separate component. |
| Derived-at timestamp | When this row was computed, so a reader can tell freshness from truth. |
None of this is exotic. The value comes from there being one of it, computed once, with history, that everything else is required to read.
Definitions live here once, or they live everywhere forever
The governing rule is that a team needing a different notion of active adds a
named field to the state model rather than a private predicate to its own query.
Two named fields that disagree are a conversation someone can have. Two
undocumented WHERE clauses that disagree are a discrepancy nobody discovers
until a number is challenged in a meeting.
This is the cheapest component in the section and the one most often skipped, because it delivers no new capability. What it delivers is that results stay comparable across quarters, which is invisible right up until it is the only thing that matters.
Pick a grain and say so
Person, account, household, or device. The grain has to match the level at which treatment is delivered and the level at which interference happens, and those are not always the same.
Mixing grains quietly is how assignment leaks. If the state model is person-level and two people share a subscription, then a household-level treatment lands on both while the assignment thinks it randomized one. The interval will be clean and the contrast will be contaminated, which is the failure mode this whole section exists to prevent.
Maturity, honestly staged
Stage 1: one table, one grain, current state. The definitions become shared, even without history. Most of the vocabulary problem goes away here.
Stage 2: history on the fields that audiences read. Point-in-time answers become possible, and tests become reconstructable.
Stage 3: contact history and derived eligibility flags. Fatigue caps and audience definitions stop being reimplemented per campaign.
Stage 4: as-of correctness enforced in the interface. Consumers cannot accidentally read current state when they meant historical state, because asking without a timestamp is an error rather than a silent default.
As-of joins and the leakage rule Technical
History is usually kept as a slowly changing dimension: each row carries a
valid-from and valid-to timestamp, and a query for state at time
t selects the row whose interval contains it. The join is
ordinary and the discipline is not, because it is easy to build and then let
one consumer read the current table for convenience.
The same mechanism enforces the rule that matters for any model trained on this data. Features have to be as of the decision, never as of now. A model trained on current state learns from fields that were updated after the outcome it is predicting, which produces excellent validation scores and a model that fails immediately in production. Point-in-time correctness is usually sold as a measurement requirement and it is equally a modeling one.
Context
Best suited for
Any organization where more than one team defines the same customer concept, and where results need to remain comparable across quarters.
Data requirements
- A canonical identifier at a stated grain, with an alias table that records merges
- Change history on any field an audience or a model reads
- Contact events from every channel, including the ones bought outside the marketing team
- A refresh cadence fast enough that eligibility is not stale by the time a campaign ships
What changes by context
- B2B SaaS
- The account is the grain that matters and the person is the one you can contact, so the model has to carry both and the relationship between them. Seats move between accounts, which makes the alias problem a business event rather than a data quality one.
- B2C subscription
- Households share accounts, so person-level state under-describes who is being reached. Carrying a household key alongside the person key costs little and decides whether assignment holds.
- Product-led
- State changes fast and usage is the main signal, so the refresh cadence becomes the binding constraint rather than the schema. Daily state against an hourly product is a targeting system aiming at last night.
- Lifecycle-led
- Contact history is the field that earns its keep, because fatigue caps cannot be enforced against an estimate and every campaign wants to spend the same budget.
When it breaks down
- Organizations with no stable identifier, where the grain is a session and nothing persists long enough to hold state.
- Programs where the channel tool is the system of record and the warehouse only receives a summary, so history cannot be reconstructed.
- Cases where the useful state is real-time, such as in-session personalization, which needs a different serving path than a batch table.