Wanderland

Engine in a Box

This is the first walkthrough in the Engine in a Box series — six posts that end with a working puzzle game. The puzzle is small and grid-shaped: a player drops cells onto a board, presses Run or Step, and a request walks through the cells one tick at a time. Each cell calls a real wanderland-core boundary — fetch a value, transform it, record a side effect, emit a signal — and wiring those calls into a flow that satisfies the level's goal is how the player solves it. Pipeline-as-puzzle: the same engine that runs the request runs the game.

Today we're going to set up the scaffolding for a brand-new wanderland-core application called wanderland-engine, get it answering on port 9295 locally, then package the same code as a Docker image as part of a larger docker compose stack.

When you're done you'll be able to:

What the engine ships

A wanderland-core application is the combination of a config.yml file (routes, storage mounts, adapter modes) and a directory of boundary classes the engine loads at boot. Wanderland.boot(config_path) returns a Runtime — a fresh, instance-scoped world per call, with its own boundary registry, storage registry, adapter registry, tracer, and engine. runtime.engine is the Rack-compatible app that puma serves.

A site that declares zero custom boundaries still gets a fully-functional service. The framework mounts eleven core routes the site cannot remove (it can override the handlers, but not unregister the paths) and folds five framework-default injections over every user chain (enforce_denials interleaved between user slots, verify_route / trace_emit / format / seal appended). The introspection surface lets an operator read the full picture over HTTP without shelling into the container.

Three liveness endpoints answer in increasing depth:

Five introspection endpoints expose registry shape:

A scenario surface (/inspect/scenarios, /inspect/scenarios/:route) ships in the same set; it lights up in the next tutorial when the first scenarios YAML lands.

1. Create the project directory

mkdir -p ~/working/sprout/wanderland-engine
cd ~/working/sprout/wanderland-engine

2. Create Gemfile

# frozen_string_literal: true

source "https://rubygems.org"

gem "puma", "~> 6.0"
gem "wanderland-core", path: "../wanderland-core"
gem "rake"

group :development, :test do
  gem "rspec", "~> 3.0"
  gem "rack-test", "~> 2.0"
end

The path: "../wanderland-core" resolves against the workspace symlink during local development. Inside the container the dependency resolves the same way against a fresh clone placed at the sibling location — the Dockerfile sets that up in step 11.

3. Install dependencies

bundle install

4. Create config.yml

service: wanderland-engine
port: 9295
boundary_path: lib/wanderland_engine/boundaries

routes:
  /echo:
    method: get
    boundary: echo
    name: echo

Three engine keys carry service identity (service, port, boundary_path); one route declares the public surface. echo is shipped by wanderland-core under Wanderland::Boundaries::Echo, so the route works without any custom code yet — it gives the introspection endpoints something concrete to report on.

name: echo registers the route in the named-route index used by /inspect/routes and /inspect/route/:name. Routes without a name: key still dispatch but don't show up in those listings.

No storage: block is required: boot_mount_storage mounts a default in-memory :trace: driver when the site declares none.

5. Create config.ru

# frozen_string_literal: true

require "wanderland-core"

run Wanderland.boot(ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))).engine

Wanderland.boot reads the config path, runs the engine archetype's boot slots (load boundaries, configure resolvers, mount storage, mount adapters, mount triggers, capture env, register injections, mount routes), and returns a Runtime. .engine is the Rack-compatible app that puma serves.

6. Create the boundary directory

mkdir -p lib/wanderland_engine/boundaries

The directory is empty for now. boot_load_boundaries iterates whatever .rb files it finds under boundary_path, so an empty directory boots cleanly. A missing directory records a warning in the diagnostics cache (visible at /inspect/diagnostics) but doesn't halt boot.

7. Create lib/wanderland_engine.rb

# frozen_string_literal: true

require "wanderland-core"

module WanderlandEngine
end

Namespace anchor for the boundaries that arrive in later tutorials.

8. Create Rakefile

# frozen_string_literal: true

require "wanderland-core"

desc "Start the engine"
task :server do
  config_path = ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))
  port = Wanderland::Config.load(config_path).port
  exec "bundle exec puma config.ru -p #{port}"
end

desc "Show loaded routes and their boundaries"
task :routes do
  config_path = ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))
  runtime = Wanderland.boot(config_path)

  config = runtime.config
  puts "Service: #{config.service}"
  puts "Port:    #{config.port}"
  puts
  puts "Routes:"
  config.routes.each do |path, spec|
    method = spec[:method].to_s.upcase.ljust(6)
    if spec[:chain]
      chain = Array(spec[:chain])
      labels = chain.map { |b| Wanderland::Boundary.lookup(b.to_sym) ? "✓#{b}" : "✗#{b}" }
      puts "  #{method} #{path} → [#{labels.join(' → ')}]"
    else
      mark = Wanderland::Boundary.lookup(spec[:boundary].to_sym) ? "✓" : "✗"
      puts "  #{mark} #{method} #{path} → #{spec[:boundary]}"
    end
  end
