Wanderland

Wanderland Core: Injections

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.

Overview

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.

The Injection Struct

Injection = Struct.new(:boundary, :position, keyword_init: true)

Two fields:

Build from a config hash:

Injection.from_hash({ "boundary" => "audit_emit", "position" => "interleave" })
Injection.from_hash({ "boundary" => "latency",    "position" => { "after"  => "auth_gate" } })

Positions

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 } }.

Application Semantics

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.

Registry

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

The register_injections Boundary

One 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.

Archetype Wiring

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.

Site Configuration

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 Consumption

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.

Named Routes

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?.

Common Patterns

Request audit trail

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.

Rate limit at the head

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.

Paired latency timer

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.

Request ID correlation

injections:
  - boundary: request_id_stamp
    position: first

Generates a UUID once, writes it to context; every subsequent crossing's from carries it implicitly.

Scope

The injection layer is a boot-time construct. The following are intentionally out of scope:

Related Concepts