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.
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:
context["effective_mime"] — the mime the adapter announced on the way in.Wanderland::Formatters by exact-match mime, with a boot-configured default as the fallback.Boundary.execute call so it shows up in the trace chain like anything else.{ body, content_type, formatter_used } onto the final crossing — the Rack/CLI adapter reads those fields and emits the response.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.
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.
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.
format Slotclass 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.
Four ship with the framework:
json_formatter → application/jsonStraight JSON.generate(data). The canonical default-if-no-site-config.
text_formatter → text/plainpretty_inspect(data) with a small affordance for flat hashes: key: value per line. Nested content drops to pretty_inspect.
html_formatter → text/htmlSmart dispatch based on data shape:
<table> with keys as column headers.<pre>#{JSON.pretty_generate(data)}</pre>.Minimal styling. No templates, no escaping bugs: CGI.escape_html everywhere.
markdown_formatter → text/markdownSymmetric to HTML:
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#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).
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.
serves: Option — Why It's Framework-LevelThe 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.
Before Stage 4, Engine#format_response handled:
_halt: true → JSON error with status (pre-flow-control leftover, already refactored to Types.blocking? in Stage 2).html: "..." → inline HTML response (only from hand-crafted boundary returns)._raw: + _content_type: → generic raw-bytes passthrough.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.
effective_mime is the single selector; no fallback through a ranked list. Accept: text/html, application/json;q=0.9 resolves to text/html today. A site without an HTML formatter and without a default gets 406, not JSON. This is the strict-mode default; Accept walking is a future addition in the adapter, not the formatter.html_formatter's scope is table/pre only. Template integration is a separate conversation and a separate boundary (likely template_formatter registered against a custom MIME or a site-config override)._trace. Formatters render _trace as a regular data key for now. Fancier rendering (expanding <details>, ANSI-color stack) is formatter-internal and incremental.