Skip to main content

Context Fields ($-prefixed)

A rule's condition leaf normally references a question by its field_key. Sometimes you want to gate on something the survey itself can sense: the current time, the respondent's device, an A/B bucket, or a computed score. The engine reserves the $-prefix for those "pseudo-fields", which are context resolvers built into the engine.

Each context field plugs into the same operator catalogue as any answer comparison. $hour < 9 uses the same < operator that compares a numeric question's answer; no special-cased branching in the engine.

FamilyFieldsRuns
Schedule$hour, $weekday, $dom, $month, $year, $nowClient
Locale$locale, $langClient
Device$device, $viewport_wClient
Quality$quality.speeder, $quality.straight_lining, $quality.honeypot, $quality.ip_throttle, $quality.anyServer
A/B variant$ab, $variant3Both
Loop$loop.countBoth
Score$score.<name>Client
Segment$segment.<tag>Client

How $-fields are evaluated

The rule engine sees a condition leaf and inspects the field:

Field shapeBehaviour
field_key (no $)Look up the question with that field_key; resolve its answer; compare
$<name> or $<ns>.<key>Call the registered context resolver; compare its return value
Unknown $-fieldTreat the leaf as "not applicable". Returns null, which makes the leaf fail safely.

Resolvers run at evaluation time, so dynamic state like $now reflects the current moment, not the moment the survey was authored.

Operators that apply

Every operator works on context fields the same way it works on answers:

  • =, !=: string-style equality
  • >, >=, <, <=: numeric comparison (operands coerced to floats)
  • contains, not_contains: substring match (string fields only)
  • empty, filled: null-test

A non-comparable mismatch (e.g. $device > 5) just doesn't fire. No crash, no log noise.


Schedule fields

FieldReturnsRangeNotes
$hourCurrent hour (number)0–23Reads the browser's local clock
$weekdayCurrent weekday (number)0–60 = Sunday, 6 = Saturday
$domDay of month (number)1–31
$monthCurrent month (number)1–12Jan = 1
$yearCurrent year (number)4-digit
$nowEpoch milliseconds (number)intUseful for before/after deadline rules

Example: business-hours only

Rule on every question (or use `show` on key questions):
When $hour < 9, hide the support question
When $hour > 17, hide the support question

Or compose:

When $weekday < 6 AND $hour >= 9 AND $hour < 17,
show question "Talk to a sales rep"

Example: hard deadline

Rule on first page:
When $now > 1735689600000, ← 2025-01-01 00:00:00 UTC in ms
end with message "This survey closed at the end of 2024."

Locale fields

FieldReturnsExample
$localePrimary language subtagen, fr, de
$langFull BCP-47 tagen-GB, pt-BR, zh-Hans

Both read navigator.language on the client. Surveys served to users whose browsers report en-GB can branch on $locale = 'en' (broad match) or $lang = 'en-GB' (precise match).

Rule on the first page:
When $locale = 'de' OR $locale = 'fr' OR $locale = 'it',
show "GDPR consent" question

Or invert with not_contains for a quick exclude:

When $lang contains '-eu',
show … (won't fire; this is illustrative since locales don't carry a "-eu" tag)

Device fields

FieldReturnsExample values
$deviceCoarse device classmobile, tablet, desktop
$viewport_wViewport width in px360, 1024

