Wanderland-core is a Ruby framework for HTTP services whose entire surface is composed of small, named, addressable units called boundaries. A request comes in, gets dispatched to a route, runs through a chain of boundaries — auth, validation, the actual work, formatting — and the chain's last crossing is the response. Each step is observable, testable, and replaceable on its own. The framework owns the wiring: registration, dispatch, tracing, halt propagation, response shaping. The application owns the boundaries.
This tutorial scaffolds a fresh app from nothing — Gemfile, config.yml, one custom boundary called greet, and the scenarios that exercise it. By the end you'll have a service that responds to GET /greet/:name, a CLI that runs the same boundaries from the shell, and three test lenses that all check the same scenarios from different angles.
A wanderland-core app is a small directory tree. config.yml declares the routes (path → boundary), the boundaries directory (where the runtime auto-loads *.rb files at boot), and any storage mounts the service needs. Each boundary file declares a class that mixes in Wanderland::Boundary, registers its identity and contract, and implements #call. The runtime takes care of everything between the route and the boundary — request parsing, identity threading, capability checks, halt handling, response rendering.
Scenarios live alongside routes in config.yml. A scenario is a named (input, expected) pair under a route. The same pair runs through a CLI test runner (--type test), through a per-request HTTP probe (X-Wanderland-Verify), and through an rspec helper (Scenario.rspec_describe!). One declaration, three lenses, depending on which is convenient in the moment.
The result is that the surface a developer touches stays small. A new endpoint is a route line in config.yml, a boundary file under lib/<app>/boundaries/, and a scenario block. The framework provides the boot chain, the dispatcher, the audit trail, and the introspection endpoints; the developer provides the #call body and the contract that names it.
Assumes the wanderland-core symlink sits at the workspace root (e.g. ~/working/sprout/wanderland-core → ~/working/wanderland-core). The new app is a sibling directory.
mkdir -p ~/working/sprout/my-app
cd ~/working/sprout/my-app
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
bundle install
config.ymlservice: my-app
port: 9294
boundary_path: lib/my_app/boundaries
storage:
mounts:
":pki:":
driver: sqlite
path: ":memory:"
":identities:":
driver: sqlite
path: ":memory:"
routes:
/hello:
method: get
boundary: echo
name: hello
config.ru# frozen_string_literal: true
require "wanderland-core"
run Wanderland.boot(ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))).engine
mkdir -p lib/my_app/boundaries
lib/my_app.rb# frozen_string_literal: true
require "wanderland-core"
module MyApp
end
lib/my_app/boundaries/greet.rb# frozen_string_literal: true
module MyApp
module Boundaries
class Greet
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:greet",
name: "Greet",
roles: [:boundary],
type: :service,
scopes: [:read]
).freeze
boundary :greet,
identity: IDENTITY,
requirements: [:read],
capabilities: [:greet],
description: "Greet a named caller"
def call(input)
name = input.dig("params", "name") || "world"
{ "greeting" => "Hello, #{name}!" }
end
end
end
end
config.ymlroutes:
/hello:
method: get
boundary: echo
name: hello
/greet/:name:
method: get
boundary: greet
name: greet
Rakefile# frozen_string_literal: true
require "wanderland-core"
desc "Start the HTTP server"
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"
task :routes do
config_path = ENV.fetch("CONFIG", File.join(__dir__, "config.yml"))
runtime = Wanderland.boot(config_path)
runtime.engine.compiled_routes.each do |key, route|
puts "#{key} → #{route[:spec]}"
end
end
desc "Run specs"
task :spec do
exec "bundle exec rspec"
end
task default: :spec
bundle exec rake server
# or directly:
bundle exec puma config.ru -p 9294
curl http://localhost:9294/health
curl 'http://localhost:9294/hello?message=world'
curl http://localhost:9294/greet/alice
bundle exec wanderland --type cli config.yml hello message=world
bundle exec wanderland --type cli config.yml greet name=alice
bundle exec wanderland --type cli config.yml help
config.ymlScenarios declare the shape each route's response is supposed to produce. Add a top-level scenarios: block, keyed by route name. Each scenario carries input: (the synthetic request) and expected: (the ShapeMatcher shape). description: is optional and surfaces in failure messages.
scenarios:
hello:
default:
description: "Canonical example"
input:
params:
message: "Hello world"
expected:
echoed: "Hello world"
greet:
alice:
description: "Canonical greeting format"
input:
params:
name: alice
expected:
greeting: "Hello, alice!"
default_output:
description: "Empty name defaults to world"
input:
params:
name:
expected:
greeting: "Hello, world!"
polite_tone:
description: "Greeting always starts with Hello"
input:
params:
name: bob
expected:
greeting:
matches: "^Hello, "
The ShapeMatcher vocabulary (matches, contains, keys, has_key, first, any, count, gte/lte, not, empty, and more) is available inside expected:. See wanderland-core-shape-matcher for the full list.
--type test modebundle exec wanderland --type test config.yml
Expected output:
hello
✓ default — Canonical example
greet
✓ alice — Canonical greeting format
✓ default_output — Empty name defaults to world
✓ polite_tone — Greeting always starts with Hello
4 scenarios, 4 passed, 0 failed
Filter by route or by scenario name:
bundle exec wanderland --type test config.yml --route greet
bundle exec wanderland --type test config.yml --scenario alice
bundle exec wanderland --type test config.yml --route greet --scenario polite_tone
Exits 0 on full pass, 1 on any failure — wire into CI directly. See --type test for the full runner.
X-Wanderland-VerifyThe same scenarios attach to live requests via the verify header. On match the response includes _verify; on mismatch the request halts with 422 naming the failing scenario.
bundle exec rake server # if not already running
Match (200 + _verify array):
curl -s -H 'X-Wanderland-Verify: alice' http://localhost:9294/greet/alice | python3 -m json.tool
# {
# "greeting": "Hello, alice!",
# "_verify": [
# { "name": "alice", "description": "Canonical greeting format",
# "passed": true, "failures": [] }
# ]
# }
Mismatch (422 with scenario failures):
curl -s -H 'X-Wanderland-Verify: alice' http://localhost:9294/greet/bob
# {
# "error": "scenario mismatch",
# "route": "greet",
# "verify": "alice",
# "scenarios": [
# { "name": "alice", "description": "Canonical greeting format",
# "passed": false,
# "failures": ["greeting: expected \"Hello, alice!\", got \"Hello, bob!\""] }
# ]
# }
Run every scenario configured for the current route in one shot:
curl -s -H 'X-Wanderland-Verify: route' http://localhost:9294/greet/alice
Combine with X-Wanderland-Trace: 1 and the verify crossing lands in the trace chain alongside the response. See Verify Route for the full probe behaviour.
--wanderland-verifySame option, CLI-side. The verify boundary fires the same way it does under HTTP.
bundle exec wanderland --type cli config.yml greet name=alice --wanderland-verify=alice
bundle exec wanderland --type cli config.yml greet name=bob --wanderland-verify=alice
bundle exec wanderland --type cli config.yml greet name=alice --wanderland-verify=route
On mismatch the process exits non-zero and prints the same {error, route, scenarios} payload the HTTP lens returns.
spec/spec_helper.rb# frozen_string_literal: true
require "wanderland-core"
spec/scenarios_spec.rbOne file, one helper call. rspec_describe! emits a describe block per route and an it block per scenario. Each example calls the same Scenario#verify(runtime:) primitive the --type test runner uses — one source of truth across dev, CI, and live probe.
# frozen_string_literal: true
require "spec_helper"
runtime = Wanderland.boot(File.join(__dir__, "..", "config.yml"))
Wanderland::Scenario.rspec_describe!(runtime)
bundle exec rspec
Expected output:
config scenarios
hello
✓ default
greet
✓ alice
✓ default_output
✓ polite_tone
4 examples, 0 failures
Failures show the ShapeMatcher output verbatim — same shape as --type test and the HTTP probe's 422 body. See RSpec Integration for the full helper surface.
my-app/
├── Gemfile
├── Gemfile.lock
├── Rakefile
├── config.ru
├── config.yml
├── lib/
│ ├── my_app.rb
│ └── my_app/
│ └── boundaries/
│ └── greet.rb
└── spec/
├── spec_helper.rb
└── scenarios_spec.rb
One scenarios block in config.yml; three lenses reading it (live probe, batch runner, rspec). Add a scenario, every lens picks it up.