The shape
A conjunction of groups; within a group, a disjunction of conditions.(A OR B) AND C, and two levels is the whole grammar. Arbitrary nesting is a
precedence question, a parser and a form nobody can draw; any-of AND any-of is
what an audience builder actually offers, and this one says out loud what it
cannot express instead of half-supporting it.
At most 8 groups of 12 conditions. That is a large audience and a query
Postgres plans without noticing — the bound exists so a definition cannot be
made arbitrarily expensive by pasting.
An empty group is refused, never read as “always true”: a builder that lost
its last condition would otherwise silently become everybody.
Four sources
Which operators apply is decided by the field, and a mismatch is refused:
created_at contains "@" is not a query with an empty answer, it is a question
nobody meant to ask. Text fields take eq/neq/contains/not_contains/
starts_with/ends_with/in/not_in/is_set/is_not_set; booleans take
is_true/is_false; timestamps take before/after/within_days/
not_within_days; events take occurred/not_occurred; metrics take
at_least/at_most.
An operand the condition does not read is refused rather than dropped. A days
on a condition where days mean nothing would otherwise be a window you set, the
engine ignored, and you believe you have.
An event condition’s window is mandatory (1 to 730 days). There is no
unbounded occurred, because a segment that scans every event ever stored is a
segment that gets slower every month it is left alone. If you mean “ever”, say
two years and mean it.
A timestamp that is not set matches none of the four time operators —
not_within_days included. An absent value answers no question about itself;
reach those contacts with is_not_set.
Filtering on an event property
An event condition can look at one key of whattrack() sent:
checkout.completed.pro beside checkout.completed.free — which turns the
namespace into a product and takes the grouping the explorer is built on with it.
Four rules, each there for a reason that shows up later:
- Only on
occurred. “Did not happen with plan=pro” reads two ways — never happened, or happened with another plan — and the engine refuses rather than picking. For the first, usenot_occurredon the event itself. - One key per condition. Two would be an AND, and a group of conditions is already how this engine spells AND.
- The comparison is textual. A property is compared as the text it renders
as:
4is"4",trueis"true". The column carries no schema, and any other reading would need a declared type per key. The operators are the text ones minusin/not_in— over a property those are an OR, and an OR is a second condition in the same group. - Absent and
nullare the same state as empty. Sois_not_setis how you ask for the events that carry nothing, andneqdoes not match them by accident.
count still applies alongside it: “checked out with plan = pro at least
twice” is one condition.
An event property is not a contact attribute. Writing properties.plan as an
attribute gets a refusal that points here.
What it refuses, and why refusing beats approximating
Two conditions are perfectly reasonable to want, and this engine will not state them. Both name themselves in the error, so you hit a sentence rather than an empty audience. A custom contact attribute.attributes.plan_tier has nothing behind it —
the contact columns are the profile and the first-touch attribution, and there
is no attribute store to match against. The only way to honour it would be to
match something nearly right. A segment built on an approximation is not a
smaller mistake than an error message: it is an audience that gets mailed, or a
feature that ships to the wrong tenth of your users, and nobody finds out from
the definition.
An entitlement. Whether a customer holds a feature is resolved by the
entitlements engine — overrides that replace rather
than maximise, an expiry, a dunning grace, and a status vocabulary whose unknown
member honestly answers “cannot tell”. Restating that in SQL would be a second
copy of the one function that is deliberately kept alone, and the copy would be
wrong in the cases that matter most. What a segment can target is the plan,
which is a row: plan.key and plan.subscription_status are mirrors of your
gateway, not resolutions of anything.
A third refusal is narrower and worth knowing: a metric condition in a test
environment. The activity meter never records test, so the condition could only
ever match nobody — and an audience that is empty for a structural reason looks
exactly like an audience that is empty because nothing has happened yet.
Two evaluators, one meaning
The same definition is compiled twice: into SQL over an environment’s contacts (“who is in this”), and into an in-process evaluation of one contact (“is this person in it, right now”). A campaign resolving recipients wants the first; a flag deciding what to send a browser atboot, or a survey asking whether
somebody just entered, wants the second.
Two implementations of one meaning is the shape that drifts, so every field
carries both halves in one place and a test drives real contacts through both
paths and refuses a disagreement. It is worth knowing this exists, because it is
the reason you can trust the preview: the count you saw is produced by the same
rules the flag will apply one page load later.
Membership, entry and departure
Who is in a segment is written down, not recomputed on every read — because what the modules above actually subscribe to is the entry. “Somebody entered trial-ending-in-3-days” is a difference between two evaluations, and a difference has nowhere to live unless the previous evaluation was recorded. Two things keep it current:- A fact lands and the contacts it is about are re-decided in seconds. This is the path that carries anything caused by somebody doing something.
- A nightly sweep walks every live definition and recomputes. It is the only thing that can notice a condition that became true because time passed — “created more than 30 days ago”, “not seen in three weeks” turn true with nothing published anywhere, because nothing happened.
segment.entered and leaving publishes
segment.left. Both are deliverable as webhooks, and the payload carries the ids
and the segment’s name — never the definition.
The first computation of a definition announces nothing. Writing an audience
down for the first time is a backfill: nobody changed, the audience did, and one
webhook per existing contact for an act you performed in the panel three seconds
ago is not a fact about anybody. Editing the definition puts it back in that
state, for the same reason — re-aiming an audience is not a hundred people
entering it. definition_version is bumped on a definition change and never on a
rename, so anything holding a compiled audience can tell it is stale by comparing
an integer.
left_at is a timestamp rather than a deletion, because leaving is also
something you will want to act on: a win-back audience is people who left
“active in the last 30 days”.
Preview before you aim anything at it
Permissions
Reading a segment iscustomers:read — the same seat that can list contacts,
because a preview is reading contacts. Creating, editing and archiving is
segments:manage, which owner and admin hold by default.
The split is deliberate. An audience is a piece of product configuration that
every other module inherits; being able to look at one is not the same act as
being able to move what a flag, a survey and a release note are all aimed at.