A frame-of-mind primer for AI agents (Claude) working in the wanderland-* repos. Read this at the start of every session. It frames the model, the method, the testing discipline, and the constraints; the section-by-section detail lives in wanderland-dev-ai-reference — pull a section when you need it. Verified against on-disk code (gem 2026.07.127) as of 2026-07-30.
Mantra: Before writing anything, look for what already exists. The most expensive mistake an AI makes here is inventing a thing that's already there under a different name. The shell / http / secret / json_rpc / store adapter kinds exist. The containerized_deployment archetype exists. The iterate_artifacts iterator exists. The chain: wrapper exists. Grep before you invent.
Wanderland is a Ruby framework where the unit of work is a boundary: a class with a declared identity, scopes, input/output shape, and a single #call(input). Boundaries reach the outside world only through adapters (http, shell, secret, json_rpc, store, aws, clock, drone) that wrap an external dependency behind a uniform facade with mode-switching (live / replay / mock / dry_run). A site's config.yml declares which adapters to mount and which boundaries to wire into routes (an HTTP path + verb, also a CLI subcommand). For pipeline-shaped work, archetypes (containerized_deployment, infrastructure_deployment, loom_ingest) declare slot orderings that user config fills in. Packs are gems that add adapter kinds, boundaries, and archetypes to the core engine.
Every dispatched boundary produces a signed, chained Crossing (caller identity → boundary identity, type-addressed). The append-only Context log of all crossings for a request is what later slots read — boundaries communicate through the context, not by returning values to each other.
Deep dives: wanderland-dev-ai-reference §Boundaries, §Adapters, §Dispatch & flow control.
The order of work is fixed. It is the cheapest path and the one that keeps a change honest. When a task gets into the weeds, return to this order.
output_shape, a shared boundary) and what differs per instance (the variant → config, fixtures, !UserConfig). This split is the line between the pack and the site that mounts it.The data model is the spec. Between the shapes, the scenarios that pin them, the conversation, and the code comments, the implementation is determined. Write the fixture, watch the scenario miss, build the boundary, watch it pass. Start from the data a call must produce, not from the boundary.
Investigation to pack (looking-glass). Not every capability starts as a spec — some start as a question you answered by hand against a live system. Poke the live surface (/inspect/routes, a GraphQL endpoint, kubectl, a drone sortie) until it makes sense; when the same join/filter/verdict recurs, that recurrence wants to be a boundary; prototype it in wanderland-looking-glass (the scratch pack) and use it for real; productionize into the owning domain pack if it earns its keep, delete it if not. Cheap either way. Investigation discovers the shapes; data-models-first is how you build them once you know them.
Three ways in, one seam. --type cli (Commander subcommand per route), --type http (Puma + Rack), and --type test (scenario runner) all funnel through the same Dispatch.invoke:
adapter parses protocol → params
→ resolve route → allocate a fresh append-only Context (:trace:<svc>:req-<uuid>)
→ announce the adapter → walk each slot:
effective when: guard → Boundary.execute → context.append(crossing)
→ terminal crossing → formatter → response body
The mental shape is adapter → chain (of slots) → formatters. Key properties:
Dispatch.invoke) under all three runners; the chain walker visits every slot._halt short-circuit. Flow control is by when: guards keyed on counts of :signals:stop:* crossings in scope (BASE_DEFAULT_WHEN = "run only when zero stops"). A halt lands a stop-crossing; downstream default-guarded slots then silently skip. Recovery/finally slots are just boundaries whose when: matches the stop count.context["key"], last-writer-wins) and a provenance projection (context.<boundary> → the crossing). Per-boundary duration_ms is observational and kept off the merkle signature.serves: registers a FORMATTER for a MIME, not "this route returns that type" — a data boundary needs no serves: (see Constraints).Detail: wanderland-dev-ai-reference §Dispatch & flow control, §Configuration & routes (the 12-slot boot chain), §Formatters.
Four extension points, in rough order of frequency. The rule is always find what exists first — the registries are global and names collide.
boundary :name, ... per file at lib/<service>/boundaries/<name>.rb, include Wanderland::Boundary. The rich input_shape / output_shape field-rule vocabulary is the interface contract. Compose several into one with the declarative chain: wrapper (pure pipeline, no #call) or input.dispatch(:sub) inside #call (conditional). Grep boundary :<name> before adding — registry is global.BootMountAdapters.register_kind("name") { … } in a pack entry file. Grep register_kind across all packs first.archetype:. Fill a slot or write a sub-boundary an iterator dispatches — don't write per-stage scaffolding.What already exists (grep before inventing):
| Extension point | What's there |
|---|---|
| Adapter kinds | http, shell, secret, json_rpc, store (core); aws, clock, drone (packs) |
| Archetypes | containerized_deployment, infrastructure_deployment (pipelines); loom_ingest (loom) |
| Iterators | iterate_artifacts (artifacts), iterate_components (component) |
| Packs | aws, pipelines, k8s, cheshire, drones, lantern, loom, sutra |
Detail + full boundary/adapter/pack inventories: wanderland-dev-ai-reference §Boundaries, §Adapters, §Archetypes, §Pack catalog. Loom (the markdown-graph engine behind the oculus tools) has its own node: wanderland-dev-loom-pack.
Reading from the input context — pull data with the BoundaryInput accessors, never by hand:
input.find("args.only") — falsy-safe indifferent dig from any input member; nil when absent, but keeps a stored false/0/[].input.find_or_fail("args.bucket") — same, but throws a halt Signal for a missing required arg.input.first_of_or("args.components", "domain_config.pipeline.components", default: {}) — first present across candidate paths; the path head names the source (args, config, params, …; domain_config routes through the runtime config-domain fallback), and the default is baked in so there's no trailing || {}.Do not Wanderland::HashUtils.dig_indifferent(input[…], …) or chain input["args"]["x"] — Wanderland/InputAccessByHand flags both. Rule of thumb: a big block of defensive hash-digging means there's a better way somewhere — usually one of these accessors, or a first_of_or across the candidate paths.
Schema seam — validation belongs in input_shape, not #call. find / first_of_or also enforce the field's declared input_shape rule for free: a default: fills in for an absent/blank value, and constraints: (ShapeMatcher rules — e.g. constraints: { matches: '\S' } for non-blank) validate a present value and throw a 400 halt on failure. Purely additive — a path with no rule, or a rule without default/constraints, is a no-op. So declare the argument contract once in the schema and read with input.find, instead of hand-rolling defaults and blank-checks. Pattern: calver's format — optional, default: DEFAULT_FORMAT, constraints: { matches: '\S' }, and a one-line #call.
Red→green is the discipline for wanderland-core changes. Failing scenarios precede the code change — new feature: write the spec, watch it fail for the right reason, implement, watch it pass; changing behavior: update the spec that pinned the old behavior, watch it fail, implement, pass. Skipping this is not optional — "or it doesn't count."
Two patterns, used side by side:
spec/wanderland/<topic>_spec.rb) — a fresh runtime per example (Boundary.reset! / reload!); inline test boundaries register on the runtime overlay. Use for a single method as the unit, and for direct adapter(:x).run branches that scenario mocks don't cover.spec/scenarios/<topic>/<NN>.yml) — operation + input + expected (ShapeMatcher), optional mocks (route mode only). Use for end-to-end behavior, chain composition, halt propagation.Run site scenarios with bundle exec wanderland --type test config.yml. Full ShapeMatcher vocabulary (~30 matchers), the reset/reload harness, the route-mode-only mocks gotcha, and layered dispatch scenarios: wanderland-dev-ai-reference §Scenarios & testing.
The high-value, expensive mistakes — the ones worth catching before you write code, several of which also flag where the platform still wants UX revision. Long-form field notes (deploy war-stories) live in wanderland-dev-ai-reference §Tips & tricks.
grep -rh "boundary :<name>" wanderland-*/lib first.serves: is a formatter registration, not a content-type hint. Put it on a data endpoint and that boundary's #call intercepts /health and every other response of that MIME — halting without its args and crashlooping the pod via the liveness probe. A data/read boundary needs no serves:. Grep your boundaries for serves: if every route starts returning one boundary's error.warn "…" inside a boundary raises. The mixin overrides warn to take zero args (ArgumentError: given 1, expected 0) — and the debug line itself crashes the method, so it looks like the code never ran. Use $stderr.puts for ad-hoc tracing; drop to a standalone unit and trust its stack trace over a scenario's silence.${VAR} substitution inside wanderland. That's a JCasC / Kustomize / shell layer one level out. Use !Env { name: FOO } (tracked in env_snapshot) or <%= FOO %>. And beware the double-ERB: config.yml and every !Fixture body are ERB-rendered before YAML parses — escape a tag meant for a later runtime pass as <%%= … %>.!Context for_boundary.<name> reaches the most-recent crossing of that recorded name — same-named boundaries shadow each other. Two chain: wrappers in one deploy both record as chain, so for_boundary.chain.image_uri can resolve to the wrong one and halt 400 image is required. Disambiguate with as:. (Full worked case in the reference.)mocks: only fire in route mode, and only feed dispatched sub-boundaries — a boundary that shells adapter(:kubectl).run(...) directly gets no stub even when args: match. Cover direct-adapter branches with an rspec unit.dry_run returns symbol-keyed hashes while live/replay/mock return string-keyed — a real code bug; defensive boundaries accept both.DeleteStack — clearing it needs an admin. Expect a manual delete between a failed first CREATE and the retry.!ListenerPriority off the live listener.Gemfile.lock can flip from the path gem to a pinned dated build under you mid-session (Bundler::GemNotFound). git diff Gemfile.lock before assuming it's yours; git restore it.when: guard's match target depends on where it's declared. A route-chain/slot when: is walked by the dispatcher; a dispatch-time input.dispatch(:sub, when:) guard is walked by boundary_input. Same syntax + same ShapeMatcher, but historically different targets: the slot guard saw the Context only (so it gates on count(:signals:stop:*) for flow control, but could not see its own args), while the dispatch guard saw { args, context }. So copying when: { args: { url: {…} } } from a Ruby dispatch onto a config slot silently never matched — it descended a nonexistent context args field. Since core 2026.08.166 (Dispatcher::GuardView) the slot guard also matches { args, context }: it is the Context for count/events/field-descent (stop-count flow control byte-for-byte unchanged) and adds args (the slot's resolved args, lazily — a skipped flow-control slot never fires its args' !Context/!SsmLookup) + context. A slot can now gate on its own resolved arg: when: { args: { url: { empty: false } } } runs the step only when url resolved non-empty (that's how the jcasc reload skips on local, fires on coreweave). And not_empty is not a matcher — it silently matches a phantom field and always skips; use { empty: false }.LoggingDecorator logs every adapter call, render_log_array prints a scalar argv verbatim under 200 characters, and shell adapters carry no masks — so a token or password passed as run(..., "-p", payload) / run("--password", token) lands in the debug log in full (and a boundary's own info { } line prints it a second time). Pass it on the stdin: kwarg (summarised) or the env: kwarg (not rendered) — see k8s_secret_json_patch, ecr_docker_login, gem_push. The SecretInShellArgv cop flags the flag-carried shape; a secret inside a KEY=VALUE pair or a URL is yours to catch.key: true in a ShapeMatcher shape matches whenever the key is PRESENT (non-nil). It never compares the value: matches? returns a match on expected == true && !actual.nil? (shape_matcher.rb), so match: { flag: true } fires for every record where flag exists — and a derived boolean always exists (true OR false), so it matches everything. Gate on the value with { flag: { eq: true } } (eq: false for false). This drives every Derive.partition category match: (agenda buckets, compliance partitions); the stock jira-agenda stale: true bucket carries the latent bug, masked by the catch-all beneath it.Retention, expiry, counts, aggregates, "find the old ones and delete them" — these are database queries, not Ruby loops. Never load a mount into memory to sift it. TTL reaping is DELETE ... WHERE at < cutoff; the only Ruby is the cutoff instant. The database does the finding and the deleting.
Scar tissue (2026-08): a reaper computed age in Ruby by reading every record on the mount (under(prefix)) into an array to find old cells. On a ~258k-row pg store that spiked the pod past its memory limit and OOM-killed it into CrashLoopBackOff. The fix was a one-line set-based delete_stale in SQL — bounded memory, no scan. If your storage code holds rows in Ruby to decide something a WHERE clause could decide, stop and write the query.
The crossing store is one append-only tree, rooted at : — the pinch point. It is not several namespaces side by side. It is one address space that describes itself.
A record (a crossing) carries four address pointers plus a payload, and every one of them points into this same tree:
to_addr — where the record lives (spatial).from_addr — who put it there (identity).type_addr — what kind it is (capability).trace_addr — what caused it (causal).The property that matters: an address is itself a walkable node. A type_addr like :bridges:remark is also a to_addr you can walk to, and the crossings sitting there are what define that type — its handlers, its bridge, its denials. So the ontology is data at addresses: types, sessions, bridges, plans, and the configuration of the system itself are all crossings in the one store, composed by the same walk. That is what data-driven means here — there is no schema off to the side.
A read is a walk up an address to the root, collapsing the layers it collects. The hierarchy sets precedence, and merge operators order sets and lists.
An anti is not special machinery. It is an ordinary crossing whose type sits at the known sibling address :anti:<kind>, the mirror of :types:<kind>. The collapse has one rule: an :anti:X cancels the most-recent :types:X at the same address, last in first out, because type_addr.sub(":anti:", ":types:") maps the sibling back to its twin.
So there is no mount you file a record into, and no second tree for types. There is one tree and four pointers into it. To make a record kind reversible, its type_addr must sit under :types: — where its :anti: twin is defined to look. An edge types as :types:edge:<relation>, so an unlink is an :anti:edge:<relation>. A list item types as :types:rupa, so a remove is an :anti:rupa. A type pointer parked in a data region (:rupa:edge:<relation>) has no anti twin that can find it, so nothing cancels it. When you add a new record kind, place its type under :types:.
Full model: crossings-architecture — the four-address store, the walk, the self-describing ontology. The sūtra pack is the reduced version: the same four-address record and anti-collapse, without the compile, execute, and bridge machinery.
| Repo | What lives here |
|---|---|
wanderland-core |
engine, dispatch, registries, boot chain, built-in adapter kinds (http/shell/secret/json_rpc/store), YAML resolvers, formatters, scenario runner, introspection routes |
wanderland-aws-pack |
aws kind; ECS/ECR/CFN/S3/SSM boundaries; !SsmLookup/!ListenerPriority resolvers |
wanderland-pipelines-pack |
clock kind; pipeline boundaries + iterators; containerized_deployment / infrastructure_deployment archetypes |
wanderland-k8s-pack |
kustomize/kubectl deploy boundaries (mounts a :kubectl shell adapter) |
wanderland-loom-pack |
the markdown-graph engine behind the oculus tools (wanderland-dev-loom-pack) |
wanderland-{lantern,cheshire}-pack |
server-rendered HTML "grin/cat" component layer |
wanderland-drones-pack |
drone kind; drone-sortie runner + hive registry |
wanderland-sutra-pack |
recursive case/runbook primitive (rūpa / sūtra) |
vivarta-jenkins, per-project images (cool-tools, cfn-tools, …) |
site-specific boundaries + their own config.yml, consuming the packs above as gems |
Pack file layout and the entry-file registration pattern: wanderland-dev-ai-reference §Pack catalog.
/Users/graemefawcett/working/wanderland-* (authoritative when docs disagree)