Variable Resolution¶
Vayu resolves {{variableName}} placeholders at request-execution time using a layered
priority system. Higher-priority layers override lower ones; within each layer the last
write wins.
Where resolution runs (since issue #226): the engine owns
execution-time resolution - POST /compose interpolates and resolves
inherit auth, and every client executes the composed result
(engine/src/http/request_composer.cpp). The renderer keeps a preview-only
copy of the same rules (app/src/lib/variable-resolution.ts, consumed by
useVariableResolver) for tab titles, previews and the unresolved-token
painting. The rules on this page are the contract both implement, pinned by
the shared conformance fixture
(engine/tests/fixtures/variable-resolution-conformance.json), which the
engine's gtest suite and the renderer's vitest suite both drive.
A third reader, MCP (issue #1207): the same rule set is served as the MCP
resource vayu://variables/resolution, and the resolve_variables tool
answers which definition wins for a given collection/environment context and
every definition it shadowed. See
MCP - Variables.
One matcher in the renderer. {{name}} is recognised by
VARIABLE_PATTERN in app/src/constants/variables.ts and nowhere else -
import it (or containsVariableToken / isVariableToken, which wrap the two
boolean questions) rather than writing the literal again. The app had four
identical copies before issue #227, and copies are how a preview drifts from
what /compose will substitute without anything failing. The C++ side is the
one legitimate other copy, and the conformance fixture is what holds it to
this one. constants/variable-pattern-single-source.test.ts fails on a fifth.
Malformed stored data (the D17 rules, decided in #226). A definition whose
enabled is absent (or not a boolean) counts as enabled - only an
explicit enabled: false disables. A non-string stored value reads as
the empty string. One rule, both sides: the engine enforces it in
parse_variables, the renderer in lib/variable-resolution.ts, and the
fixture varies both fields so a divergence cannot hide.
Priority order (lowest → highest)¶
A variable set in the active environment always wins over a collection variable, which always wins over a global. The fourth tier exists only while a data row is bound - a data-driven collection run, a load run given rows, or a single send bound to one row (issue #1007). With no dataset the ladder is the three-tier one it has always been; a bare name resolves exactly as it did before this tier existed.
data.* is reserved, and sits outside this order¶
{{data.column}} addresses a column of a collection run's data file (issue
402), and it is not a fourth tier above the three. It is a reserved¶
namespace, disjoint from them: {{data.id}} and {{id}} are different names,
so a data set can neither shadow nor be shadowed by a variable, and attaching a
data file to a collection cannot change what its other tokens resolve to. That
is what dissolves the precedence question rather than answering it, and
nothing about it changed.
A bound row's bare column names are a different rule, and they are a
tier (issue #1007). Postman binds a dataset's columns to bare names -
{{username}}, not {{data.username}} - so an imported data-driven
collection is written that way, and Vayu answering {{username}} from the
scopes (or from nowhere) sent a request the file's author never wrote. While a
row is bound, that row's own column names answer a bare {{username}} above
the active environment - the ladder in the section above. A column the row
does not carry is not a bind failure: the name falls through to the scopes
exactly as it would with no dataset at all, which is what lets one script or
one request work both with and without a data file.
Mechanically, neither spelling is substituted by composition. A plan is
composed once, before any row exists to bind - so composition can no more
substitute a bare bound column than it can substitute {{data.username}}. It
defers the token instead, leaving it written exactly as it stands, and the
per-row bind (core::apply_data_template) is what joins both spellings
against the row, through the same walk. That is what makes the two rules cost
nothing extra to keep consistent: a bare column gets the identical JSON/XML
escaping, the identical missing-column refusal, the identical null-cell
refusal, and the identical header CRLF/NUL and header-collision refusals that
{{data.column}} has always had. There is no second, looser substitution path
for the bare spelling - see Data-Driven Runs for what
those refusals say.
Which bare names a bind owns travels as a set of names, never values - the
engine fills it wherever a dataset is known (a collection or scenario run's
plan, a single-request load run, a single send carrying one row), and a client
composing ahead of a run of its own states it explicitly as the dataColumns
field of POST /compose: an array
of the data file's column names, absent or null meaning no dataset and
composition exactly as before.
Nothing in the tables above resolves a {{data.column}} token. Both resolvers
- the engine's resolve_template and the renderer's resolveTemplate - leave
it written exactly as it stands, because only a scenario run's iteration knows
which row is bound; the run's worker substitutes it immediately before each
send. A data.* token in an ordinary Send therefore reaches the wire as
written: there is no row. {{data.}} with nothing after the dot names no column
and follows the ordinary unknown-name rule instead - which since #1009 also
leaves it written as it stands, for a different reason.
The UI paints it as its own state, not as a broken variable. Unresolved is
the accurate word for what the resolvers do with the token, but it is the wrong
thing to show: the builder used to render {{data.email}} red, hover it to
"not defined", and offer to create a variable of that name - which, the
namespace being disjoint, can never answer for the column. So a data.* token
gets the muted run-time treatment (RuntimeToken) and a tooltip naming the run's
data file, and no surface offers to create one. See
COMPONENTS.md
for the four token states.
A collection run is the one place that reading is not left to the user: a
run started without a data file whose plan still carries a data.* token is
refused with a 400 naming the step and the token, rather than sending the
literal text once per iteration (issue #415). The single Send above keeps its
behaviour - a token someone typed into a request they are editing is not yet a
run.
The conformance fixture pins all three cases, so the two resolvers cannot drift on them. See api-reference.md for what the engine does with the token once a row exists.
$vu and $iteration are reserved too, for the same reason¶
{{$vu}} and {{$iteration}} name the run that is executing - which virtual
user this request belongs to, and which of that user's iterations it is (issue
994). They are spelled like a dynamic variable and behave like data.*: both¶
compose-time resolvers leave them written exactly as they stand, and the
executor substitutes them immediately before each send, because the value
belongs to the iteration rather than to the request. A script resolves them
(issue #1057): pm.variables.replaceIn("{{$vu}}") renders the number the
request beside it was bound with, since by then the send has one - see
the scripting docs.
Being reserved is what makes them bindable at all. A variable someone happens
to name $vu does not answer for the identity - unlike $guid, where a
scope of that name wins (rule 1 below) - because a scope that could answer would
substitute one value at composition and leave every iteration of the run sending
it. That is the one thing these names exist to prevent. The two names are
matched exactly, so {{$vus}} is an ordinary unknown $name and keeps its
braces.
The UI paints them as their own state too (issue #1101), for the reason the
data.* paragraph above gives and one more. Until #1101 the builder rendered
{{$vu}} red, hovered it to "not defined" and offered to create a variable of
that name - the one offer that is guaranteed to change nothing, since the
resolver answers the identity ahead of every scope. So they get the same muted
RuntimeToken treatment, noted not generated here to tell them from the
generator beside them, and no surface offers to create one. {{$vus}} keeps the
red paint, which is what makes the near-miss visible.
What they resolve to depends on the shape of the run, and every shape has an answer:
| Where | {{$vu}} |
{{$iteration}} |
|---|---|---|
| A collection load run | the virtual user's own number, 1-based | that user's iteration, 0-based |
| A single request under load | 1 |
the submission's index, 0-based |
| A collection run in design mode | 1 |
the pass, 0-based |
| A single Send | 1 |
0 |
A single request repeated is one user's iterations, however many are in
flight: concurrency says how many of them overlap, which is a different
question from how many users there are. Virtual users that differ from one
another are a collection load run's shape, and that is the run where {{$vu}}
spans more than 1. A run that needs distinct values per request without a
collection uses {{$iteration}}, which is unique per submission - and is the
same counter the data-row cursor claims from, so iteration i binds row
i % rows and a script cannot be told it is on iteration 3 while holding row
1's values.
Every bindable field binds, credentials included (issue #1055): the URL,
header names and values, the body and both halves of every form field, each
escaped for the document it lands in - and a basic-auth username, a bearer
token or an api key. A credential is encoded when the request is built, so one
carrying either name defers that build and is bound before apply_auth
encodes it, which is the same order a {{data.*}} credential has always taken.
A run does not need a data set for it: the identity comes from the iteration
rather than from a row, so user-{{$vu}} binds on a plain load run exactly as
it does on a data-driven one. OAuth 2.0 is the exception, and it is refused by
name rather than sent wrong - its token is acquired once, before any iteration
exists.
D18 - a bound row's bare column names outrank the environment (issue #1007)¶
This repo labels the decisions #226 recorded D1, D2, D16, D17. This one is new, and it is not from #226: recorded here as D18.
What it costs. #402 deliberately bought the property that attaching a data
file to a collection cannot change what any existing token resolves to -
{{id}} and {{data.id}} were guaranteed different names, full stop. D18
trades part of that away: a collection whose data file happens to have a
column sharing a variable's name now resolves that name differently while a
row is bound than it does on a plain Send. That is a real, deliberate
narrowing of #402's guarantee.
Why it is worth it anyway. The owner's standing direction prefers Postman
compatibility here: Postman binds a dataset's columns bare, so every imported
data-driven collection is already written {{username}}, and #402's guarantee
was making every one of those requests send the literal token instead of the
row's value (see {{data.*}} puts the row into the request
itself). A collection that never
attaches a data file, or whose columns share no variable's name, pays nothing.
Why the reserved data.* spelling is kept too. {{data.username}} still
addresses the column even where no row is bound, still refuses a header a
value would forge with the same message, and can never collide with a
same-named variable - it is the collision-proof spelling for a request written
by hand, or shared with people who are not relying on Postman muscle memory.
D18 does not replace it; it adds the bare spelling beside it, at Postman's
position in the ladder.
Why the deferral is what makes both work at once. Composition already had
to leave {{data.*}} unresolved, because a plan is composed once and a row is
bound per iteration - D18 gives a bare bound column the identical treatment,
so both spellings reach the same bind-time join and the same refusal rules.
Nothing about the two rules can drift, because there is only the one path that
substitutes either of them.
What the builder's preview shows now (issue #1062). With a row picked, a
Send previews the bind as well as the composition: a bare {{username}} and
the reserved {{data.username}} both show the row's own cell - the URL bar's
preview line, the params and body previews, every key-value row, and resolved
auth - above the environment, the tier this section names. That is what the
send is about to put on the wire, not composition's guess at it.
A pick lasts until a Send that does not carry it. Send and the send chord
both send without a row, so the pick is cleared by one: a row left standing
across a plain Send would put the file's value in every preview beside a request
that had just gone out with the environment's, which is this same disagreement
arrived at from the other side. Nothing else clears it - the picker's memory
still survives a tab switch and a return (issue #659), because neither of those
is a send. Closing the request's tab does drop it, which is not the same act: it
ends the memory rather than unbinding the row, and it is what bounds a map the
provider would otherwise hold for the life of the session (issue #1271, see
state-management.md).
Why the renderer can show this and composition cannot. A plan is composed
once, before any row exists to bind, so composition still defers both
spellings exactly as above. The preview goes further only because it holds
something composition never does: the picked row itself, threaded from
RequestBuilderProvider's per-request row memory into
useVariableResolver({ boundRow }). With it, resolveString reads
resolveTemplateWithRow (app/src/lib/variable-resolution.ts) instead of
resolveTemplate - a restatement of the engine's own
resolve_template_with_data, the function pm.variables.replaceIn reaches
for the same reason (issue #890), not a second rule invented for the preview.
A collection run, a load run and a scenario step still bind their row during
the run itself, so nothing about them changes: this is the one caller, a
single Send, that now happens to hold the row before the request goes out.
The paint stays the accent; the popover now says why (issue #1064). The
token state VariableInput paints is unchanged: a bare name still paints as
a bound column only where no scope defines it, and a shadowed bare name still
paints as the variable, in the same accent it always has. That was the open
question this section used to leave for later, and it has been decided rather
than merely left open: the accent stays, and the explanation is what moves.
Opening VariablePopover on a shadowed name while a row is picked now names
the row as the origin - unstruck, above the definitions it beat, which render
struck beneath it exactly as any other shadowed definition does - so the
popover states plainly that the row's cell, not the environment's value, is
what the send will use. The hover tooltip reads the same way, from the same
origins list, because hovering and clicking are two readings of one token and a
token that answers them differently is worse than one that answers both
wrongly. Enter and Space on a focused token are a third way into the same
popover rather than a fourth reading of the origins (issue #1215): the token is
a role="button" in the field's roving Tab strip, and what opens is the control
this paragraph describes, unchanged. The body and GraphQL editors answer the
same way over their own tokens - a third reading of one answer, not a second
one, so a token means the same thing whether it sits in a single-line field or
in the body beneath it - but no longer through a Monaco hover provider. Since
issue #1320, EditorVariableTokensProvider draws the app's own tooltip card
there instead, over the token's rectangle, from the same getVariableOrigins.
That provider is mounted inside the builder rather than registered per
language outside it, so the bound row reaches it the ordinary way, through
useVariableSupport, with nothing left to read from bound-row-store
directly the way the tab strip still does (issue #1074); without that the
editor would rank the scopes while the field one line above ranks the row over
them, which is the disagreement this paragraph exists to forbid.
A name that no scope defines but the row does is the same claim in the
other direction, and it stops being reported as undefined: the destructive red
states a token that reaches the server with its braces still on, and this one
does not. The chip reads row, the popover names the column, and a create
offer - still worth making, since the variable answers every row-less send -
says what the row already answers rather than implying the token is unanswered.
What the tab strip shows, and how the row reaches it (issue #1074). A tab's
title resolves through one list-wide useVariableResolver() in
app/src/components/layout/tab-descriptors.ts - the strip has to know each
label before it can decide how many fit, and a hook inside a map is a variable
number of hooks - so the strip is the one preview surface that is not below the
builder's provider and cannot take the row off its context. Left alone it
labelled a tab from the environment while the bar one row beneath it showed the
file's value, which is the same one-bind-two-answers split as above wearing a
different coat.
The row crosses that boundary through bound-row-store.ts: one slot, holding
the row the open builder is bound to and the id of the request it is bound
for. One slot rather than a map because the builder binds a row for the
request it is showing, so that is the only request an on-screen preview can be
bound for; publishing a row per remembered index would be publishing rows out of
a file that is no longer the one loaded. The id is what makes a reader check
rather than assume, so a slot left standing cannot relabel the next tab. It is
never persisted and is cleared when the builder unmounts, on the rule the rows
have carried since #601: they must not outlive the send that uses them.
resolveString takes the row as an optional second argument for this one
caller. Every other caller names its row once, as useVariableResolver's
boundRow option, because it resolves for a single request; the strip resolves
for all of them at once and so has to say which row per call.
Which contract answers for a request: nearest declared ancestor¶
A collection can declare the columns its data files carry (the Data tab,
issue #599). Which declaration applies to a given request is a chain answer, and
the rule is the variable chain's own read backwards: the nearest declared
ancestor, leaf to root (resolveDataContract in lib/data-contract.ts). A
request in a sub-collection run recursively under a parent binds the parent's
data, so when the sub-collection declares nothing the walk finds the parent's
contract - and when it declares its own, its own wins, exactly as a leaf
variable shadows an ancestor's.
A collection that declared a contract and then cleared it holds {}, which is
how "no contract" is spelled, so the walk treats it as transparent rather than
as a contract of zero columns - an empty declaration cannot shadow a working one
above it.
This is what makes the token states possible: with a contract in scope,
{{data.email}} is checkable. A declared column paints informationally, a
column no contract in scope declares paints amber with the declared list in
its tooltip, and a chain that declares nothing keeps the neutral token above. It
is authoring-time advice in every case - the run's file is the authority, and a
run with a mismatched file is still the user's to start. Declared columns are
also completed: {{data. offers them in the request fields and the body editors,
and pm.iterationData.get(" offers them in the script editors (see below). The
body editors also colour and explain these tokens on hover, the same three
states as the request fields; the script editors do not, for the same
interpolation reason as everywhere else in this document (D16).
The script panel's "Names mentioned:" chips read the same three states
(issue #604). They used to paint a name red whenever no scope defined it, which
for a data.* name is always - the reading that made a working column look
broken in a row whose whole job is to say whether a name resolves. A column
reached by its bare name gets that same paint (issue #1063): a script reads
one through pm.iterationData.get("email"), or through pm.variables.get("email")
while no scope defines email, and both are the column {{data.email}} names.
The accessor is what decides, not the spelling - pm.environment.get("email")
cannot see the row whatever the collection declares, so it stays an ordinary
variable chip.
The audit in the Data tab is the same comparison in the other direction - the declared columns against the tokens the collection's requests actually carry.
Every surface that acts on a contract resolves it this way, including the Run dialog: running a sub-collection under a declaring parent offers the parent's declared file as the pre-fill and diffs the chosen file against the parent's columns, the same answer Send-with-row and the token painter give. See COMPONENTS.md for the paint and Data-Driven Runs for the file itself.
Layers¶
1. Globals¶
App-wide variables stored in the singleton globals table. Edited via the Globals panel.
These form the base layer - any layer above can override them.
2. Collection chain¶
Each collection can define its own variables. When a request belongs to a nested
collection (e.g. Root → API → Users), variables are merged walking root-first:
Root.variables → applied first
API.variables → overrides Root
Users.variables → overrides API ← request's direct parent
This means a child collection's variable always takes precedence over an ancestor's variable of the same name.
3. Active environment¶
The environment selected in the variables store (top of the sidebar). Environment variables override everything else. This is the intended override point for per-environment values like base URLs and API keys.
Implementation¶
useVariableResolver (app/src/hooks/useVariableResolver.ts) collects every
definition of every name on every render via useMemo, keyed by
(collectionId, environmentId), in precedence order (lowest first):
// 1. Globals - a singleton, so no source name
for ([key, val] of globalsData.variables) push(key, val, "global");
// 2. Collection chain - root first so leaf overrides parent. Each collection is
// its own origin: two in one chain both have scope "collection".
const chain = buildCollectionChain(activeCollectionId, collections); // root-first array
for (const col of chain)
for ([key, val] of col.variables)
push(key, val, "collection", { id: col.id, name: col.name });
// 3. Environment - highest priority
for ([key, val] of env.variables)
push(key, val, "environment", { id: env.id, name: env.name });
// The winner is the last *enabled* definition.
buildCollectionChain(startId, collections) walks parentId links upward and
returns the chain with the root at index 0. It keeps a seen set and stops on
a revisit: the engine rejects parent cycles on write (issue #79), so a cycle
means the database already went bad, and the walk runs inside a useMemo -
an unterminated one is a frozen window, not a wrong preview.
Collection scope is explicit only. It comes from the collectionId option
and nothing else; a caller that passes none resolves against globals +
environment. There used to be a session-store fallback (activeCollectionId),
but nothing ever wrote that field, so it could only ever hold a value
rehydrated from an old build - scoping a preview to a collection the user had
left, or deleted, versions ago. It was removed in the vayu.session v2
migration.
The resolved Record<string, ResolvedVariable> is derived from that list
(the origin carrying winner: true) rather than built beside it, so the two
cannot disagree about which definition won. A name whose every definition is
disabled is absent from the map, not present-and-empty - the red token keys off
absence, so a present-and-empty entry would paint it resolved and send "".
getScopeVariables(scope)¶
Returns what one scope answers on its own: the names it defines through
enabled rows, each valued as pm.<scope>.get would read it, independent of
which scope wins the whole ladder (issue #1302).
A different question from getAllVariables, and the difference is the point.
pm.collectionVariables.get reads the collection chain and answers from it
whether or not the environment defines the name too, so a caller that wants
"what does this scope hold?" cannot get it by filtering the winners: a
collection's own shop_domain disappears from that filter the moment an
environment shadows it, which is the one configuration where the scoped read
and the {{name}} beside it disagree. The completion list inside a
single-scope accessor is built from this (below).
Derived from the same origins as variableMap, through the same "last enabled
wins" rule - scopeAnswer in lib/referenced-variables.ts, which
describeScopedRead reads as well, so what a list offers and what the chip
above the editor says about it cannot drift apart. A name whose definitions in
that scope are all disabled is absent rather than present-and-empty, for the
reason it is absent from variableMap: get reads enabled rows only.
Display-only, like getVariableOrigins. The bound row never appears here - it
is not a scope, and no accessor reads a single scope through it.
getVariableOrigins(name)¶
Returns every definition of a name, lowest precedence first, including the disabled ones that never resolve. Empty array for a name nothing defines.
Display-only; nothing about execution reads it. It exists because the winner alone cannot answer "why is this the value?" - and the two cases that need answering are exactly the ones a flat map destroys:
- the same name defined at several scopes, where the losers were overwritten;
- a name whose highest-scope definition is disabled, which is the more
common surprise. The old loop skipped those with
if (v.enabled), so nothing could report that the value you set is being skipped rather than missing.
The variable popover renders this as its "also defined" list. VariableOrigin
carries enabled and an explicit winner flag - once disabled definitions are
in the list, "last" and "wins" are different things.
Since issue #1064, the bound row is layered on top of this list, not into
the scope ladder it is built from. While a row is picked and its column
answers the name, getVariableOrigins appends a { scope: "row" } origin
after the scope-derived ones and takes winner away from whichever of them
had it - the row is what the send will use, so only it may claim the flag. The
layering happens in this accessor alone: originsByName, the ladder
variableMap and therefore getVariable / getAllVariables resolve from, is
untouched, so a picked row still cannot make ResolvedVariable.scope report
anything but a scope someone could actually write to.
ResolvedVariable.sourceId / sourceName name the specific environment or
collection the winning value came from (absent for global), so the popover can
say which environment rather than just "Environment".
An edit writes back to that source, never to one re-derived. The context bar
commits against sourceId, so the definition that receives the value is the one
the bar displayed. It used to walk the collection chain itself for the first
definition with a truthy enabled, which disagrees with the resolver's
isEnabledDefinition on every definition where enabled is absent (D17 counts
absent as enabled) - so a leaf definition without the key displayed while an
ancestor's took the write. Any second implementation of "which definition wins"
can drift from this one; there is only meant to be the one, and sourceId is how
it is carried.
The engine implementation¶
engine/src/http/request_composer.cpp is the execution-time implementation of
this page (build_variable_values + resolve_template), reached through
POST /compose. It has no getVariableOrigins analogue - the origins list is
display metadata, and execution has no use for losers. MCP no longer carries a
copy at all: it composes via the engine and its old resolve.ts port is
deleted (#226).
The resolved map is then used by resolveString(input) (preview) and
resolve_template (engine) which replace all {{name}} occurrences.
A name nothing defines keeps its braces (#1009), plain and {{$name}}
alike. The token goes out on the wire, where it becomes a DNS or a 4xx
failure naming the thing that was never set; it used to resolve to the empty
string, which sent https://{{host}}/x as https:///x - a different request,
made silently. A definition that exists and holds an empty value still
substitutes empty: the rule is about a name nothing answers, not about a blank
answer. There is no way to escape a literal {{.
A value that itself holds {{tokens}} resolves through them (#1009), which
is how baseUrl = "{{protocol}}://{{host}}" - the shape most imported
environments are written in - composes as the URL it spells. Two rules bound
the walk, because the values come from a user's environment and nothing there
promises to terminate: a name already being expanded is a cycle and its
token is left written as it stands (a = "{{b}}" with b = "{{a}}" resolves
to the literal {{a}}), and expansion stops after 8 levels, keeping what
it resolved and leaving the rest literal. Text that a substitution did not
put there is never rescanned, and a value holding no {{ costs one search - so
everything composition can answer resolves in a single pass, at compose time.
A name composition could not answer keeps its braces (#1009's rule above) and
gets one more chance: vayu::http::routes::resolve_residual_tokens (#1008)
runs the same resolver again, after the pre-request script and before the
send, against the scopes as the script left them - so a token only the script
can answer (a freshly fetched auth token, typically) still reaches the wire.
It reads the request, not composition's decisions, so a value composition
already substituted is not touched a second time, and it costs nothing on the
ordinary request that has nothing left to resolve. The previews on this page
are unaffected, because they mirror POST /compose: a preview can show
{{token}} where the wire will carry the value the script set. See
pm API compatibility for what that changes for a
script.
One value composition refuses: a header a variable would forge (#738)¶
Substitution is textual everywhere, which is exactly right for a URL, a body and
a form field - and wrong for one field, because a header line ends at CRLF and
has no escape for it. A variable holding ok\r\nX-Admin: true written into
X-Note: {{note}} would not put that text in X-Note: it would end the line
and make X-Admin: true a header nobody wrote.
So a header is the one place POST /compose rejects a payload over a value
rather than over its shape: a 400 with code unsendable_header, whose message
names the variable - composition is the last layer that still knows which one
carried the byte. A NUL is refused with it, because the engine hands the header
line to curl_slist_append, which reads to the first NUL and would send the
rest of it missing.
Refusal rather than repair, for the same reason a --> in an XML comment is
refused: there is no encoding for a line break in a header, so stripping the
bytes would send a header holding something the author did not write. The rule
is scoped to the field, not to the value - the same variable resolves unchanged
into a URL, a body and a form field's value.
Composition is one of three layers holding the same rule, one definition
(engine/include/vayu/http/header_text.hpp): a bound {{data.column}} is
refused earlier, at bind time, naming the column and the row
(data-driven runs); everything else - a script
assigning to pm.request.headers, an auth credential, an import, a payload
posted straight to POST /execute - is refused before the transfer starts,
naming the header instead of a variable.
The other one: a header a variable would erase (#1051)¶
A header name is substituted like anything else, and the map it lands in holds
one value per name. So two names that resolve alike do not both go out:
{{tenant_header}}: acme beside a literal X-Tenant: legacy is one header once
the variable answers X-Tenant, and the other is gone. Names are compared
without case, so a {{h}} resolving to authorization takes the place of an
Authorization typed beside it.
That is the same quiet wrong request as the one above with the fault reversed -
there a value forges a header, here a name erases one - so it gets the same
answer: a 400 with code colliding_header_names, naming both spellings as
written and the name they produced. Repair is not on offer for the reason it is
not offered above: the two names are equally the author's, so choosing one is
inventing an intention, and "whichever the map reached last" is an
implementation detail rather than a rule.
Only a collision resolution made is refused. Two names typed into one request are two lines visible side by side, and the later one has always won; what this refuses is the collision that is invisible until the request comes back wrong. The distinction is the one data-driven runs already draw for a bound row.
The rule is layered too, one definition
(engine/include/vayu/http/header_names.hpp): the bind-time one naming the
column and the row, composition's 400, the execute-time residual pass - which
rebuilds the same map after a pre-request script has run, and so can meet a
collision composition never saw, refusing in the same words as a failed send
rather than a rejected payload - and a script's own pm.sendRequest (#1067),
which builds a map of its own and throws those same words with the call named in
front of them. The pre-send gate is deliberately not the
backstop here, and cannot be: by the time it sees a request the erased header is
already missing, with nothing left to notice.
And the name that resolves to nothing (#1084)¶
The same file holds a second rule about a header name, one step further along:
a name that resolves to the empty string. {{blank}}: acme with blank holding
"" - which is what an enabled row with a blank value is, an ordinary value
rather than an exotic one - leaves a key with nothing in it, and the line that
reaches the wire is ": acme", under no name at all. A strict server answers
400 and the run reads as the endpoint being broken; a lenient one ignores the
line, and the header being set is simply absent.
So it gets the answer the two above get: a 400 with code empty_header_name,
naming the name as written - which is all there is to name, since what it
produced is nothing. The pre-send gate is no backstop for this one either, and
for a sharper reason than before: it reads a header's text for the bytes that
end a line early, and an absent name ends nothing, so a nameless line passes
every check between composition and libcurl.
One difference from the collision. That one is refused only where resolution
made it, because two names typed side by side are two lines the author can see;
composition refuses this one however the name got there, a name that is not
there being nothing to see whoever wrote it. In practice both flattenings that
feed composition drop such a row first - a stored request's headers, and the
app's - so what that catches beyond a produced name is a payload built by hand.
The three layers are the ones above: composition's 400, the residual pass as a
failed send, and pm.sendRequest, which met this first because a script writes
header names of its own.
A fourth binds rather than resolves (#1095): a data row whose header-name
column is blank - {{data.header_name}}: acme against a row that has nothing in
that column - is refused at bind time, in the same wording with the row in front
of it, and the load path needs that layer because it runs no residual pass over
what it binds. A name a bind only shortens is not this rule: X-{{data.h}}
with a blank cell binds to X-, which is a name a request can carry.
And the residual pass reads every header name for this rule (#1095), not
only the ones that still held a {{token}} - so a name a script has just
emptied, and an empty key in a payload posted straight to POST /execute, are
refused there rather than sent. The collision rule keeps the narrower reach and
loses nothing by it: a request that arrives already resolved cannot carry a
collision to find, since two names that are already equal are already one key.
Dynamic variables¶
A name starting with $ is not looked up in any scope. It names a generator
in app/src/lib/dynamic-variables.ts, called where it is written:
| Name | Value |
|---|---|
$guid, $randomUUID |
UUID v4 |
$timestamp |
Unix time in seconds |
$isoTimestamp |
ISO 8601 UTC timestamp |
$randomInt |
integer 0 - 1000 |
$randomAlphaNumeric |
one alphanumeric character |
$randomBoolean |
"true" or "false" |
$randomEmail |
email address |
$randomFirstName, $randomLastName, $randomFullName |
person name |
$randomCompanyName |
company name |
$randomUrl |
absolute https:// URL |
$randomIP |
IPv4 address |
$randomPassword |
15-character password |
$randomPhoneNumber |
ten-digit phone number, 700-008-5275 |
$randomCity |
city name |
$randomStreetAddress |
street address, 5742 Harvey Streets |
$randomCountry |
country name |
$randomCountryCode |
ISO 3166-1 alpha-2 country code |
$randomDatePast, $randomDateFuture, $randomDateRecent |
datetime in the past year, the next year, the past week |
$randomWord, $randomWords |
one word, three to five words |
$randomLoremWord, $randomLoremWords |
one lorem ipsum word, three of them |
$randomLoremSentence, $randomLoremSentences |
one lorem ipsum sentence, two to six |
$randomLoremParagraph |
a lorem ipsum paragraph |
$randomColor |
color name |
$randomHexColor |
hex color, #47594a |
$randomUserAgent |
browser user-agent string |
$randomDomainName |
domain under a reserved example domain |
$randomAbbreviation |
abbreviation, SQL |
$randomPrice |
price 0.00 - 1000.00 |
$randomCurrencyCode |
ISO 4217 currency code |
$randomProductName |
product name |
$randomJobTitle |
job title |
They resolve anywhere {{name}} does - URL, headers, body, form fields - and the
{{ autocomplete offers them under a Dynamic heading in both the plain
fields and the body editors.
The three date generators write what JavaScript's Date.prototype.toString
writes, which is the shape Postman documents them in, and always in UTC:
Sat Mar 02 2019 09:09:26 GMT+0000 (Coordinated Universal Time). The engine has
no user's zone to read, and the two sides have to spell the same string.
$randomDomainName draws from the reserved example space (RFC 2606) rather than
Postman's live-looking gracie.biz, as $randomUrl and $randomEmail already
do: a generated hostname reaches DNS the moment someone writes it into a URL.
Three rules decide what happens at a token:
- Scopes win. A workspace that defines a variable literally named
$guidkeeps that value; only a name nothing defines reaches a generator. So adding this table cannot change what an existing request sends. - One value per occurrence. Two
{{$guid}}in one body are two different ids, which is the reason to write them. The table holds functions, not precomputed values. The two reserved identity names above are the deliberate exception and are not in this table: two{{$iteration}}in one request are one iteration, because they name a fact about the send rather than generate a value for it. - An unknown
$namekeeps its braces.{{$randomInteger}}(not a name Vayu has) is sent as that literal text rather than resolving to"". The$is a declaration of intent, and a typo that silently emptied a field is the defect this feature was added to fix - so it is left where it can be seen, and the token stays marked unresolved in the UI.
The Postman generators Vayu deliberately does not carry¶
Postman ships around 120 dynamic variables. The table above is the tier imported
collections actually use; everything outside it keeps its braces by rule 3 rather
than resolving to something invented. The categories deliberately left out,
wholesale: finance ($randomBankAccount, $randomCreditCardMask, IBAN and BIC),
images ($randomImageUrl and its per-category siblings), files and directories
($randomFileName, $randomMimeType, $randomFileExt), databases
($randomDatabaseColumn, $randomDatabaseType), catchphrases and business
buzz-phrases, stores and products beyond $randomProductName, grammar
($randomNoun, $randomVerb, $randomAdjective), and the remaining
name/profession/location detail ($randomNamePrefix, $randomJobArea,
$randomLatitude, $randomLongitude). A collection using one of those sends the
literal {{$randomBankAccount}}, visibly, rather than a plausible wrong value.
$randomRgbColor is on neither list because Postman does not ship it - only
$randomColor and $randomHexColor are documented.
What this does not cover¶
Script text. A {{…}} written directly in script code does not resolve
and never will - it is user JavaScript, and rewriting it could not tell a
string literal from code (#226, D16). What a script uses instead is
pm.variables.replaceIn("{{$guid}}"): the same engine resolver, run at
call time over a string the script opts in - dynamic variables included, one
value per occurrence. pm.variables.get("$guid") (getter fall-through to the
generators) is deliberately not wired. See
pm API compatibility.
Load runs generate per iteration, not once (issue #995). A run's request
half is composed once (POST /compose) and then handed to the engine, which
repeats it - so a value generated at composition would be the same {{$guid}}
on every request of every virtual user, which is the opposite of what a
unique-id token is written for. A composition made for a run therefore says so
(deferDynamicVariables), and the family is left written as it stands, exactly
as {{data.column}} and the two identity names are: the run's executor
generates a fresh value per occurrence, immediately before each send.
The hot path is what shaped the fix rather than what blocked it. A generator has
to run per occurrence per iteration, where {{$vu}} and {{$iteration}}
(issue #994) substitute two integers the executor is already holding - so the
cost is not made free, it is made conditional: a request spelling no generator
has nothing left for the compose-time scan to find, its template is empty, and
the executor tests that before doing anything. A run that does not use the
family pays what it always did. What a run that uses it pays is one table call
per token per iteration, on the same walk that binds a data row.
Two things this does not change. A Send composes once and sends once, so it
still generates at composition - POST /compose defers only when the caller
asks. And a generator inside an auth credential is still generated once, at
composition: apply_auth encodes a credential when the request is built - basic
auth collapses into one base64 value - so a token left for the bind would go out
as base64 of its own text. A {{$vu}} in a credential is no longer the same
case: a credential carrying one defers the build and binds before apply_auth
runs (issue #1055), where a generator has nothing to wait for - its value is
knowable at composition, so deferring it would buy a different id per iteration
rather than a correct one.
The engine copy of the table¶
engine/src/http/request_composer.cpp carries the C++ generator table that
actually executes; the renderer's lib/dynamic-variables.ts drives
autocomplete and preview. The two must list the same names: the
conformance fixture pins the name set, and both suites compare their table
against it, so a name added to one side fails the other side's suite. (MCP's
dynamic-variables.ts copy is deleted - MCP composes via the engine.)
$vu and $iteration are deliberately in neither table - they generate
nothing, and a renderer entry that produced a value would make the preview show
a number the engine will not send. They are reserved names on both sides
instead, and the fixture pins that too: a case asserts each stays written as it
stands, and another that a variable of the same name does not answer for it.
Auth inheritance¶
When a request's auth mode is "inherit", Vayu walks the collection ancestor chain
leaf-first (most specific wins) and uses the first collection that defines auth.
The walk that decides what is sent lives in the engine
(request_composer.cpp::resolve_inherited_auth, reached via POST /compose);
the renderer keeps resolveAuthSource
(modules/request-builder/utils/auth-resolution.ts) for the UI that explains
inheritance (InheritanceChain, AuthInheritBanner, the load dialog's OAuth
guard) - the two must agree on this walk:
Users auth → checked first (leaf, most specific)
API auth → checked second
Root auth → checked last (root, least specific)
Two collection modes mean "no credentials here" and the walk treats them differently:
Collection auth.mode |
Meaning | Effect on the walk |
|---|---|---|
none |
nothing configured at this level | stepped over - keep climbing |
noauth |
configured to send nothing | stops the walk - the request sends no auth |
noauth exists because Postman's folder-level No Auth terminates inheritance,
and collapsing it into none meant an imported No Auth folder's requests silently
regained the parent collection's credentials. A collection below a noauth one
may still define its own auth - termination is about what is inherited, not a lock
on the subtree. Engine gtest (request_composer_test.cpp) covers the
terminator; an unresolved inherit that somehow reaches POST /execute
directly is treated as no auth and logged as a warning.
If no ancestor defines auth the request executes without auth.
Auth variable placeholders (e.g. {{bearer_token}}) are resolved through the
same variable map inside the winning block at compose time - deliberately
before any OAuth 2.0 token cache key is derived from the config, so two
environments whose configs differ only through {{vars}} never share a token.
Script composition¶
Pre-request and post-request scripts run as one script assembled from the collection chain plus the request's own, in order root → leaf → request:
Root.preRequestScript
API.preRequestScript
Users.preRequestScript
request.preRequestScript ← runs last
This lets a parent collection set up shared variables or auth tokens that child requests and their own scripts can rely on.
The app builds the ordered list of script parts (empty or whitespace-only
scripts are dropped); the engine joins the surviving parts with a blank
line and runs the result. See docs/engine/architecture.md → Request
composition boundary for the wire shape.
Reading a variable from a script¶
A script does not see {{name}} - those are resolved at compose time,
strictly before any script runs, and that includes the dynamic variables
above: pm.variables.get("$guid") is not a thing Vayu supports. Composition
still runs first and still owns resolution (#226, decision D1 stands) - but
since #1008, a name composition could not answer is resolved a second time
after the pre-request script and before the send, against the scopes as the
script left them. That works because of #1009: an unknown name keeps its
braces at compose time instead of becoming "", so it survives to be resolved
later. So the canonical pattern now works: pm.environment.set("token", …) in
a pre-request script does reach Bearer {{token}} in the same send, as
long as nothing answered {{token}} at compose time - a value composition
already substituted is finished text, and this second pass does not touch it.
A script that must change a value composition already resolved still edits
pm.request directly (the write-back is applied to the outgoing request).
A script reads a scope by name (pm.environment.get,
pm.collectionVariables.get, pm.globals.get) or reads across all three with
pm.variables.get, which walks environment → collection → global and stops
at the first scope that has the name enabled - this page's priority order, read
from the top down. While a row is bound, pm.variables.get (and .has and
.toObject) checks that row's bare column names first, above all three
scopes - the same D18 tier the ladder above adds, reached from a script. See
scripting.md and
pm API compatibility for the details.
The collection scope a script reads is the whole chain, the same one
{{name}} merges (issue #234; load_script_variable_scopes in
execution.cpp builds it from collection_chain). A variable defined on an
ancestor collection therefore reads the same in pm.collectionVariables.get,
pm.variables.get and pm.variables.replaceIn("{{name}}") as it substitutes
in the URL.
Reads walk the chain; writes stay on the leaf. set, unset and clear
only ever touch the request's own collection - the one
persist_script_variables writes back. So the rule for an inherited name is
CSS-like:
pm.collectionVariables.set("token", x)on a descendant shadows the ancestor'stoken; the ancestor's stored value is untouched.pm.collectionVariables.unset("token")removes the descendant's copy, andget("token")then finds the ancestor's value again.pm.collectionVariables.clear()empties the request's own collection only.
Inheritance can be shadowed from below, never deleted from below. That
asymmetry is the point rather than an oversight: #226 (decision D2) originally
kept the whole chain out of scripts precisely because a single merged,
writable map would have let one set() copy every ancestor's variables down
into the leaf collection on the next persist. Read-only ancestors make that
impossible while still answering the read. A disabled row is looked past
wherever it sits, so unticking an inherited name in a descendant falls through
to the ancestor's value rather than hiding it. See
pm API compatibility and
scripting.md.
Autocomplete inside the accessors¶
The script editors complete variable names inside the string argument of a
pm.* accessor - pm.environment.get("…"), and the set / has / unset
spellings beside it (useScriptVariableCompletionProvider). The {{name}}
list is deliberately not registered for javascript: braces are not the
syntax in a script, so offering them there would teach the wrong thing. The
names are the same names; only the place you type them differs.
These rules make the offered set match what the call can actually read:
- The accessor picks the scope.
pm.environment.getlists environment variables only, because that is the one scope it reads - a collection variable offered there would be a name that returnsundefined. Only the mergedpm.variables.getlists all three, and it alone also lists the declared columns (below), because it alone reads both. - And the scope answers for itself. A single-scope list is the names that
scope defines, through enabled rows -
getScopeVariables(above) - not the names whose ladder-winner happens to sit there (issue #1302). The two are different lists wherever a name is defined twice: a collectionshop_domainshadowed by the active environment is still a namepm.collectionVariables.getreads and answers, so withholding it would hide the very case the list is worth opening for.detailis that scope's own answer for the same reason - its value,(empty)for an enabled empty row, orsecretfor a masked one - rather than the winner's value, which the call will not return. Where that answer is an empty row while another scope holds a value,detailcarriesdescribeScopedRead's sentence (Empty at collection scope - this read returns "") and the documentation names the scope that holds it - the same words the chip above the editor uses, from the same function. - Collection variables come from the active tab. Collection scope is
explicit-only (see Collection scope is explicit only above) and a Monaco
completion provider is registered once per language, not per editor, so it
has no request builder context to take a
collectionIdfrom. Both providers get one fromuseActiveCollectionIdinstead - the active tab's request's collection, or a collection tab itself - so the list is globals + the collection chain + the active environment, the same set every other surface offers. - The script list carries the same chain. The engine walks the collection
chain for scripts too (#234), so an ancestor's variable is offered inside
pm.collectionVariables.get()and the mergedpm.variables.get()- it is a name the call can read. This list narrowed to the immediate collection while the engine did; the rule underneath is unchanged, which is that the list offers exactly what the call reads. pm.iterationDatacompletes columns, not variables. The row it reads is bound from the collection's data file, so the names offered insidepm.iterationData.get("…")and.has("…")are the declared columns of the contract in scope (issue #600) - the same list the{{data.*}}tokens are painted against, so an editor and the builder cannot disagree. Optional chaining counts as the dot it is (pm.iterationData?.get("), because the surface isundefinedoutside a data-driven run or a send-with-row, and its own documentation tells scripts to guard before calling. Nothing is offered when the chain declares no contract.pm.variablescompletes both, because it reads both. A bound row answers bare column names through the merged accessor, above every scope (issue #1007), so the declared columns are blended into its list beside the variables (issue #1063) - as bare names insideget("…")/has("…"), and as{{column}}tokens insidereplaceIn, which resolves them from the same row. They carry the field icon and name their declaring collection, so a column is distinguishable from a variable at a glance, and a name that is both is offered twice on purpose: the row wins while one is bound and the scope answers when none is. The single-scope accessors never see the row, so no column is offered there. The prefixed{{data.column}}spelling is offered toreplaceInand to nothing else (issue #1077):replaceInresolves both spellings from the same row, so withholding one of two that work would be the same gap, whilepm.variables.get("data.email")reads no column at all - the namespace is disjoint from the scopes there - and offering it in a name argument would teach a call that returnsundefined. The bare entry says(bare)only where the prefixed one sits beside it, since that word exists to tell two adjacent spellings apart.- Generators belong to
replaceInalone.pm.variables.replaceIntakes a template and interpolates it, so it gets brace-style completion including{{$guid}};pm.variables.get("$guid")is not a lookup that resolves, so no generator is offered there. - So does the identity (issue #1057).
{{$vu}}and{{$iteration}}are offered insidereplaceIn, from the sameITERATION_VARIABLEStable the URL and body editors read, because that is where they resolve - the script resolver answers them with the numbers the request beside the script was bound with. They are offered in no other script list, since neither is a name any lookup answers. Unlike a generator, the identity is never withheld for a same-named scope variable: the engine resolves these two names ahead of every scope, so a variable called$vushadows nothing.
The dotted pm.* completions (served by the engine, see
the scripting docs) yield inside a string literal so
the two lists never appear together.
Scope labels¶
ResolvedVariable.scope is a display hint used by the variable inspector:
| Value | Meaning |
|---|---|
"global" |
Came from globals |
"collection" |
Came from any collection layer |
"environment" |
Came from the active environment |
VariableOrigin.scope is wider - a VariableOriginScope (VariableScope |
"row") - because the origins list can also carry a bound row. It has no row in
this table: nobody wrote a row's cell and nothing can edit it, so it is never a
value ResolvedVariable.scope reports or a target updateVariable writes to.