The deep-dive companion to wanderland-dev-ai-primer. The primer frames the mental model and the constraints; this node holds the section-by-section detail. Pull one section at a time — the whole node is large by design (oculus_look returns an index for large nodes; read a single section with its section_path).
Verified against on-disk wanderland-core (gem 2026.07.127), wanderland-aws-pack, wanderland-pipelines-pack, wanderland-k8s-pack, and the cheshire / drones / lantern / loom / sutra packs, as of 2026-07-30. Where a reference node disagrees with the code, trust the code. Source paths use /Users/graemefawcett/working/wanderland-*.
The unit of work. include Wanderland::Boundary gives a class the call convention plus logging/tracing; boundary :name, ... registers it.
The DSL (lib/wanderland/boundary.rb, ClassMethods#boundary):
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 → registers a FORMATTER (see §Formatters, and the gotcha)
default_format: false, # when serving, register as a precedence floor rather than a hard win
env: [...] | { ... }, # ENV keys this boundary consumes
chain: [...] # optional — make this boundary a wrapper (see below)
All keywords are stored as @boundary_meta and re-registered on Boundary.reload! (how spec isolation works — see §Scenarios & testing).
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 nil/false/blank-string/empty-collection. Strings are stripped. Override with signal: :denied, status: 401, error: "...".input.find("args.x") — lenient sibling; returns nil only for absent or blank-string. Empty [] / {} / false / 0 are treated as present.input.adapter(:name) — pulls the configured adapter; declaration is the capability grant — an undeclared adapters: name throws a halt. Applies the per-request wrap order (see §Adapters).input.dispatch(:sub, **overrides) — run a sub-boundary (see "Wrapper pattern" below).input come from Envelope.framework_keys (introspect at /inspect/framework-schema): 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", ...) (the address must lead with :). A bare hash auto-lifts via Signal.from_hash (promoting _type_addr / _capabilities).input_shape / output_shape — full field-rule vocabularyEach field maps to one of these forms:
| Form | Meaning |
|---|---|
"field" => true |
Declared, optional. No constraints. Present-or-absent. |
"field" => false |
Declared but disabled — explicit "not used here." Rarely needed. |
"field" => { matches: /regex/ } |
Shorthand for required + regex. The 80% case. |
"field" => { required: true } |
Required-flag; documentation/introspection only. find_or_fail is the actual halt mechanism — declaring required: does not by itself raise. |
"field" => { required:, description:, constraints:, metadata: } |
Full structured form. |
The rich field rule:
required: — true / false. Documentation + introspection. Halt enforcement is on the author via find_or_fail.description: — one-line free text. Surfaces at /inspect/boundary/:name. Write descriptions for every non-obvious field — they are the boundary's interface contract.constraints: — a ShapeMatcher rule (same vocabulary as scenario expected: blocks — see §Scenarios). Validates at read-time under shape strictness. Example: constraints: { matches: "^[a-z][a-z0-9_-]+$" }.metadata: — free-form hash. Not interpreted by the validator; used for ops tagging ({ pii: true }, { unit: :seconds }).type:, default:, examples: — structured but no validation effect today.Nested fields declare the nesting explicitly:
input_shape: {
"args" => {
"stack_name" => {
required: true,
description: "CFN stack name; <system>-<environment>-<workload>",
constraints: { matches: "^[a-z][a-z0-9-]{2,127}$" }
},
"lint_threshold" => {
required: false,
description: "Severity floor that halts the verify chain",
constraints: { includes: ["info", "warning", "error"] }
}
}
}
Anti-pattern: declaring every field as bare true. It registers the shape but tells the operator and the scenario writer nothing. Write the rich form.
output_shape uses the same vocabulary against the keys the boundary writes to input.output. The engine uses it to scrub adapter mask-sets, verify scenarios, and document the product shape at the inspector.
chain: — both implementedA boundary can compose sub-boundaries. Two forms:
#call, input.dispatch(:sub, **overrides) runs a sub-boundary. Sub-crossings accumulate on a lazily-created inner context, not the outer route context — so the wrapper is the audit unit: one signed crossing to the route context, sub-work folded inside. A sub emitting :signals:stop:* propagates as the wrapper's halt (throw :wanderland_signal). Reserved overrides: when: (skip-with-marker) and as: (renames the sub-crossing + its address leaf). dispatch routes through runtime.execute, so boundary overlays are honoured.chain: — declare chain: [:step_one, :step_two, { boundary: :step_three, args: {...}, as: alias }] and, for a pure pipeline, no #call at all. The mixin ships a default #call that walks reg.chain, dispatching each slot via input.dispatch. project("context.parameters")-style thunks in slot args resolve against the wrapper's inner context. Sub output_shapes are spliced in for validation. Conditional wrappers still implement #call and call dispatch directly.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
File layout: one boundary :name, ... per file at 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.
A boundary that needs ENV vars declares them on registration so the value flows through the same audit/snapshot path as everything else:
boundary :read_node,
...
env: ["OCULUS_BASE_URL", "OCULUS_USER", "OCULUS_PASS"]
# or rich: { OCULUS_BASE_URL: { default: "https://i.loss.dev", description: "..." } }
Array, {KEY: default}, and {KEY: {default:, description:}} all normalize to {"KEY" => default}. 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.
Boundaries reach the outside world only through adapters — a uniform facade over an external dependency with mode-switching.
| 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 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 |
json_rpc |
core | JSON-RPC 2.0 over a transport | request/notify surface | modes live/replay/mock (no dry_run) |
store |
core | the append-only crossing store | append/read/query | modes memory / live (distinct mode set) |
aws |
aws-pack | Aws::<Service>::Client (method_missing forwards SDK) |
the SDK's call surface | service: + region: |
clock |
pipelines-pack | mode-switched system clock | .now |
mode: |
drone |
drones-pack | the drone CLI (a shell adapter) |
.run(*args) |
defaults command: drone |
Config.yml shape (kind defaults to the mount 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
live — real binding.replay — reads recorded envelopes from cassette_dir; a miss raises CassetteMissError (loud).mock — consults an in-process registry (config-seeded via mocks: or per-scenario via register_mock_envelope); strict; miss raises CassetteMissError.dry_run — engaged only via the --wanderland-dry-run request flag (with_dry_run wrap); logs the planned call, walks dry_run_responses:, returns first match or a uniform default. Present on http + shell; secret/json_rpc/store do not carry it.memory / live — the store kind's own two-mode set.dry_run_request returns symbol-keyed hashes while live/replay/mock return string-keyed. A real code issue; defensive boundaries accept both.Mode env override resolves at config-load (static for the runtime lifetime): mode: !Env { name: WANDERLAND_HTTP_MODE, default: live }.
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. Recording (RecordingHTTP / RecordingShell / RecordingAWS) only fires in :live mode; other modes pass through.
!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.
BoundaryInput#adapter wraps innermost→outermost: with_endpoint (LocalStack/MinIO/moto redirect) → with_recording → with_dry_run → LoggingDecorator (outermost). Each with_* returns a dup with a changed @mode/wrapper and no-ops unless @mode == :live. The wrapped adapter is memoized per BoundaryInput, so sequence counters stay shared across multiple input.adapter(:name) calls. Triggered by request options record_to / dry_run / aws_endpoint / localstack / debug pulled from context["options"].
Scrubbing: Adapters.scrub(value, masks) redacts declared mask plaintexts (longest-first) — applied in Registration#invoke against declared adapters only.
#client escape hatch: the bare gem handle — Net::HTTP for the last live HTTP request; Aws::*::Client always for AWS; a Struct(command:, baseline_env:) for shell (not the full Open3 handle).
HTTP basic auth: no native helper. Base64-encode user:pass and pass headers: { "Authorization" => "Basic <base64>" }. Ruby 3.4 needs require "base64".
Only when truly no existing kind fits — first grep register_kind across all 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:, pipeline:, tags:.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:, as:}, ...]
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 + body — body wins; captures relabeled UTF-8). On CLI they become --<name> VALUE options. !PathCapture does not exist in code.args: splices into the slot's input["args"]; the chain walker sets it before execute and deletes after.params: on a route are gated by a ParameterBroker (soft/strict), surfaced at /inspect/params/:name and as CLI --help.Defined in lib/wanderland/runtime.yml, run once by Runtime#boot_from_config against a @boot_context. One bad slot lands in Diagnostics; the rest continue.
| # | slot | boundary | role |
|---|---|---|---|
| 1 | boundaries |
boot_load_boundaries |
require every .rb under boundary_path |
| 2 | resolvers |
boot_configure_resolvers |
wire !Fixture / !Oculus / !Task etc. from resolvers: |
| 3 | storage |
boot_mount_storage |
sqlite/pg/file/memory drivers from storage.mounts; default :trace: mount |
| 4 | adapters |
boot_mount_adapters |
instantiate core + pack-registered kinds |
| 5 | triggers |
boot_mount_triggers |
reactive triggers |
| 6 | env |
env_snapshot |
union of env_allowlist: + boundary-declared env; emit signed :types:env crossing tagged context_seed |
| 7 | denials |
context_from_shape |
seed denial pool from denials: under capability [:denials] |
| 8 | core_injections |
register_injections |
framework injections: enforce_denials (interleave), then verify_route / trace_emit / format / seal (last) |
| 9 | user_injections |
register_injections |
site-declared injections from injections: |
| 10 | pre_flight_collect |
pre_flight_collect |
walk config for _tag markers, register resolver boot callbacks |
| 11 | pre_flight_run |
pre_flight_run |
fire pre-flight callbacks; halt boot on PRE_FLIGHT_FAILED |
| 12 | archetype |
boot_mount_archetype |
terminal — build Wanderland::Engine, mount CORE_ROUTES, walk config.routes, resolve each route's archetype→chain, fold through InjectionRegistry#apply |
Per-route archetype precedence (slot 12): explicit route archetype: > the selected pipeline's archetype > top-level config.archetype > none (route's chain: verbatim). archetype: ~ / false opts a route out; unknown archetype fails boot loudly.
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.
Engine::CORE_ROUTES — a site can override the handler but not remove the route. All also surface as CLI subcommands.
/health, /status, /healthcheck/inspect/config[/:path]/inspect/routes, /inspect/route/:name (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/params/:name, /inspect/resolvers/inspect/docs/boundaries, /inspect/docs/boundary/:name (JSON, or Accept: application/x.wanderland.template for ERB markdown)Diagnostic recipes:
curl /inspect/diagnostics (look for failed to mount adapter <name>).curl /inspect/boundaries | jq '.boundaries | index("<name>")'.curl /inspect/route/<name>.Engine#call matches Mustermann routes, reads + rewinds the body once, merges query + captures + body into params.Dispatch.invoke — the shared seam under every adapter. Builds the input envelope via Envelope.build(:request) (seven-key validated hash + optional identity), wraps params in a ParameterBroker, allocates a fresh Context (to_addr = :trace:<service-slug>:req-<uuid>) unless one is passed, re-seeds boot-time context_seeds, announces the adapter (runs adapter_http / adapter_cli and appends its signed crossing so downstream reads client / effective_mime / capabilities), then calls route[:dispatcher].execute(input, ctx).Dispatcher::Chain#execute walks every slot; never short-circuits. Per slot: effective guard = slot.when_shape || Boundary.effective_when(name) || BASE_DEFAULT_WHEN; next unless guard.matches?(context); build a fresh per-slot Envelope.build(:request) carrying context + args (slot args re-resolved through a :dispatch-phase TemplateEngine); execute the boundary; context.append(crossing). Returns context.events.last.Response.from(crossing) then subtype-dispatched render_rack_<subtype> (polymorphism, no case). nil status → 200; diagnostics dumped to stderr on ≥400.to_addr (where it lives), from_addr (who produced it — the boundary's identity id), type_addr (signal class), trace (causal link = prior crossing's signature). sign(storage) signs the canonical payload (auto-keys in dev/test). canonical excludes signature and duration_ms — per-boundary timing is observational and kept off the merkle commitment. Quacks like a Hash.input["context"]) — append-only event log of every crossing this request. Dual projection: value context["key"] (last-writer-wins scan across result hashes) and provenance context.<boundary> (most recent matching crossing). context["env"] short-circuits to the boot-captured frozen runtime.env_snapshot. Compose filters: context.signed.for_boundary(:auth).by_identity("x").since(5)["scopes"].count(type:/type_prefix:) tallies net events, subtracting paired :anti:<type> crossings.append soft-verifies signatures (bad sigs marked unverified; a tampered legacy-hash crossing is rejected). .signed filters to verified crossings; verify_chain validates the full merkle DAG.fork_at writes a fork-point crossing and returns a sub-context nested one level deeper; sub_at namespaces without a fork-point; for_event_filter widens scope to reach into sub-streams. Each scope has its own merkle head.Type-address namespaces: :types:* normal, :signals:stop:* blocks, :signals:pass:* observational.
BASE_DEFAULT_WHEN = { count: { type_prefix: ":signals:stop:", equals: 0 } }. A slot with no declared guard runs only when zero stop-signals are in scope. This is how halts propagate: once a stop lands, downstream default-guarded slots silently skip. There is no _halt short-circuit — the walker visits every slot; gating is by when: count, not by exception.enforce_denials emits :signals:stop:denied:<path>, so a recovery catcher can target one requirement. A recovery boundary declares when: { type_addr: { prefix: ":signals:stop:foo" } } ("throw/catch in this world"). when: { always: true } is finally-style.Wanderland::When narrowing keys — by_identity, signed, since, for_boundary, with_capability filter the context view before matching remaining shape keys. nil and {always: true} collapse to a frozen always-singleton.All under lib/wanderland/runners/; each drives the same compiled routes / Dispatch.invoke seam.
--type cli (runners/cli.rb) — Commander subcommand per route (name from spec["name"] else path). Captures + declared params: → typed --flag options; trailing key=value argv folds into params. Dispatches adapter: :cli; response subtype-dispatched to stdout, exit 0/1. emit_cli_ok keeps a trailing interleave injection leaving an OK last crossing from crashing the CLI. --wanderland-render short-circuits to a resolved-config dump.--type http (runners/http.rb) — Puma via Rackup::Handler.get("puma"). WEB_CONCURRENCY>0 → cluster mode with preload_app! + on_worker_boot { runtime.storage.reconnect! } (fork-safe sqlite). A config.ru can still run Wanderland.boot(...).engine.--type test (runners/test.rb) — walks scenarios.<route>.<case>, synthesises a request with X-Wanderland-Verify: <case>; the in-chain verify_route injection does the shape comparison. See §Scenarios.serves: registers a boundary in the formatter registry as the handler for a MIME type — not "this route returns that type." The registered formatter's #call runs on every response of that content type, across all routes. (See §"serves: is a formatter" for the crashloop trap.)
Wanderland::Formatters — a module-level MIME→boundary map. register(mime, name, default:) where default: true is a floor (@registry ||= ...), so pack/site formatters win regardless of load order. Default MIME is application/json unless a site sets format.default:.Boundary.register: Formatters.register(serves, name, default: default_format) if serves.json_formatter→application/json, text_formatter→text/plain, html_formatter→text/html, markdown_formatter→text/markdown, colored_text_formatter→text/x-ansi, template_formatter→application/x.wanderland.template (default_format: true).format slot (tail of every chain, when: always) reads context["effective_mime"], resolves a formatter (wildcard/default logic), dispatches it, and stamps {body, content_type, formatter_used}. A MIME miss → 406 text/plain. Boundaries that already produced body + content_type pass through.template_formatter — ERB template render; output MIME from the template extension; input_shape: { target: true }.A plain data/read boundary needs no serves: — the http adapter renders JSON by default (honouring Accept); the CLI renders YAML. Just return your hash / Signal.ok(...).
An archetype is a YAML file shipped by a pack declaring a named pipeline shape: ordered slots, each {name, boundary, args}. A site picks one with top-level or per-route archetype: <name>. Boundary fields can be !UserConfig pipeline.stages.<stage>.type-style thunks that resolve against user 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 read sub-results. Halts propagate: a sub-crossing whose type_addr starts :signals:stop: is halt_passthrough'd — status/error extracted and re-emitted as the iterator's own halt.
Archetypes shipped:
containerized_deployment (pipelines-pack) — 7 slots: start_run → collect (a chain, recorded as: collect) → load_metadata → build (artifacts over pipeline.artifacts.builds; optional, !UserConfig default: noop) → image (artifacts over images) → deploy (component over pipeline.components) → complete_run.infrastructure_deployment (pipelines-pack) — start_run → load_metadata → provision (boundary = !UserConfig provisioner, e.g. cfn_provisioner / shell_provisioner) → publish (publish_cfn_outputs / publish_stack) → complete.loom_ingest (loom-pack) — ast → section_markers → fence_markers → pattern_match → write_records (each reads upstream via !Context for_boundary.<slot>.<field>).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 | k8s_provisioner | cfn_provisioner
args: { task_definition:, service:, wait_timeout_s: }
Each entry's _name (its hash key) auto-splices into the per-iteration args.
Injections: the user-declared chain (from archetype slots) is folded through InjectionRegistry#apply — a per-runtime registry, left-fold (declaration order = application order). Each injection is {boundary, position}:
:first → prepend; :last → append; :interleave → a slot before every user slot.{interleave: <when-cond>} (conditional), {before: <name>}, {after: <name>}.Framework defaults (all real boundaries): enforce_denials (interleave), then verify_route / trace_emit / format / seal (last). seal produces the merkle commitment _seal {chain_valid, chain_depth, sealed_sigs, sealed_at} over the whole chain. Injected slots are marked injected? and stripped from the named-route "chain" projection.
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).
Tag → registered phase. Each resolver subclass declares tag, phase, ttl, plus a description/input_shape/output_shape/examples manifest feeding /inspect/resolvers.
Phases: :early (load/boot, before boundaries) → :late (a second boot pass, so a late resolver can consume an already-resolved early marker) → :dispatch (resolved in the dispatcher just before each slot's args reach its boundary; sees request context). TemplateEngine phase :all = everything except :dispatch.
| Tag | Phase | Argument shapes | Returns |
|---|---|---|---|
!Env |
:early |
string raises if unset; {name:, default:} 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> |
:early |
path into UserConfig.data |
nil on miss (silent — typos vanish) |
!Fixture <path> [at] |
:early |
file + optional dig | YAML fixture; ERB pre-interpolates <%= … %> |
!Merge [layer, …] |
:early |
array of hashes | HashUtils.deep_merge left-to-right; arrays replace |
!Patch [base, overrides] |
:late |
base + {dot.path: value} |
base with dotted-path writes, auto-vivifying hashes |
!Coalesce [a, b, …] |
:late |
list | first non-nil |
!Route <name> |
:late |
pipeline/route name | resolved route reference |
!Oculus / !Task |
:late |
path | remote API fetch via HttpProvider |
!Dig <path> |
:dispatch |
dotted path | marker-aware dig into request context |
!Sub <template> |
:dispatch |
template | request-time string interpolation |
!Context <path> |
:dispatch |
path | reach into the request context (see gotcha below) |
!Verify <spec> |
:dispatch |
spec | boot/dispatch-time verification hook (aws-pack registers IAM ARN patterns) |
!MappedList <spec> |
:dispatch |
spec | mapped-list projection |
!Merge is :early (its class comment still says "Late" but phase :early is declared — so boot_mount_adapters sees merged mock arrays). !Patch remains :late. boot_mount_archetype resolves phase: :all at mount time so late resolvers fire against archetype slots.!Env ≠ !Environment. !Env reads ENV[NAME]. !Environment digs the active block of the loading config (requires wanderland_environment: at the top level, often !Env { name: WANDERLAND_ENVIRONMENT, default: local }). Different concepts.!Context for_boundary.<name> reaches the MOST-RECENT crossing of that recorded name — same-named boundaries shadow each other. Disambiguate with as: (see Tips & tricks).Two independent ERB passes run before YAML parses:
Config.load runs ERB.new(raw).result(EnvBinding...) over the raw YAML text before parsing (names resolve to ENV[NAME.upcase]), then an :early TemplateEngine pass.!Fixture file — !Fixture re-runs ERB over the fixture body before its own YAML parse.Use ERB (<%= … %>) for pre-YAML text substitution (interpolating a name into a YAML key); use !Env for value substitution (the common case — also tracked in env_snapshot). Escape a tag meant for a later runtime ERB as <%%= … %> so the load pass emits a literal. (Wanderland::Template.render is a separate standalone ERB+method-chain helper used by template_formatter.)
**There is no ${VAR}` substitution inside wanderland.** That's a JCasC / Kustomize / shell layer one level out. Vivarta-jenkins's JCasC uses `${AGENT_IMAGE_REF} because Jenkins reads it; wanderland's analogue inside vivarta.yml is !Env { name: AGENT_IMAGE_REF } or <%= AGENT_IMAGE_REF %>.
Red→green is the discipline for wanderland-core changes. Failing scenarios precede the code change. New feature: write the spec, watch it fail for the right reason, implement, watch it pass. Changing behavior: update the spec that pinned the old behavior, watch it fail, implement, watch it pass. "Or it doesn't count."
The repo holds two patterns side by side — use both.
spec/wanderland/<topic>_spec.rb. spec_helper.rb (22 lines) boots a fresh runtime per example:
config.before(:each) do
Wanderland::Formatters.reset!
Wanderland::Boundary.reset! # nils the registry + default identity/requirements/capabilities
Wanderland::Boundary.reload! # re-registers every class that includes Wanderland::Boundary from @boundary_meta
@runtime = Wanderland.boot(File.join(__dir__, "config.yml"))
end
reset! wipes proc-based boundaries; reload! brings class-based ones back — so inline test boundaries register on the runtime overlay, not the global registry:
before do
@runtime(:passthrough) { |input| input.respond_to?(:to_h) ? input.to_h : input }
end
Runtime#register_boundary stores into @boundary_overlay; Runtime#execute checks the overlay first, then the global registry. The overlay dies with the runtime — no cross-test leakage.
Use unit specs for: DSL surface, Registration shape, signal throw/catch, adapter wrapping order, Slot.from_spec, direct-adapter(:x).run branches (which scenario mocks do NOT cover — see below), anything where a single method is the unit.
spec/scenarios/<topic>/<NN_name>.yml, run by spec/wanderland/<topic>_scenarios_spec.rb. Loader pattern:
scenario_dir = File.expand_path("../scenarios/<topic>", __dir__)
Wanderland::Scenario.load_all(scenario_dir).each do |scenario|
it scenario.name do
scenario.runtime = @runtime
failures = scenario.verify
expect(failures).to be_empty, -> { failures.join("\n") }
end
end
Full YAML vocabulary:
name: — human label (the it description).description: — free text.operation: — boundary name dispatched via runtime.execute(operation, input). Falls back to a detect_operation scan.input: — request hash (params:, headers:, args:, …). resolve_input! runs TemplateEngine when _tag/_arg markers are present (skip with resolve: false). An input.context array is hydrated into a real Context.expected: — ShapeMatcher rule against the resulting crossing's result + context. Alias: expect:.mocks: — scenario-scoped adapter stubs (route-mode only — see gotcha).blanks: — { path:, answer:, hint: } Game-mode fill-in-the-blank.level: — { world:, number:, hint: } Game ordering.Two modes: :boundary (default; bare operation: + input:, runs runtime.execute) and :route (from_route_config / from_config, dispatches the full request path — this is what --type test uses).
Registry-based (lib/wanderland/shape_matcher.rb). Leaf true = presence wildcard (any non-nil); symbols normalize to strings for equality. In an expected: hash, matcher-named keys run against the whole actual; non-matcher keys descend by key.
| Key | Behavior |
|---|---|
count |
count: N → size == N; count: {type:/type_prefix:, equals:/gt:/gte:/lt:/lte:} → Context net-count |
gte / lte / gt / lt |
numeric compare (Numeric, else .size) |
eq |
literal == (the only way to match a literal true) |
present |
true = non-nil, false = nil |
matches |
regex on to_s |
prefix / begins / ends |
start_with? / end_with? |
contains |
Array membership (rejects String actual → use matches) |
includes / excludes |
all / none of listed items present in Array |
in_order / run |
values appear in order (gaps allowed) / contiguous |
occurs |
{of:, count:} — count of matching items equals count |
nth |
{index:, value:/shape:} element assertion |
first / last / any |
recursive submatch on head / tail / any element |
keys / has_key |
exact key-set / single key presence |
not |
negation |
empty |
true / false |
all / any_of |
AND / OR of sub-shapes |
is_a |
type family: integer/string/boolean/array/hash/float/numeric/nil |
one_of |
membership in a value list |
min_length / max_length |
length bounds |
Collision rule: any expected: key whose name equals a registered matcher is interpreted as a matcher, never a literal field. To assert on a field literally named count / first / last / keys / empty, use eq: for the value or nest it one level down.
Envelope shape {request:, response:}, keyed by adapter name; TemplateEngine(phase: :all) resolves !Fixture/!Merge before registration:
mocks:
http:
- request: { url: "https://example.com/probe", method: GET }
response: { status: 200, body: "stubbed", headers: {} }
kubectl: # a shell adapter mounted under this name
- request: { args: [get, pods] }
response: { exit_code: 0, stdout: "seeded\n" }
Mockable adapters implement register_mock_envelope / clear_mocks!: http, shell (kubectl/cloudformation/…), json_rpc. clear_mocks! restores the boot/config-seeded set — scenario stubs drop, site seeds survive.
The gotcha: mock registration runs only inside verify_via_dispatch (route mode). A boundary-mode scenario (bare operation: + input:) never registers mocks. And even in route mode, mocks feed the dispatched sub-boundary path (input.dispatch(:k8s_job) → adapter); a boundary that shells the adapter directly — input.adapter(:kubectl).run("get", "jobs", …) — does not get the canned response even when args: match. Cover direct-adapter branches with an rspec unit (a fake input whose adapter(:kubectl).run returns the canned JSON). Precedent: wisp classify dedupe (spec/classify_dedup_spec.rb).
--type test runnerbundle exec wanderland --type test config.yml # all scenarios in the scenarios: block
bundle exec wanderland --type test config.yml --route deploy
bundle exec wanderland --type test config.yml --scenario happy_path
Each site scenario runs the normal dispatch path with headers["X-Wanderland-Verify"] = <name>. The verify_route boundary (chain tail) reads the prior crossing's result as actual (never synthesizes input), ShapeMatcher.matches? per scenario, passthrough on match / Signal.halt(422, "scenario mismatch", scenarios: failed) on miss. Exit 0 all pass; 1 fail-or-no-match; 2 bad argv. --wanderland-debug / WANDERLAND_DEBUG cranks logging to stderr.
cd wanderland-core && bundle exec rspec # excludes :live by default
bundle exec rspec spec/wanderland/boundary_input_spec.rb # one file
bundle exec rspec spec/wanderland/wrapper_chain_scenarios_spec.rb # one scenario topic
WANDERLAND_LIVE=1 bundle exec rspec # include :live (spawns puma / bin/wanderland; slow)
:live gating is two-fold: spec_helper excludes live: true unless WANDERLAND_LIVE is set, and specs tag at the describe level (RSpec.describe "...", :live do). Live files spawn external processes or make real network calls (adapters_http_spec.rb, dispatch_http_scenarios_spec.rb, recording_http_spec.rb, …).
spec/scenarios/dispatch/ uses a separate harness (DispatchScenario / DispatchHarness) with site:, request:, and an expected: block keyed by layer: crossing (in-memory Dispatch.invoke), response/rack (in-memory render triple), cli (Open3 against bin/wanderland), http (Net::HTTP against spawned puma). Sub-dirs layer_1_seam … layer_4_http_binary; each runner only runs scenarios that assert its layer.blanks: / level: let a scenario double as a fill-in-the-blank puzzle — check_answer runs the scenario, digs blank.path into the output, scores user_correct + engine_correct. Many scenario YAMLs carry blanks:/level: even when only their expected: is exercised.spec/scenarios/<topic>/ if the change crosses boundaries.The deploy slot of containerized_deployment targets Kubernetes as well as ECS. wanderland-k8s-pack ships kustomize/kubectl boundaries; a site mounts a :kubectl shell adapter (adapters: { kubectl: { kind: shell, command: kubectl } }) 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. 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 names it, image stage goes noop. |
k8s_secret_json_patch |
Set a dot-path value inside JSON held in a live Secret key (read → splice → patch back). |
k8s_job |
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.
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. !Fixture config/sizes.yml searches sizes.yml.yml → 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. Unset ⇒ empty ⇒ every !Fixture misses:resolvers:
Fixture:
fixture_dirs: ["."] # relative to this config; add more roots as needed
The double-ERB trap. A string meant for a later runtime ERB (e.g. Wanderland::Template.render at dispatch) is evaluated too early by the config/fixture ERB pass — 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
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):
--wanderland-render [render] — short-circuits before dispatch and dumps the fully-resolved service / config / route (every !Env / !Fixture / !Environment 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, the crossing type. First thing to reach for when a chain surprises you. (stdout still gets the clean result.)--wanderland-trace [trace] — appends the signed crossing record of the terminal result: to_addr, duration_ms, signature, and the trace (Merkle link to the prior crossing). Provenance + timing.--wanderland-verify <case> [verify] — runs the route and shape-checks the result against the named scenario, printing pass/fail; non-zero exit on mismatch. Ad-hoc --type test against a single invocation.--wanderland-record-to <dir> [record_to] — captures live adapter calls into cassettes for later mode: replay. No-op in mock/replay. Record once, replay forever.--wanderland-dry-run [dry_run] — flips live adapters to dry-run: 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).--wanderland-run-id <id> [run_id] — pins the request run id (shows in the :trace: address + debug/trace output).--wanderland-no-color [no_color] — strip ANSI.--wanderland-aws-endpoint <url> [aws_endpoint] / --wanderland-localstack [localstack] — point aws-kind adapters at a custom endpoint / localstack.Combine: --wanderland-debug --wanderland-trace to watch the chain and see the signed terminal crossing; --wanderland-render alone to debug resolution before 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 — see template_formatter / markdown_formatter./health, /status) returns your boundary's error. Grep your boundaries for serves: first.All packs extend wanderland-core via the adapter → boundary → archetype model. Only aws / clock / drone register adapter kinds; the rest register boundaries/formatters/painters or consume adapters a site mounts. Loom warrants its own deep-dive: wanderland-dev-loom-pack.
| Pack | registers kind | boundaries | archetypes | consumes |
|---|---|---|---|---|
| aws | aws |
28 | — | (its own) + resolvers/Patterns |
| pipelines | clock |
28 | containerized_deployment, infrastructure_deployment |
— |
| k8s | — | 6 | — | :kubectl (shell) |
| cheshire | — | 2 | — | :cheshire (SSR bridge) |
| drones | drone |
5 | — | :drone (shell) |
| lantern | — | 1 (+ painter registry) | — | — |
| loom | — | 34 | loom_ingest |
:store, :remark, :ruby_runtime, :python_runtime |
| sutra | — | 5 | — | core storage registry |
wanderland-aws-pack — the aws adapter plus the ECS/ECR/CFN/S3/SSM deploy-and-verify set. Spec: service, region, mode (live/replay/mock), cassette_dir, mocks, dry_run_responses, retry_mode, max_attempts, endpoint, mask, assume_role (lazy sts:AssumeRole, cached session), client_max_age. Boundaries: ECS (register_task, poke_service, wait_service_stable, ecs_service_deploy, ecs_describe_*, ecs_cluster_report, ecs_drain_container_instances, live_image), ECR (ecr_repository_provisioner, ecr_verify_image, ecr_docker_login, ecr_latest_image), CFN (cfn_template_analyzer, cfn_param_guard, cfn_provisioner, publish_cfn_outputs, publish_components), identity/verify (aws_caller_identity, verify_aws_resources), S3 (s3_object), SSM sweep trio (detect_stale_managed_instances, deregister_managed_instances, sweep_stale_managed_instances). Also ships resolvers !SsmLookup, !EcsOptimizedAmi, !Now, !ListenerPriority, and a pre-flight IAM-role ARN pattern registered with Wanderland::Patterns.
wanderland-pipelines-pack — deployment-pipeline archetypes + slot boundaries, plus the clock adapter kind (mode-switched clock behind calver). Boundaries beyond the archetype slots: version/metadata sources (calver, git_hash, gemspec, pom_xml, package_json, load_metadata), gem publishing (gem_build, gem_push), git_release, generic http_request / chain / noop, and the iterators (iterate_artifacts→artifacts, iterate_components→component). Ships tags/default.yml that load_metadata deep-merges under the site's tags:. Note: there is no docker_image boundary — the pack ships docker_build + publish_image (docker_image is the pipeline actor name).
wanderland-k8s-pack — see §Kubernetes deploy.
wanderland-lantern-pack — the base SSR/HTML layer. One boundary lantern_html (serves: "text/html") renders a {layout, data, title} principal result into a full page (overrides core html_formatter by MIME). The substance is the module surface: Wanderland::Lantern painter registry (register/component), Lantern::Renderer (layout tree + data → HTML), Lantern::Page (page frame — "Bureau Light" inline CSS + <ln-live> enhancement JS + a JSON data carrier), and built-in painters (header, kv-table, link-list, markdown, groups, live-view, nav, raw). Philosophy: server paints inert HTML + a JSON carrier ("the grin"); the browser enhances from the same carrier ("the cat").
wanderland-cheshire-pack — the SSR half of the Cheshire component kit; sits on top of lantern-pack, registers Lantern painters emitting cheshire-* custom elements, and consumes a :cheshire adapter (a Node SSR bridge, contract (tag, props) -> {html, props}). Boundaries: cheshire_html (serves: "text/html", adapters: [:cheshire], overrides lantern_html when loaded) and cheshire_showcase (component gallery with deterministic sample data — doubles as a visual-regression target). Painters: cheshire-page, cheshire-nav, cheshire-modeline, cheshire-version, cheshire-node, cheshire-footer. Without the :cheshire adapter mounted, a site should use plain lantern_html.
wanderland-drones-pack — run drone sorties from inside Wanderland (the Wanderland→drone half of interop). Registers the drone kind (a shell adapter, default binary drone). Boundaries: drone_runner (synchronous drone run <sortie>, nonzero exit → halt 502) and the async "hive" suite — hive_run (validate params, spawn detached, register run under runs_dir, no shell interpolation), hive_runs (run registry/changelog, secrets redacted), hive_sortie (one sortie's declared params as a form spec), hive_sorties (list runnable sorties). hive_run needs gem "vivarta-drones" alongside for sortie introspection.
wanderland-sutra-pack — the recursive attributed case/runbook primitive. One primitive, the rūpa: a named form with metadata, an append-only tale, and a state; a runbook is a sūtra of rūpas whose state gates the callable action set. Runs on core's storage registry (no new engine). Boundaries: sutra_create (mint a plan + initial run tip), sutra_force (trigger an available action → append a tip advancing state), sutra_here (current state + trail), sutra_read (walk the trace, derive the shaped view at each tip), sutra_graphql (GraphQL over the crossing store). Storage: metadata at :rupa:<id>, children under :rupa:<id>:mount:, tale under :rupa:<id>:tale:.
| 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>. |
| Run a shell command | kind: shell adapter with command: <binary>; input.adapter(:foo).run(*args, cwd:, env:). One 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: + region:. SDK via method_missing. |
| Iterate a config bucket and dispatch per entry | Iterator boundary (iterate_artifacts / iterate_components). Don't reinvent. |
| Wrap several sub-boundaries into one | Declarative chain: (pure) or input.dispatch(:sub) inside #call (conditional). Halt-passthrough on :signals:stop:*. |
| Read a process env var in config | !Env { name:, default: } (tracked in env_snapshot). |
| Read an environment-specific value | !Environment <path> (requires top-level wanderland_environment:). |
| Splice runtime user data into a slot | !UserConfig <path> (silent on miss). |
| Reach the request context at dispatch | !Context <path> / !Dig / !Sub (:dispatch phase). Disambiguate same-named boundaries with as:. |
| Replay recorded HTTP/shell/AWS in tests | Adapter mode: replay, point cassette_dir: at the cassettes. |
| Stub one shape per scenario | Scenario mocks: block (route mode only) — envelope / !Fixture / !Merge. |
| Add a new adapter kind | First grep register_kind everywhere. If truly new: BootMountAdapters.register_kind("name") { ... } in the pack 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 an archetype slot, or write a sub-boundary an iterator dispatches. No per-stage scaffolding. |
| Find what's currently registered | curl /inspect/boundaries, /inspect/route/:name, /inspect/diagnostics. |
| Record a cassette | --wanderland-record-to <dir> in live mode; next live call writes, subsequent runs replay. |
Reusable lessons from live pipeline and deploy work. These catch the high-value, expensive mistakes and flag where the platform still needs UX revision. The primer carries the distilled one-liners; this is the long form. When a failure teaches something the next service will hit too, record it here — the shared copy every session can read.
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. 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 use $stderr.puts, never warn "…". It surfaces cleanly in a standalone require "wanderland-core" unit — 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).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.!Context for_boundary.<name> reaches the MOST-RECENT crossing of that recorded name — same-named boundaries shadow each other. The context resolver filters ctx.for_boundary(name).last; for_boundary matches crossing["boundary"] == name || crossing["source_boundary"] == name. The recorded boundary name is the slot/step's as: override if given, else the registration name. The trap: the containerized_deployment collect slot dispatches the chain boundary, so k8s_live_image's promotion output lands on a crossing recorded as chain, referenced downstream as !Context for_boundary.chain.image_uri. If the deploy stage then runs a component that is ALSO a chain: wrapper (e.g. a cfn-DNS leg beside the k8s workload), THAT crossing is recorded as chain too — and for_boundary.chain now returns the DNS chain, which has no image_uri → the workload's image: resolves to nil → k8s_provisioner halts 400 image is required. Single-component deploys and image-consumer-first single chains never surface it. Fix: disambiguate with as:. as: is honored in three spots — archetype slots, route/dispatcher chain steps, and declarative chain-wrapper / sub-dispatch steps. Name the disambiguating crossing (e.g. the DNS step as: dns) and reference the specific name. Note _name (the iterator key) namespaces only the ADDRESS leaf, NOT the for_boundary reach-back name — only as: sets both. Surfaced on the first kitsune-meadow k8s-promotion prod runs (case task-17f6120d).tags — mind the deployer scope. cfn_provisioner / publish_cfn_outputs name the stack via StackIdentity.stack_name, joining tags in order: tier-[product-]system-environment-sub_environment[-generation] (blank segments dropped; product optional). The stack_name_prefix guard requires the derived name to lead with the prefix the deployer IAM role scopes CloudFormation to — today advaita-ecs-workloads. So a pipeline tagged tier: iscanguide / product: iscanguide derives iscanguide-iscanguide-<system>-<env>-<sub> → 422 out-of-scope halt at cfn_template_analyzer. Workaround: CoreWeave cfn deploys must tag tier: advaita-ecs-workloads (matching foxfire/wisp) and omit product; system/sub_environment are free. Proper fix (case task-30692a48): widen the deployer role's CFN scope → eventually per-platform roles, then restore the intended tier/product. Same tags build the SSM output prefix /<tier>/<sub_env><env>/<system>, so changing tier moves where outputs are published.Where code disagrees with a reference node, trust the code. Resolved since the older drift list: the declarative chain: wrapper and input.dispatch(...) are now implemented; the boot chain is 12 slots; json_rpc / store adapter kinds and the :dispatch-phase resolvers exist. Remaining items to watch:
shell / secret / aws / json_rpc / store kinds, dry_run mode, the with_endpoint/with_recording/with_dry_run/LoggingDecorator wrap order, or register_kind._halt gate short-circuit. Current boot is the 12-step runtime.yml 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. It's only on :boot-stage envelopes; at request stage reach it via input["runtime"].config or input["config"]["_config_dir"].!Environment, !Patch, !Reference, !Coalesce, !Route, and the whole :dispatch phase (!Context / !Dig / !Sub / !Verify / !MappedList); lists !Mapped (now !Reference); marks !Merge late (now early).Wanderland::Injections); it's a per-runtime InjectionRegistry. Doc .apply(...) calls no longer compile.!Merge class comment still says "Late phase" though phase :early is declared.dry_run_request key-style bug — returns symbol-keyed hashes while live/replay/mock return string-keyed. Code bug, not just docs.render_params is referenced in vivarta-jenkins-tutorial-cfn-verify as belonging in pipelines-pack but does not exist on disk. Tutorial-ahead-of-code; don't assume it exists.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) never sees Net::HTTP, Open3, or raw AWS SDK — those live on the other side of an adapter facade. No backticks / system(...); use kind: shell. No direct Net::HTTP; use kind: http.adapters: [:foo] matches the config.yml mount key — symbols on both sides.grep -rh "boundary :<name>" /Users/graemefawcett/working/wanderland-*/lib — registry is global, names collide.grep -r "register_kind" /Users/graemefawcett/working/wanderland-*/lib.Tutorials (useful as code examples): wanderland-dev-tutorial-hello-world, wanderland-dev-writing-boundaries, wanderland-dev-adapter-modes, wanderland-dev-aws-adapter-modes, wanderland-dev-tutorial-engine-service, wanderland-dev-pipelines-pack, wanderland-dev-containerized-pipeline-tutorial, vivarta-jenkins-tutorial-secrets, vivarta-jenkins-tutorial-cfn-verify.
Reference nodes (flag drift per §Sharp edges): wanderland-core-boundary, wanderland-core-engine, wanderland-core-config, wanderland-core-dispatch, wanderland-core-context, wanderland-core-flow-control, wanderland-core-template-engine, wanderland-core-archetype-system, wanderland-core-injections, wanderland-core-shape-matcher, wanderland-core-scenario, wanderland-core-introspection, wanderland-core-adapters, wanderland-dev-aws-pack, wanderland-dev-k8s-pack, wanderland-dev-loom-pack.
Source code (authoritative when docs disagree):
/Users/graemefawcett/working/wanderland-core/lib/wanderland/ — engine, config, dispatch, runtime, signal, types, diagnostics.../lib/wanderland/adapters/{http,shell,secret,json_rpc,store,cassette,recording_*}.rb.../lib/wanderland/boundaries/boot/*.rb — the 12 boot slots; .../runtime.yml — the boot chain.../lib/wanderland/resolvers/{env,environment,reference,user_config,fixture,merge,patch,coalesce,route,dig,sub,context,verify,mapped_list}.rb.../lib/wanderland/{shape_matcher,scenario,dispatch_scenario}.rb, .../runners/{cli,http,test}.rb/Users/graemefawcett/working/wanderland-aws-pack/lib/wanderland-aws-pack.rb — aws kind/Users/graemefawcett/working/wanderland-pipelines-pack/lib/wanderland-pipelines-pack.rb — clock kind + boundaries + archetypes/Users/graemefawcett/working/wanderland-{k8s,cheshire,drones,lantern,loom,sutra}-pack/