Wanderland

Wanderland Core: Flow Control

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.

The Core Model

A boundary chain is a flat list of slot entries. Each entry has:

The 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 as Type Addresses

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.

Three namespaces, two default channels, infinite fan

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 and catch

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:.

The when Field

when 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.

Boundary DSL Declaration

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.

Base Default

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.

Resolution Order (most specific wins)

effective_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.

Walker Logic

lib/wanderland/dispatcher.rbDispatcher::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 as Downstream Routing

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.

Signal routing as delayed dispatch

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:

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 verify lane: expect as a flow-control idiom

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.

Neutralization Resumes Flow

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:.

Signal sub-typing

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.

Filter composition

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:

The same filters that Context already exposes for [] and other projections work here.

Broad antis

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.

Recovery shape

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.

Audit

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.

The ENV Projection

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.

Default 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.

Site allowlist extension

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.

Shape access

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.

Boot crossing

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.

Scenario injection

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.

Archetypes as Signal Routing Tables

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.

Relationship to Other Systems

What This Replaces

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.

Scenarios Required

Covered by the current spec suite (235/0 baseline):

Pending (land with matcher-scope, expect, ENV, and schema-synthesis work on task-bac15977):