Wanderland

Wanderland Core: Dispatch

After the engine boots, every action the system can take is a route. Routes are the command surface. HTTP verbs, CLI subcommands, MCP tool calls, AMQP messages — each is a different way of arriving at the same compiled route and executing its boundary chain.

Dispatch is the seam between "how the request arrived" and "what the system does." One shape in, one crossing out. Adapters sit on either side.

Why It Exists

Without a common seam, each adapter ends up re-implementing the same pre-flight: parse the external protocol, shape the input, find the route, run the chain, format the response. The input-building code starts to drift between adapters. A field appears in HTTP but not in CLI. A _halt directive is respected by one and ignored by the other. The engine and the CLI runner both know the same seven input keys by convention only.

Dispatch pulls the middle three steps — shape, resolve, run — into a single function. Adapters own protocol parsing and response formatting; the seam owns everything else.

┌────────────────────┐   ┌────────────────────┐   ┌────────────────────┐
│  Rack  (HTTP)      │   │  Commander  (CLI)  │   │  MCP / AMQP / ...  │
│  parses request    │   │  parses argv       │   │  parses whatever   │
└─────────┬──────────┘   └─────────┬──────────┘   └─────────┬──────────┘
          │  route + params + headers + path                │
          └──────────────────┬──────────────────────────────┘
                             ↓
            Wanderland::Dispatch.invoke(runtime, route, **args)
                             ↓
                    route[:dispatcher].execute(input, Context.new)
                             ↓
                          crossing
                             ↓
          ┌──────────────────┴──────────────────────────────┐
          │                                                 │
┌─────────┴──────────┐   ┌────────────────────┐   ┌────────┴───────────┐
│  format as HTTP    │   │  format as stdout  │   │  format as MCP     │
│  (status, JSON)    │   │  (pretty JSON)     │   │  (tool result)     │
└────────────────────┘   └────────────────────┘   └────────────────────┘

The Shared Shape

Every adapter translates its external protocol into the same seven-key input hash:

{
  "params"  => Hash,    # the callable's arguments, merged from path captures + query + body
  "query"   => Hash,    # adapter-specific; HTTP uses query string, CLI uses key=value argv
  "path"    => String,  # the route path, post-capture
  "headers" => Hash,    # canonicalized header map (HTTP); empty for non-HTTP adapters
  "config"  => Hash,    # runtime.config.domain — everything that isn't an engine key
  "route"   => Hash,    # the route spec (boundary, chain, name, args)
  "runtime" => Runtime  # the engine instance for recursion
}

The dispatcher executes the boundary chain against this hash and returns a crossing record. The crossing's result field is what adapters format back out.

Adapter Anatomy

An adapter is three things:

Everything in the middle — input building, dispatcher invocation, crossing production — goes through Wanderland::Dispatch.

Existing Adapters

Adapter File External protocol Route resolution Format
HTTP lib/wanderland/engine.rb Rack Mustermann match on path + method JSON with status (_halt, html, _raw supported)
CLI lib/wanderland/runners/cli.rb Commander Route name: field → command name Pretty JSON to stdout

Both resolve to the same route[:dispatcher] object — a Dispatcher::Chain (or Grid) compiled once by Engine#mount_route at boot.

The Test Property

Adding a new adapter is the test of whether the seam is clean. If implementing an MCP server means writing:

class MCPAdapter
  def handle_tool_call(tool_name, arguments)
    route = @runtime { |r| r[:spec]["name"] == tool_name }
    crossing = Wanderland::Dispatch.invoke(@runtime, route, params: arguments)
    { "content" => [{ "type" => "text", "text" => JSON.generate(crossing["result"]) }] }
  end
end

...then the architecture is right. The adapter is small because the seam owns the work. If it would need to re-build the input hash, or duplicate the dispatcher lookup, or handle _halt itself, the seam is leaking and needs tightening.

This is the property to defend. New adapters (gRPC, AMQP, SSE streaming, file-watcher, cron trigger) are each a single file. Testing the seam itself is one spec that calls Dispatch.invoke with a fabricated route and params.

Route as Command

Once boot completes, runtime.engine.compiled_routes is the command table. Every entry is an invocable operation: signed, traced, capability-gated, identity-bound. The adapter chooses how you arrive; the route chooses what happens.

This is the operating-system analogy. The kernel has a syscall table. Userland programs reach it through different interfaces — the C library, direct assembly, a ptrace-based debugger, a Go runtime. The syscall table doesn't care. Wanderland's route table plays the same role for whatever an engine is for: source code indexing, game simulation, document compilation, tool execution. Adapters are the user-facing frontends; routes are the syscalls.

Layering Summary

external protocol        ← adapter parses
──────────────────────
Dispatch.invoke          ← one hop: shape, resolve, run
──────────────────────
route[:dispatcher]       ← Chain or Grid, compiled at boot
──────────────────────
Boundary.execute         ← per-boundary: identity, PKI, tracing, signed crossing
──────────────────────
storage, context, etc.   ← the substrate

Four layers, clearly owned. Adapters are swappable. The seam is one function. Below the seam is the engine proper.

Source