Value resolution
Resolution is the rule that turns "here is a library set and some tags" into "here is one value per variable".
The rule
Given a set of tags:
- Start from the default. The value carrying no tags is the answer unless something beats it.
- Prefer an exact match. A value whose tags are exactly the set you asked for — same tags, same count — wins outright.
- Otherwise take the best partial match. Among values whose tags are all present in what you asked for, the one carrying the most of them wins. Where several tie, the one with the fewest tags overall is preferred.
- A value carrying a tag you did not ask for cannot win. Every one of a value's tags must be in your set for it to be considered at all.
Point 4 is the one that catches people out, and it is the point of the design. A value tagged
Environment/Production, Region/EU is not a candidate when you ask only for Environment/Production — it is more
specific than where you are, not less.
Worked example
One variable, four values:
| Value | Tags |
|---|---|
localhost | (none) |
prod.internal | Environment/Production |
eu-prod.internal | Environment/Production, Region/EU |
eu-stage.internal | Environment/Staging, Region/EU |
What comes back:
| Tags asked for | Result | Why |
|---|---|---|
| (none) | localhost | Only the untagged value is a candidate. |
Environment/Production | prod.internal | Exact match. The EU value carries a tag not asked for. |
Environment/Production, Region/EU | eu-prod.internal | Exact match on both. |
Environment/Production, Region/US | prod.internal | Best partial: one tag of two. |
Environment/Staging | localhost | The staging value also needs Region/EU, so it is not a candidate. Falls back to the default. |
Region/EU | localhost | Both EU values also carry an Environment tag. |
Environment/Staging, Region/EU | eu-stage.internal | Exact match. |
Nothing about this depends on the order of tag groups, or on any inheritance chain. There is only "how much of what you asked for does this value know about".
Several library sets at once
?set=1&set=2&set=3 fetches all three. Sets are walked in name order — not the order of the query string —
and where two sets both hold a variable of the same name, the set that sorts later wins.
If you rely on one set overriding another, name them so the ordering is obvious and stable. Renaming a set can change which one wins.
Parent library sets
A library set can optionally have a parent. Resolving a set always follows parentVariableSetId up to the root
first, so a set inherits everything its whole ancestor chain defines, and can override any of it.
Ancestors are loaded automatically — you never list them in ?set=. Asking for a child pulls in every set above
it the same way asking for several sets at once pulls in all of them.
Hierarchy only ever breaks a tie. Tag-matching precedence (points 1-4 above) is decided first, for every set in the combined batch — parent and child alike — and only when two candidates are equally specific does parentage decide it, with the nearer set (the child) winning. A parent's exact tag match still beats a child's plain default; parentage never overrides specificity, it only replaces "which same-specificity set is named later" with "which one is nearer".
| Set | Parent | Timeout |
|---|---|---|
Defaults | (none) | 30 (untagged) |
Team A | Defaults | 60 (untagged) |
| Requested set | Result | Why |
|---|---|---|
Defaults | 30 | No parent to inherit from. |
Team A | 60 | Same specificity (both untagged) — the child wins. |
A set may not be its own ancestor, directly or several generations up — saving a parentVariableSetId that would
create one is rejected with 400 Bad Request. A set that others have as their parent cannot be deleted
(409 Conflict) until they are re-pointed or removed.
Asking for a resolution
GET /variablevalues?set=1&tag=Environment/Production&tag=Region/EU
set— repeated once per library set. At least one is required; none produces400 Bad Request.tag— repeated once per tag, inGroup/Tagform. Optional; none means every variable resolves to its default.
A set naming a library set that does not exist, or belongs to another organization, produces 404 Not Found —
the whole request, not just that set.
Requires VariableRead. Costs 10 credits per call.
{
"total": 3,
"items": {
"DatabaseConnection": "Server=eu-prod.internal;Database=orders",
"LogLevel": "Warning",
"FeatureFlags": "checkout-v2"
}
}
The response is a flat name-to-string map. That is deliberate: it is meant to be poured straight into environment variables or an app settings file without anything having to walk a structure first.
Previewing a resolution
When you want to know not just what you would get but which value it was, ask
GET /variablevalues/preview with the same parameters. It runs identical resolution and returns the whole winning
value:
{
"total": 1,
"items": {
"DatabaseConnection": {
"id": 3,
"value": "Server=eu-prod.internal;Database=orders",
"sensitive": false,
"hint": null,
"hash": null,
"tags": [
{ "id": 1, "name": "Production", "tagGroupId": 1 },
{ "id": 3, "name": "EU", "tagGroupId": 2 }
]
}
}
}
A preview costs 10 credits per call, the same as /variablevalues.
This is what the management UI's preview pane uses, and it is the quickest way to diagnose a value you did not expect — the tags on the winner tell you immediately whether the problem is a mistagged value or a tag you forgot to ask for.
Variable substitution
A value can reference another variable by name, written @[VariableName]. Once tag matching and parent
inheritance above have settled on one winning value per variable, any @[name] token in that text is replaced by
the other winning value it names.
{
"Host": "api.example.com",
"Endpoint": "https://@[Host]/v1"
}
Endpoint resolves to https://api.example.com/v1.
A marker matches a variable's name without regard to case — @[host] and @[HOST] both find Host — the
same way tags already do. This is also why a set may not hold two variables whose names differ only by
case; see Name uniqueness.
References can chain: if A references B and B references C, C is resolved first, then B, then A —
each exactly once, however many other values reference it.
A few rules govern the edges of this:
- A name substitution can't find is left as literal text.
@[Missing]stays exactly that in the result. - A secret is fixed. Its own text is never scanned for tokens — a sensitive value that happens to contain
@[...]is returned exactly as stored — but it can still be referenced by another value. - Referencing a secret is infectious. If a plain value embeds one, directly or through a chain of references,
the composed result is itself treated as sensitive: a fresh hint and hash are generated for the composed text
(not the secret's own hint/hash), and it is withheld from anyone without
VariableReadSecretsexactly as a stored secret would be. See Sensitive values. - A cycle is rejected before it can happen. Saving a value whose references would loop back on themselves —
through its own library set or any ancestor — fails with
400 Bad Request. This is checked at save time, not request time, because what a value is combined with at request time (?set=1&set=2&set=3, sets that aren't related by parentage) can't be known in advance; substitution defends against that residual case at runtime by leaving an unresolvable reference as literal text rather than looping.
Substitution is on by default for both /variablevalues and /variablevalues/preview. Add
&substitute=false to /variablevalues/preview to see the winning values exactly as resolved, with no
@[name] tokens replaced — useful for telling a mistagged value apart from an unresolved reference.
Secrets during resolution
Both endpoints resolve and substitute first, using the real value of anything sensitive, and withhold only on the way out — substitution needs a secret's actual text to compose correctly, so blanking it any earlier would leave the hint embedded in whatever referenced it instead of the value that was meant.
/variablevalues returns a hint in place of anything that ends up sensitive — whether it always was, or only
became sensitive by embedding one — so a consumer without VariableReadSecrets gets a map whose sensitive entries
hold hints rather than secrets.
/variablevalues/preview maps on the way out, so a withheld secret comes back as an empty value with
its hint beside it — a value that is plainly absent rather than one pretending to be real.
Both require VariableRead; seeing the actual secret additionally requires VariableReadSecrets. See
Sensitive values.