Templating¶
- Status: ✅ Accepted
- Scope: the token grammar (
$(scheme:path)), where tokens may appear, how they combine with literal text, escaping, the (deliberate) absence of nesting, the node-tree resolution model and the observable typing it produces, and the structural$(config:path)include/splice token. What a token'spathmeans per scheme (the value it produces) is owned by resolvers.md; vault-selection parsing inside asecret:path is owned by vault-registry.md §5. This spec fixes syntax and the substitution mechanics; resolvers fix semantics.
1. Token grammar¶
A token is the only templating construct. Its form:
token = "$(" scheme ":" path ")"
scheme = letter { letter | digit } ; lowercase ASCII, e.g. env, secret, file, config
path = { any character except ")" and ":" } ; opaque to the engine, interpreted per scheme
letter = "a".."z"
schemeis lowercase ASCII, starting with a letter (env,secret,file,config, or a custom registered scheme, resolvers.md §2). An unknown scheme is a load-time error (ErrUnknownScheme).- A token ends at the first
). Apaththerefore cannot contain); this is a documented limitation (secret addresses and env names never need it, and a file path that does should be referenced another way). There is no balanced-paren matching. - Everything between
$(and the closing)after the first:is thepath, passed verbatim to the scheme (the engine does not trim, split, or interpret it — except for the whole-value/typing rules below andconfig:in §7).
2. Node-tree model (normative)¶
Templating operates on the parsed YAML node tree, never on raw file bytes. This is normative because it dictates observable behavior:
- Values only, never keys. The engine walks scalar values; mapping
keys are never scanned for tokens.
$(env:X): ykeeps the literal key$(env:X). - Scalar-content injection only. A resolved scalar token can only ever become
the content of a scalar. It can never introduce a mapping, a sequence,
an alias, a tag, or a new document — so a resolved value (e.g. a secret whose
text happens to be
{a: 1}or- x) is injected as a plain string and cannot alter the document's structure. (The sole structural construct is$(config:…), §7, which is an include resolved as a distinct step, not a scalar substitution.) - Resolved values are inert. After a token resolves, its value is not
re-scanned: a
$(...)sequence inside a resolved secret/env/file value is literal, never a second token. There is consequently no nesting — a token'spathis not itself templated (see §5). - Untagged results. A substituted scalar is left untagged (YAML
Tag=""), so normal type inference applies at bind time (§6).
3. Placement & combining with literal text¶
For the scalar schemes (env, secret, file, custom):
- A token MAY stand as an entire scalar value:
token: $(secret:artifactory/token). - A token MAY be embedded in literal text and combined with other tokens within
one scalar:
url: https://$(env:HOST):$(env:PORT)/api. - Multiple tokens in one scalar are each resolved and spliced into place; the surrounding literal text is preserved exactly.
$(config:…) is the exception: it MUST be a whole value and MUST NOT be
embedded in text (§7).
4. Escaping¶
The two-character opener $( is the only trigger. To emit a literal $( in
a value, write $$( — the engine replaces $$( with the literal $( and does
not treat it as a token:
- A lone
$not followed by(is always literal and needs no escaping (cost: $5is just$5). - The escape is scoped to the opener: only the sequence
$$(is special.$$elsewhere is left untouched.
5. No nesting (deliberate)¶
flexconf does not support nested or recursive tokens:
- A token's
pathis literal — it is never scanned for inner tokens, so$(secret:$(env:NS)/key)is not "resolve env first, then secret." Thesecret:resolver receives the path$(env:NS)/keyverbatim (and will reject it as a malformed address). - Resolved values are inert (§2), so composition cannot happen after resolution either.
The rationale is the same as the node-tree choice: a statically knowable,
non-recursive substitution is safe (no injection, no resolution-order surprises)
and easy to reason about. Composition that genuinely needs a computed path is an
application concern, done in Go after Load.
6. Typing of resolved scalars¶
Because a substituted scalar is left untagged (§2), its Go type is inferred from the resolved content and how it sits in the scalar, exactly as a literal would be — token resolution does not wrap values in a string type:
- Whole-value token, no surrounding text → typed by content. If
PORT=8080,port: $(env:PORT)binds to anintfield as8080;flag: $(env:FLAG)withFLAG=truebinds to abool. (This mirrors config-loading.md §5 step 4:"8080"→int,"true"→bool,"30s"→time.Duration.) - Token combined with literal text, or multiple tokens in one scalar → the
result is always a string (
https://host:8080/apiis not a number).
A value that does not fit its target field fails loud at bind time, naming the
field and key path — with the offending value redacted when it originated
from a secret: token (config-loading.md §5, §6).
7. The $(config:path) include token¶
$(config:path) splices the referenced YAML file's parsed tree into the
document in place of the token. It composes different files within one logical
config, a distinct axis from the Loader's directory layering (which merges the
same file across dirs, config-loading.md §3).
agents: $(config:agents.yaml) # value becomes the whole tree parsed from agents.yaml
toolsets:
- $(config:base.yaml) # may also stand as a sequence item
Rules (normative):
- Whole value only. The token MUST be the entire scalar (a map value or a
sequence item). It MUST NOT be embedded in literal text or combined with other
tokens; a
config:token mixed with text is a load-time error. - Splice, not merge. The referenced tree replaces the node verbatim — map, sequence, or scalar. There is no deep-merge with sibling keys (a deliberate non-feature; use directory layering for merging).
- Literal path.
pathis a literal file path, never itself templated (consistent with §5), so the full include graph is statically knowable before any scalar token resolves. - Extension.
pathMUST end in.yamlor.yml; any other extension is a load-time error. - Relative to the containing file. A relative
pathresolves against the directory of the file that contains the token, not the process working directory (same base rule as$(file:…), resolvers.md §4). - Containment. The resolved path MUST satisfy
fs.ValidPathsemantics and MUST NOT escape the config directory (a..traversal out of the tree is the errorpath escapes the config directory). - Cycle detection. An include cycle is a load-time error reporting the full
chain (e.g.
a.yaml → b.yaml → a.yaml). - Depth cap. Includes nest up to
maxIncludeDepth = 16; exceeding it is a load-time error.
7.1 When includes expand (lifecycle placement)¶
Include expansion is a structural, pre-resolution step:
- During read (config-loading.md §5 step 1), each layer's
file is parsed and its
config:includes are expanded recursively into that layer's full node tree (depth cap + cycle detection apply across the whole chain). Included subtrees are then indistinguishable from inline content. - Layer merge (step 2) then operates on the fully-expanded per-layer trees.
- Scalar token resolution (
env/secret/file/custom, step 3) runs last, on the merged tree — so tokens inside an included file resolve normally, and a value introduced by a higher-precedence layer may still contain scalar tokens.
Thus config: is resolved before the scalar schemes and before merge; the
scalar schemes are resolved after merge. config: never sees a scalar token's
output and vice versa (no interaction, no ordering ambiguity).
7.2 Includes on a static Loader¶
Include expansion is part of the default processing a Loader performs. A Loader
whose resolver set is empty (WithResolvers(), resolvers.md
§2.1) is fully static: it performs neither scalar resolution nor $(config:)
expansion, so a $(config:…) in its input is a load-time error rather than a
splice — there is no handler for it. This is what keeps the vault registry
literal end to end: loaded through the shared pipeline as a static Loader
(config-loading.md §4.1), it admits no $(...) construct,
including includes (vault-registry.md §2.3).
8. Per-scheme grammar summary¶
Syntax here; semantics in resolvers.md.
| Scheme | Path grammar | Placement | Notes |
|---|---|---|---|
env |
NAME |
value, embeddable | Missing var is a hard error — there is no :-default syntax (§10). |
secret |
[vault:]namespace/key |
value, embeddable | Vault-selection parsing in vault-registry.md §5; value is tainted for redaction. |
file |
/abs/or/relative/path |
value, embeddable | Verbatim contents; relative to the containing file. |
config |
relative-or-child/path.yaml |
whole value only | Structural splice; §7. |
9. Errors¶
Grammar/expansion sentinels (full taxonomy in errors.md; scheme-semantic errors live in resolvers.md §7):
var (
ErrBadToken = errors.New("flexconf: malformed template token") // e.g. no ":", unterminated "$("
ErrIncludeEmbedded = errors.New("flexconf: $(config:…) must be a whole value") // §7
ErrIncludeExtension = errors.New("flexconf: include path must end in .yaml/.yml") // §7
ErrIncludeEscape = errors.New("flexconf: include path escapes the config directory") // §7
ErrIncludeCycle = errors.New("flexconf: include cycle detected") // §7 (message names the chain)
ErrIncludeTooDeep = errors.New("flexconf: include depth exceeds maxIncludeDepth") // §7
)
Errors name the offending file and key path; a scalar-token error whose value
came from secret: redacts the value (config-loading.md §6).
10. Resolved decisions¶
- Grammar —
$(scheme:path), lowercase scheme, path opaque and ended by the first)(no)in paths, no balanced-paren matching) (§1). - Node-tree model, normative — values only, scalar-content-only injection, resolved values inert, results untagged (§2). Keys are never templated.
- Combining — scalar schemes embeddable and multi-per-scalar; a whole-value token is typed by content, a mixed scalar is a string (§3, §6).
- Escaping —
$$(→ literal$(; a lone$is literal (§4). - No nesting — token paths are literal, never recursively templated (§5).
$(config:path)includes — YES. Whole-value splice (no deep-merge), literal path,.yaml/.yml, relative to the containing file,fs.ValidPathcontainment, cycle detection,maxIncludeDepth = 16(§7). Expanded at read, before merge and before scalar resolution (§7.1).$(env:NAME:-default)— NO. A missing environment variable is always a hard error (resolvers.md §3); there is no defaulting syntax in the grammar for any scheme. Config-file defaults (via the schema/binding layer) are the way to supply a fallback.
11. Settled — no open grammar questions¶
- Custom schemes reuse the grammar as-is. A custom scheme uses
$(scheme:path)unchanged; any scheme needing richer path syntax handles that inside its ownpath, never by extending the engine grammar. Not an open item. - Secret-origin marking, no dump format. The engine leaves
secret:outputs flagged secret-origin so they never appear in error messages (errors.md §1). v1 has no config-dump feature, so there is no«redacted»dump format and noredaction.md— resolved, not deferred (errors.md §4).