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