Skip to main content

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:

  1. Start from the default. The value carrying no tags is the answer unless something beats it.
  2. Prefer an exact match. A value whose tags are exactly the set you asked for — same tags, same count — wins outright.
  3. 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.
  4. 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:

ValueTags
localhost(none)
prod.internalEnvironment/Production
eu-prod.internalEnvironment/Production, Region/EU
eu-stage.internalEnvironment/Staging, Region/EU

What comes back:

Tags asked forResultWhy
(none)localhostOnly the untagged value is a candidate.
Environment/Productionprod.internalExact match. The EU value carries a tag not asked for.
Environment/Production, Region/EUeu-prod.internalExact match on both.
Environment/Production, Region/USprod.internalBest partial: one tag of two.
Environment/StaginglocalhostThe staging value also needs Region/EU, so it is not a candidate. Falls back to the default.
Region/EUlocalhostBoth EU values also carry an Environment tag.
Environment/Staging, Region/EUeu-stage.internalExact 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".

SetParentTimeout
Defaults(none)30 (untagged)
Team ADefaults60 (untagged)
Requested setResultWhy
Defaults30No parent to inherit from.
Team A60Same 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 produces 400 Bad Request.
  • tag — repeated once per tag, in Group/Tag form. 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.

200 OK
{
"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:

200 OK
{
"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.

Two variables
{
"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 VariableReadSecrets exactly 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.