Wanderland

Wanderland Core: Format

The response-side counterpart to dispatch. Where dispatch converges many external protocols onto one canonical input shape, format picks one renderer and emits bytes matched to the adapter's wire. Both are linear — no branching in the walker, no fan-out, just a single slot that selects and runs.

Shape of the Slot

Chain placement is data-driven, not hardcoded. The format slot enters the chain via wanderland-core-injections — the declarative patch layer that folds framework-default and site-custom slots into the user's route chain at boot. Format's entry in the archetype's core_injections list is { boundary: format, position: last }, declared after trace_emit. The paragraphs below describe what the slot does once it's there; injections is the spec for how it got there.

:format is the true tail of every compiled chain — injected automatically by Engine#resolve_chain, after trace_emit and after the user's slots. It does four things:

When no formatter matches and no default is set, emits Signal.halt(status: 406, error: "no formatter", supported: [...]). That's the phase-4 "unsupported mime halts 406" behavior — the set of registered formatters is the capability claim.

Selection, Not Fanout

An early draft of this node called the slot a "fanout" — it isn't. The registry is a one-to-one map keyed on mime; at any given request there is exactly one matched formatter. The linearity everywhere else in the system (one walker step per slot, one crossing per step) is preserved. "Format" is selection + execution, not branching.

Registry

Wanderland::Formatters is a module-level registry:

module Wanderland
  module Formatters
    # Boundary DSL extension: `serves: "application/json"` auto-registers.
    def self.register(mime, boundary_name)
      @registry = boundary_name.to_sym
    end

    def self.for_mime(mime)
      @registry
    end

    def self.registered_mimes
      @registry
    end

    def self.default
      @default  # set via configure(default:) at boot
    end
  end
end

Formatters are ordinary boundaries carrying a serves: declaration:

class JsonFormatter
  include Wanderland::Boundary
  boundary :json_formatter,
    serves: "application/json",
    capabilities: [:formatter]

  def call(input)
    data = input["target"]
    Signal.ok(body: JSON.generate(data), content_type: "application/json")
  end
end

The serves: keyword extends the existing Boundary.boundary DSL; the Boundary registry notices it and delegates to Formatters.register. One declaration, two registries in sync.

The format Slot

class Format
  include Wanderland::Boundary
  boundary :format,
    capabilities: [:format, :passthrough],
    when_shape: { "always" => true }  # runs on happy and halted paths

  def call(input)
    context = input["context"]
    mime = context["effective_mime"]
    prior = context.events.last

    formatter_name = Wanderland::Formatters.for_mime(mime) || Wanderland::Formatters.default
    unless formatter_name
      return Signal.halt(
        status: 406,
        error: "no formatter for #{mime.inspect}",
        supported: Wanderland::Formatters.registered_mimes
      )
    end

    sub_input = input.merge("target" => prior && prior["result"])
    inner = Wanderland::Boundary.execute(formatter_name, sub_input)
    inner_result = inner["result"] || {}

    # Stamp the selection on the crossing; preserve whatever type_addr
    # the prior chain landed on, so halts stay halts.
    Signal.new(
      type_addr: prior ? prior["type_addr"] : Wanderland::Types::OK,
      payload: inner_result.merge("formatter_used" => formatter_name.to_s),
      extra_capabilities: []
    )
  end
end

Two crossings appear in context: the format dispatch decision and the concrete formatter's render. Both carry type_addr; both signed if identities exist; both visible to trace.

input["target"]

The prior crossing's result is the "data" to be rendered. This is the principal work output of the chain — already augmented by trace_emit with _trace when the caller requested it. Formatters treat _trace as just another key in the data; fancier per-formatter trace rendering (an expanding <details> block in HTML, an indented stack in text) is a later concern and lives inside individual formatters, not in the dispatch slot.

Shipped Formatters

Four ship with the framework:

json_formatterapplication/json

Straight JSON.generate(data). The canonical default-if-no-site-config.

text_formattertext/plain

pretty_inspect(data) with a small affordance for flat hashes: key: value per line. Nested content drops to pretty_inspect.

html_formattertext/html

Smart dispatch based on data shape:

Minimal styling. No templates, no escaping bugs: CGI.escape_html everywhere.

markdown_formattertext/markdown

Symmetric to HTML:

Boot-Time Default

A new archetype slot seeds the default formatter, using the existing context_from_shape generic loader:

- name: format
  boundary: context_from_shape
  args:
    under: format
    shape: !UserConfig format
    capabilities: [format_config]

Site config form:

format:
  default: application/json

At boot, Formatters.configure(default: context["format"]["default"]) reads the hydrated value and sets the registry default. Sites without a format: block get no default — unmatched mimes halt 406, which is the strict-mode behavior.

Engine Integration

Engine#format_response contracts down. The old special cases (_halt, html, _raw) retire — every response shape comes from a formatter. The method becomes:

def format_response(crossing)
  result = crossing&.fetch("result", nil) || {}
  type_addr = crossing&.fetch("type_addr", nil)
  status = (result["status"] || (Wanderland::Types.blocking?(type_addr) ? 500 : 200)).to_i
  content_type = result["content_type"] || "application/json"
  body = result["body"] || JSON.generate(result)

  [status, { "content-type" => content_type }, [body]]
end

CLI adapter's writeback mirrors: read result["body"] for stdout, exit non-zero when Types.blocking?(type_addr).

Chain Shape

After Stage 4, every compiled chain is the result of folding the registered injections over the user's declared slots:

head:  enforce_denials
       user_slot_1
       enforce_denials
       user_slot_2
       enforce_denials
       ...
       user_slot_N
       trace_emit        # augments data with _trace when requested
tail:  format            # renders bytes; real tail; `when: always`

The three framework injections — enforce_denials (interleave), trace_emit (last), format (last) — are declared in the archetype's core_injections slot. Declaration order is placement order: format is declared after trace_emit, so its last application lands after trace_emit's tail, making format the real final slot. Sites adding custom boundaries (audit, metrics, correlation) append to the user injections: list in config.yml and slot in without touching framework code.

The serves: Option — Why It's Framework-Level

The same question came up for when: and got the same answer: when a boundary-level declaration has plumbing consequences (auto-registration, default application), it belongs in the DSL. serves: "application/json" on a boundary is "tell the framework where I fit." Sites never touch Formatters.register directly — they write a formatter class with serves: and the framework wires it.

What This Replaces

Before Stage 4, Engine#format_response handled:

After Stage 4 all four collapse. Every response goes through format. Writing HTML is no longer "return { html: '...' } from your boundary" — it's "serve application/json normally, let the adapter's Accept negotiation pick html_formatter when the client asks." Content negotiation is the ONE place that decides wire shape.

Scope This Round (What's NOT Being Built)