A YAML tag-based resolution engine. Custom tags (!Env, !Reference, !Oculus, !Task, !Fixture, !Merge, !Patch, !Environment, !UserConfig) become marker hashes at parse time and resolve through a registry-dispatched, phase-gated, memoizing-cache pipeline. The YAML is a function from environment + fixtures + remote data to a fully-realized config tree.
Lives in wanderland-core. Available to every engine, archetype, and scenario harness in the project.
The set of resolvers registered when wanderland-core boots. Each entry: what the tag does, the argument shapes it accepts, the resolution phase, the cache lifetime, and a worked example.
Reads a value the current route supplied under its args: bag. A pipeline declares the holes a route fills inline with !Route <key>; boot_mount_archetype sets Route.data to the route's args before resolving that route's pipeline/archetype, so each !Route marker reads route.args.<key>. Keeping the inputs under args: keeps them clear of the framework route keys (pipeline, method, name).
:late. Not :early: Config.load runs an early pass over the whole config with no route context, and an early !Route would fire there against an empty Route.data and bake to nil before any route mounts. :late survives that pass and fires at boot_mount_archetype, where Route.data is the route being mounted.-1 — route args are static once mounted.String — dotted path into the route's args (system, template_path).Hash — path: (required).HashUtils.dig_resolving, so a !Route can sit behind a dig (e.g. start_run reading tags.system) or hold a nested thunk value.nil. A pre_flight check turns a required-but-missing hole into a boot halt rather than a silent nil deep in a deploy.pipelines:
core-infra:
stages:
deploy:
provisioner:
args: { template_path: !Route template_path }
metadata:
tags: { system: !Route system }
routes:
/core-infra-route53-bddev:
pipeline: core-infra
args:
system: route53
template_path: stacks/r53.yml
Substitutes {name} placeholders in a format string with entries from a values: map. The dispatch-phase string composer — it builds the image refs, tags, and any value assembled from request-context outputs.
:dispatch — so nested !Context markers in values: resolve from the request context before substitution.0.Hash withformat: (required) — the template string; {name} placeholders interpolate.values: (optional) — map from placeholder name to substitution value. Non-string values stringify via to_s.{name} with no entry in values: (or one resolving to nil) halts via the _resolution_error envelope — typos surface at dispatch, not as a nil downstream.image_tag: !Sub
format: "{version}-{sha}"
values:
version: !Context for_boundary.chain.version
sha: !Context for_boundary.chain.sc_website.short_sha
Maps each element of a source list through an optional per-item format string, producing a new list. The list-shaped sibling of !Sub.
:dispatch — so nested markers in over: / values: (!Reference, !Context, !Env) resolve before the mapping runs.0.Hash withover: (required) — the source list. Resolves to an Array, or a comma-string (mirroring a CFN CommaDelimitedList), which is split on commas and stripped.format: (optional) — per-item template. {item} is the current element; any other {name} pulls from values:, exactly like !Sub. Omit format: for identity passthrough of the resolved list.values: (optional) — constants for the non-{item} placeholders.item: (optional) — rename the per-element placeholder (default item).!Sub.DeveloperUserArns: !MappedList
over: !Reference metadata.developers # [gfawcett-deploy, ...]
format: "arn:aws:iam::{account}:user/{item}"
values:
account: "789905347053"
# => ["arn:aws:iam::789905347053:user/gfawcett-deploy", ...]
# No format — pass the resolved list through unchanged.
names: !MappedList
over: !Reference metadata.developers
Internal — users never write !Dig. The engine mints a Dig marker (via HashUtils.dig_resolving) when a path walk crosses a marker that can't resolve at the current phase — e.g. !UserConfig a.b.c where a is a !Context that only resolves at dispatch. The marker carries the partially-resolved base and the remaining path, and completes the dig once the base goes concrete at its own phase.
:dispatch. A base that survived the mint is, by construction, a later-phase marker; deferring to dispatch lets the phase gate hold the Dig as a marker through earlier passes (resolving its arg subtree along the way) and fire it when the base can finally resolve. The common case — a dig across an :early/:late marker like !Merge — never mints a Dig at all: dig_resolving resolves it inline.-1.Listed for completeness: it is the mechanism by which a deep path like !UserConfig tags.system still resolves when tags is itself a dispatch-phase value.
Reads a process environment variable at load time.
:early — resolves before any boundary runs.-1 (forever within the load).String — variable name. Halts with Env::MissingError if not set.Hash — name: (required) and default: (optional). When default: is present, the variable is optional.Resolvers::Env.consumed_keys. The runtime subtracts that set from the env-snapshot allowlist so engine inputs don't double-count as projected ambient state.# Required — halts if WANDERLAND_HTTP_MODE isn't set.
mode: !Env WANDERLAND_HTTP_MODE
# Optional with default.
mode: !Env { name: WANDERLAND_HTTP_MODE, default: live }
# As input to another resolver.
wanderland_environment: !Env { name: WANDERLAND_ENVIRONMENT, default: local }
Returns the first non-nil layer from an array of candidates. Mirrors SQL's COALESCE.
:late — resolves after sub-markers in each layer resolve, so the comparison sees real values.-1.Array of layers. Any non-Array is wrapped.nil check — false / 0 / "" round-trip as present values. Avoids the indifferent-hash || falsy bug.The motivating case: !UserConfig returns nil when its key isn't declared, but !Merge halts on a non-Hash layer. Wrap the optional !UserConfig lookup with a hash fallback so the merge always sees a Hash:
args: !Merge
- template_path: !UserConfig template
parameters: !UserConfig parameters
- !Coalesce
- !UserConfig provision_args # may be nil
- {} # empty hash fallback
Or for any "first one that's set" pattern across resolver kinds:
region: !Coalesce
- !Env { name: AWS_REGION } # honor explicit env first
- !UserConfig region # fall back to config
- us-east-1 # last-resort default
Reads from the chain's request context at dispatch time. The context is the array of crossings prior slots in the chain have produced.
:dispatch — only fires at the dispatcher's pre-boundary pass. Returns the marker unchanged at boot/mount time and during the :all phase.0.String — dotted path with three shortcuts (last.<field>, events.<n>.<field>, for_boundary.<name>.<field>). A bare key (!Context stack_id) is shorthand for last.stack_id.Hash — path: (required) and default: (optional). Default returned when the path resolves to nil.Index forms:
| Path | Reads |
|---|---|
last.<field> |
Most-recent crossing's result.<field>. |
events.<n>.<field> |
Specific event by index — negative indices supported. |
for_boundary.<name>.<field> |
Most-recent crossing whose boundary == <name>, then result.<field>. |
<field> |
Bare-key shorthand for last.<field>. |
# Single-step chain — feed the next boundary the previous result.
chain:
- boundary: cfn_provisioner
args: { template_path: cfn/vpc.yml }
- boundary: publish_cfn_outputs
args:
stack_name: !Context last.stack_name
# Multi-step chain — name the boundary the value came from.
chain:
- boundary: aws_assume_role
args: { role_arn: ... }
- boundary: cfn_provisioner
args:
template_path: cfn/web.yml
parameters:
DeployerSession: !Context for_boundary.aws_assume_role.session_name
Lives in wanderland-aws-pack. Listed here because it's the canonical example of a :dispatch-phase resolver that reaches through the runtime adapter registry for a live cloud-side read.
:dispatch.0.String — SSM parameter path.Hash — path:, default:, adapter: (defaults to ssm), with_decryption: (defaults to false), mode: (defaults to resolve), severity: (defaults to error).resolve (default) — returns the parameter value at dispatch.verify — returns true if the parameter exists at dispatch; raises ResolutionError if missing. The boundary sees a boolean, not the parameter value.!SsmLookup marker in the loaded config registers itself with the runtime's pre_flight registry. At boot, one batched ssm:GetParameters call covers every unique path. Missing paths halt boot with Types::PRE_FLIGHT_FAILED; paths declared with severity: warning emit Types::PRE_FLIGHT_WARNING and boot continues.:ssm adapter (or the named adapter:) mounted on the runtime. Halts with a clear "no :ssm adapter mounted" message if not.Aws::SSM::Errors::ParameterNotFound returns the default (or nil) in resolve mode; raises ResolutionError in verify mode. Every other error propagates as a ResolutionError and surfaces in the auto-diagnostics dump.chain:
- boundary: cfn_provisioner
args:
template_path: cfn/web.yml
parameters:
VpcId: !SsmLookup /advaita-core-infra/nonprod/vpc/VpcId
SubnetA: !SsmLookup
path: /advaita-core-infra/nonprod/vpc/SubnetA
default: subnet-fallback
WebSg: !SsmLookup
path: /advaita-core-infra/nonprod/vpc/WebSg
mode: verify
TaskRole: !SsmLookup
path: /advaita/nonprod/ecs/TaskRole
severity: warning
The pattern generalizes — pack-authored resolvers can declare phase :dispatch and reach engine.runtime.adapters.lookup(name) to do any cloud read at the dispatcher's pre-boundary pass. Resolvers that want a boot-time check additionally implement the self.pre_flight(arg, registry, runtime) class hook (see Pre-flight in Architecture).
Digs a dotted path under the currently-active environment block of the loading document. Lets one config file carry per-environment values without duplicating the whole tree per env.
:early.0 (never cached — resolved values may be shared Hash references that consumers mutate).String — dotted path relative to the active env block (cluster, network.subnets.0).Hash — path: (required).environments.<active>.<path> → environments.default.<path> → halt with MissingPathError naming both lookups.wanderland_environment: key. Typically driven by !Env { name: WANDERLAND_ENVIRONMENT, default: ... }.subnets: !Env DEV_SUBNETS inside an env block works.!Reference breadcrumb trail; a !Reference inside an env block cannot loop back to itself via !Environment.wanderland_environment: !Env { name: WANDERLAND_ENVIRONMENT, default: local }
environments:
default:
region: us-east-1
local:
cluster: vivarta-ci
subnets: [subnet-localdev-aaa, subnet-localdev-bbb]
aws-dev:
cluster: jenkins-dev
subnets: !Env { name: DEV_SUBNETS, default: ["subnet-1"] }
aws:
AWS::ECS::Service:
my-svc:
cluster: !Environment cluster # active env wins
region: !Environment region # falls back to environments.default
subnets: !Environment subnets
Loads a YAML fixture file from a configured search path. Used by the scenario harness, by replay tests, and by any boundary that wants to substitute a recorded payload for a live call.
:early — resolves at boot, while loading config (not at request/dispatch time).-1 (forever).String — path with an optional whitespace-separated at subpath dig (mocks/oculus-tags response).Hash — path: (file name; searched under Fixture.fixture_dirs, with a mocks/ subfolder fallback and _ → - normalization) and optional at: dotted dig.<%, ERB runs against the raw text first, bound to Resolvers::Fixture.params.{ "_error" => true, "status" => 404, "message" => ... } rather than raising.nodes: !Fixture mocks/oculus-tags
response: !Fixture mocks/oculus-tags response # whitespace shorthand for `at: response`
first_tag: !Fixture { path: mocks/oculus-tags, at: response.tags.0 }
http_replay: !Fixture cassettes/http/abc123
Deep-merges an array of maps left-to-right. Right-most wins for scalars and arrays; hashes recurse.
:early — resolves at boot, while loading config. Nested markers in the layers (e.g. !Fixture, !UserConfig) resolve first via the recursive walk, so the merge sees real values.-1.Array of Hash values. Empty array halts; a non-Hash layer halts with the offending index.mocks:
http:
- !Merge
- !Fixture cassettes/http/abc123 # full envelope
- response:
status: 503 # twist one field
Fetches from the Oculus API.
:late.60 seconds.String — slug or slug/peek_path (the second form digs a section of the node).Hash — path: or slug:, plus arbitrary query params. ttl: and nocache: are recognized by the engine.Resolvers::Oculus.backend and Resolvers::Oculus.auth, set from site config at boot.{ "_error" => true, ... }.node: !Oculus wanderland-core # full node JSON
content: !Oculus wanderland-core/content # section peek
nodes: !Oculus { path: nodes, sort: slug, limit: 5 }
fresh: !Oculus { path: nodes, nocache: true } # skip cache
Path-addressed overrides on top of a base hash. Where !Merge deep-merges structure, !Patch reaches a specific slot by dot path and writes one value, leaving the rest of the base untouched.
:late.-1.Array form: [base_hash, overrides_hash].Hash form: base: and set:.reservations.0.instances.0.state.name).!Patch can introduce a new field). Missing array indices halt — extending an array is ambiguous, so it isn't guessed.mocks:
ec2:
- !Patch
- !Fixture cassettes/ec2/sha # base
- response.data.reservations.0.instances.0.state.name: running # one field
response.data.reservations.0.instances.0.state.code: 1
# Hash form — readable when the base is large.
ec2_alt:
- !Patch
base: !Fixture cassettes/ec2/sha
set:
response.data.reservations.0.instances.0.state.name: running
Within-file pointer. Digs a dotted path into the loading document and inlines the value at the call site.
:early.0 (never cached — cache hits would share Hash references across consumers).String — dotted path.Hash — path: (required).items.0.slug).MissingPathError — typos at boot, not nils at request time.!Reference a where a: !Reference b walks transitively. The engine carries a breadcrumb trail; cycles raise CircularReferenceError with the full trail.grids:
grocery_walk:
max_ticks: 10
cells: { ... }
routes:
/run: { grid: !Reference grids.grocery_walk }
/run/step: { grid: !Reference grids.grocery_walk }
Fetches from the Task API.
:late.0 (ephemeral — case state changes constantly).String — path segment after /api/tasks/ (e.g. current, task-abc-123).Hash — path: or id:, plus arbitrary query params.Resolvers::Task.backend and Resolvers::Task.auth, set from site config at boot.current: !Task current
case: !Task task-abc-123
cases: !Task { path: list, is_case: true }
Reads the user's config YAML — the data the boot archetype consumes when wiring a site.
:early.-1.String — dotted path (boundary_path, storage.mounts, pipeline.artifacts.build.0.source).Hash — path: (or the whole data hash when path is empty or nil).Resolvers::UserConfig.data, set by the boot orchestrator before resolving the archetype.nil. Where !Reference halts on a missing path, !UserConfig yields nil so an archetype can probe for optional fields.mounts: !UserConfig storage.mounts
builders: !UserConfig pipeline.stages.image.builder.type
all: !UserConfig # entire data hash
Opt-in wrapper that flags a value for boot-time pattern-based pre-flight. At dispatch the marker resolves to the inner value verbatim — boundaries downstream see the string as if !Verify were not there. At boot, the marker's pre_flight class hook runs the wrapped value through the global Wanderland::Patterns registry; every matching pattern fires its registered handler.
:dispatch.0.String — the value to pre-flight (and return at dispatch).Wanderland::Patterns.match(value) and invokes each matching block with (value, runtime.pre_flight, runtime). Blocks decide what callbacks to register on the pre-flight registry; the standard halt/warn/ok machinery applies.!Verify are not pattern-scanned. CFN template bodies, scratch values, and other ambient strings are left alone unless the operator explicitly elects to verify them.deployer_role_arn: !Verify arn:aws:iam::789905347053:role/advaita-cfn-deployer-ecs-workloads
cfn_service_role_arn: !Verify arn:aws:iam::789905347053:role/advaita-cfn-service-ecs-workloads
Pack-registered patterns include wanderland-aws-pack's IAM role ARN pattern, which collects wrapped role ARNs and batches one iam:GetRole call per unique role at boot.
Load. YAML.safe_load runs with Psych domain types registered for every known tag. Each !Tag produces a marker hash:
{ "_tag" => "Oculus", "_arg" => "wanderland-core" }
After load the tree carries plain YAML values mixed with marker hashes. No fetch has happened.
Resolve. TemplateEngine#resolve_tree walks the tree. At each marker it looks up the resolver class in ResolverRegistry, checks the phase gate, consults the cache, calls fetch_with_context, walks the result (so a resolver can return a tree that contains more markers), and writes the result back into the cache.
The recursive walk-then-resolve pattern lets resolvers compose: !Merge of two !Fixture payloads, !Reference to a block that contains !Env, !Environment falling back to a block whose values are themselves markers.
A resolver declares its phase with the phase :early | :late | :dispatch directive in the class body. Default is :early.
| Phase | Resolved when | Members |
|---|---|---|
:early |
At boot, while loading site and archetype config | !Env, !Environment, !Reference, !UserConfig, !Merge, !Fixture |
:late |
At boot, second pass — consumes already-resolved early markers as input | !Coalesce, !Oculus, !Patch, !Task, !Route |
:dispatch |
At the dispatcher, just before a slot's args reach its boundary; the engine has the request context and runtime attached | !Context, !SsmLookup (in wanderland-aws-pack), !Verify, !Sub, !MappedList, !Dig |
The engine is constructed with a phase filter: :early, :late, :dispatch, or :all. Markers whose resolver doesn't belong to the active phase pass through unchanged, so a boot pass leaves dispatch-phase markers intact and the dispatcher's pre-boundary pass resolves them.
:all is the convenience for "everything except :dispatch" — dispatch-phase resolvers need a request context, which is never available at boot, so they're explicitly excluded from :all to keep boot-time resolution deterministic.
A parallel mechanism that uses the same _tag markers the resolver pipeline reads. After boot's resolver phases run, a pair of boot slots (pre_flight_collect, pre_flight_run) walk the loaded config tree and give every resolver a chance to register a boot-time check before any request runs.
The class hook is Resolver.pre_flight(arg, registry, runtime). Default is a noop — resolvers opt in by overriding. Hooks register callbacks on runtime.pre_flight (a Wanderland::PreFlightRegistry) via register_once(key). Many markers in a config collapse to one callback per key, so the actual cloud-side check fires once with batched inputs.
Callbacks return signals. Types::PRE_FLIGHT_FAILED (under STOP_PREFIX) halts boot with the union of error messages. Types::PRE_FLIGHT_WARNING (under PASS_PREFIX) is observational — collected and reported, boot continues.
Pattern-based participation runs alongside the resolver hook. Wanderland::Patterns.register(regex) { |value, registry, runtime| ... } lets packs declare "any string matching this regex is mine — pre-flight it." The !Verify resolver is the opt-in surface — its pre_flight hook runs the wrapped value through the Patterns registry. Strings not wrapped in !Verify are never pattern-scanned, so an IAM ARN buried in a CFN template body doesn't get checked against the caller's permissions unless the operator explicitly elects to verify it.
Worked example — !SsmLookup registers a callback that calls ssm:GetParameters once with every collected path; wanderland-aws-pack registers an IAM role ARN pattern whose callback batches iam:GetRole over every !Verify-wrapped role.
class Wanderland::Resolvers::Oculus < Wanderland::Resolver
tag "Oculus"
ttl 60
phase :late
def fetch(arg)
# arg is a String or Hash from YAML.
# Return any Ruby value — Hash, Array, scalar.
end
end
tag registers the class with ResolverRegistry and installs a Psych domain type that converts !Tag syntax into a marker hash. ttl sets the default cache duration in seconds. phase declares early/late.
Resolvers that need access to the loading tree or to the engine's breadcrumb state override fetch_with_context(arg, engine) instead of fetch. The engine, passed as the second argument, exposes:
engine.root — the top-level loaded tree.engine.resolve_subtree(value) — recursively resolve a captured value.engine.reference_breadcrumbs — shared cycle-detection stack (used by !Reference and !Environment).ResolverCache keys entries on "tag:canonical_arg". The canonical arg drops ttl and nocache keys and sorts hash arguments so two YAML spellings of the same call share a cache slot.
| TTL value | Meaning |
|---|---|
nil or 0 |
Ephemeral — not stored. Every load fetches anew. |
| Positive integer | Seconds. Entry expires at Time.now + ttl. |
-1 |
Forever — never expires within the cache's lifetime. |
nocache: true on the hash form of an argument bypasses both read and write.
Cache stats (hits, misses, size, keys) ride alongside the resolved tree for scenario verification via TemplateEngine#resolve_with_stats.
Resolvers raise subclasses of Wanderland::Resolver::ResolutionError when a config-level mistake makes a value unresolvable: missing reference path, circular reference, missing required env var, missing environment block. The engine catches the base class and substitutes a marker envelope at the failed node:
{ "_resolution_error" => true, "error" => "<message>" }
Consumer boundaries scan the resolved tree for _resolution_error: true and surface it as a halt response. Missing fixtures and HTTP failures use the looser _error envelope so the consumer can choose between halting and substituting a fallback.
Site YAML wires the API-backed resolvers (!Oculus, !Task) to their providers:
providers:
oculus:
backend: https://i.loss.dev
auth: { user: x, pass: y }
tasks:
backend: https://i.loss.dev
auth: { user: x, pass: y }
The boot archetype maps these into class-level config on Resolvers::Oculus and Resolvers::Task before any late-phase pass runs.
| Syntax | Mechanism | Scope |
|---|---|---|
!Tag arg |
Resolver + cache | External data fetch, file load, cross-tree reference |
$namespace.path |
Variable resolve | Walk the resolved tree, no resolver dispatch |
{attribute} |
Attribute resolve | Iteration-local — bind a single record's field |
!Tag brings data in. $ and {} reference data already in the tree. Each tier addresses its scope.
lib/wanderland/resolvers/<name>.rb under Wanderland::Resolvers.Wanderland::Resolver.tag "<Name>", ttl <seconds>, and phase :early | :late.fetch(arg) — or fetch_with_context(arg, engine) if the resolver needs engine.root, engine.resolve_subtree, or the breadcrumb trail.ResolutionError subclass for any config-level mistake the resolver can detect, so the engine surfaces it through the _resolution_error envelope.lib/wanderland/resolvers.rb so the Psych domain type registers at load.spec/scenarios/template_engine/ exercising the happy path, each argument shape, and each halt condition.@oculus:wanderland-core-template-engine annotation in the file header and a new H3 entry in the Resolver Reference above.wanderland.dev