The call convention for everything the engine dispatches. A boundary is a class (or proc) registered under a symbol, invoked through a uniform input/output shape, and recorded as a signed crossing in the request context. The engine's chain walker, the trigger registry, the scenario runner, and the verify probe all reach handlers through Boundary.execute(name, input) — the boundary is the shared contract, not any one of those callers.
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],
capabilities: [:transform],
description: "One-line description for /inspect/boundaries"
def call(input)
{ "transformed" => input["params"] }
end
end
include Wanderland::Boundary extends the class with the registration DSL and mixes in Wanderland::Logging. boundary :name, ... registers the class under that symbol with the given metadata; the registry dispatches by symbol so route configs, archetype slot refs, and trigger then: clauses all stay declarative.
boundary DSL| Key | Type | Purpose |
|---|---|---|
identity |
Wanderland::Identity |
Becomes the crossing's from_addr and the signing key. Optional but expected for any boundary that produces a record auditors will read. |
requirements |
[Symbol] |
Scopes the caller must hold. Read by enforce_denials against the caller identity. |
capabilities |
[Symbol] |
What the boundary claims to provide. Surfaces in crossing.capabilities; downstream context filters can read it. |
description |
String |
One-line human label for /inspect/boundaries and /inspect/boundary/:name. |
when_shape |
Hash or Wanderland::When |
The default guard the chain walker checks before running this boundary in a slot. The slot's own when: overrides; both fall back to BASE_DEFAULT_WHEN. |
serves |
String (MIME) |
Registers the boundary as a formatter for that content-type with Wanderland::Formatters. |
adapters |
[Symbol] |
The transport adapters this boundary expects to be available (e.g. [:http]). Adapter mounts in config.yml are validated against this list at boot. |
input_shape |
Hash | Per-field declaration: required-vs-optional, description, ShapeMatcher constraints, free-form metadata. Drives both runtime validation and /inspect/boundary/:name documentation. See Field rules below. |
output_shape |
Hash | Same vocabulary as input_shape, applied to keys written into input.output.*. |
env |
Array or Hash | Declared env consumption. See Declaring env consumption below. |
boundary itself is the only registration call. There is no register!, no after_boot, no side-effecting initialize — every site that registers handlers (Boundary.register, the class macro, Runtime#register_boundary) goes through the same Registration struct.
input_shape and output_shape map each field name to a rule. The rule says whether the field is required, what it means, what shape its value should take, and any free-form metadata to flow through to introspection. Four forms, all interoperable at registration time:
input_shape: {
"required_field" => true, # required, present-any-shape
"optional_field" => false, # optional, present-or-not
"matched_field" => { matches: %r{^https?://} }, # required + ShapeMatcher rule (backwards-compat)
"documented_field" => { # structured form with metadata
required: true,
description: "One-line explanation for the inspector",
constraints: { matches: %r{^https?://}, count: { gte: 1 } },
metadata: { secret: true, examples: ["http://localhost:8081"] }
}
}
Disambiguation between the bare matcher hash (third row) and the structured form (fourth row) keys off the presence of any reserved metadata key. The reserved keys are required, optional, description, constraints, metadata, type, default, examples. A hash with at least one of those is treated as the structured form; a hash with none is treated as a ShapeMatcher rule directly.
Wanderland::BoundaryInput.normalize_field_rule(rule) returns the canonical { required:, description:, constraints:, metadata: } shape — the introspection surface reads this; the validator uses :required and :constraints.
| Form | Meaning |
|---|---|
true |
Required. The field's absence isn't enforced by the validator (the boundary's find_or_fail does that), but /inspect/boundary/:name reports it as required. |
false |
Optional. The validator skips constraint checks when the field is blank. The boundary reads it via input.find (returns nil) and applies its own default. |
{ required: true } |
Same as true. |
{ required: false } / { optional: true } |
Same as false. |
Required-ness is metadata for documentation and introspection; the boundary's own find_or_fail is what halts a request when a required field is missing.
"repository_url" => {
required: true,
description: "Deploy endpoint, e.g. http://admin:secret@localhost:8081/snapshots"
}
A single string. One per field. Surfaces at /inspect/boundary/:name so an operator browsing the inspector sees what each parameter is for without grepping the call body.
constraints: takes a ShapeMatcher rule — the same vocabulary that drives scenario expected: blocks. The validator runs the constraint against the field's value when the field is present and non-blank.
"image_tag" => {
required: false,
description: "Tag for the built image",
constraints: { matches: %r{^[a-z0-9][a-z0-9._-]{0,127}$} }
}
ShapeMatcher rule keys (curated; the full set lives in lib/wanderland/shape_matcher.rb and is exercised under spec/scenarios/shape_matcher/):
| Key | Operand | Match |
|---|---|---|
matches |
regex / string | actual.to_s.match?(rule) |
starts_with |
string | actual.to_s.start_with?(rule) |
is |
class / value | exact equality, or actual.is_a?(rule) for a class |
count |
integer or { equals/gt/gte/lt/lte: n } |
array/hash size comparator |
gt / gte / lt / lte |
numeric | numeric comparison against actual |
contains |
element | Array#include?(rule) |
includes |
array | every element of rule appears in actual |
excludes |
array | none of rule's elements appear in actual |
in_order |
array | rule's elements appear in actual in order (gaps OK) |
run |
array | rule's elements appear contiguously |
occurs |
{ of:, count: } |
exactly count occurrences of of |
nth |
`{ index:, value: | shape: }` |
first / last |
shape | submatch the first / last element |
any |
shape | at least one element matches |
any_of |
array of shapes | actual matches any one of them |
all |
array of shapes | actual matches every one of them |
keys |
array | exact key set (Hash) |
has_key |
string / symbol | the key is present (Hash) |
not |
shape | negation — actual must NOT match |
empty |
true / false |
actual is empty / non-empty |
Constraints compose: a hash with multiple keys at the top level is treated as "all of these must match" (logical AND).
metadata: is free-form. Anything goes; nothing inside is interpreted by the validator. Sites use it to tag fields for tooling — secret: true to elide a field from logged responses, examples: [...] for an inspector to render sample values, since: "0.5.0" for change-log discipline. Keys you want introspection consumers to standardize on get reserved over time; for now, anything you put under metadata: flows through the inspector untouched.
"repository_url" => {
required: true,
description: "Deploy endpoint",
metadata: { secret: true, examples: ["http://admin:***@host/snapshots"] }
}
type:, default:, examples: are recognized as structured-form triggers (their presence makes the validator treat the rule as structured rather than as a ShapeMatcher hash) but not enforced. They're reserved so the data structure is ready when the matching enforcement lands without breaking sites that already use them. Treat them as documentation-only today.
When the value is itself a hash with sub-fields, declare the nesting:
input_shape: {
"args" => {
"source_path" => { required: true, description: "Repo root with pom.xml" },
"image_tag" => { required: false, description: "Defaults to 'latest'" }
}
}
Nested fields are documented for /inspect/boundary/:name but the runtime validator only enforces top-level rules. Path-based reads in the boundary code (input.find_or_fail("args.source_path")) handle nested presence themselves.
A boundary that needs ENV vars declares them on registration so the value flows through the same audit + projection surface as the rest of the engine: ambient env never enters the engine without a contract that names it.
boundary :oculus_fence,
identity: IDENTITY,
description: "Execute an oculus fence and record the purchase in the bag.",
env: {
OCULUS_BASE: "https://i.loss.dev", # default value
OCULUS_USER: nil, # required (or nil at runtime)
OCULUS_PASS: nil
}
def self.call(input)
base = input["context"]["env"]["OCULUS_BASE"]
user = input["context"]["env"]["OCULUS_USER"]
pass = input["context"]["env"]["OCULUS_PASS"]
...
end
The env: field accepts three shapes:
env: ["FOO", "BAR"]. No defaults; reads nil if unset.key => default — env: { "FOO" => "fallback", "BAR" => nil }. nil means required (or accept-nil-at-runtime; the boundary author decides).env: { "FOO" => { default: "x", description: "the foo" } }. The description: is informational; future versions of /inspect/boundary/:name will surface it for doc generators.Symbol keys (OCULUS_BASE:) are accepted and normalized to strings — env vars are conventionally uppercase strings, but the symbol form reads naturally in the DSL.
env_snapshot (the boot boundary that captures ENV into runtime.env_snapshot) unions every registered boundary's declared keys into the allowlist. A site that adds a boundary needing OCULUS_USER does not need a separate env_allowlist: edit in config.yml.runtime.env_snapshot["OCULUS_BASE"] returns "https://i.loss.dev" even when the host has no such env. Boundaries read defaults through the same context["env"] lookup; no special-casing in the call body./inspect/boundary/:name surfaces the env declaration. The doc generator and the introspection API both show which env keys a boundary depends on.ENV[...] from boundary codeReading ENV["FOO"] directly inside call bypasses the snapshot, the allowlist, the defaults, and the audit. It's ambient state — undeclared, untracked, untestable from a scenario. Always go through input["context"]["env"]["FOO"].
If ENV access is genuinely necessary (one-off scripts, boot-time-only reads outside the framework), wrap it in a separate small object that exposes the env it cares about; the boundary still reads from context["env"] and the wrapper handles the access in one place.
def call(input)
# input.params, input.headers, input.config, input.route, input.runtime,
# input.context, input.adapter, input.identity, input.config_dir, input.query
# are framework keys, always available.
# Shape-validated reads — only succeed if declared in input_shape:
message = input.message
# Output writes — validated against output_shape:
input.output.echoed = message
input.output.to_h
end
input is a BoundaryInput wrapping the raw hash plus the declared input_shape. Hash-style access (input["params"]) and method access (input.params) both work; method access enforces the shape.
A boundary returns one of:
Signal.from_hash. _type_addr and _capabilities keys are promoted; everything else becomes the result payload.Wanderland::Signal — used as-is. The typed constructors (Signal.ok, Signal.halt, Signal.error, Signal.denied) set the type_addr classifier explicitly.Wanderland::BoundaryOutput — the funnel-out object, when the call body wrote into input.output.* and wants to return that bag.Anything else raises ArgumentError from Registration#funnel_into_output.
Boundary.execute(name, input) returns a Wanderland::Crossing — the signed, linked record the chain walker appends to context.
{
"boundary" => "my_boundary",
"from_addr" => "boundary:my_boundary", # identity.id, falls back to boundary name
"caller_addr" => "user:alice", # input["identity"]&.id, optional
"requirements" => ["read"],
"capabilities" => ["transform"],
"result" => { "transformed" => { ... } },
"type_addr" => ":signals:ok",
"at" => "2026-04-24T22:31:00Z",
"to_addr" => ":trace:req-...", # context.allocate_addr
"signature" => "base64...", # Activities::PKI.sign
"trace" => "<previous crossing's signature>" # merkle link
}
The crossing is the unit the rest of the system reads. Context appends it (verifying the signature on the way in), the trace chain links it to the previous one, sinks fan out copies, and the response formatter eventually unwraps result. The boundary's call body never builds this record — it returns the payload, the engine wraps it.
Wanderland::Boundary.registered # [:my_boundary, :echo, :health, ...]
Wanderland::Boundary.lookup(:my_boundary) # the handler (Class or Proc)
Wanderland::Boundary.info(:my_boundary) # the full Registration struct
Wanderland::Boundary.manifest # all registrations with effective metadata
Wanderland::Boundary.requirements_for(:my_boundary) # []
Wanderland::Boundary.capabilities_for(:my_boundary) # [:transform]
Wanderland::Boundary.identity_for(:my_boundary) # the Identity instance
Wanderland::Boundary.effective_when(:my_boundary) # the boundary's when_shape, or nil
/inspect/boundaries and /inspect/boundary/:name read through these methods; nothing in introspection bypasses the registry.
Every successful execution emits a span. Sinks are callables that receive each completed crossing — point them at storage drivers, jsonl files, dashboards.
Wanderland::Boundary.add_sink { |crossing| Storage::Registry.append(crossing) }
Wanderland::Boundary.add_sink { |crossing| File.write("trace.jsonl", crossing.to_h.to_json + "\n", mode: "a") }
Wanderland::Boundary.span_sinks # [proc, proc]
Wanderland::Boundary.clear_sinks!
Sinks run synchronously inside the trace block; expensive work belongs behind a queue.
BASE_DEFAULT_WHEN is the fallback the chain walker checks for any slot that doesn't declare a when: and whose boundary doesn't declare a when_shape::
BASE_DEFAULT_WHEN = Wanderland::When.coerce(
"count" => { "type_prefix" => Types::STOP_PREFIX, "equals" => 0 }
).freeze
Translation: run unless an earlier crossing has emitted a stop-class signal. This is what lets a single halt propagate through the rest of the chain without short-circuiting in the dispatcher — every later slot is still considered, but the default guard suppresses execution. Recovery boundaries override this by declaring a when_shape: that matches a specific stop address.
Wanderland::Boundary.reset! # drops the registry, default identity, default scopes, sinks
Wanderland::Boundary.reload! # walks ObjectSpace and re-registers every loaded boundary class
reset! is for tests that need a clean slate; reload! re-runs the DSL using each class's stored @boundary_meta so class-based boundaries come back without a require pass. Runtime#initialize exposes a per-runtime overlay (runtime.register_boundary(:name) { ... }) for handlers that should live on one runtime instance instead of the global registry.
Wanderland::Boundary.default_identity = Wanderland::Identity.new(...)
Wanderland::Boundary.default_requirements = [:read]
Wanderland::Boundary.default_capabilities = [:announce]
Boundary metadata falls back through effective_identity, effective_requirements, effective_capabilities. Set the defaults once at boot and any boundary that doesn't override its own gets them; introspection and the crossing record both reflect the effective values, not the literal nils on the registration.
Boundary.execute produces.ok / halt / denied / error constructors and the from_hash lifter.from_addr and the signing key reference./inspect/* surface that reads through manifest and info.wanderland.dev