Breakpoints (match the SPA's CSS):

WidthClass
< 600pxmobile
< 960pxtablet
>= 960pxdesktop

Both re-evaluate on every rule pass, so a respondent who rotates or resizes mid-survey gets the right gating without losing their answers.

Example: hide a desktop-only question on mobile

Rule on the file-upload question:
When $device = 'mobile',
hide question

Or invert: show a mobile-friendly variant:

Rule on a mobile-specific photo question:
When $device != 'mobile',
hide question

Quality fields

Bitmask flags computed server-side after the answers are written. Each field returns 1 or 0 (string) so the standard = operator works.

FieldBitWhen it's 1
$quality.speeder1Response submitted faster than the survey's min_duration_seconds
$quality.straight_lining2Every row of a matrix answer got the same column
$quality.honeypot4The hidden honeypot field arrived non-empty
$quality.ip_throttle8The same IP has submitted more than 10 responses to this survey in the last hour
$quality.anyOR of all fourConvenience for "any quality concern"

See Validation & Guards → sanity check for the underlying signals.

Server-only evaluation

The SPA cannot know quality flags until submit, so these fields are server-only. Client-side rule passes skip them; the server runs the same rule against $context after computing the flags. This means:

  • tag, notify, and auto_close rules gated on $quality.* fire reliably.
  • A skipto rule gated on $quality.* doesn't make sense (the page transition happens on the client). The engine treats it as "not applicable", which is false.

Example: alert when a bot probe arrives

When $quality.honeypot = 1,
notify Slack #security
message: "Honeypot tripped on survey {{survey.title}}"

Pair with auto-close if you want hard rejection on top:

When $quality.honeypot = 1,
auto-close with reason "Honeypot tripped, manual review needed"

Variant fields

Stable per-respondent A/B (or A/B/C) assignment. Computed by hashing the response id with a Wang-style mixer; the same respondent always lands in the same bucket.

FieldBucketsValuesUse case
$ab2A, B50/50 question-wording experiments
$variant33A, B, CThree-way feature comparisons

Identical on client and server

PHP's hash mirrors the JavaScript bit-perfectly (32-bit mask matches >>> 0). A rule like When $ab = 'A', tag … fires for the same respondents on both the client's show/hide pass and the server's tag pass. There's no risk of skew between "what the respondent saw" and "what the server recorded".

Example: question-wording A/B test

Page 1, Intro
Page 2 (slug: variant-a), "How satisfied are you?" (1–5)
Page 3 (slug: variant-b), "On a scale of 1 to 5, how would you rate us today?" (1–5)
Page 4, Wrap-up

Rule on Page 1's last question:
When $ab = 'A', skipto section variant-a
When $ab = 'B', skipto section variant-b

The bucket is fixed per respondent, so resuming from email lands them on the same flow.

Anonymous surveys

$ab and $variant3 only work when a response id has been issued, so responseStart must have fired. Fully anonymous "no row" responses (which the engine doesn't currently support) would return null and the leaf safely no-ops.


Loop fields

FieldReturnsNotes
$loop.countNumber of items the source question collected0 when the source is unanswered

Currently set by the loop action's runtime stub. See Routing → loop / repeat. Full canvas duplication is on the roadmap, but $loop.count is already usable for branching.

Example: skip the loop section when there's nothing to iterate

Rule on the source question:
When $loop.count = 0, skipto section after-loop

The respondent who picked zero items in the source question skips the entire loop block.


Score variables

Score score actions write to named variables; downstream rules read them via $score.<name>.

Field shapeResolves to
$score.<variable_name>The numeric value set by a prior score rule. 0 when undefined.

Example: branch on combined score

Rule 1: Score `nps_score` = ref(nps_question)
Rule 2: Score `feedback_quality` = length(detailed_feedback) / 10

Rule 3: When $score.nps_score >= 9 AND $score.feedback_quality >= 5,
tag response with "actionable-promoter"

Rule 3 reads the variables Rules 1 and 2 wrote. Order matters within a single submit: scores are accumulated in the same pass as the conditions that read them, so authors should keep score rules above the rules that depend on them.

Special variable: $loop.count

$loop.count is technically a score variable. The loop runtime writes to scoreVariables.set('$loop.count', count). The $loop.* namespace is reserved for engine-managed variables; don't write to it from authored score actions.


Segment tags

score actions with buckets emit segment tags. Downstream rules read whether a tag was emitted via $segment.<tag>.

Field shapeResolves to
$segment.<tag>1 (string) if the segment tag was emitted by a prior score rule, 0 otherwise

Example: chain segments

Rule 1: Score `nps` from nps_question, buckets:
detractor (max 6)
passive (7–8)
promoter (min 9)

Rule 2: When $segment.detractor = 1 AND plan = 'enterprise',
notify Slack #cs-vip-escalations
message: "Enterprise detractor, {{q_5_email}}"

Segment tags compose with answer-field conditions just like any other leaf.


Custom context fields

The context registry is extensible. Adding a new field is one line on the client and one branch on the server.

Built-ins live in react-app/src/site/hooks/context-fields.ts. Server-side resolution is in ApiController::resolveServerContextField() for $quality.*, $ab, and $variant3. A future plugin event will let third-party extensions register their own resolvers; until then, custom fields require code changes.


Operator quick-reference

The full operator catalogue applies to every context field:

OperatorDescriptionWorks on
=Strict equality (string-style)All
!=InequalityAll
>, >=, <, <=Numeric comparisonNumeric fields
containsSubstring matchString fields
not_containsSubstring non-matchString fields
emptyField is null / empty stringAll
filledField has any valueAll

Numeric-only operators silently return false on string fields. No crash, no log noise.