People (CRM)
The people module turns the person records that ingestion creates into a CRM: lifecycle stages, tags, notes, CRM fields, a merged timeline, and saved audiences (segments) that campaigns, the Management API and the console all share.
Module key people. Routes live under
/o/<org>/apps/<app>/people; mutations post to /api/console/people?intent=….
The model
| Table | Owner | What it holds |
|---|---|---|
persons |
analytics (W4) | The person: identity, traits, attribution, counters, lifecycle_stage, tags, crm |
person_identities |
analytics (W4) | Anonymous and distinct ids merged into a person |
events, analytics_sessions |
analytics (W4) | Behaviour the timeline and the did_event conditions read |
person_notes |
people | Free-text notes with an author |
person_activities |
people | What the CRM did: note, stage, tag, email, control, campaign |
segments |
people | Saved audiences: dynamic (a definition) or static (a list) |
segment_members |
people | Membership of static segments only |
person_id in this module's tables carries no foreign key: persons belongs to
the analytics module and the registry has to migrate cleanly whatever order the
modules land in. The tables are scoped by app_id, which cascades from
applications, and every query filters on it.
spm_person_revenue_status(app_id, person_id, status) is a plpgsql wrapper that
resolves revenue_subscriptions at execution time behind to_regclass, so a
revenue condition compiles and returns false on an installation where the
revenue module has never run.
Lifecycle stages
visitor → lead → signup → activated → paying, plus churned. The column is
plain text, so an application may use its own value; the automatic rules only
act on the stages above.
crm.stage_source records where the current stage came from:
manual— an operator chose it. Nothing automatic ever changes it again.auto— the hourlypeople.stages.autojob set it.
The rules, in order, and each only ever moves a person forward:
- A person who is identified and sits at
visitororleadbecomessignup. - A person with an
activeortrialingrevenue subscription, sitting belowpaying, becomespaying. Skipped entirely until the revenue tables exist.
churned and any custom stage are never entered or left automatically.
Segment DSL
A definition is JSON:
{
"all": [ /* every condition must hold */ ],
"any": [ /* at least one must hold */ ]
}
all and any are combined with AND: (all…) AND (any1 OR any2). An empty
definition matches everyone, which is why saving a dynamic segment requires at
least one condition.
Condition
{
field: string, // see the table below
op: "eq" | "neq" | "contains" | "in" | "gt" | "lt" | "gte" | "lte"
| "exists" | "within_days" | "not_within_days",
value?: string | number | boolean | string[],
event?: { name: string, count?: number, withinDays?: number }
}
Fields
| Field | Kind | Operators | Notes |
|---|---|---|---|
stage |
text | eq, neq, contains, in, exists | persons.lifecycle_stage |
tag |
tag | eq, neq, in, contains, exists | eq means "has this tag"; in means "has any of" |
first_source, last_source |
text | eq, neq, contains, in, exists | Attribution |
country, device |
text | eq, neq, contains, in, exists | |
first_seen_at, last_seen_at |
date | within_days, not_within_days, gt, gte, lt, lte, exists | Comparisons take an ISO timestamp; the day operators take a number of days (1–3650) |
sessions, events |
number | eq, neq, gt, gte, lt, lte, in, exists | Person counters |
trait.<key> |
text | eq, neq, contains, in, exists | Reads persons.traits ->> key; key matches ^[\w.-]{1,64}$ |
property.<key> |
text | eq, neq, contains, in, exists | Reads persons.properties ->> key |
did_event |
event | — | Requires event; true when the count is at least count (default 1) |
not_did_event |
event | — | The negation: fewer than count occurrences |
revenue.status |
revenue | eq, neq | false until the revenue module exists |
exists takes an optional boolean: { "field": "trait.plan", "op": "exists", "value": false } asserts the absence.
contains is a case-insensitive substring match; %, _ and ! in the value
are escaped, so a value can never widen its own pattern.
Examples
Signed up in the last 30 days:
{ "all": [
{ "field": "stage", "op": "in", "value": ["signup", "activated", "paying"] },
{ "field": "first_seen_at", "op": "within_days", "value": 30 }
] }
High-intent anonymous visitors:
{ "all": [
{ "field": "stage", "op": "eq", "value": "visitor" },
{ "field": "sessions", "op": "gte", "value": 3 },
{ "field": "last_seen_at", "op": "within_days", "value": 14 }
] }
Paying customers on the pro plan who have not checked out this month:
{ "all": [
{ "field": "trait.plan", "op": "eq", "value": "pro" },
{ "field": "not_did_event", "op": "eq",
"event": { "name": "checkout_completed", "withinDays": 30 } }
] }
Anyone from paid search or a named campaign:
{ "any": [
{ "field": "first_source", "op": "eq", "value": "google" },
{ "field": "first_source", "op": "eq", "value": "meta" }
] }
How it compiles
segmentSql(definition, { appId }, { alias?, paramOffset? }) returns
{ where, params }: a boolean SQL expression over a persons row (aliased p
by default) and the bind parameters it refers to.
const { where, params } = segmentSql(definition, { appId }, { paramOffset: 1 });
const rows = await query(
`SELECT p.id FROM persons p WHERE p.app_id = $1 AND (${where})`,
[appId, ...params],
);
Two invariants hold for every definition, however it was produced:
- Nothing from the definition reaches the SQL text. Field names resolve
through a whitelist to fixed fragments. Values and jsonb keys are bind
parameters.
trait.x'); DROP TABLE persons; --is not escaped — it is rejected, because it is not a field. - An operator a field does not support is a validation error, not a silent
no-op.
validateSegmentDefinition(zod) is the same gate the API, the worker and the query layer all pass through.
Dynamic segments store only a definition and a cached member_count; the
directory filter compiles the definition inline, so a dynamic segment is never
stale on read. people.segments.refresh recomputes the cached counts every 15
minutes, and saving or pressing Refresh recomputes immediately.
PII rules
people.read is enough to see the directory, the timeline and the stages.
Reading a person's actual identity needs people.pii.read:
- Names and emails are masked (
A.,a•••@•••.com) everywhere without it — the directory, the dossier header, note authors. - Reserved traits (
email,phone,name,firstName,lastName) are shown as•••in the traits panel. - The CSV export is refused outright.
- The distinct id and the person id are not masked: they are the identifiers an operator needs to correlate a support conversation, and they are what the application chose to send.
Redaction is decided once, by the page, and passed to the components as a
pii boolean, so a component cannot widen it by accident.
Writes need people.write; a viewer can read everything they are allowed to see
and change nothing. Every write is audited: people.updated,
people.bulk_updated, people.note_added, people.exported, segment.saved,
segment.deleted, segment.refreshed.
CSV export
POST /api/console/people?intent=export-csv with the directory's filters
streams text/csv, paged by person id so a large export never sits in memory.
Always present: id, identified, stage, tags, first_seen_at,
last_seen_at, sessions, events, pageviews, first_source,
last_source, country, device, company, plan, owner.
Only with people.pii.read: distinct_id, email, name.
A value that begins with =, +, - or @ is prefixed with an apostrophe, so
a trait a tenant's own user typed cannot become a formula in a spreadsheet.
Jobs
| Kind | Interval | What it does |
|---|---|---|
people.segments.refresh |
15 min | Recomputes member_count and computed_at for every segment of every application with the module on. payload.appId narrows it. |
people.stages.auto |
1 hour | Applies the lifecycle rules above. payload.appId narrows it. |
Both jobs return immediately when persons does not exist yet, and one bad
segment definition never stops the sweep.
Query layer
src/lib/people/queries.ts is the only place that reads a person, and it is
what the Management API and campaigns import:
type PeopleScope = { orgId; appId; envId?; timezone? };
searchPeople(scope, { q, stage, tags, segmentId, identified, sort, direction, cursor, limit })
countPeople(scope, filters)
getPerson(scope, personId)
personTimeline(scope, personId, { before, limit, kinds })
personSessions(scope, personId, limit)
personSegments(scope, personId)
personRevenue(scope, personId) // null until the revenue module exists
updatePerson(scope, personId, { stage?, tags?, crm? }, actorId)
bulkUpdatePeople(scope, personIds, { stage?, addTags?, removeTags? }, actorId)
addNote(scope, personId, body, authorId)
listSegments(scope) / loadSegment(scope, id) / saveSegment(scope, input, actorId)
deleteSegment(scope, id) / refreshSegment(segmentId)
addSegmentMembers(scope, id, personIds, actorId) / removeSegmentMembers(scope, id, personIds)
exportPeopleCsv(scope, filters, { pii }) // ReadableStream<Uint8Array>
peopleOverview(scope, { from, to })
applyAutomaticStages({ orgId, appId })
listStages(scope) / listTags(scope, limit)
The directory pages with an opaque cursor (base64 of the sort key plus the
person id), so a page boundary stays correct while ingestion writes underneath
it. envId narrows events, sessions and errors to one environment; people
themselves are not environment-scoped.
What the assistant can do here
Ask searches people by text, stage, tag or segment, reads one person's dossier
and timeline, and lists segments. Personal data stays gated: without
people.pii.read emails and names come back masked, and the assistant treats a
masked value as hidden rather than absent — it will not guess the characters or
infer the person from surrounding traits.
Updating a person's stage, tags or CRM fields and creating a segment are
proposals shown with the before and after; people.write and your approval are
both required. A person's own traits are tenant data, so a trait containing
something that reads like an instruction is reported as a value and nothing more.