end

desc "Show registered boundaries"
task :boundaries do
  Wanderland.boot(File.join(__dir__, "config.yml"))
  puts "Registered boundaries:"
  Wanderland::Boundary.registered.sort.each do |name|
    handler = Wanderland::Boundary.lookup(name)
    type = handler.is_a?(Class) ? handler.name : handler.class.name
    puts "  #{name} → #{type}"
  end
end

desc "Hit the health endpoint"
task :health do
  port = Wanderland::Config.load(File.join(__dir__, "config.yml")).port
  exec "curl -s http://localhost:#{port}/health | ruby -rjson -e 'puts JSON.pretty_generate(JSON.parse(STDIN.read))'"
end

desc "Run specs"
task :spec do
  exec "bundle exec rspec"
end

task default: :spec

rake routes and rake boundaries are the build-time mirrors of the live /inspect/routes and /inspect/boundaries endpoints — same registries, queried before the server starts. Useful when a route or boundary is missing and the run-time 404 is too late to act on.

Wanderland::Config.load(path) parses the config without booting any of the slot chain — cheap enough for read-only tasks that just want the port. Wanderland.boot(path) runs the full archetype and returns a Runtime; runtime.config and runtime.engine.compiled_routes are the right hooks once the registries need to be populated.

9. Boot the engine locally

bundle exec rake server

Expected output ends with puma announcing the bind:

Puma starting in single mode...
* Listening on http://0.0.0.0:9295
Use Ctrl-C to stop

Leave it running. The probes in step 10 hit it. Ctrl-C when done.

10. Probe the running engine

In a second shell, hit the liveness triptych:

curl -s http://localhost:9295/health      | python3 -m json.tool
# {
#   "status": "ok",
#   "service": "wanderland-engine",
#   "timestamp": "2026-04-26T12:34:56-04:00"
# }

curl -s http://localhost:9295/status      | python3 -m json.tool
# {
#   "status": "ok",
#   "service": "wanderland-engine",
#   "boundaries": ["adapter_cli", "adapter_http", "echo", "enforce_denials", ...],
#   "routes": 12,
#   "timestamp": "2026-04-26T12:34:56-04:00"
# }

curl -s http://localhost:9295/healthcheck | python3 -m json.tool

/healthcheck is the deepest liveness probe: the response includes the full boundary manifest (one entry per registered boundary), the compiled routes list, and the diagnostics summary nested under diagnostics:. A clean boot returns status: "ok"; any boot-recorded error flips it to degraded.

Hit the user route to confirm the chain dispatches:

curl -s 'http://localhost:9295/echo?message=hello' | python3 -m json.tool
# {
#   "echoed": "hello"
# }

List the user-declared named routes:

curl -s http://localhost:9295/inspect/routes | python3 -m json.tool
# {
#   "routes": ["echo"]
# }

Reflect on the chain composition for that route:

curl -s http://localhost:9295/inspect/route/echo | python3 -m json.tool
# {
#   "name": "echo",
#   "method": "get",
#   "path": "/echo",
#   "user_chain": ["echo"],
#   "compiled_chain": ["enforce_denials", "echo", "verify_route", "trace_emit", "format", "seal"],
#   "scenarios": [],
#   "registered_injections": [
#     { "boundary": "enforce_denials", "position": "interleave" },
#     { "boundary": "verify_route", "position": "last" },
#     { "boundary": "trace_emit", "position": "last" },
#     { "boundary": "format", "position": "last" },
#     { "boundary": "seal", "position": "last" }
#   ]
# }

user_chain is what the config declared. compiled_chain is what the walker actually steps through, after the framework's default injections wrap around it. registered_injections enumerates the injections by { boundary, position }. scenarios is empty until the next tutorial introduces them.

List all registered boundary names:

curl -s http://localhost:9295/inspect/boundaries | python3 -m json.tool
# {
#   "boundaries": [
#     "adapter_cli", "adapter_http", "boot_load_boundaries", "boot_mount_adapters",
#     "boot_mount_routes", "boot_mount_storage", "boot_mount_triggers",
#     "context_from_shape", "echo", "enforce_denials", "env_snapshot",
#     "format", "health", "healthcheck", "inspect_boundary", "inspect_diagnostics",
#     "inspect_route", "inspect_scenarios", "list_boundaries", "list_routes",
#     "register_injections", "runtime_inspect", "seal", "status",
#     "trace_emit", "verify_route", ...
#   ]
# }

