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

Bare dot tries boundary first, falls back to result-key. An optional symbol disambiguates on collision:- context.echo — boundary echo, or any crossing whose result carries echo.

Use the disambiguator when a name appears in both places. A boundary named status and a result key called status both exist? Bare context.status returns the boundary's crossing; context.status(:result) returns the most recent crossing that wrote result["status"]. Names that resolve to neither raise NoMethodError.

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.

Recorded names. A crossing's boundary field carries the name it was recorded under. By default that is the boundary's registration name; a dispatch site can override it with as: (a chain-slot field, an archetype-slot field, or a reserved key on input.dispatch), and the registration name is preserved on the crossing's source_boundary. for_boundary matches either field, so a reference written against the registration name keeps resolving after the slot is given a distinct recorded name. Reach-back references in configs should use the recorded name — it is chosen per site, so two slots running the same boundary (a collect step-chain and a component's chain, say) stay distinguishable:

context.for_boundary(:collect)["image"]   # the slot recorded as `collect`
context.for_boundary(:chain)["image"]     # any chain crossing — ambiguous when several ran

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)
context["type_addr"]              # => ":types:ok"  (top-level fallback — no result has this key)
context["env"]                    # => runtime.env_snapshot

# === Provenance projection — full crossing record ===
context.resolve_repo              # boundary "resolve_repo": full crossing
context.repo_path                 # result-key "repo_path": crossing where it was last set
context.status(:boundary)         # explicit boundary lookup; raises NoMethodError if no boundary
context.status(:result)           # explicit result-key lookup; raises NoMethodError if no key

# === Chainable filters ===
context.for_boundary(:resolve_repo)["repo_path"]       # value scoped to one boundary
context.signed["repo_path"]                            # only signature-verified events
context.signed.for_boundary(:auth_gate)["scopes"]      # composed
context.by_identity("auth-service")["scopes"]
context.with_capability(:gate)["status"]
context.from("auth_gate", signed: true)["scopes"]      # convenience for for_boundary + signed

# === Accumulation helpers (every contributor wrote a piece) ===
context.for_boundary(:emit_chunk).pluck("chunk")       # array of result["chunk"], in order
context.for_boundary(:emit_chunk).flatten("items")     # concat array results
context.for_boundary(:emit_flag).merge("flags")        # last-wins hash merge

# === Stack queries ===
context.find { |e| e["result"]["status"] == "denied" }  # first matching event
context.select { |e| e["boundary"].end_with?("_gate") } # all gates
context.has?("auth_gate")           # did this boundary execute?
context.boundaries                  # all boundary names in execution order
context.events                      # full event log (dup)
context.last                        # most recent event's result hash
context.size                        # how many crossings
context.since(5)                    # last 5 events as a filter view; chainable
context.count(type: ":signals:stop:halt")           # net count by exact type_addr
context.count(type_prefix: ":signals:stop:")        # summed under a prefix

The Engine Side

The engine creates a fresh Context per request and the dispatcher's chain walker appends after each boundary executes:

context = Context.new(
  storage: runtime.storage,
  to_addr: Dispatch.request_addr(runtime),   # :trace:<service>:req-<uuid>
  runtime: runtime
)

route[:dispatcher].execute(input, context)
# inside the chain walker:
#   crossing = Wanderland::Boundary.execute(boundary_name, slot_input, record_as: slot.as)
#   context.append(crossing)

Boundaries never append to the request 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.

Each appended crossing soft-verifies its signature: if the crossing carries a signature and storage is available, append checks the signature against the identity's public key and stores the result in a parallel boolean array. Bad signatures don't reject the crossing — the audit trail records what happened, including events that wouldn't have verified. The signed filter excludes them at read time. Trust is determined on read, not on write.

Addressing

The request root carries the config's service name — :trace:<service>:req-<uuid> — so a store shared across services filters per service with an address-prefix query. allocate_addr gives each main-chain crossing a positional address under the root (…:req-X:0, :1, :2, in execution order), stamped before signing so to_addr is part of the signable payload. Context#request_root returns the root prefix from anywhere in the address tree; views built over other contexts adopt it, which is how storage-scoped reads stay confined to their own run.

Sub-Dispatch Contexts

A boundary that dispatches sub-boundaries (input.dispatch, the chain: DSL, the pipeline iterators) accumulates their crossings in its own inner context — a real addressed context one level under the wrapper's slot. The address leaf is the sub's as: name when one is given, else the iterator's _name key, else a sequence number:

:trace:foxfire:req-X:4            the collect slot's crossing (recorded as `collect`)
:trace:foxfire:req-X:4:0          its first chain step
:trace:foxfire:req-X:3:dns        a component named `dns` by its iterator
:trace:foxfire:req-X:3:dns:0      that component's first chain step

The request context sees one rolled-up crossing per wrapper (LWW merge of sub-results plus a _signals trail); the storage mount receives every crossing individually at its nested address. Context#dispatch_prefix supplies the wrapper's slot address (derived from the running sequence on a request context, pinned to the sub's own address on the view a sub-boundary receives), which is what makes the nesting recurse.

Sub-streams that need their own merkle heads use fork_at / sub_at — the grid runner's per-item streams are the worked example.

Storage Routing

If the context has a to_addr prefix and the runtime has a storage registry, every appended crossing — main chain and sub-dispatch alike — is also routed through runtime.storage.append(crossing). The mount prefix decides persistence; the default :trace: mount is a sqlite database at the path named by WANDERLAND_TRACE_STORE (in-memory when unset), so exporting one variable captures a run's full trace to a file with no config. Storage routing is best-effort; the in-memory event log is always intact.

The persisted trace is queryable while the run is in flight and after it ends: the !Context resolver's { under:, boundary:, path: } hash form reads it scoped to the current request (reaching nested sub-crossings the in-memory view never surfaces), and the trace explorer boundaries (trace_stores, trace_runs, trace_query, trace_replay — see examples/trace-explorer) list, query, and replay stored runs step by step.

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 the signature) with sorted keys, JSON-serialized. The signature is attached as "signature" on the record.

On append, Context soft-verifies: if the crossing carries a signature and the context was constructed with storage, the signature is checked against the identity's public key and the result lands in a parallel @verified boolean array. The crossing itself is always appended — verified or not. The audit trail records every event that crossed the seam, including the ones whose signatures didn't check out. Consumers decide whether to trust them at read time via the signed filter.

Unsigned crossings are accepted with @verified = false; not all boundaries have keys, and signing is opt-in. Same flag, two reasons (no signature OR signature didn't verify) — signed excludes both, which is the right floor for cryptographic provenance.

# 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 — soft-verify, store result, append unconditionally
verified = soft_verify(crossing)   # returns true / false; never raises
@events << crossing
@verified << verified

A separate verify_chain method walks the entire log and confirms both the signature on each crossing AND the merkle link (each crossing's trace field equals the previous crossing's signature). That's the offline audit pass; per-append verification is for read-time filtering.

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


Site Audit

wanderland.dev

oculus-view: fence: Fence execute bit is disabled. Use force=true to override.