Wanderland

Wanderland Core: Adapter Capabilities

The adapter tells the chain who the caller is and what they can render. The chain decides what to return. Boundaries enforce their own caps by signing context writes that only their own signatures can unlock on read.


Every route is a command. Every adapter is a translator from some external protocol (HTTP, CLI, MCP, AMQP) into the canonical seven-key input hash that Wanderland::Dispatch.invoke hands to the boundary chain. But adapters differ in what their caller can see and do: a browser accepts text/html, a CLI wants JSON on stdout, an MCP tool wants a structured content array, a Prometheus scrape wants text/plain. And some callers (an authenticated admin CLI) get capabilities a public HTTP request never will.

The adapter sits at the edge. It knows its caller. What it knows needs to ride into the chain without becoming a special case at every boundary.

The Adapter Crossing

When the adapter invokes Dispatch.invoke, it prepends one crossing to the context before the chain runs:

context.append({
  "boundary" => "adapter",
  "identity" => "adapter:http",          # or adapter:cli, adapter:mcp
  "result" => {
    "client" => { "ip" => ..., "user_agent" => ..., "authenticated_as" => ... },
    "capabilities" => { "render.html" => true, "render.json" => true, ... },
    "effective_mime" => "text/html"      # adapter's best guess from Accept, flags, etc.
  },
  "sig" => adapter_identity.sign(result),
  "trace" => context.head_sig
})

That crossing rides the same rails as every other boundary result. It's append-only, signed, and on the trace chain. Boundaries downstream read from context like they would any other fact:

caps = context["capabilities"]
mime = context["effective_mime"]

No special-cased adapter parameter, no threadlocal, no implicit kwarg. The adapter's knowledge is context, and context is already how boundaries talk.

Caps and Denials Are Paired

A capability without a denial is a lie. If a boundary declares render.html as a capability, the system has to know what to do when the caller doesn't have it. The DSL refuses to ship one without the other:

class HtmlRenderer
  include Wanderland::Boundary
  boundary :html_renderer,
           capabilities: { "render.html" => { on_deny: :halt_406 } }

  def call(input)
    { "html" => render(input["result"]) }
  end

  # The `on_deny` side is wired automatically by the mixin:
  # a trigger that fires when the chain would invoke this boundary
  # but caps don't include render.html. It short-circuits with 406.
end

The on_deny handler is itself a boundary — a trigger that fires reactively when the shape matches "this boundary was about to run, caps say no." It's not an exception path grafted onto the engine; it's the engine's own reactive substrate telling the chain to halt.

Three rules the DSL enforces:

Trust on Read, Not Write

The tricky part is: what stops a malicious adapter (or a compromised boundary upstream) from writing capabilities.render.html: true into the context and lying about the caller?

Nothing. The context is append-only and anyone can write anything. That's the point — see wanderland-core-context § Trust on Read, Not Write. The enforcement is on the read side.

Every capability key is namespaced to the boundary that owns it:

capabilities.adapter.render.html       # written by identity=adapter:*
capabilities.auth.can_modify_billing   # written by identity=auth
capabilities.pki.signing_enabled       # written by identity=pki

When a boundary reads context["capabilities"], the read goes through a filter that matches the namespace prefix against the identity of the crossing that wrote it. capabilities.adapter.* is only visible if the crossing was signed by an identity matching adapter:*. capabilities.auth.* only if signed by auth. Anything else is silently dropped from the read projection.

A boundary that tries to forge capabilities.auth.can_modify_billing: true writes a crossing signed by itself, not by auth. The write succeeds — the audit trail captures the attempt — but the read filter never surfaces it. Spoofers tell on themselves. The attacker leaves fingerprints in the context that downstream compliance queries will find.

This is the same pattern the context engine already uses for everything else, applied to caps. Audit on write, secure on read, single source of truth.

The Renderer Is a Chain

Formatting the response doesn't belong to the adapter; it belongs to the chain. The adapter declares effective_mime, and a trailing renderer boundary maps the chain's result to bytes appropriate for that MIME.

/reports/:id:
  method: get
  chain:
    - authorize
    - load_report
    - render       # dispatches on effective_mime in context

render is itself a dispatcher — a grid keyed on MIME type:

Dispatcher::Grid.new(
  "text/html" => :html_renderer,
  "application/json" => :json_renderer,
  "text/plain" => :text_renderer,
  "text/markdown" => :markdown_renderer
)

Each renderer is a boundary with its own capability. The denial pair handles the case where the caller asked for a MIME the route doesn't support — halt 406, handled by the same reactive trigger machinery.

The adapter's job ends at translating bytes out of the chain's final crossing into its protocol's envelope (HTTP response, stdout write, MCP content array). It does not choose the format. The chain does.

Why This Is Structurally Right

Minimum Viable

Three deliverables get this to working:

Once these land, every existing adapter (HTTP, CLI) becomes provably thin, and MCP / AMQP / gRPC adapters drop in at ~50 lines each.

Source

Revision: Archetype-Slot Model (Supersedes the DSL Sections Above)

The earlier "Caps and Denials Are Paired" DSL section proposed a boundary-mixin with capabilities: keyword and a new denial-trigger registration path. That's more machinery than the archetype system already gives us. The archetype is a YAML-to-YAML linker: slots are boundaries, slot args can reference !UserConfig <key>, and the site's config.yml fills those slots. Denials fit that pattern exactly.

The Slot

One new slot in engine.yml (and test.yml), positioned before routes so triggers are armed when routes mount:

- name: denials
  boundary: context_from_shape
  args:
    under: denials
    shape: !UserConfig denials

The User-Facing Block

Sites declare their requirements in config.yml under a top-level denials: key. The shape is just a list of "require X, or halt with Y":

denials:
  - require: client
    on_miss: halt_401
  - require: effective_mime
    on_miss: halt_406
  - require: capabilities.render.text
    on_miss: halt_406

routes:
  /hello:
    method: get
    boundary: echo
    name: hello

The Generic Boundary

context_from_shape is not caps-specific. It takes two args — under: (a context header / section name) and shape: (any YAML structure) — and appends the shape into context under that header, as a single signed crossing. Any archetype slot that needs to seed context from user config uses the same boundary. Denials is one instance; feature flags, environment labels, quota policies, all the same pattern.

How Enforcement Happens

Denial declarations in context are just shapes. The existing trigger system watches the chain and fires when a shape matches. A denial trigger's match condition is "the declared require path does not resolve in the current context's read projection." When it fires, it halts the chain with the declared status.

No new trigger machinery. No capabilities DSL on boundaries. The security model from the earlier sections still holds — caps crossings written by the adapter are signed, and the read-side namespace filter on capabilities.* still drops spoofs — but nothing about that lives in a boundary mixin. It lives in the context engine and the trigger system, which already existed.

What Actually Needs to Be Built

Dropped to two things:

The capabilities read-filter and the renderer-grid (from the earlier sections) are still needed, but they are independent of the denial-declaration mechanism. Denial declarations are just data in context; the filter and renderer consume that data like any other boundary would.

Changelog

2026-04-14: Revised — the earlier DSL approach is superseded by the archetype-slot model. The !UserConfig thunk system in archetypes is already a YAML-to-YAML linker, so denials become a site-config section hydrated into an archetype slot by a generic context_from_shape boundary. Nothing new in the framework beyond that one boundary.