A data-driven layer that modifies each compiled chain at boot time. The archetype defines the slot sequence for framework startup; injections define the slot sequence for every request chain the engine ends up running. They're declared as a list, applied in order, and can come from the framework archetype, site config, or both.
A request chain starts life as whatever the route declared in config.yml:
routes:
/hello:
method: get
boundary: echo
That's the user chain — here, one slot: echo. Before any request runs, the engine compiles every route by folding the registered injections over the user chain. The final chain — the one the walker actually steps through — is the product of that fold.
Framework-default injections (enforce_denials, trace_emit, format) ship in the archetype. Site-custom injections (audit emitters, rate limiters, request-id stampers, latency timers) register through an injections: block in config.yml. Both feed the same registry.
Injection = Struct.new(:boundary, :position, keyword_init: true)
Two fields:
boundary — the boundary name to invoke when the injected slot runsposition — where in the chain to place the slot (see Positions below)Build from a config hash:
Injection.from_hash({ "boundary" => "audit_emit", "position" => "interleave" })
Injection.from_hash({ "boundary" => "latency", "position" => { "after" => "auth_gate" } })
A position describes where an injection's slot goes relative to the chain it's being folded into. Five forms:
| Position | YAML shape | Effect |
|---|---|---|
| First | first |
Prepend once at the head |
| Last | last |
Append once at the tail |
| Interleave | interleave |
Insert before every slot currently in the chain |
| Conditional interleave | { interleave: <shape> } |
Interleave only before slots matching the ShapeMatcher shape |
| Before | { before: boundary_name } |
Insert before every occurrence of that boundary name |
| After | { after: boundary_name } |
Insert after every occurrence of that boundary name |
The conditional interleave reuses the ShapeMatcher vocabulary used by slot guards and expectations. The shape is matched against the slot's own data (boundary name, args, when_shape):
injections:
- boundary: latency_timer
position:
interleave:
boundary: { matches: "^(auth|gate|repo)_" }
position: interleave with no shape is equivalent to position: { interleave: { always: true } }.
Injections apply in declaration order. Each injection operates on the chain as it exists at that moment of the fold — not on the original user chain, and not on the final chain. Three consequences follow:
Late last wins the actual tail. When two injections both declare position: last, the first gets appended to the chain, then the second gets appended to that. A declared before B means the chain reads […user slots…, A, B]. Declaration order is final-placement order.
Interleave sees whatever came before it. An injection with position: interleave only interleaves between slots present when it runs. Injections declared earlier in the list are themselves candidates for interleaving; injections declared later are not, because they haven't been added yet. This is how the framework achieves "denials between every user slot but not between trace_emit and format" — enforce_denials is declared first, then trace_emit, then format, so denials interleave only the user slots.
before: and after: match every occurrence. An injection with { after: echo } places a copy of its slot after every slot whose boundary is echo. If an earlier injection duplicated a slot or the user chain repeats one, every occurrence gets the neighbor. Targeted single-point insertion is achieved through narrower matching, not through "first occurrence only" semantics.
InjectionRegistry is a class mounted per-runtime — one instance on Wanderland::Runtime#injections, populated during boot and consumed when routes mount. Per-runtime scoping lets adapter types (http, cli, test, future MCP/AMQP) all follow the same mount-via-runtime path without sharing global state across boots.
module Wanderland
class InjectionRegistry
def initialize
@registry = []
end
def register(injection)
@registry << injection
end
def all
@registry
end
def size
@registry
end
def reset!
@registry = []
end
# Fold every registered injection over a chain of Slots.
# Returns the patched chain.
def apply(chain)
@registry(chain) { |acc, inj| apply_one(inj, acc) }
end
private
def apply_one(injection, chain)
slot = Dispatcher::Slot.from_spec(injection.boundary, injected: true)
case injection.position
when :first then [slot] + chain
when :last then chain + [slot]
when :interleave then chain.flat_map { |s| [slot, s] }
when Hash then apply_hash_position(injection, slot, chain)
else
raise ArgumentError, "unknown position: #{injection.position.inspect}"
end
end
def apply_hash_position(injection, slot, chain)
pos = injection.position
if pos.key?(:interleave)
cond = Wanderland::When.coerce(pos[:interleave])
chain.flat_map { |s| cond.matches?(slot_fact(s)) ? [slot, s] : [s] }
elsif (name = pos[:before])
name_sym = name.to_sym
chain.flat_map { |s| s.boundary == name_sym ? [slot, s] : [s] }
elsif (name = pos[:after])
name_sym = name.to_sym
chain.flat_map { |s| s.boundary == name_sym ? [s, slot] : [s] }
else
raise ArgumentError, "unknown position hash: #{pos.inspect}"
end
end
def slot_fact(slot)
{
"boundary" => slot.boundary.to_s,
"args" => slot.args,
"when" => slot.when_shape
}
end
end
end
Each Slot.from_spec carries injected: true — see Named Routes for what that flag controls in introspection.
register_injections BoundaryOne boundary registers injections from a list. The framework defaults and the site config both flow through it — same mechanism either way:
class RegisterInjections
include Wanderland::Boundary
boundary :register_injections,
capabilities: [:boot, :injection_config],
description: "Register chain injections for route compilation",
input_shape: {},
output_shape: { "registered" => true }
def call(input)
registry = input["runtime"].injections
Array(input["args"]["injections"]).each do |h|
begin
registry.register(Wanderland::Injection.from_hash(h))
rescue ScriptError, StandardError => e
label = h.is_a?(Hash) ? (h["name"] || h[:name] || h["boundary"] || h[:boundary]).to_s : h.inspect
Wanderland::Diagnostics.record(
:error, "register_injections",
"failed to register injection #{label}",
error: e, injection: label
)
end
end
Signal.ok(registered: registry.size)
end
end
Anatomy:
input["runtime"].injections reaches the per-runtime InjectionRegistry instance. The boundary doesn't see a global — boot can run on a fresh runtime, or in test, or across multiple runtimes in the same process without spillover.input["args"]["injections"] is the list to register. Array(…) coerces nil to [] (the user-injections slot has nothing to register when the site declares no custom injections), and Injection.from_hash validates each entry.Wanderland::Diagnostics and continues. One ill-formed injection doesn't poison the rest of the boot.Signal.ok(registered: registry.size) returns the total registered (not just this call's contribution), so core_injections reports 5 and user_injections reports 5 + N.The framework's boot orchestration lives in lib/wanderland/runtime.yml — one ordered slot list run once per Wanderland::Runtime#boot_from_config. Two register_injections slots sit between the rest of the boot steps and the route-mounting terminal slot:
# lib/wanderland/runtime.yml
name: runtime
slots:
- name: boundaries
boundary: boot_load_boundaries
args: { target: !UserConfig boundary_path }
- name: resolvers
boundary: boot_configure_resolvers
args: { config: { resolvers: !UserConfig resolvers } }
- name: storage
boundary: boot_mount_storage
args: { config: { storage: !UserConfig storage } }
- name: adapters
boundary: boot_mount_adapters
args: { config: { adapters: !UserConfig adapters } }
- name: triggers
boundary: boot_mount_triggers
args: { config: { triggers: !UserConfig triggers } }
- name: env
boundary: env_snapshot
args:
env_allowlist: !UserConfig env_allowlist
- name: denials
boundary: context_from_shape
args:
under: denials
shape: !UserConfig denials
capabilities: [denials]
- name: core_injections
boundary: register_injections
args:
injections:
- { boundary: enforce_denials, position: interleave }
- { boundary: verify_route, position: last }
- { boundary: trace_emit, position: last }
- { boundary: format, position: last }
- { boundary: seal, position: last }
- name: user_injections
boundary: register_injections
args:
injections: !UserConfig injections
- name: archetype
boundary: boot_mount_archetype
args: {}
Slot order matters: core_injections registers the framework defaults first, user_injections adds site-provided ones, boot_mount_archetype then walks every route and folds the now-fully-populated registry over each route's chain. Both register_injections slots write to the same runtime.injections registry instance.
The five framework defaults — enforce_denials (interleave), verify_route / trace_emit / format / seal (each last) — wrap every compiled chain. Site injections from !UserConfig injections append on top; late :last injections wrap earlier ones, so a site-level audit_emit last ends up running after seal. To slot site work between framework defaults, use { before: <name> } or { after: <name> } rather than last:.
Sites declare custom injections under a top-level injections: key in config.yml:
injections:
- boundary: request_id_stamp
position: first
- boundary: audit_emit
position: { after: echo }
- boundary: latency_timer
position:
interleave:
boundary: { matches: "^(auth|gate|repo)_" }
routes:
/hello:
method: get
boundary: echo
Custom boundaries drop in as entries in the list. No framework code to edit.
Engine#resolve_chain converts the user's declared slots to Dispatcher::Slot structs and folds them through the runtime's InjectionRegistry:
def resolve_chain(spec)
entries = if spec[:chain]
Array(spec[:chain])
elsif spec[:args] || spec[:when]
# Short-form route (`boundary: name`) carrying route-level
# args:/when: — wrap into a single-slot hash so the
# dispatcher slot picks them up.
[{ "boundary" => spec[:boundary], "args" => spec[:args], "when" => spec[:when] }.compact]
else
[spec[:boundary]]
end
user_slots = entries.map do |entry|
raw = entry.is_a?(Hash) ? deep_stringify(entry) : entry
Dispatcher::Slot.from_spec(raw)
end
registry = @runtime&.injections
registry ? registry.apply(user_slots) : user_slots
end
The dispatcher receives the post-fold chain and walks it normally. From the walker's perspective, injected slots and user slots are indistinguishable — each is a Dispatcher::Slot, each is invoked via Boundary.execute, each appends a crossing to context. The injected: true flag on injected slots is preserved for introspection (see Named Routes below); it does not change dispatch behavior.
A runtime with no InjectionRegistry mounted (unusual — used in some unit-test paths) skips the fold entirely. In that case the user chain runs verbatim with no framework defaults wrapping it.
The named-routes registry (Engine#named_routes) exposes the user-declared chain only, not the post-fold chain. oculus-button and other downstream tooling see the chain the site wrote, not the framework plumbing. The Slot struct carries an injected? flag set by Injections.apply; the named-routes view filters to !injected?.
class AuditEmit
include Wanderland::Boundary
boundary :audit_emit, capabilities: [:audit]
def call(input)
prior = input["context"].events.last
Signal.ok(
audit: {
boundary: prior["boundary"],
at: Time.now.iso8601,
caller: input["context"]["client"]
}
)
end
end
injections:
- boundary: audit_emit
position: interleave
Every slot — including enforce_denials and format — gets followed by an audit crossing.
injections:
- boundary: rate_limit
position: first
rate_limit runs before anything else, halts with Signal.halt(status: 429) on quota exceeded. Downstream slots skip via BASE_DEFAULT_WHEN.
injections:
- boundary: latency_start
position: { before: pipeline_work }
- boundary: latency_end
position: { after: pipeline_work }
Two boundaries, two injections — start stamps a timestamp into context, end reads it and emits the delta.
injections:
- boundary: request_id_stamp
position: first
Generates a UUID once, writes it to context; every subsequent crossing's from carries it implicitly.
The injection layer is a boot-time construct. The following are intentionally out of scope:
when: shape, evaluated by the walker at each step. An injected slot that shouldn't run under some conditions declares those conditions on its boundary registration.group: auth_stack abstraction. Shared sets of injections are expressed with YAML anchors.when: of its own. Conditional behavior lives on the invoked boundary.Injections.register is intended for the boot sequence only. Modifying the registry at request time has no effect — the chain was compiled at mount.register_injections runs.{ boundary: format, position: last } in core_injections.when: guards and stop-signal semantics.wanderland.dev