Boundaries at the seams. Interceptors run before and/or after every Boundary.execute call, gated by run_level. They are the observability, validation, and debugging layer.
This node is the original design. What shipped in wanderland-core is the injection mechanism, not an Interceptor module. Reconciled against code (lib/wanderland/injection.rb, boundaries/boot/register_injections.rb, runtime.rb):
Real:
Injection = Struct.new(:boundary, :position) — a boundary spliced into a route's compiled chain. No run_level, no core: flag, no before/after-Boundary.execute wrapping.InjectionRegistry#apply(chain) is a left-fold applied at route compile (Engine#resolve_chain), not a per-execution wrapper. Declaration order = application order.:first, :last, :interleave (slot before every existing slot — chain.flat_map { [slot, s] }), {interleave: <when>} (conditional via ShapeMatcher on slot facts), {before: name}, {after: name}.injections: list via the register_injections boot boundary (!UserConfig injections); the same path registers the core injections.trace_emit exists as a core injection (records each crossing); enforce_denials is the interleave exemplar.Design only, not built:
Interceptor module / Registration struct with position: before|after|both + run_level + core.always / debug / monitor / trace) and a current-run-level set.Boundary.execute — injections wrap route-chain slots, not every boundary call. (An execution-level wrapper remains genuinely distinct future work; injections cover the route-chain observability goal.)result_validator and trace_recorder core interceptors — no Wanderland.tracer.record; the tracer exists under other names.To emit/observe today: use an injection. { position: interleave } fires between every slot the way this node's "interleaved, fires every time" intent describes. See wanderland-core-injections for the reference and pkb-coreweave-compute-plane for the foxfire_emit emitter that uses it.
Framing (per Graeme, 2026-07-18): this is morph, not drift. The interceptor idea became the chainpatch — injection.rb names itself "one declarative chain-patch entry." An interleave chainpatch is the same thing as an execution wrapper; the difference is only that the chainpatch is data-driven (declared in the site YAML) while a before/after Boundary.execute hook is hardcoded and hard to make data-driven. So the core concept shipped in its configurable form. Read the "Design only, not built" list as "realized as a data-driven chainpatch, plus a few un-built extras" (run_levels, result_validator/trace_recorder, the before|after|both naming) — not "abandoned."
An interceptor is a boundary with two extra properties: position (before/after/both) and run_level. The engine wraps every Boundary.execute with interceptor calls.
module Wanderland
module Interceptor
Registration = Struct.new(
:name, :boundary, :position, :run_level,
:core, # true = cannot be removed or overridden
keyword_init: true
)
end
end
before — runs before the boundary executes. Can inspect/modify input. Can halt (return _halt to prevent boundary execution).after — runs after the boundary executes. Can inspect/modify the crossing result. Can flag violations.both — shorthand for registering both before and after.always — runs on every boundary execution. Core interceptors use this.debug — runs when the engine is in debug mode.monitor — runs when monitoring is active.trace — runs when tracing is active (finer than debug).The engine maintains a current run_level set. Interceptors fire if their run_level is in the set.
Like the health triptych routes, core interceptors ship with the gem and cannot be removed or replaced. Sites add interceptors on top.
Validates that boundary results do not use reserved ShapeMatcher keywords as top-level keys. Prevents the count collision where a result key shadows a matcher keyword.
Reserved keywords: count, gte, lte, gt, lt, matches, contains, includes, excludes, any, keys, has_key, not, empty, first, last.
If a boundary result uses a reserved keyword at the top level, the interceptor flags it with a warning (not a halt — backwards compatibility). Future: configurable to halt.
class ResultValidator
include Wanderland::Boundary
boundary :result_validator,
capabilities: [:validate]
RESERVED = %w[
count gte lte gt lt matches contains includes excludes
any keys has_key not empty first last
].freeze
def call(input)
result = input["crossing"]["result"]
return { "valid" => true } unless result.is_a?(Hash)
collisions = result.keys & RESERVED
if collisions.any?
Wanderland.logger.warn("Interceptor") {
"Boundary '#{input['crossing']['boundary']}' result uses reserved " \
"ShapeMatcher keywords as keys: #{collisions.join(', ')}. " \
"These will shadow matcher operations in expected shapes."
}
end
{ "valid" => collisions.empty?, "collisions" => collisions }
end
end
Records every crossing to the tracer. Already happens in Boundary.execute via Wanderland.tracer.record. Moving it to an interceptor makes it explicit and configurable.
Registered in config YAML, same as triggers:
interceptors:
- boundary: audit_logger
position: after
run_level: always
- boundary: debug_inspector
position: before
run_level: debug
- boundary: dashboard_feed
position: after
run_level: monitor
- boundary: request_timer
position: both
run_level: always
Boundary.execute(:my_boundary, input)
|
v
run before-interceptors (filtered by run_level)
|
v
execute the boundary
|
v
run after-interceptors (filtered by run_level)
|
v
return crossing
Before-interceptors receive { "boundary" => name, "input" => input }. They can modify input or halt.
After-interceptors receive { "boundary" => name, "crossing" => crossing }. They can flag, log, or modify the crossing result.
core: true. Interceptor.reset! does not remove them. Sites cannot override them.core: false. They can be reset, removed, or overridden.Same pattern as Engine::CORE_ROUTES — enforced defaults, extensible by sites.
Complement the runtime validator with a static lint check:
# A custom RuboCop cop that flags hash literals containing
# reserved ShapeMatcher keywords as keys in boundary result hashes.
# Catches the collision at development time, not runtime.
Both layers: runtime interceptor catches it in production, static cop catches it in development. Belt and suspenders.
Gate interceptors on context shape, not just run_level. ShapeMatcher against a time/context shape:
interceptors:
- boundary: dashboard_feed
position: after
when:
day_of_week: wednesday
run_level: monitor
Same when syntax as triggers. The interceptor fires when the context matches AND the run_level is active. This is the "feed to a dashboard on wednesdays" pattern.
Interceptors and triggers are complementary:
Triggers are event-driven. Interceptors are aspect-oriented. Both are just boundaries with metadata about when they fire.
Interceptor module — registry with core/site separation, position, run_levelBoundary.execute — before/after hooksResultValidator as first core interceptorwanderland.dev