A frame-of-mind primer for AI agents (Claude) working in the wanderland-* repos. Read this at the start of every session. Built from the current state of the code as of 2026-05-13; flags doc-vs-code drift inline.
Mantra: Before writing anything, look for what already exists. The most expensive mistake an AI makes in this codebase is inventing a thing that's already there with a different name. The kind: shell adapter exists. The :http adapter exists. The containerized_deployment archetype exists. The iterate_artifacts boundary exists. Grep before you invent.
Wanderland is a Ruby framework where the unit of work is a boundary: a class with a declared identity, scopes, input/output shape, and a single #call(input). Boundaries talk to the outside world through adapters (HTTP, shell, secret, AWS) that wrap external dependencies behind a uniform facade with mode-switching (live / replay / mock / dry-run). A site's config.yml declares which adapters to mount and which boundaries to wire into routes (HTTP path + verb, also a CLI subcommand). For pipeline-shaped work, archetypes (like containerized_deployment) declare slot orderings that user config fills in. Packs are gems that add adapter kinds and boundaries to the core engine.
Every dispatched boundary produces a signed, chained Crossing (caller identity → boundary identity, type-addressed). The append-only Context log of all crossings for a request is what later slots read — boundaries communicate through the context, not by returning values to each other.
The order of work here is fixed. It is the cheapest path and the one that keeps a change honest. When a task gets into the weeds, return to this order.
output_shape, a shared boundary) and what differs per instance (the variant: it belongs in config, fixtures, !UserConfig). This split is the line between the pack and the site that mounts it.The data model is the spec. Between the shapes, the scenarios that pin them, the conversation, and the code comments, the implementation is determined. Write the fixture, watch the scenario miss, build the boundary, watch it pass. Start from the data a call must produce, not from the boundary.
| Repo | What lives here |
|---|---|
wanderland-core |
engine, dispatch, registries, built-in adapter kinds (http, shell, secret), boot boundaries, YAML resolvers, scenario runner, introspection routes |
wanderland-aws-pack |
aws adapter kind, ECS-shape boundaries (register_task, poke_service, wait_service_stable, ecs_service_deploy) |
wanderland-pipelines-pack |
pipeline-shape boundaries (start_run, complete_run, mvn, compile_jar, publish_jar, docker_build, docker_image, publish_image, compose_deploy, shell_provisioner, publish_stack, iterators artifacts / component); archetypes (containerized_deployment, infrastructure_deployment) |
vivarta-jenkins, per-project images (cool-tools, cfn-tools, …) |
site-specific boundaries, their own config.yml, consume the packs above as gems |
Pack file layout (verified on-disk):
<pack>/
<pack>.gemspec
lib/
<pack>.rb # entry: requires deps + each boundary; register_kind here
<pack_snake>/
adapters/<kind>.rb
boundaries/<name>.rb
archetypes/<name>.yml
Pack entry pattern: require "wanderland-core", require_relative each adapter/boundary (or Dir.glob), BootMountAdapters.register_kind("name") { … } for new kinds, Wanderland::Archetypes.register(name, path) for archetypes. Boundary DSL self-registers on file load.
The DSL:
boundary :name,
identity: IDENTITY, # Wanderland::Identity — id becomes from_addr on every crossing
requirements: [:scope, ...], # scopes the *caller* must hold; enforce_denials checks at dispatch
capabilities: [:cap, ...], # what this boundary claims to provide
description: "one line",
input_shape: { ... }, # rich field-rule vocabulary, see below
output_shape: { ... }, # same vocabulary, applied to input.output writes
adapters: [:http, :shell], # contract: input.adapter(:name) halts hard if name not declared
when_shape: { ... }, # optional guard for the chain walker
serves: "application/json", # optional MIME for the format injection
env: [...] | { ... }, # ENV keys this boundary consumes — see "Env declaration"
chain: [...] # optional — make this boundary a wrapper, see "Chain wrappers"
Inside call(input):
input.find_or_fail("args.x") — dot-path read; throws :wanderland_signal (caught by registration's invoke) with Signal.halt(status: 400, error: "x is required") on blank/nil/empty. Strings are stripped before returning. Override with signal: :denied, status: 401, error: "...".input.find("args.x") — lenient sibling; returns nil on blank.input.adapter(:name) — pulls the configured adapter; halts hard if :name not in the boundary's adapters: [...] (declaration is the capability grant).input: runtime, config, params, query, headers, path, route, adapter (the protocol adapter — cli/http/test, not a user-mounted one). context and args are added per-slot by the chain walker.Signal: Signal.ok(**), Signal.halt(status:, error:), Signal.denied(**), Signal.error(**), Signal.emit(":signals:domain:event", ...). Bare hash auto-lifts via Signal.from_hash.input_shape / output_shape — full field-rule vocabulary (per wanderland-core-boundary):
Each field maps to one of these forms:
| Form | Meaning |
|---|---|
"field" => true |
Declared, optional. No constraints. Shape-validates as present-or-absent. |
"field" => false |
Declared but disabled — explicit signal "not used here." Rarely needed. |
"field" => { matches: /regex/ } |
Shorthand for required + regex constraint. Most boundary writers use this for the 80% case. |
"field" => { required: true } |
Required-flag set; documentation-only enforcement at validate-time. find_or_fail is the actual halt mechanism — declaring required: does not by itself raise. |
"field" => { required: true, description: "...", constraints: ..., metadata: { ... } } |
Full structured form. |
The rich field rule:
required: — true / false. Documentation + introspection. (Halt enforcement is on the boundary author via find_or_fail.)description: — one-line free text. Surfaces at /inspect/boundary/:name so an operator browsing the inspector knows what the field is for. Write descriptions for every non-obvious field — they're the boundary's interface contract.constraints: — a ShapeMatcher rule. Same vocabulary as scenario expected: blocks (matches:, includes:, gte:, lte:, keys:, etc.). Validates at read-time when shape strictness is on. Example: constraints: { matches: "^[a-z][a-z0-9_-]+$" } for a slug-shaped field.metadata: — free-form hash. Not interpreted by the validator. Used for ops tagging ({ pii: true, audit: :strict }, { unit: :seconds }).type:, default:, examples: — their presence makes the rule structured but they don't affect validation today.Nested fields: when a value is itself a hash with sub-fields, declare the nesting:
input_shape: {
"args" => {
"stack_name" => {
required: true,
description: "CFN stack name; <system>-<environment>-<workload>",
constraints: { matches: "^[a-z][a-z0-9-]{2,127}$" }
},
"params_file" => {
required: true,
description: "Path to the params YAML relative to the workspace root"
},
"lint_threshold" => {
required: false,
description: "Severity floor that halts the verify chain",
constraints: { includes: ["info", "warning", "error"] },
metadata: { default_visible_to_operator: "warning" }
}
}
}
Anti-pattern: declaring every field as bare true. That technically registers the shape but tells the operator at /inspect/boundary/:name nothing, and the scenario writer nothing about the contract. Write the rich form.
output_shape uses the same vocabulary, against the keys the boundary writes to input.output. Used by the engine to scrub adapter mask-sets, to verify scenarios, and to document the boundary's product shape at the inspector.
Composition is the wrapper pattern. No dispatch(...) helper in the boundary mixin (today). Wrapper boundaries call Wanderland::Boundary.execute(:sub_name, sub_input) directly, get a Crossing back, halt-passthrough on :signals:stop:*, and context.append(crossing) so later slots can read sub-boundary results. The cfn_verify tutorial walks the worked example. (See "Chain wrappers" below for the in-progress declarative replacement.)
Chain wrappers (in progress, design settled, code in flight):
class CfnVerify
include Wanderland::Boundary
boundary :cfn_verify,
identity: IDENTITY,
requirements: [:execute, :read],
capabilities: [:verify],
adapters: [:cloudformation, :cfn_lint],
chain: [
:render_params,
:cfn_validate_template,
:cfn_lint_template,
{ boundary: :cfn_change_set_preview,
args: { parameters_resolved: project("context.parameters") } }
]
end
The wrapper IS the unit of audit. One signed crossing emitted to the route context, signed by the wrapper's identity. Sub-boundaries run internally, produce signals, but their crossings are not appended to the outer context — implementation detail. Halts from any sub-boundary become the wrapper's halt verbatim (same payload, same type_addr). Pure-chain wrappers don't need #call; conditional ones implement #call and use dispatch(:sub_name, **args) from the mixin.
File layout: lib/<service>/boundaries/<name>.rb. boot_load_boundaries requires every .rb under boundary_path: at boot. Boundaries are registered globally by symbol — two with the same name silently overwrite. Grep boundary :<name> across the workspace before adding one.
Env declaration: a boundary that needs ENV vars declares them on registration so the value flows through the same audit/snapshot path the rest of the framework uses:
boundary :read_node,
...
env: ["OCULUS_BASE_URL", "OCULUS_USER", "OCULUS_PASS"]
# or rich form: { OCULUS_BASE_URL: { default: "https://i.loss.dev", description: "..." } }
The env_snapshot boot boundary auto-extends the allowlist with these keys. Anti-pattern: reading ENV["FOO"] directly inside call — bypasses the snapshot, the allowlist, the defaults, and the trace.
Kinds today — exhaustive. Don't invent.
| Kind | Provider | Wraps | Call surface | Required spec |
|---|---|---|---|---|
http |
core | Net::HTTP |
.get(url, headers:), .post(url, body:, headers:), .request(method, url, body:, headers:) → {"status", "body", "headers"} |
none (mode defaults to live) |
shell |
core | Open3.capture3 against ONE fixed binary |
.run(*args, cwd:, env:) → {exit_code:, stdout:, stderr:, duration_s:} (symbol keys) |
command: (e.g. mvn, docker, git) |
secret |
core | dotenv-style stores | .fetch(key), .key?, .keys, .resolve(template) |
provider: (e.g. dotenv); modes live/mock only |
aws |
aws-pack | Aws::<Service>::Client (method_missing forwards SDK) |
the SDK's call surface (.describe_instances(...), returns SDK response struct) |
service: + region: |
Config.yml shape (kind defaults to adapter name; mode defaults to live):
adapters:
http: # name `:http`, kind defaults to "http"
mode: !Env { name: WANDERLAND_HTTP_MODE, default: live }
cassette_dir: spec/fixtures/cassettes
mvn:
kind: shell
command: mvn
baseline_env: { JAVA_HOME: /opt/java }
api:
kind: http # name `:api`, kind `http` — one process, two HTTP mounts
mode: live
ec2:
kind: aws
service: ec2
region: us-east-1
The four modes:
live — real binding.replay — reads recorded envelopes from cassette_dir; miss raises CassetteMissError (loud).mock — consults an in-process registry (config-seeded via mocks: or per-scenario via register_mock); strict.dry_run — engaged only via --wanderland-dry-run request flag (with_dry_run wrap); logs the planned call, walks dry_run_responses:, returns first match or a uniform default.dry_run_request returns symbol-keyed hashes while live/replay/mock return string-keyed. That's a real code issue; defensive boundaries handle both.Mode env override: mode: !Env { name: WANDERLAND_HTTP_MODE, default: live }. Resolves at config-load (static for runtime lifetime).
Cassettes: HTTP path <cassette_dir>/http/<sha1>.yml; SHA1 over canonical (method, url, body, headers). Shell adds (cwd, env-subset, sequence); AWS adds (operation, params, sequence). Record via --wanderland-record-to <dir> (CLI) or X-Wanderland-Record-To: <dir> header. Only fires in :live mode; other modes pass through with_recording.
!Fixture <path> lifts a cassette as a {request:, response:} envelope (usable in scenario mocks:). !Merge [base, overlay] deep-merges. !Patch [base, {dot.path: value}] writes single paths into a base.
Wrap order: BoundaryInput#adapter wraps in order — recording innermost (with_recording), then dry-run outermost (with_dry_run). Both no-op unless adapter's @mode == :live. Wrapped result is memoized per BoundaryInput, so sequence counters stay shared across multiple input.adapter(:name) calls.
#client escape hatch: returns the bare gem handle (Net::HTTP for the last live HTTP request; Aws::*::Client always for AWS; Struct(command:, baseline_env:) for shell — not the full Open3 handle).
HTTP basic auth: no native helper. Encode user:pass in Base64 and pass headers: { "Authorization" => "Basic <base64>" }. Ruby 3.4 needs require "base64".
Registering a new kind (only when truly no existing kind fits — first grep register_kind across all three packs):
Wanderland::Boundaries::BootMountAdapters.register_kind("mykind") do |spec, config_dir|
build_adapter_instance_from(spec, config_dir)
end
Core kinds register at the bottom of boot_mount_adapters.rb. Packs register from their entry file. Unknown kind at boot raises with the known list; per-adapter failures land in Diagnostics and boot continues.
config.yml top-level keys:
service: — string, default "wanderland-engine".port: — int, default 9293 (HTTP).boundary_path: — dir (relative to config) walked by boot_load_boundaries.archetype: — optional named archetype default for every route.routes: — map of /path → spec.input["config"]. Common keys: adapters:, storage:, triggers:, resolvers:, injections:, denials:, env_allowlist:, scenarios:, wanderland_environment:, environments:.Route shape:
routes:
/read-node/:slug:
method: get
name: read-node # CLI subcommand name; /inspect/route/:name key
boundary: read_node # single boundary; or `chain: [name, {boundary:, args:, when:}, ...]`
args:
base_url: !Env { name: OCULUS_BASE_URL, default: https://i.loss.dev }
:capture segments are Mustermann-style placeholders. Captures land in input.params (merged with query and body — body wins). On CLI they become --<name> VALUE options. !PathCapture does not exist in code — older docs reference it but the tag isn't registered.args: splices into the slot's input["args"]; the chain walker sets it before execute and deletes after.Boot chain — defined in lib/wanderland/runtime.yml, executes against a @boot_context. One bad slot lands in Diagnostics; the rest continue.
boot_load_boundaries — requires every .rb under boundary_path.boot_configure_resolvers — wires !Fixture, !Oculus, !Task from domain.resolvers.boot_mount_storage — sqlite/file drivers from storage.mounts.boot_mount_adapters — instantiates http/shell/secret + pack-registered kinds.boot_mount_triggers — reactive triggers.env_snapshot — union of site env_allowlist: and boundary-declared env keys; captures into runtime.env_snapshot; emits a signed :types:env crossing tagged context_seed.context_from_shape — domain denials: → context-seed crossings.register_injections (core) — enforce_denials (interleave), verify_route/trace_emit/format/seal (last).register_injections (user) — site-declared injections from domain.injections.boot_mount_archetype — builds Wanderland::Engine, mounts CORE_ROUTES, walks config.routes, resolves each via archetype slots through TemplateEngine, folds through InjectionRegistry#apply.After boot: runtime.engine.compiled_routes is the command table; named_routes indexes by name:; runtime.context_seeds carries every boot crossing tagged context_seed.
Dispatch flow: adapter parses protocol → resolves route from compiled_routes → Dispatch.invoke(runtime, route, params:, headers:, path:, adapter:) builds the envelope (Envelope.build(:request) — seven-key validated hash plus optional identity), allocates a fresh Context (:trace:req-<uuid>), re-seeds from runtime.context_seeds, runs the adapter-announce boundary (adapter_http / adapter_cli), then route[:dispatcher].execute(input, ctx) walks each slot: effective when: (slot → boundary when_shape: → BASE_DEFAULT_WHEN) → Boundary.execute(name, input) → context.append(crossing). Terminal crossing returns to the adapter, which renders via Response.from(crossing).
Flow control: type-address namespaces — :types:* normal, :signals:stop:* blocks (BASE_DEFAULT_WHEN skips downstream until count returns to zero via paired :anti:<type> crossings), :signals:pass:* observational. No _halt short-circuit — the chain walker visits every slot; gating is by when: count, not by exception. Exception handlers are later slots whose when: matches :signals:stop: count > 0; when: { always: true } is finally-style.
Context (input["context"]): append-only event stack of every crossing this request. Dual projection — context["key"] is the value (last writer wins across result hashes); context.<boundary> returns the most recent matching crossing. context["env"] short-circuits to runtime.env_snapshot (boot-captured, frozen). Compose filters: context.signed.for_boundary(:auth).by_identity("x").since(5)["scopes"].
Three invocation flavors:
--type cli — Commander subcommand per route, name from spec["name"] else from path. Captures → --<name> VALUE options. Dispatch.invoke(adapter: :cli) → Response.body to stdout, exit 0/1.--type http — Puma + Rack. Mustermann match → Dispatch.invoke(adapter: :http) → Rack triple.--type test — walks domain.scenarios.<route>.<case>, synthesises request with X-Wanderland-Verify: <case>; the verify_route injection does shape comparison inside the chain.Always-mounted CORE routes (Engine::CORE_ROUTES; site can't remove, only override handlers):
/health, /status, /healthcheck/inspect/config[/:path]/inspect/routes, /inspect/route/:name (returns user_chain, compiled_chain, registered_injections, scenarios)/inspect/boundaries, /inspect/boundary/:name (identity, requirements, capabilities, shapes, source class)/inspect/scenarios[/:route]/inspect/diagnostics[/:severity]/inspect/framework-schema[/:stage]/inspect/shape-schema[/:module]/inspect/docs/boundaries, /inspect/docs/boundary/:name (JSON or Accept: application/x.wanderland.template for ERB markdown)All also surface as CLI subcommands.
Diagnostic recipes:
curl /inspect/diagnostics (look for failed to mount adapter <name>).curl /inspect/boundaries | jq '.boundaries | index("<name>")'.curl /inspect/route/<name>.An archetype is a YAML file shipped by a pack declaring a named pipeline shape: ordered slots, each a {name, boundary, args}. User picks one with top-level or per-route archetype: <name>. Boundary fields can be !UserConfig pipeline.stages.<stage>.type-style thunks that resolve against the user's config at boot.
Iterator slots — iterate_artifacts (registered as artifacts) and iterate_components (as component) — walk a bucket (pipeline.artifacts.<bucket> or pipeline.components), dispatch each entry's type: actor with entry["args"] + {_name: <key>}, and append each sub-dispatch crossing to input["context"] so later slots can read sub-results.
Halt propagation: crossings with type_addr starting :signals:stop: are halts. Iterators halt_passthrough — extract status/error from sub-crossing's result, re-emit as own halt.
Archetypes today (in pipelines-pack):
containerized_deployment — start_run → build (artifacts iterator over pipeline.artifacts.builds) → image (artifacts over images) → deploy (component over pipeline.components) → complete_run.infrastructure_deployment — start_run → provision (boundary = !UserConfig provisioner, e.g. shell_provisioner) → publish_stack → complete_run.Pipeline data shape:
pipeline:
metadata:
name: <string>
tags: { system:, environment:, sub_environment:, version: }
artifacts:
builds: # iterator bucket; {} = noop
<name>:
type: mvn # actor; compile_jar → publish_jar
args: { source_path:, repository_url:, repository_id: }
images:
<name>:
type: docker_image # actor; docker_build → publish_image
args: { context_path:, image_name:, image_tag:, docker_build_args: }
stages:
build: { type: artifacts }
image: { type: artifacts }
deploy: { type: component, args: { output_root: ... } }
components: # iterator bucket for deploy stage
<name>:
type: ecs_service_deploy | compose_deploy
args: { task_definition:, service:, wait_timeout_s: }
Each entry's _name (its hash key) is auto-spliced into the per-iteration args.
YAML resolver family (tag → registered phase):
| Tag | Phase | Argument shapes | Returns |
|---|---|---|---|
!Env <NAME> / !Env { name:, default: } |
:early |
string raises if unset; hash falls back | ENV[NAME] or default |
!Environment <path> |
:early |
dotted path | environments.<active>.<path> (then environments.default.<path>); <active> from top-level wanderland_environment: |
!Reference <dotted.path> |
:early |
path into the loading doc | deep-dup of subtree, recursively resolved |
!UserConfig <path> |
both | path into UserConfig.data |
nil on miss (silent — typos vanish) |
!Fixture <path> [at] |
:late |
file + optional dig | YAML fixture; ERB pre-interpolates <%= … %> |
!Merge [layer, layer, …] |
:late |
array of hashes | HashUtils.deep_merge left-to-right; arrays replace |
!Patch [base, overrides] |
:late |
base + {dot.path: value} writes |
base with dotted-path writes, auto-vivifying hashes |
!Oculus / !Task |
:late |
path | remote API fetch via HttpProvider |
!Env ≠ !Environment. !Env reads ENV[NAME]. !Environment digs the active block of the loading config (and requires wanderland_environment: at the top level, often !Env { name: WANDERLAND_ENVIRONMENT, default: local }). Different concepts.
ERB escape <%= ... %> runs before YAML parses — two places: (1) Config.load over the whole config.yml with EnvBinding resolving names to ENV[NAME.upcase]; (2) !Fixture over fixture file bodies. Use ERB for pre-YAML text substitution (interpolating a name into a YAML key); use !Env for value substitution (the common case — also gets tracked in env_snapshot).
**There is no ${VAR}` substitution inside wanderland.** That's a JCasC / Kustomize / shell layer one level out. Vivarta-jenkins's JCasC config uses `${AGENT_IMAGE_REF} because Jenkins reads it; wanderland's analogue inside vivarta.yml is !Env { name: AGENT_IMAGE_REF } or <%= AGENT_IMAGE_REF %>.
Injections: the user-declared chain (from archetype slots) is folded through InjectionRegistry#apply. Each injection is {boundary, position}: :first / :last / :interleave / {interleave: <shape>} / {before: <name>} / {after: <name>}. Application is a left fold — late :last wins the tail; interleave only sees what came before in the registry. Framework defaults: enforce_denials (interleave), then verify_route / trace_emit / format / seal (last).
Runtime services (adapters, identity, context) don't ride via injections — they're built into the input envelope by Dispatch.build_input (framework keys) and the chain walker (context, args per-slot). input.adapter(:name) looks up on runtime.adapters gated by the boundary's declared adapters: [...].
Red→green is the discipline for wanderland-core changes. Failing scenarios precede the code change. Adding a new feature: write the spec, watch it fail for the right reason, implement, watch it pass. Changing existing behavior: update the spec that pinned the old behavior, watch it fail, implement the new behavior, watch it pass. Skipping this step is not optional — "or it doesn't count."
The wanderland-core repo holds two test patterns side by side. Use them both.
1. RSpec unit/feature specs at wanderland-core/spec/wanderland/<topic>_spec.rb. The spec_helper boots a fresh Runtime per example and resets Boundary.registered between specs. Inline test boundaries register on the runtime overlay:
# spec/wanderland/boundary_input_spec.rb
require "spec_helper"
RSpec.describe Wanderland::BoundaryInput do
describe "#find_or_fail" do
it "throws a halt Signal when the path is blank" do
input = described_class.new({ "params" => {} }, {})
signal = catch(:wanderland_signal) { input.find_or_fail("params.name") }
expect(signal).to be_a(Wanderland::Signal)
expect(signal.type_addr).to eq(Wanderland::Types::HALT)
expect(signal.payload).to eq("status" => 400, "error" => "name is required")
end
end
end
Use these for: DSL surface, Registration shape, signal throw/catch, adapter wrapping order, Slot.from_spec, anything where a single method is the unit.
2. Scenario YAMLs at wanderland-core/spec/scenarios/<topic>/<NN_name>.yml, run by a matching wanderland-core/spec/wanderland/<topic>_scenarios_spec.rb. The runner calls scenario.verify against the boot-fresh runtime. Each YAML has operation, input, expected, optional mocks, optional blanks (Game-mode puzzles), optional level metadata.
# spec/scenarios/wrapper_chain/01_two_steps.yml
name: "wrapper chain runs sub-boundaries in declaration order"
description: |
A wrapper boundary declares chain: [:step_one, :step_two].
Both register on the runtime overlay; step_one emits a token,
step_two reads it. The wrapper emits one crossing signed with
its own identity; the sub-boundaries' crossings are not
appended to the request context.
operation: cfn_verify_test
input:
params: {}
expected:
result:
step_two_saw: "from-step-one"
context_size: 1
boundaries: [cfn_verify_test]
Use these for: end-to-end behavior, chain composition, halt propagation across multiple slots, request-shaped flows where the full envelope matters.
Scenario YAML shape — full vocabulary (per wanderland-core/spec/scenarios/):
operation: — boundary name dispatched via Wanderland::Boundary.execute(operation, input)input: — request hash (params:, headers:, args:, etc.)expected: — ShapeMatcher rule against the resulting crossing's payload + contextmocks: — scenario-scoped adapter mocks (http:, shell:, cloudformation:)blanks: — { path:, answer:, hint: } for Game-mode where the scenario doubles as a puzzlelevel: — { world:, number:, hint: } for ordering in the GameShapeMatcher in expected: — full reserved-key set:
== matchcount:, gte:, lte:, gt:, lt: — numeric guardsmatches: <regex> — regex against a string valuecontains:, includes: [...], excludes: [...] — collection membershipfirst: <shape>, last: <shape>, any: <shape> — collection projectionskeys: [...], has_key: <name> — hash structurenot: <shape> — negationempty: / always: true — degenerate casesCollision rule: if a real response field is literally named count / first / last / keys / empty, the matcher swallows it. Escape via blanks: dot-paths or restructure the response.
Running the suite:
# all wanderland-core specs (excludes :live by default)
cd wanderland-core && bundle exec rspec
# one spec file
bundle exec rspec spec/wanderland/boundary_input_spec.rb
# one scenario topic
bundle exec rspec spec/wanderland/wrapper_chain_scenarios_spec.rb
# include :live specs (spawn external processes; slow)
WANDERLAND_LIVE=1 bundle exec rspec
Running site scenarios (against a project's config.yml):
# all scenarios declared in config.yml's scenarios: block
bundle exec wanderland --type test config.yml
# filter
bundle exec wanderland --type test config.yml --route deploy
bundle exec wanderland --type test config.yml --scenario happy_path
Exit codes: 0 all pass, 1 fail/no-match, 2 bad argv.
Workflow checklist for a wanderland-core change:
spec/scenarios/<topic>/ for end-to-end coverage if the change crosses multiple boundaries.Tutorials' scenarios (in a project's config.yml, like vivarta-jenkins's scenarios: block) follow the same shape — --type test is the runner. Same red→green discipline applies when adding a boundary to a tutorial: write the scenario first, watch it miss, then build the boundary.
For reader-facing nodes, see [wanderland-dev-style-guide]. Rules that apply to code-style writing too:
For code:
boundary :name, ... per file at lib/<service>/boundaries/<name>.rb.call(input) body should not see Net::HTTP, Open3, raw AWS SDK calls — those belong on the other side of an adapter facade.system(...) in a boundary. Use kind: shell.Net::HTTP directly. Use kind: http.adapters: [:foo] matches the config.yml mount key — symbols on both sides.grep -rh "boundary :<name>" /Users/gfawcett/working/wanderland-*/lib — registry is global, names collide.grep -r "register_kind" /Users/gfawcett/working/wanderland-*/lib.The deploy slot of containerized_deployment targets Kubernetes as well as ECS. wanderland-k8s-pack ships the kustomize/kubectl boundaries; a site mounts a :kubectl shell adapter and names them in the deploy slot instead of the ECS/CFN ones.
| Boundary | Role |
|---|---|
kustomize_set_image |
Pin an overlay's kustomization.yaml images: entry to a built ref. Pure YAML transform. |
kubectl_apply |
Render + kubectl apply an overlay, optional rollout-restart + wait. load_restrictor: renders with kubectl kustomize then apply -f -. context: targets a named cluster. |
k8s_provisioner |
Deploy-slot wrapper: kustomize_set_image then kubectl_apply as one group. The kubectl analogue of cfn_provisioner. |
k8s_live_image |
Read a running Deployment's image (promotion / deploy-only); prod collect_steps names it, image stage goes noop. |
k8s_job |
Launch primitive: kubectl apply -f - a Job manifest, fire-and-forget or wait: for completion. The kubectl analogue of ECS run-task — launch vs deploy. |
Mixed substrate: a deploy slot can interleave a cfn_provisioner component (a Route53 A record to the cluster's nginx-ingress IP) with a k8s_provisioner component (the workload) — AWS DNS + Kubernetes workload in one iterator, DNS first so cert-manager's HTTP-01 challenge resolves.
The fe/be/task-launcher pattern (UI submits work → backend launches a k8s Job → an observability receiver collects it) is documented end to end, with the echo + foxfire reference suite and a laptop-runnable drone, in [canonical-fe-be-task-launcher].
The reference nodes are partly "2b shape" — frozen at an earlier point. Where code disagrees with docs, trust the code. Specific drifts found:
shell / secret / aws kinds, dry_run mode, with_recording / with_dry_run wrap protocol, or register_kind's role for external packs._halt gate-protocol short-circuit. The current code runs the full 10-step runtime.yml init chain; _halt is gone, flow control is when: guards keyed on :signals:stop: counts via BASE_DEFAULT_WHEN.input.config_dir as a request-time framework key. Actually it's only on :boot-stage envelopes; at request stage it's not in framework_keys. Reach it via input["runtime"].config or input["config"]["_config_dir"].!Environment, !Patch, !Reference, and the :early/:late phase split. Lists !Mapped which is now !Reference.infrastructure_deployment but doesn't describe containerized_deployment (the now-primary archetype). Iterator boundaries (artifacts / component) aren't covered.Wanderland::Injections); refactored to instance-scoped InjectionRegistry mounted on the runtime. .apply(...) calls in the doc no longer compile.Tutorial-ahead-of-code: render_params is referenced in [vivarta-jenkins-tutorial-cfn-verify] as belonging in wanderland-pipelines-pack but does not exist on disk in either pack. Either tutorial is forward-looking or the boundary is planned. Don't assume it exists.
HTTP dry_run_request key-style bug: returns symbol-keyed hashes while live/replay/mock return string-keyed. Code bug, not just docs.
Seeding mock/seed data so a boundary's adapter call returns a canned response — and the traps that each cost a debugging cycle.
Config-level adapter mocks — the direct way. Don't wrap a seed-reading branch inside a boundary. Put the mock on the adapter in config; input.adapter(:name).get(url) returns it:
adapters:
http:
kind: http
mode: !Env { name: WANDERLAND_HTTP_MODE, default: mock } # mock locally, live deployed
mocks:
"GET http://foxfire.mock/envelope/img.recontrast_run": # key = "<METHOD> <URL>", exact
status: 200
body: !Fixture fixtures/envelopes/img.recontrast_run # the file becomes the body
mock_key(method, url) = "GET https://…" (exact method + URL; body/headers are not part of the key). boot_mount_adapters seeds mocks: verbatim; mock mode looks up by that key. A miss raises CassetteMissError.!Fixture) — the adapter returns it as-is. A boundary that also serves the live path should accept String (JSON) or Hash.{request:, response:} envelope shape works in scenario mocks: blocks and cassette files.!Fixture path rules:
!Fixture config/sizes — the resolver appends .yml. Writing !Fixture config/sizes.yml searches for sizes.yml.yml → Fixture not found.foo_bar also tries foo-bar.yml.boot_configure_resolvers sets Fixture.fixture_dirs from resolvers.Fixture.fixture_dirs and overwrites the config-load default (which had the config dir). Unset ⇒ empty ⇒ every !Fixture misses:resolvers:
Fixture:
fixture_dirs: ["."] # relative to this config; add more roots as needed
The double-ERB trap. wanderland ERB-renders config.yml (at Config.load) AND every !Fixture file whose text contains <%. So a string meant for a later runtime ERB (e.g. Wanderland::Template.render at dispatch) is evaluated too early — it fails on the undefined var (or renders blank). Escape the tags so the load pass emits a literal for the runtime pass:
run_opts:
- --name
- "<%%= run_id %>" # load-ERB emits "<%= run_id %>"; the boundary evaluates it later
<%%= → <%= is the standard ERB literal escape. Symptom without it: undefined local variable or method 'run_id' for FixtureBinding at boot.
--wanderland-* flags)Every route is also a CLI subcommand — no server to boot. The tightest iterate loop for a chain:
bundle exec wanderland --type cli config.yml <route> key=value key=value
# e.g. … preview job_type=img.recontrast_run latency_class=deferred
Path captures become declared options; any leftover key=value argv folds into params. The terminal crossing renders to stdout (via the matched formatter); exit 0 on ok, non-zero on halt. Edit a fixture/config, re-run, read the diff.
Global flags (canonical option name in brackets — behaviour verified against a live route):
--wanderland-render [render] — short-circuits before dispatch and dumps the fully-resolved service / config / route (every !Env / !Fixture / !Environment / !Context-free value already resolved). The "what did my config actually become, and does this fixture/mock path resolve?" view. Does not run the chain.--wanderland-debug [debug] — streams a per-slot execution trace to stderr: each boundary enter/exit, its resolved args, its result, and the crossing type. The first thing to reach for when a chain does something you didn't expect. (stdout still gets the clean result.)--wanderland-trace [trace] — appends the signed crossing record of the terminal result to the output: to_addr (:trace:<svc>:req-<uuid>:<n>), duration_ms, signature, and the trace (Merkle link to the prior crossing). Provenance + timing, not just the value.--wanderland-verify <case> [verify] — runs the route and shape-checks the result against the named scenario (from the scenarios: block), printing a pass/fail report; non-zero exit on mismatch. Same ShapeMatcher vocabulary as --type test, but ad-hoc against a single invocation. (Watch the expected-shape nesting — a mismatched wrapper reads as X: missing in result.)--wanderland-record-to <dir> [record_to] — captures live adapter calls into cassettes under <dir> for later mode: replay. No-op in mock/replay mode — only live calls record. Record once against the real dependency, replay forever.--wanderland-dry-run [dry_run] — flips live adapters to dry-run: they log the planned call and return a default instead of acting. The safe way to see what a mutating route would do (e.g. the kubectl apply a launcher emits) without doing it.--wanderland-run-id <id> [run_id] — pins the request run id (shows up in the :trace: address and the debug/trace output). Handy for correlating a run.--wanderland-no-color [no_color] — strip ANSI; pipe-friendly.--wanderland-aws-endpoint <url> [aws_endpoint] / --wanderland-localstack [localstack] — point aws-kind adapters at a custom endpoint / localstack.Combine them: --wanderland-debug --wanderland-trace to watch the chain and see the signed terminal crossing; --wanderland-render alone to debug resolution before you ever dispatch.
serves: registers a FORMATTER, not a content-type hint (gotcha)serves: "application/json" on a boundary does not mean "this route returns
JSON." It registers the boundary in the formatter registry as the handler
for that MIME, so its #call runs on every response of that content type,
across all routes. Put it on a normal data endpoint and that boundary intercepts
/health and everything else — its #call runs without the args it expects and
halts (e.g. a 422), which crashloops the pod via the liveness probe.
serves:. The http adapter renders JSON by
default (and honours Accept); the CLI renders YAML. Just return your hash /
Signal.ok(...).serves: only when you are actually writing a formatter (the thing that
turns a result into a body for a MIME) — see template_formatter /
markdown_formatter./health, /status)
returns your boundary's error. Grep your boundaries for serves: first.| If you need to… | Use |
|---|---|
| Make an HTTP call from a boundary | kind: http adapter; input.adapter(:http).get(url, headers:). Basic auth: Authorization: Basic <base64> header. |
| Run a shell command | kind: shell adapter with command: <binary>; input.adapter(:foo).run(*args, cwd:, env:). One adapter mount per binary. |
| Read a secret | kind: secret adapter with provider: dotenv; or address :secrets:<provider>:<key> via .resolve(template). |
| Call AWS | kind: aws adapter with service: and region:. SDK calls go through method_missing. |
| Iterate over a config bucket and dispatch per entry | Iterator boundary pattern (iterate_artifacts / iterate_components). Don't reinvent. |
| Wrap several sub-boundaries into one user-visible boundary | Wrapper pattern: Wanderland::Boundary.execute(:sub, sub_input), context.append(crossing), halt-passthrough on :signals:stop:*. |
| Read a process env var in config | !Env { name: ..., default: ... } (tracked in env_snapshot). |
| Read an environment-specific value from the loading config | !Environment <path> (requires top-level wanderland_environment:). |
| Splice runtime user data into a config slot | !UserConfig <path> (silent on miss). |
| Replay recorded HTTP/shell/AWS calls in tests | Set adapter mode: replay and point cassette_dir: at the cassettes. |
| Stub one shape per scenario | Scenario mocks: block — inline envelope, !Fixture, or !Merge/!Patch overlays. |
| Add a new adapter kind | First grep register_kind everywhere. If truly new: BootMountAdapters.register_kind("name") { ... } in your pack's entry file. |
| Add a new boundary | lib/<service>/boundaries/<name>.rb, include Wanderland::Boundary, boundary :name, .... Grep boundary :<name> first — registry is global. |
| Add a pipeline stage | Fill a slot in the archetype, or write a sub-boundary that an iterator dispatches. Don't write per-stage scaffolding. |
| Find what's currently registered | curl /inspect/boundaries, /inspect/route/:name, /inspect/diagnostics. |
| Test a boundary | --type test config.yml --scenario <case> or rspec via Wanderland::Scenario.rspec_describe!(runtime). |
| Record a cassette | Run with --wanderland-record-to <dir> in live mode; the next live call writes a cassette and subsequent runs replay. |
Reusable lessons from live pipeline and deploy work. When a failure teaches something the next service will hit too, record it here as well as in any local agent memory — the primer is the shared copy every session reads; a lesson that lives only in one agent's memory store is invisible to everyone else.
elbv2 adapter (needs elasticloadbalancing:DescribeRules on the deployer role) and set each priority parameter with !ListenerPriority, giving it the listener ARN (!SsmLookup off the env key) and the rule's host. At dispatch it reuses the priority of an existing rule for that host or allocates a free slot, so first deploys and redeploys both converge. Precedent: payments, solr-upgrade, pgwebapp10.cloudformation:DeleteStack — clearing the dead stack takes an operator with admin credentials. auto_purge_failed is a written-out TODO in wanderland-aws-pack's cfn_provisioner; until it exists, expect a manual delete between a failed first CREATE and the retry.templateUrl whose case didn't match the file on disk — invisible through years of workstation builds. Before containerizing another Angular repo, sweep every templateUrl/styleUrls against the exact on-disk name; while there, note that workstation-only npm prepare scripts (husky, playwright installs) fire on any npm i and need deleting inside the build stage.kustomize_dir points at its OWN overlay, so its k8s_provisioner/kubectl_apply step touches only that one workload. Point two services at the same shared overlay and every deploy re-asserts the OTHER services' committed image tags — one service's deploy silently reverts another's. The platform bundle builds no image, so its pipeline is apply-only (image stage noop, deploy = a single kubectl_apply of the overlay, no extra_repos); apply it first on a fresh namespace. Service bundles reference platform-owned resources by name (the PVC claim, the pull secret, the mongo Service). The ResourceQuota still couples the services — one per namespace, budgeting them together. Precedent: iScanGuide (iscanguide-platform + sc-website + sc-task-queue + sc-server-zoom).namespace: directive rewrites metadata.namespace, not strings. The namespace transformer sets metadata.namespace on every resource and renames the Namespace object, but a namespace baked into a value — a mongo rs.initiate host, an oplog URL, an app's public ROOT_URL — passes through untouched. Set those per environment in a patch, and verify a namespace change by rendering the overlay, not by reading metadata.namespace. Precedent: iScanGuide (patch-mongo-dns / patch-root-url per env).metadata.namespace must equal the value in the base file (often one hardcoded namespace); the overlay's namespace: directive rewrites both afterward. A patch that names the overlay's target namespace fails to match and drops with no error. Precedent: iScanGuide.kubectl rollout status times out even though the deploy eventually finishes. Set maxSurge: 0 on quota-constrained Deployments and give the namespace CPU/memory headroom. Precedent: iScanGuide sc-website (co-tenant render service consuming the namespace quota).noop, the collect stage still runs — reading the source repo's version and sha — and the deploy step's kustomize_set_image overrides whatever tag is committed in the overlay. A deploy-only run deploys the current source-HEAD image (already built), so it does not roll a live deployment back to the tag pinned in the file. Precedent: iScanGuide.warn inside a boundary is Wanderland::Logging's zero-arg call, not Kernel#warn. warn "some debug" in a boundary raises ArgumentError: wrong number of arguments (given 1, expected 0) — the mixin overrides warn to take none. It's a nasty trap when debugging: the debug line itself crashes the method under test, so it looks like the code never ran (no output, an early-return path taken) rather than "your probe blew up." For ad-hoc tracing in a boundary use $stderr.puts, never warn "…". It surfaces cleanly in a standalone require "wanderland-core" unit (immediate stack trace) — so when a scenario behaves oddly, drop to a unit and trust its trace over the scenario's silence.mocks: kubectl: feed dispatch(:k8s_job), not a direct adapter(:kubectl).run. The launch path (dispatch(:k8s_job) → kubectl apply -f -) is mocked by the scenario harness, but a boundary that shells the kubectl adapter directly — input.adapter(:kubectl).run("get", "jobs", …), as k8s_resource and a dedupe lookup do — does not receive the canned response even when the mock's args: match exactly; it falls through to a default empty result. Cover direct-adapter branches with an rspec unit (a fake input whose adapter(:kubectl).run returns the canned JSON), not a scenario. Precedent: wisp classify dedupe (spec/classify_dedup_spec.rb) — the first rspec spec in an otherwise scenario-only suite.Gemfile.lock can flip from the path gem to a pinned build under you. wisp/foxfire lock wanderland-core as a path gem (remote: ../wanderland-core, version 0.0.1.dev). Tooling that publishes a dated build (2026.07.NNN) can rewrite the lock to pin that version, and if it isn't installed locally bundle exec dies with Could not find wanderland-core-… (Bundler::GemNotFound) — mid-session, with no change of yours. git restore Gemfile.lock returns to the path-based lock the scenarios run against. Check git diff Gemfile.lock before assuming it's yours to commit; it usually isn't.Tutorials (current shape, useful as code examples):
Reference (flag drift per §Sharp edges):
Source code (authoritative when docs and code disagree):
/Users/gfawcett/working/wanderland-core/lib/wanderland/ — engine, config, dispatch, runtime, signal, types, diagnostics/Users/gfawcett/working/wanderland-core/lib/wanderland/adapters/{http,shell,secret,registry,cassette,recording_*}.rb/Users/gfawcett/working/wanderland-core/lib/wanderland/boundaries/boot/*.rb — the 10 boot slots/Users/gfawcett/working/wanderland-core/lib/wanderland/runtime.yml — boot chain definition/Users/gfawcett/working/wanderland-core/lib/wanderland/resolvers/{env,environment,reference,user_config,fixture,merge,patch}.rb/Users/gfawcett/working/wanderland-core/lib/wanderland/runners/{cli,http,test}.rb/Users/gfawcett/working/wanderland-aws-pack/lib/wanderland-aws-pack.rb — aws kind registration/Users/gfawcett/working/wanderland-aws-pack/lib/wanderland_aws_pack/adapters/aws.rb/Users/gfawcett/working/wanderland-pipelines-pack/lib/wanderland-pipelines-pack.rb — boundaries + archetypes auto-load/Users/gfawcett/working/wanderland-pipelines-pack/lib/wanderland_pipelines_pack/boundaries/{iterate_artifacts,iterate_components,start_run,complete_run,mvn,compile_jar,publish_jar,docker_build,docker_image,publish_image,compose_deploy,shell_provisioner,publish_stack}.rb/Users/gfawcett/working/wanderland-pipelines-pack/lib/wanderland_pipelines_pack/archetypes/{containerized_deployment,infrastructure_deployment}.yml