Declarative conditional execution for boundary chains. No try/catch, no hard short-circuit, no special-case halt handling. Every slot has a when condition. The walker evaluates it against the most recent crossing. If the shape matches, the slot runs. If not, it skips. That's the entire flow control system.
A boundary chain is a flat list of slot entries. Each entry has:
name — human labelboundary — which boundary to executeargs — input args for the boundarywhen — optional ShapeMatcher conditionThe walker iterates the list. For each entry, it hands the entry's when: guard the boot context and asks whether the slot runs. The guard evaluates its shape against the context — the full append-only event stack plus the projections Context exposes (.signed, .by_identity, .for_boundary, value-projection via [key]). If the shape matches, the boundary executes and its crossing is appended. If not, the walker skips the slot and moves on.
That's the whole model: conditional execution routed against the context.
Signals are addresses in a crossing's type_addr field. The walker routes on them the same way it routes on any other address — via when: shapes matched against the boot context.
The whole signal system is convention on top of three things already present: every crossing has type_addr; the walker evaluates when: shapes against the context; ShapeMatcher's prefix: operator tests address hierarchies. Everything else is a choice of prefix.
| Address prefix | Meaning | Base default behaviour |
|---|---|---|
:types:* |
Normal classifications — :types:ok and any domain-specific success a boundary wants to declare. |
Runs the next slot. |
:signals:stop:* |
Blocking signals. Halt, error, denied, any named exception. | Skips the next slot. |
:signals:pass:* |
Non-blocking signals. Warnings, retry requests, cache misses — anything downstream might observe without interrupting normal flow. | Runs the next slot. |
The stop and pass lanes are naming conventions. A boundary writing :signals:stop:quota_exceeded gets the same default-skip treatment as :signals:stop:halt — the prefix is all it takes. :signals:pass:cache_miss rides alongside any existing chain.
The mechanism fans as deep as you want. Pick an address under :signals:, decide whether it should block default flow, and the new category is live. A third lane for observational signals (the verify lane — see the expect pattern below) works exactly like stop and pass: a prefix under :signals:, caught by boundaries with a matching when:.
Emit is writing a crossing with the chosen type_addr — either by returning a Signal with that type, or by stamping _type_addr on the result hash and letting Boundary.execute promote it.
Catch is a downstream boundary whose when: matches the chosen prefix, anywhere in the context's event stack:
# Catch a specific stop
when: { type_addr: { prefix: ":signals:stop:quota_exceeded" } }
# Observe any pass signal
when: { type_addr: { prefix: ":signals:pass:" } }
# React to any stop
when: { type_addr: { prefix: ":signals:stop:" } }
# Intercept one branch of a verify lane
when: { type_addr: { prefix: ":signals:verify:schema:" } }
Throw/catch is this. Any hierarchical discipline you want — deadlines, quotas, retries, contracts, verifications — lives as a prefix under :signals: and is caught by whichever boundaries declare the matching when:.
when Fieldwhen is a ShapeMatcher shape matched against the boot context — the append-only event stack plus its projections (.signed, .by_identity, .for_boundary, value-projection via [key]). Shapes can narrow to the most recent crossing or scan across history, depending on what keys and filters they probe. The full ShapeMatcher vocabulary applies — exact values, patterns, deep shapes, combinators (any, all, not), comparisons (gt, gte, lt, lte), regex (matches), set membership, and — load-bearing for signal routing — address prefixes via prefix:.
# Matches when the context shows a recent normal type
- name: validate
boundary: shape_validate
when:
type_addr:
prefix: ":types:"
# Catch a specific named stop anywhere in the stack
- name: retry_on_timeout
boundary: retry
when:
type_addr:
prefix: ":signals:stop:timeout"
# Catch any stop (broad recovery)
- name: error_reporter
boundary: error_reporter
when:
type_addr:
prefix: ":signals:stop:"
# Observe any pass signal
- name: cache_stats
boundary: cache_stats
when:
type_addr:
prefix: ":signals:pass:"
# Read a verify-lane result (expect pattern)
- name: escalate_mismatch
boundary: verify_to_stop
when:
type_addr:
prefix: ":signals:verify:mismatch"
# Always runs — no matter what came before
- name: cleanup
boundary: cleanup_handler
when:
always: true
The invocation-time when: on a slot overrides whatever the boundary declares via when_shape: in its DSL. If neither is present, the base default kicks in.
Boundaries declare their default when: via the boundary DSL using the when_shape: keyword, alongside capabilities and requirements. (The slot-level YAML key is when:; the Ruby DSL keyword is when_shape: because when is reserved.)
class ShapeValidate
include Wanderland::Boundary
boundary :shape_validate,
capabilities: [:validate],
when_shape: { "type_addr" => { "prefix" => ":types:" } }
end
class QuotaRecoverer
include Wanderland::Boundary
boundary :quota_recoverer,
capabilities: [:recovery],
when_shape: { "type_addr" => { "prefix" => ":signals:stop:quota_exceeded" } }
end
class AuditLogger
include Wanderland::Boundary
boundary :audit_logger,
capabilities: [:audit],
when_shape: { "always" => true }
end
class VerifyToStop
include Wanderland::Boundary
boundary :verify_to_stop,
capabilities: [:escalate],
when_shape: { "type_addr" => { "prefix" => ":signals:verify:mismatch" } }
end
A DSL-declared when_shape becomes the default condition for that boundary any time it appears in a slot chain. The slot-level when: in YAML overrides it when present.
The base Boundary class provides the fallback when: a slot inherits when neither the slot nor the boundary declares its own. It runs the next slot when the context has no unrecovered stops.
BASE_DEFAULT_WHEN = Wanderland::When.coerce(
"count" => {
"type_prefix" => Types::STOP_PREFIX,
"equals" => 0
}
).freeze
# Types::STOP_PREFIX == ":signals:stop:"
The predicate reads context.count. For any type address, the net count is positive events minus :anti:<type> events summed across the context. The base default runs the next slot when the summed count under :signals:stop: is zero.
Pass signals, verify signals, and any other non-blocking lane under :signals: contribute nothing to the stop count, so the default routes around them.
Boundaries that want to see a specific signal override the default explicitly:
boundary :cache_warmer,
when_shape: { "count" => { "type_prefix" => ":signals:pass:cache_miss", "gt" => 0 } }
boundary :quota_recoverer,
when_shape: { "count" => { "type" => ":signals:stop:quota_exceeded", "gt" => 0 } }
boundary :expect_logger,
when_shape: { "count" => { "type_prefix" => ":signals:verify:", "gt" => 0 } }
Recovery from a stop is a boundary whose when_shape matches the stop count above zero and whose output is the paired :anti:<type> crossing. The anti subtracts from the count; default-matching slots downstream see the count at zero and resume. Audit preserves both events — the stop and the anti — signed by their respective originators.
when in YAML — invocation-time override, highest prioritywhen_shape: from DSL — class-declared default{ type_addr: { not: { prefix: ":signals:stop:" } } }, global fallbackeffective_when = entry["when"] ||
Boundary.effective_when(boundary_name) ||
BASE_DEFAULT_WHEN
A slot in YAML overrides the boundary's declared default. A boundary's declared default overrides the base. Each layer declares the condition at the site where it matters.
lib/wanderland/dispatcher.rb — Dispatcher::Chain#execute:
def execute(input, context)
@chain do |slot|
guard =
slot.when_shape ||
Wanderland::Boundary.effective_when(slot.boundary) ||
Wanderland::Boundary::BASE_DEFAULT_WHEN
next unless guard.matches?(context)
input["context"] = context
input["args"] = slot.args if slot.args
crossing = Wanderland::Boundary.execute(slot.boundary, input)
input.delete("args")
context.append(crossing)
end
context.events.last
end
The loop visits every slot. On each iteration it consults the effective when: — slot override first, then the boundary's when_shape: declaration, then BASE_DEFAULT_WHEN — and either executes or skips. The guard evaluates against the whole context, so shapes can narrow to the most recent crossing or range over the full event stack.
A stop signal lands as a crossing like any other; downstream slots whose when: matches the stop (wherever it sits in the stack) catch it, and downstream slots that default-match continue skipping until the chain ends.
The chain's terminal value is the most recent crossing on the stack. In a chain that ended in a stop with no recovery, that's the stop crossing — which is what the adapter reads to produce the response.
Exception handling is a slot further down the chain with a when: that matches the stop prefix you care about.
slots:
- name: main_work
boundary: do_the_thing
args: { ... }
- name: success_path
boundary: shape_validate
when:
type_addr:
prefix: ":types:"
- name: handle_quota
boundary: quota_recoverer
when:
count:
type: ":signals:stop:quota_exceeded"
gt: 0
- name: handle_anything_else
boundary: error_reporter
when:
count:
type_prefix: ":signals:stop:"
gt: 0
- name: cleanup
boundary: cleanup_handler
when:
always: true
If main_work returns :types:ok, the success path runs and all the stop handlers skip. If it returns :signals:stop:quota_exceeded, the specific handler matches first (structural order of declaration), the broad handler matches after, and cleanup runs. If it returns :signals:stop:network_error, the broad handler and cleanup run.
Routes are declarative. The vocabulary is counts and prefixes against type_addr.
when: is evaluated against the whole context. A boundary writes a crossing under some prefix; downstream slots whose when: finds that prefix in the context's event stack fire. The catch can sit at the next slot or twenty slots later; the when: shape determines when it fires.
This maps onto try/finally/catch:
when: { count: { type_prefix: ":signals:stop:…", gt: 0 } }. The matcher reads the full context, so catches fire on count presence anywhere in the stack. A logger between the throw and the catch keeps the signal available to downstream matchers.when: { always: true }.Slots execute in declaration order. Emit writes a crossing to the context. Catch is any downstream slot whose when: predicate matches against that context.
The expect boundary (case item 4) is the canonical example of the fan-out property. It writes crossings under :signals:verify:*, typically :signals:verify:pass on match and :signals:verify:mismatch on failure, with the shape diff in the payload.
:signals:verify:* sits under :signals: but outside :signals:stop:, so the base default runs over it. Verify signals accumulate in the event stack as observations, and flow continues. A test suite reads them back via Context#events after the chain runs and asserts on the chain shape. A production chain that wants to escalate a verify failure wires it into the slot's when::
slots:
- name: work
boundary: do_the_thing
- name: expect_shape
boundary: expect
args:
shape:
user_id: { type: integer }
# writes :signals:verify:pass or :signals:verify:mismatch
- name: verify_to_stop
boundary: verify_to_stop
when:
env:
WANDERLAND_ENV: production
count:
type_prefix: ":signals:verify:mismatch"
gt: 0
# writes :signals:stop:verify_failure — downstream default slots skip
- name: success_path
boundary: shape_validate
when:
type_addr:
prefix: ":types:"
Both sub-shapes must match for verify_to_stop to fire. A boot with WANDERLAND_ENV=production and an unrecovered verify mismatch in the context runs the escalator. A boot with WANDERLAND_ENV unset or set to anything else skips the slot; verify signals stay observational. The slot stays in the chain across environments; the environment decides whether it fires. See the ENV projection section below.
The fan continues downward. :signals:verify:schema:input_mismatch, :signals:verify:schema:output_mismatch, :signals:verify:contract:pre, :signals:verify:contract:post — a catcher at :signals:verify:schema: sees schema verifies; a catcher at :signals:verify: sees them all. Observation is one branch, intervention is another, and both run through the same mechanism.
Every type address has a net count across the context: positive events contribute +1, :anti:<type> events contribute -1. Context exposes this as a projection:
context.count(type: ":signals:stop:halt") # exact match
context.count(type_prefix: ":signals:stop:") # summed under prefix
A stop event contributes +1 to its type's count. A recovery boundary's crossing has type_addr: :anti:<type> and contributes -1. Counts summed under a prefix answer "any unrecovered event with this prefix present?" The base default asks exactly that question against :signals:stop:.
Signals fan by sub-typing. A boundary that wants a catcher to target its specific failure mode emits a specific type. Denials subdivide by requirement name (:signals:stop:denied:missing_client, :signals:stop:denied:expired_token); stops by cause (:signals:stop:quota:exceeded, :signals:stop:quota:throttled); verifies by schema path (:signals:verify:schema:input_mismatch, :signals:verify:contract:pre). Catchers match on whatever depth of prefix suits.
The tree is the API. New branches cost nothing — a new prefix is an emit call and a when: clause.
Context's chainable filters pass through to count, so filter + count answers the disambiguated questions that raw counting flattens:
context.by_identity("adapter:http").count(type_prefix: ":signals:stop:")
# unrecovered stops attributed to adapter:http
context.by_identity("adapter:http").since(5).count(type_prefix: ":signals:stop:")
# unrecovered stops from adapter:http in the last 5 events of that filtered view
context.signed.count(type_prefix: ":signals:stop:")
# unrecovered stops that were signed — forged stops stay outside this view
.since(N) is lookback by count from the end of the current view. The view is whatever filters have already narrowed — chain filters to pick the anchor, then .since takes the last N events within that view. Stack anchors (position 0, position N-1) need no special filter; they fall out of .events[0] / .events.last for chain-level queries.
Filter dimensions recover the information counting alone drops:
.by_identity("<id>") + count.since(N) + count for lookback, combined with any filter anchor.signed + count, restricted to verified eventsThe same filters that Context already exposes for [] and other projections work here.
An anti event can target a specific type or a prefix. :anti:signals:stop:halt pairs with a single :signals:stop:halt positive (exact match). :anti:signals:stop: clears all positives under :signals:stop: at or before its position in the chain — on-error-resume-next semantics for that branch of the signal tree.
# Emit chain:
# position 0: :signals:stop:halt (count at :signals:stop: = 1)
# position 1: :signals:stop:quota:exceeded (count at :signals:stop: = 2)
# position 2: :anti:signals:stop: (broad anti — count at :signals:stop: = 0)
# position 3: :signals:stop:network_error (count at :signals:stop: = 1)
Broad antis fan as deep as the signal tree. :anti:signals: clears any signal; :anti:signals:verify:schema: clears just the schema verifies. A chain-author who wants strict pairing avoids broad antis; one who wants coarse recovery branches uses them.
Specific catchers paired with specific antis is the normal shape. Broad antis are available for deliberate coarse recovery — flag-it, clear everything, keep going.
A recovery boundary catches the stop via count and emits the paired anti as its own crossing:
class QuotaRecoverer
include Wanderland::Boundary
boundary :quota_recoverer,
when_shape: { "count" => { "type" => ":signals:stop:quota:exceeded", "gt" => 0 } }
def call(input)
# recovery logic ...
Signal.new(type_addr: ":anti:signals:stop:quota:exceeded")
end
end
The walker appends the anti-crossing. The count projection subtracts. Default-matching slots downstream see the count at zero and run.
Failed recovery emits a :types:* crossing — :types:recovery_failed, :types:quota_recovery_timeout, whatever names the failure. This is observational, outside the :signals:stop:* namespace, and adds nothing to the stop count. The original stop crossing is still unpaired; the stop count stays at 1; default slots keep skipping. A broader recovery boundary later in the chain can catch the same stop and try a different approach, reading the :types:recovery_failed crossing from the context to decide whether to try at all.
Emitting another stop from a failed recovery doubles the count and breaks the pairing model. The recovery boundary either succeeds and emits its anti (specific or broad), or reports the failure through a non-stop type.
The stop crossing, any recovery-failed observations, and the eventual anti (if one lands) all live in .events, each signed by its originator. A compliance auditor walking the chain sees the fault, every recovery attempt, and the resolution; the merkle chain links them in sequence. Broad antis carry the same audit weight as specific antis — the scope of the clearance is on the record.
Context exposes a read of the boot-time environment snapshot at context.env. when: predicates condition on external configuration through normal shape descent.
The runtime captures ENV at boot. The snapshot is fixed for every request in that boot; restarts pick up ENV changes. Captured keys are filtered to an allowlist.
The runtime exposes keys starting with WANDERLAND_. Nothing else passes through. Production ENV carries credentials, API keys, and private connection strings; the allowlist keeps those out of any code path an HTTP socket can reach.
Sites add other keys via config.yml:
env_allowlist:
- RACK_ENV
- NODE_ENV
- FEATURE_FLAGS
The listed keys merge with the default WANDERLAND_* prefix match. Explicit keys are literal matches.
when: predicates read the snapshot through Context's standard descent:
- name: verify_to_stop
boundary: verify_to_stop
when:
env:
WANDERLAND_ENV: production
count:
type_prefix: ":signals:verify:mismatch"
gt: 0
Both sub-shapes must match. A boot with WANDERLAND_ENV=production and a matching count runs the slot; any other boot skips it.
The runtime writes the filtered snapshot as a boot-time crossing with type_addr: ":types:env", payload equal to the filtered hash. The crossing is signed by the runtime identity and enters the merkle chain for every request. A compliance auditor walking the chain sees which environment configuration drove the request.
Scenarios override the process environment at runtime boot:
runtime:
env:
WANDERLAND_ENV: production
slots:
- ...
The injected hash replaces the snapshot; scenario fixtures have deterministic control over what shapes resolve against.
An archetype is a slot chain with a specific pattern of when: clauses. Writing a new execution mode = writing a new archetype. In the current build, archetypes are assembled by the injection layer (core_injections for framework defaults, user_injections for site-specific additions — both declared in archetype YAML). Either way, the archetype's signal routing is the archetype.
Test archetype — routes success to validate; stops skip past validation by default:
slots:
- execute_boundary
- shape_validate # when_shape declared on the boundary: type_addr prefix :types:
Debug archetype — dumps trace on every crossing, flags stops and passes separately:
slots:
- execute_boundary
- trace_dumper # when: always
- shape_validate # when: type_addr prefix :types:
- stop_logger # when: type_addr prefix :signals:stop:
- pass_logger # when: type_addr prefix :signals:pass:
- verify_logger # when: type_addr prefix :signals:verify:
Production archetype — runs error handler if any stop lands, audits and cleans up either way:
slots:
- main_work
- success_notifier # when: type_addr :types:ok
- error_reporter # when: type_addr prefix :signals:stop:
- audit_writer # when: always
- cleanup # when: always
Today's framework defaults (injected via core_injections) are enforce_denials, trace_emit, format, seal, and — when the site opts in — persist_context for durable audit. Each is a slot with a specific when_shape: declared on its boundary, composable with any site archetype.
type_addr values on crossings. The storage engine handles them like any other crossing.to_addr via LIFO cancellation in the overlay SQL. Walker-stack signal inversion is a separate concern — see the neutralization section above.when conditions use the existing matcher vocabulary.!UserConfig splicing handles extensibility; when slots into that model directly.:signals:stop:halt in the chain is as cryptographically anchored as a :types:ok — signal type makes no difference to provenance.Halt is a classification. Some slots react to it via their when:; others skip past. The walker iterates every slot and uses type_addr plus when: to decide what runs.
Covered by the current spec suite (235/0 baseline):
when: skip — entry with non-matching condition is skipped (spec/scenarios/phases/phase1b_enforce/, phase5_injections/)when: match — entry with matching condition executes (phase1b, phase2_adapter_caps)when_shape: — boundary declares its own default, takes effect when no slot override (boundary_spec, merkle_chain_spec)when_shape: skips after a stop signal (phase1b_enforce)when: { type_addr: prefix ":signals:stop:" } runs on stop (phase1b_enforce)when: { always: true } runs regardless of signal state (seal, trace_emit, persist_context)BASE_DEFAULT_WHEN skips downstream slots after any :signals:stop:* (phase1b_enforce)persist_context runs on halts with when_shape: { always: true } (phase6_audit/05, phase6_audit/06)Pending (land with matcher-scope, expect, ENV, and schema-synthesis work on task-bac15977):
guard.matches?(context) replaces guard.matches?(last_crossing); shapes resolve against the whole context and its filter surfacecontext.count(type:) / context.count(type_prefix:); :anti:<type> events subtractBASE_DEFAULT_WHEN resolves to { count: { type_prefix: ":signals:stop:", equals: 0 } }enforce_denials emits :signals:stop:denied:<requirement_name> instead of generic :signals:stop:halt; catchers target specific requirementscount gt 0 for a specific stop type and emits :anti:<type>; count returns to zero; default slots resume:anti:<prefix> (prefix form); clears all positives under that prefix from the count at or before its position.by_identity, .signed, .since(N) compose with count.since(N) lookback — last N events in the current view; chains with other filters (.by_identity("x").since(5) takes last 5 events from the X-filtered view)context.env returns the boot snapshot filtered to the allowlist; when: { env: { KEY: value } } matches against itWANDERLAND_* keys pass; unrelated keys do not surface in context.envconfig.yml env_allowlist: merges additional literal keys with the default prefixruntime.env::types:env with the filtered snapshot, signed by the runtime identityexpect writes :signals:verify:*; base default runs past itverify_to_stop catches count: :signals:verify:mismatch gt 0 when env.WANDERLAND_ENV=production