The primitives everything else composes on. A boundary is the call convention. An archetype is the program. Injections fold onto chains at boot. Shape-matcher verifies structure. The template engine resolves YAML thunks. Overlay operators compose storage records. Scenarios turn YAML into test cases. The game engine turns those same scenarios into a learning UI. None of these are features — they're mechanisms the rest of the system bends to do its job.
Boundary the call convention — one input hash in, one crossing out
│
├─ composed into ─→ Chain an ordered list of boundary slots, walked by the dispatcher
│
├─ declared via ─→ DSL identity, requirements, capabilities, when_shape, serves
│
└─ assembled by ─→ Archetype a YAML slot sequence with !UserConfig thunks
│
├─ modified by ─→ Injections boot-time fold over every chain
│
└─ verified by ─→ ShapeMatcher expected-shape checking
Template Engine tag-dispatched YAML resolution with memoizing cache
Overlay Operators CRDT-friendly composition of storage records
Scenario + Game YAML scenarios doubled as tests and learning exercises
class MyBoundary
include Wanderland::Boundary
IDENTITY = Wanderland::Identity.new(
id: "boundary:my_boundary",
name: "MyBoundary",
roles: [:boundary],
type: :service,
scopes: [:read, :transform]
).freeze
boundary :my_boundary,
identity: IDENTITY,
requirements: [:read], # scopes the caller must hold
capabilities: [:transform], # what this boundary claims
description: "Transforms incoming shape into outgoing",
when_shape: { type_addr: { prefix: ":types:" } }, # default guard
serves: "application/json" # optional — formatter registration
def call(input)
# input has: params, query, path, headers, config, route, runtime, context, args
# return a Hash → becomes crossing.result
# or return a Wanderland::Signal for explicit type_addr control
{ "transformed" => transform(input["params"]) }
end
end
Including Wanderland::Boundary gives you Logging and Tracing for free. The boundary :name, ... declaration registers the class so Boundary.execute(:my_boundary, input) finds it. All metadata is declarative — no register! calls, no side-effecting initialize.
An archetype is a YAML file shipped with the gem. It declares a slot sequence for one kind of execution. !UserConfig thunks in slot args get hydrated from the user's config.yml at boot.
# lib/wanderland/archetypes/engine.yml — the HTTP service archetype
name: engine
slots:
- name: boundaries
boundary: boot_load_boundaries
args: { target: !UserConfig boundary_path }
- name: resolvers
boundary: boot_configure_resolvers
args:
config:
resolvers: !UserConfig resolvers
- name: storage
boundary: boot_mount_storage
args:
config:
storage: !UserConfig storage
- name: core_injections
boundary: register_injections
args:
injections:
- { boundary: enforce_denials, position: interleave }
- { boundary: trace_emit, position: last }
- { boundary: format, position: last }
- name: user_injections
boundary: register_injections
args:
injections: !UserConfig injections
- name: routes
boundary: boot_mount_routes
args: {}
The engine ships archetypes for engine, scenario, cli, and test. The terminal slot determines runtime shape: long-running service, one-shot validation, one-shot command.
Boot-time modifications to every compiled chain. Six positions cover every insertion pattern.
injections:
# Prepend once at head
- boundary: request_id_stamp
position: first
# Append once at tail (format and trace_emit use this)
- boundary: audit_writer
position: last
# Insert before every user slot (enforce_denials uses this)
- boundary: auth_check
position: interleave
# Interleave conditionally — ShapeMatcher shape over the slot's own data
- boundary: latency_timer
position:
interleave:
boundary: { matches: "^(auth|repo)_" }
# Before or after every occurrence of a named boundary
- boundary: cache_probe
position: { before: expensive_fetch }
- boundary: cache_store
position: { after: expensive_fetch }
Declaration order is final placement order: two position: last injections in sequence mean the second one ends up after the first. Interleave sees only the slots present when it runs — framework enforce_denials interleaves just the user slots because it's declared before trace_emit and format.
ShapeMatcher walks actual and expected in parallel. Plain values compare directly; matcher keywords dispatch to registered strategies.
expected:
repos:
count: 3 # collection size matcher
first: # first-element matcher (recurses)
name: kremis
any: # some-element matcher
grammars:
contains: ruby
version:
matches: "^v\\d+\\.\\d+" # regex matcher
errors:
empty: true # emptiness matcher
status: ok # plain field comparison
Built-in matchers: count, gte/lte/gt/lt, matches, contains/includes/excludes, first/last, any, keys/has_key, not, empty. Custom matchers register by name and receive (actual, operand, path, failures, matcher) — the last argument lets custom matchers recurse.
The template engine loads YAML with custom tag handlers. Each !Tag becomes a Thunk at load time; Thunk#resolve runs the fetch lazily with a memoizing TTL-keyed cache.
# Scalar — bare path
node: !Oculus wanderland-core
# Scalar with subpath
content: !Oculus wanderland-core/content
# Mapping form with params + cache control
nodes: !Oculus
path: nodes
sort: slug
limit: 5
ttl: 300
# Skip cache this one time
live: !Task
path: current
nocache: true
# Fixture files for testing
mock: !Fixture mocks/oculus-nodes
Write a new resolver by subclassing Wanderland::Resolver, calling tag to register, and implementing fetch(arg). Everything else — caching, TTL, the two-phase walk — is the framework's.
A seed record plus a stream of operations at the same to_addr. The overlay reads all records, applies operations in at order, returns the composed state. Operations are signed crossings like anything else.
# Map operations — k/v data
{ op: $set, path: "result.shape", value: "star" }
{ op: $merge, path: "result", value: { extra: true } }
{ op: $inc, path: "result.score", value: 1 }
{ op: $min | $max, path: "result.best", value: 5 }
# Stream operations — ordered sequences
{ op: $append, path: "result.items", value: ["new"] }
{ op: $prepend, path: "result.items", value: ["first"] }
{ op: $union, path: "result.tags", value: ["new"] }
{ op: $intersection, path: "result.allow", value: ["a", "b"] }
# Structural
type_addr :types:snip # remove key at path
type_addr :types:splice # positional array edit { path, index, delete, insert }
type_addr :types:antiparticle # cancel a prior record by (at, type)
type_addr :types:annotate # additive metadata
Most map operations are commutative CRDTs (two interceptors writing different paths produce the same result regardless of order). Stream splice is positional and non-commutative — use it sparingly where OT-grade resolution isn't needed.
One YAML file serves as an RSpec test, a game level, and a documentation fixture.
name: "parameter extraction from path"
description: |
The route pattern /node/:slug has one dynamic segment.
When the request path is /node/sprout-api, :slug captures "sprout-api".
level:
world: 1
number: 2
hint: "Dynamic segments start with : and capture the path value"
operation: route_match
input:
method: GET
path: /node/sprout-api
routes:
- pattern: /node/:slug
layout: node-view
expected:
matched: true
params:
slug: sprout-api
blanks:
slug_value:
path: params.slug
answer: sprout-api
Scenario#run calls Boundary.execute(operation, input). Scenario#verify runs ShapeMatcher against expected. blanks: are paths the game hides and asks the player to fill. The scenario doesn't know whether it's being run by a test runner, a game engine, or an emacs buffer — same YAML, three modes.
Declarative first, imperative never. Identity, requirements, capabilities, when_shape, and serves: all live on the boundary DSL. Don't add register! calls, after_boot hooks, or initialize-time wiring. If the framework doesn't have the declaration you need, add it to the DSL — don't route around it.
Let the archetype slot decide. Anything a site needs to hand to the engine at boot (storage mounts, trigger configs, denials, injections, resolvers) flows through a dedicated archetype slot via !UserConfig. New boot-time concerns become one more slot, not one more code path.
Injections are boot-time, when: is request-time. Chain structure is decided once, when the route compiles. Conditional execution is decided at each step, against the walker's current context. Don't try to make an injection skip itself — give its boundary the right when_shape: and let the walker handle it.
ShapeMatcher matchers compose. A custom matcher receives the ShapeMatcher instance as its last argument, so you can call matcher.match(inner_actual, inner_expected, inner_path, failures) to recurse. The built-ins like first: and any: use this to nest.
Use the template engine instead of YAML-then-regex. If site config references external data (oculus nodes, API responses, fixtures), declare a resolver with tag and fetch, then let !Tag do the work. The cache, TTL semantics, nocache override, and param-resolution fall out of the framework.
Overlay by append, read by overlay. Mutation is never in-place. Write a poke/snip/splice record at the same to_addr as the seed, let overlay(addr) compose the current state. Concurrent writers are safe on commutative operators (map + union/append).
Write scenarios, not unit tests, for boundary behaviour. A scenario with input: + expected: covers the same ground as an rspec it block but also lives as a fixture, a game level, and documentation. Unit tests that only exercise the boundary's class internals stay inline; anything about shape and contract goes to a scenario.
boundary DSL metadata (identity, requirements, capabilities, description, when_shape). Crossing records.client, capabilities, effective_mime. Namespace-filtered reads (capabilities.adapter.* visible only if written by adapter:*). Caps + denials paired. Renderer as a chain.!UserConfig thunks. Engine / scenario / cli / test archetypes. Combustion model — same engine, different fuel.!Config, !Env, !Fixture, !Oculus, !Task, !Mapped).wanderland.dev