Wanderland

Context: Layered Event Stack with Dual Projection

Every boundary result knows where it came from. One object, two readings — value or provenance — chosen by operator.


The Pattern

The engine chains boundaries. Each boundary receives an input hash that includes a context — an append-only event stack that accumulates the results of every boundary that has executed in the current request.

The context is a layered value store, like Chef's node[:key]. Access resolves across all boundary results — whichever boundary last set a key is what you get. Provenance filters narrow which events are eligible before resolution.

Two Projections

Chainable Filters

Filters narrow the event set. [] collapses the filtered view to a value. Filters return immutable ContextFilter objects that support further chaining.

# Unfiltered — last writer wins across all boundaries
context["repo_path"]

# Filter to a specific boundary, then resolve
context.for_boundary(:resolve_repo)["repo_path"]

# Only from signed crossings
context.signed["repo_path"]

# Chain filters — each narrows further
context.signed.for_boundary(:auth_gate)["scopes"]
context.by_identity("auth-service").with_capability(:gate)["scopes"]

# Convenience — combines for_boundary + signed
context.from("auth_gate", signed: true)["scopes"]

Available filters:

Each filter returns a ContextFilter with the same [], filter methods, and Enumerable support. Filters compose in any order.

Why This Matters for Compliance

A compliance engine needs two things from every operation: what happened (the value) and who did it, when, and in what order (the provenance). Most systems store these separately — a result table and an audit log — and then spend effort keeping them in sync.

The Context object eliminates that problem. There is one structure. The audit trail is not a separate concern bolted on after the fact; it is the data structure itself, read from a different angle. You cannot have a value without its provenance because they are the same object.

This means:

Access Patterns

# === Value Projection (last writer wins) ===
context["repo_path"]           # => "/home/user/repos/kremis"
context["tree"]                # => <TreeSitter::Tree> (set by parse boundary)

# === Provenance Projection (full crossing record) ===
context.resolve_repo           # => { "boundary" => "resolve_repo", "identity" => "...",
                               #      "result" => {...}, "at" => "2026-..." }

# === Chainable Filters ===
context.for_boundary(:resolve_repo)["repo_path"]   # only from resolve_repo
context.signed["repo_path"]                         # only from signed crossings
context.signed.for_boundary(:auth_gate)["scopes"]   # signed + specific boundary
context.by_identity("auth-service")["scopes"]       # from specific identity
context.with_capability(:gate)["status"]             # from gate boundaries
context.from("auth_gate", signed: true)["scopes"]   # convenience shorthand

# === Stack Queries (Enumerable) ===
context.find { |e| e["result"]["status"] == "denied" }  # first denial
context.select { |e| e["boundary"].end_with?("_gate") } # all gates
context.has?("auth_gate")      # did this boundary run?
context.boundaries             # all boundary names in execution order
context.events                 # full event log (dup)
context.last                   # most recent result hash
context.size                   # how many boundaries have executed

The Engine Side

The engine creates a fresh Context per request and appends after each boundary executes:

context = Context.new

route[:chain].each do |step|
  result = Boundary.execute(boundary_name, input)
  context.append(boundary: boundary_name, result: result)
  break if result.is_a?(Hash) && result["_halt"]
end

Boundaries never append to context — only the engine does. Boundaries read from it via input["context"]. This separation means the append-only guarantee is enforced structurally, not by convention.

Signed Crossings

When a boundary's identity has a key registered with the PKI, the crossing record is signed before it leaves Boundary.execute. The canonical payload is the crossing hash (minus signature) with sorted keys, JSON-serialized. The signature is attached as "signature" on the record.

On append, Context verifies the signature against the identity's public key. A bad signature raises Context::SignatureError — the crossing never enters the context. Unsigned crossings are accepted; not all boundaries have keys, and signing is opt-in.

# In Boundary.execute — sign if identity has a key
payload = JSON.generate(crossing.sort.to_h)
crossing["signature"] = Wanderland.pki.sign(identity.key_id, payload)

# In Context.append — verify before storing
signable = crossing.reject { |k, _| k == "signature" }.sort.to_h
payload = JSON.generate(signable)
Wanderland.pki.verify(key_id, payload, signature)  # or raise

Trust on Read, Not Write

The context does not enforce namespaces or restrict which boundary can write what. Every crossing is appended as-is (after shape and optional signature validation). Trust is the caller's responsibility at read time using the chainable filter API.

The filter chain narrows, [] collapses:

# Unfiltered — trust whoever last wrote it
context["repo_path"]

# Filtered — trust only if resolve_repo wrote it
context.for_boundary(:resolve_repo)["repo_path"]

# Filtered — trust only if it was signed
context.signed["repo_path"]

# Filtered — trust only if signed by a specific identity
context.signed.by_identity("auth-service")["scopes"]

This inverts the usual security model. Instead of the system preventing unauthorized writes (which couples the context to the registry and creates a namespace administration problem), the context records everything and the consumer proves provenance at read time. If the crossing is signed, the proof is cryptographic. If not, the consumer decides whether the boundary name is sufficient trust.

The rationale: in a compliance engine, you want the full record of what happened — including things that shouldn't have happened. Rejecting writes hides evidence. Recording everything and verifying on read preserves the audit trail and lets the verifier decide the trust threshold.

Relationship to ContextVariable

The Crossings system has ContextVariable — a wrapper around every leaf value that carries both the value and the layer that set it. Same idea, different scale:

Both implement the same principle: the data and its history are one object, not two synchronised stores.

Source File

wanderland-core/lib/wanderland/context.rb