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 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.
:late — resolves at boundary entry, not at config load.-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.
:late — runs after !Fixture payloads materialize.-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 |
:late |
At boot, second pass — consumes already-resolved early markers as input | !Coalesce, !Fixture, !Merge, !Oculus, !Patch, !Task |
: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 |
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