Wanderland

Wanderland Dev — AI Reference

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-*.

Boundaries

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_shape / output_shape — full field-rule vocabulary

Each 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:

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.

Wrapper pattern & chain: — both implemented

A boundary can compose sub-boundaries. Two forms:

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.

Env declaration

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.

Adapters

Boundaries reach the outside world only through adapters — a uniform facade over an external dependency with mode-switching.

Kinds — core built-ins

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

The modes

Mode env override resolves at config-load (static for the runtime lifetime): mode: !Env { name: WANDERLAND_HTTP_MODE, default: live }.

Cassettes & recording

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.

Per-request wrap order

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".

Registering a new kind

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.

Configuration & routes

config.yml top-level keys:

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 }

Boot chain — 12 slots

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.

Always-mounted CORE routes

Engine::CORE_ROUTES — a site can override the handler but not remove the route. All also surface as CLI subcommands.

Diagnostic recipes:

Dispatch & flow control

Request path

Crossing / Context

Flow control

Type-address namespaces: :types:* normal, :signals:stop:* blocks, :signals:pass:* observational.

Three invocation flavors

All under lib/wanderland/runners/; each drives the same compiled routes / Dispatch.invoke seam.

Formatters

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.)

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(...).

Archetypes, templates, injections

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:

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}:

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).

YAML resolvers

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

The double-ERB

Two independent ERB passes run before YAML parses:

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 %>.

Scenarios & testing

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.

1. RSpec unit/feature specs

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.

2. Scenario YAMLs

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:

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).

ShapeMatcher — full reserved-key vocabulary

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.

Scenario mocks — the route-mode gotcha

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 runner

bundle 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.

Running the suite

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, …).

Layered dispatch scenarios & Game-mode

Workflow checklist for a wanderland-core change

Kubernetes deploy (wanderland-k8s-pack / CoreWeave)

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.

Fixtures, config mocks & the double-ERB (field notes)

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

!Fixture path rules:

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.

Running a route from the CLI (+ the --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):

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.

Pack catalog

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:.

Quick lookup

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.

Tips & tricks

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.

Sharp edges & doc drift

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:

Style guide

For reader-facing nodes, see wanderland-dev-style-guide. Rules that apply to code-style writing too:

For code:

Pointers

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):