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.
Wanderland::Injections is a module-level list, populated during boot and consumed when routes mount.
module Wanderland
module Injections
@registry = []
class << self
def register(injection)
@registry << injection
end
def all
@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)
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)
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])
chain.flat_map { |s| s.boundary == name.to_sym ? [slot, s] : [s] }
elsif (name = pos[:after])
chain.flat_map { |s| s.boundary == name.to_sym ? [s, slot] : [s] }
end
end
def slot_fact(slot)
{ "boundary" => slot.boundary.to_s, "args" => slot.args, "when" => slot.when_shape }
end
end
end
end
register_injections BoundaryOne boundary registers injections from a list. The framework defaults and the site config both flow through it:
class RegisterInjections
include Wanderland::Boundary
boundary :register_injections,
capabilities: [:boot, :injection_config],
description: "Register chain injections for route compilation"
def call(input)
Array(input["injections"]).each do |h|
Wanderland::Injections.register(Wanderland::Injection.from_hash(h))
end
Signal.ok(registered: Wanderland::Injections.all.size)
end
end
Generic shape loader — same pattern as context_from_shape. Takes a list of hash entries, builds Injection structs, appends to the registry.
The framework archetype places two register_injections slots before boot_mount_routes:
# lib/wanderland/archetypes/engine.yml
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: triggers
boundary: boot_mount_triggers
args:
config:
triggers: !UserConfig triggers
- 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: trace_emit, position: last }
- { boundary: format, position: last }
- name: user_injections
boundary: register_injections
args:
injections: !UserConfig injections
- name: routes
boundary: boot_mount_routes
args: {}
The slot order matters: core_injections registers the framework defaults first, user_injections adds site-provided ones, boot_mount_routes compiles every route through the now-fully-populated registry. A site wanting to reorder the framework defaults writes its own archetype that references register_injections with a different list.
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 hands them to Injections.apply:
def resolve_chain(spec)
entries = spec[:chain] ? Array(spec[:chain]) : [spec[:boundary]]
user_slots = entries.map do |entry|
raw = entry.is_a?(Hash) ? deep_stringify(entry).merge("args" => deep_stringify(entry[:args] || {})) : entry
Dispatcher::Slot.from_spec(raw)
end
Wanderland::Injections.apply(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 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.