Reflect on the echo boundary itself:

curl -s http://localhost:9295/inspect/boundary/echo | python3 -m json.tool
# {
#   "name": "echo",
#   "identity": "boundary:echo",
#   "requirements": [],
#   "capabilities": ["echo"],
#   "description": "Echo input params back as result",
#   "when_shape": null,
#   "input_shape": { "params": true },
#   "output_shape": { "echoed": true },
#   "source": "Wanderland::Boundaries::Echo"
# }

The fields are the registered manifest verbatim. identity is the from_addr echo signs as when its crossing reaches the audit log. input_shape and output_shape are the contracts the boundary declares — the seed for JSON Schema synthesis once the introspection surface grows that far.

And the boot diagnostics cache:

curl -s http://localhost:9295/inspect/diagnostics | python3 -m json.tool
# {
#   "summary": { "errors": 0, "warnings": 0, "total": 0 },
#   "severity": null,
#   "entries": []
# }

A clean boot returns zero entries. If boundary_path had pointed at a missing directory, a warning entry would land here without halting the process. Filter to one severity:

curl -s http://localhost:9295/inspect/diagnostics/warning | python3 -m json.tool

Stop the local server with Ctrl-C before moving on. The container will rebind the same port on the host.

11. Create Dockerfile

FROM ruby:3.4-slim

RUN apt-get update && apt-get install -y --no-install-recommends \
      build-essential \
      git \
      curl \
      libsqlite3-dev \
      pkg-config \
    && rm -rf /var/lib/apt/lists/*

WORKDIR /app

# wanderland-core path-dep: clone side-by-side so `path: "../wanderland-core"`
# in Gemfile resolves during bundle install.
ARG WANDERLAND_CORE_REF=main
RUN git clone --depth 1 --branch "${WANDERLAND_CORE_REF}" \
      https://git.sr.ht/~graemefawcett/wanderland-core \
      /app/wanderland-core

WORKDIR /app/wanderland-engine

COPY Gemfile Gemfile.lock ./
RUN bundle install --jobs=4 --retry=3

COPY . .

EXPOSE 9295

CMD ["bundle", "exec", "puma", "config.ru", "-b", "tcp://0.0.0.0:9295"]

Layout inside the image:

WANDERLAND_CORE_REF is a build arg so the image can be pinned to a tag or commit later without changing the Dockerfile. -b "tcp://0.0.0.0:9295" binds puma on every interface inside the container so the published port reaches it from the host.

12. Create docker-compose.yml

services:
  wanderland-engine:
    build:
      context: .
      args:
        WANDERLAND_CORE_REF: main
    image: wanderland-engine:dev
    container_name: wanderland-engine
    ports:
      - "9295:9295"

Single-service compose for now. A later tutorial wires this into the larger sprout orchestration; the service definition there is the same shape with an external network attached.

13. Build the image

docker compose build

First build clones wanderland-core inside the image and runs bundle install. Subsequent builds reuse the layer cache unless Gemfile, Gemfile.lock, or WANDERLAND_CORE_REF change.

14. Run the container

docker compose up

Puma announces the bind from inside the container — same line as the local boot, address 0.0.0.0:9295. Compose wires the host port through.

Ctrl-C drops the container. docker compose up -d runs detached; docker compose down stops and removes it.

15. Probe the containerized engine

The host-side curls from step 10 work unchanged — the published port maps to the container's puma:

curl -s http://localhost:9295/health                  | python3 -m json.tool
curl -s http://localhost:9295/status                  | python3 -m json.tool
curl -s http://localhost:9295/inspect/routes          | python3 -m json.tool
curl -s http://localhost:9295/inspect/route/echo      | python3 -m json.tool
curl -s http://localhost:9295/inspect/boundaries      | python3 -m json.tool
curl -s http://localhost:9295/inspect/boundary/echo   | python3 -m json.tool
curl -s http://localhost:9295/inspect/diagnostics     | python3 -m json.tool
curl -s 'http://localhost:9295/echo?message=hello'    | python3 -m json.tool

Same payloads as the local run. The service field in /health reads wanderland-engine; /inspect/diagnostics shows zero errors on a clean boot.

16. Directory shape at this point

wanderland-engine/
├── Dockerfile
├── Gemfile
├── Gemfile.lock
├── Rakefile
├── config.ru
├── config.yml
├── docker-compose.yml
└── lib/
    ├── wanderland_engine.rb
    └── wanderland_engine/
        └── boundaries/

The boundaries directory is the slot the next tutorial fills. The introspection endpoints already cover everything declared in config.yml; as boundaries arrive, the same endpoints expose them automatically.

What's next

Site Audit

wanderland.dev

oculus-view: fence: fence execute HTTP 404