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:
/health, /status, /healthcheck — for service answers in increasing depth/inspect/routes, /inspect/route/:name, /inspect/boundaries, /inspect/boundary/:name, /inspect/diagnosticsA 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:
/health — load-balancer ping. { status, service, timestamp }. Cheap, no dependency checks./status — service identity plus what's registered. { status, service, boundaries: [...names], routes: count, timestamp }./healthcheck — full compliance snapshot. { status, service, manifest: {...}, routes: [...], diagnostics: { summary, errors, warnings }, timestamp }. Status flips to degraded if boot recorded any error.Five introspection endpoints expose registry shape:
/inspect/routes — list user-declared route names./inspect/route/:name — chain composition for one route (user_chain vs compiled_chain)./inspect/boundaries — list all registered boundary names (framework + site)./inspect/boundary/:name — manifest for one boundary (identity, requirements, capabilities, input/output shapes, source class)./inspect/diagnostics — boot-time diagnostic cache (entries + severity summary). Filter to a single severity at /inspect/diagnostics/:severity (error or warning).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.
mkdir -p ~/working/sprout/wanderland-engine
cd ~/working/sprout/wanderland-engine
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.
bundle install
config.ymlservice: 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.
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.
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.
lib/wanderland_engine.rb# frozen_string_literal: true
require "wanderland-core"
module WanderlandEngine
end
Namespace anchor for the boundaries that arrive in later tutorials.
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.
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.
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.
DockerfileFROM 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:
/app/wanderland-core — fresh shallow clone of the public sourcehut repo./app/wanderland-engine — the engine app, with the path-dep resolving against ../wanderland-core.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.
docker-compose.ymlservices:
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.
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.
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.
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.
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.
/inspect/route/:name and /inspect/boundary/:name, including future schema-synthesis fields./inspect/diagnostics and /healthcheck surface it.!UserConfig thunks, and how a different archetype produces a different shape of service.wanderland.dev