Wanderland

wanderland-core-storage-data-model

How a read becomes a value in the little-sister storage engine — written as a ledger of the transforms that stack on the append-only log: what each consumes, what it produces, and whether it is built or proposed. This is the review gauge for Storage::Pointer (case task-06fe434c). The substrate is wanderland-core-storage-engine; the operator layer is wanderland-core-overlay-operators; the sibling that already treats labels as pointers is advaita-projection-data-model. Idiom borrowed from advaita-nextflow-router-data-model.

The one idea

Every read is a composition over an append-only log of signed records at an address. Nothing is mutated and nothing is deleted; a write only ever appends one more signed record. What you get back depends on how many transforms you ask the engine to stack on the raw log:

raw log ─▶ antiparticle overlay ─▶ operator overlay ─▶ (IOU hydration) ─▶ (pointer deref) ─▶ value
   │            what survives          base + pokes         markers→blobs      addr→target
   │              [✓ built]              [✓ built]            [✓ built]         [◇ proposed]
   └── each transform is a pure shape→shape function, each OPT-IN, each composable with the next

A boundary that just wants the record stops early — "fetch this record from storage" is the identity read, one composition and done. A boundary that wants the blob asks for hydration; a boundary that wants a pointer followed asks for a deref. The engine never forces a transform: an un-hydrated read returns lean markers, an un-deref'd pointer returns the plain pointer record. This is the whole design constraint on the pointer — it is the fourth optional transform, not a change to what a read is.

The board — cells, stacks, and pointers

Picture the store as a checkerboard. Every cell is an address paired with a type — (to_addr, type_addr) — and each cell holds a stack of generations: every time you write a record of that type to that address you drop another checker on the pile, auto-sealed to the one beneath it via trace (§The record). The board has two axes of movement, and one primitive — the pointer — travels both:

So a pointer is just a stored SELECT run through the overlay walk-up: which cell (a to_addr + type), which generation (head, or a pinned signature), and which field (a path — the projection — extracted once you land). Compose those three and you can address anything on the board — a specific cell, its live head, a frozen past revision, or a leaf deep inside whatever a chain of pointers finally resolves to — all with one primitive and the machinery the engine already has. "latest/promoted move by overlay" is just re-pointing a horizontal checker; "walk the promotion history" is reading down that cell's vertical stack. Cells, stacks, pointers — nothing else.

Realization legend

Tag Meaning
[✓] Built in wanderland-core today — file cited at the stage
[◇] Proposed by this case (task-06fe434c) — not yet in the codebase
[N] Native — the mounted driver runs it in-backend (SQL window functions / json_extract)
[R] Ruby — composed in-process by Activities::Overlay; the reference semantics [N] is property-tested equal to

[N] and [R] must agree by construction (the parity spec). The pointer is a [R]-only value object — it orchestrates existing reads, it does not push down to SQL.

The record — one shape

Every write produces exactly one of these. Physical columns of the records table (sqlite_driver.rb / postgres_driver.rb); the driver never UPDATEs or DELETEs.

to_addr:   ":events:run:echo-20260717144738-d94b94"  # where it lives; read-by-prefix returns all at/under
from_addr: ":sessions:claude"                         # who wrote it — a signed identity
type_addr: ":types:poke"                              # what KIND — itself an address; drives the transform
payload:   { op: "$merge", path: "payload", value: { status: "succeeded" } }  # the data (JSON); large leaves → IOU markers
trace:     "b64-sig-of-predecessor"                   # [✓] the generation link — predecessor's signature (SealChain)
signature: "b64…"                                     # signs the canonical record incl. `trace`
at:        "2026-07-17T14:47:53Z"                     # ISO8601 — the overlay ordering key

Two fields carry the model and deserve a warning:

The address space routes the read

Addresses are colon-paths; the first segments pick the driver. Registry.resolve does longest-prefix match, returns [driver, remainder] (registry.rb:24). This is what makes a pointer cross-mount for free — a points_at in mount A whose target lives in mount B is just two independent resolve calls.

mounts:
  ":events":       { driver: postgres }        # foxfire trace ledger
  ":projections":  { driver: postgres }         # memoized folds + labels
  ":nodes":        { driver: sqlite }            # loom markdown graph
  ":files:local":  { driver: file }              # blob backing for IOU
  ":types":        { driver: sqlite }            # the type records themselves

The transforms (stages)

Stage 1 — Append (the write) [✓]

consumes:  a boundary's intent — { to_addr, from_addr, type_addr, payload }
produces:  one signed record in the log; large string leaves offloaded as IOU markers
transport: Registry.append → resolve(to_addr) → driver.append   (registry.rb:36, sqlite_driver.rb:127)

On write, if the mount has a content driver configured, extract_with_driver walks the payload tree and replaces any string leaf over iou_threshold (default 4 KB) with a typed marker, writing the blob through the content driver (file_store). The record stored is lean; the blob lives out-of-row. This is the guarded content-driver mount landed in the sibling case (task-7be1e663, wanderland-core 2026.07.125). No content driver ⇒ everything inline.

# a task record carrying an 82KB command log — as APPENDED (post-extraction)
to_addr:   ":events:task:echo-…:d94b94:SWALLOW"
type_addr: ":events:task"
payload:
  process: SWALLOW
  exit: 0
  command_log:                                  # the big leaf, extracted on write
    _iou:    "a1b2c3d4-…"
    _driver: "file_store"
    _args:   { path: "events/…/SWALLOW/command_log" }
    _size:   82311

Stage 2 — Antiparticle overlay: what survives [✓ · N/R]

consumes:  all records at an address
produces:  the records that survive cancellation
transport: driver.overlay → overlay_records (SQL window fn) → Activities::Overlay.partition

A :types:antiparticle cancels a prior record (LIFO pairing). The sqlite/postgres drivers resolve this natively in SQL (overlay_records); the Ruby reference does the same LIFO. The cancelled record and its antiparticle both remain in the log — the cancellation is itself a signed, timestamped fact. This is undo in loom, and delete everywhere.

Stage 3 — Operator overlay: base + pokes → entity [✓ · N/R]

consumes:  the surviving records
produces:  ONE composed record (the current entity)
transport: Activities::Overlay.partition (newest seed + sorted ops) → compose  (overlay.rb:80)

partition picks the newest seed by (at, id) as the baseline and sorts the operations; compose applies each operator at its payload.path. The operator registry is open (overlay.rb:47): $set $merge $append $prepend $union $intersection $remove $inc $min $max + snip / splice. Most are commutative or last-writer-wins per key, so concurrent pokes need no operational transform — this is the CRDT-ish property loom leans on (§Case D). Entity.upsert is the write side: diff-before-write, appending a $merge poke for only the changed keys (entity.rb:27). The composed record is what "fetch this record" returns.

Stage 4 — IOU hydration: markers → blobs [✓]

consumes:  a composed record whose payload may hold IOU markers
produces:  the same record with markers replaced by blob content
transport: driver.hydrate → hydrate_markers (Drivers.lookup(_driver).read(_args))  (sqlite_driver.rb:379)

Explicit and on-demand — overlay does not hydrate. A reader that needs the blob calls hydrate after overlay (see storage_ops.rb: overlay then hydrate). A projection deliberately skips hydration so a fold never drags an 82KB leaf into Ruby (advaita-projection-data-model §Invariants). This separation is load-bearing for the pointer: overlay alone is not IOU-applied, so a pointer that must "compose with IOU" has to hydrate each hop itself (§The pointer, deref algorithm).

Stage 5 — Pointer deref: address → target [◇ · R]

consumes:  an address whose record is type_addr :types:pointer
produces:  the resolved value — walk pointer→pointer→…→source, apply path, hydrate on return
transport: Storage::Pointer.new(store:, addr:).deref   —  lib/wanderland/storage/pointer.rb (NEW)

The proposed fourth transform, and the default for a read that lands on a pointer: resolve to the target value. raw opts out (returns the undereferenced pointer record — the maintenance / label-move view). Hydration is deferred to the very end: the walk runs over composed-but-unhydrated records, and only the final, post-path value pulls its IOUs. Full spec in §The pointer.

The read pipeline

flowchart LR
  addr["address"] --> route["Registry.resolve<br/>longest-prefix mount"]
  route --> log[("append log<br/>signed records at addr")]
  log --> anti["antiparticle overlay<br/>what survives · [✓ N/R]"]
  anti --> ops["operator overlay<br/>seed + pokes → entity · [✓ N/R]"]
  ops --> rec{"composed record"}
  rec -->|"boundary stops here"| plain["the record<br/>(identity read)"]
  rec -->|"asks to hydrate"| iou["IOU hydration<br/>markers → blobs · [✓]"]
  rec -->|"type = :types:pointer<br/>+ asks to deref"| ptr["Pointer.deref<br/>path + next hop · [◇ R]"]
  ptr -.->|"points_at = another address<br/>(maybe another mount)"| route
  iou --> value["value"]
  plain --> value
  ptr --> value

The dashed edge is the whole trick: a deref hop re-enters resolve, so chaining and cross-mount fall out of the routing that already exists. Nothing in the registry learns about pointers.

The walk-up (trace, generations, lineage)

"Walk up" is the engine's answer to history and derived reads. It comes in built forms, plus the generation spine this case adds:

Walking vs. overlay — the two conjugate reads

A cell holds a stack, and there are two conjugate ways to read it — chosen by intent, not by storage:

Same cell, same chain; a pointer requests whichever it needs, and ShapeMatcher (§the selector) filters the walk.

The write lock — unique(to_addr, trace)

Appending a generation is read-head → seal-to-head. Two writers racing one cell would both seal to the same head and fork the chain. The fix lives in the DB, declaratively: a unique constraint on (to_addr, trace) — a predecessor signature can be claimed exactly once, so a fork is a constraint violation and the loser retries against the new head. That is compare-and-swap on the head, the mutex moved out of the app and into the schema — retiring foxfire's in-process write_mutex + generation cache and unblocking replicas: N (task-7d58f481 de-singularise). A single pipeline run never contends (its steps are serial; distinct runs are distinct cells); the constraint only bites where writers truly share a cell — a fold entity fed by parallel producers, or a concurrently-promoted label.

Worked example — one boundary, four records

A single, deliberately dumb boundary — fetch: "get me what is at this address." Its whole surface is an address plus two opt-in flags. The same boundary produces four very different results depending only on what record it finds and which transforms the caller asked for — which is the point of §The one idea.

# the fetch boundary contract
input:
  addr:    <address>          # required
  deref:   false              # [◇] follow a :types:pointer to its target
  hydrate: false              # [✓] replace IOU markers with blob content
output: a record  (or, with deref, a resolved value)

Case A — a plain record (the identity read) [✓]

Nothing special: overlay composes the entity, the boundary hands it back.

# input
addr: ":events:run:echo-20260717144738-d94b94"
# → overlay(addr): seed :events:run + its $merge pokes composed
# output — the composed record, verbatim
to_addr:   ":events:run:echo-20260717144738-d94b94"
type_addr: ":events:run"
payload:   { status: succeeded, duration_ms: 15509, exit_status: 0, retries: 0,
             session_id: "fee93b5e-3fd3-45e5-bb84-e09e5d436b6b" }

Case B — a pointer [◇]

The found record is a :types:pointer. A read resolves it by default — the boundary walks the chain and returns the target value; pass raw to get the pointer record itself (the maintenance / label-move view), which round-trips exactly like Case A.

# input — default resolves
addr: ":projections:acq_manifest:drugbank:promoted"
# the record found there:
to_addr:   ":projections:acq_manifest:drugbank:promoted"
type_addr: ":types:pointer"
payload:   { points_at: ":projections:acq_manifest:drugbank:001419158" }
# resolve: one hop → overlay(:…:001419158) is a source record → its payload is the value
# output (default)
version: "001419158"   status: promoted   image_digest: "sha256:37159dba…"
metrics: { tables: { drugs: 28151, products: 323875 }, totals: { tables: 122 } }

# ── raw: true ──  the undereferenced pointer, exactly like any plain read:
to_addr:   ":projections:acq_manifest:drugbank:promoted"
type_addr: ":types:pointer"
payload:   { points_at: ":projections:acq_manifest:drugbank:001419158" }

Chained + path — a pointer at a pointer, then one leaf; hydration waits until the very end:

# :deploy:img-website:current is a pointer WITH a path, at a pointer
to_addr:   ":deploy:img-website:current"
type_addr: ":types:pointer"
payload:   { points_at: ":projections:acq_manifest:drugbank:promoted",   # itself a pointer (Case B)
             path: "image_digest" }
# resolve(current): current → promoted(pointer) → 001419158(source),
#                   apply this pointer's path to the resolved value,
#                   THEN hydrate only that final leaf if it's an IOU  (nothing else pulled)
# output: "sha256:37159dba…"
flowchart LR
  c[":deploy:img-website:current<br/>pointer · path=image_digest"] -->|points_at| p[":…:drugbank:promoted<br/>pointer"]
  p -->|points_at| s[":…:drugbank:001419158<br/>source record"]
  s -->|"resolved payload"| v["{ version, image_digest, … }"]
  v -->|"path: image_digest → hydrate on return"| out["sha256:37159dba…"]

Case C — a record with an IOU [✓]

The found record is an ordinary entity, but a payload leaf is an IOU marker (extracted at Stage 1). hydrate decides whether the blob comes back.

# input
addr:    ":events:task:echo-…:d94b94:SWALLOW"
hydrate: true
# overlay(addr) → composed record with a lean marker (overlay does NOT hydrate):
payload:
  process: SWALLOW
  exit: 0
  command_log: { _iou: "a1b2…", _driver: "file_store", _args: { path: "…" }, _size: 82311 }
# hydrate: true → Drivers.lookup("file_store").read(args) splices the blob back
# output (hydrate: true)
payload: { process: SWALLOW, exit: 0, command_log: "#!/usr/bin/env bash\n… 82KB …" }
# output (hydrate: false) — the marker stays; the OOM-safe / projection read
payload: { process: SWALLOW, exit: 0, command_log: { _iou: "a1b2…", _size: 82311 } }

Pointer × IOU compose: if deref lands on a record like this, the pointer must hydrate the resolved hop for the AC "composes with IOU" to hold — because overlay alone leaves the marker (Stage 4). See the deref algorithm.

Case D — a record with many revisions (two lineage models)

D1 — overlay-as-CRDT (loom-pack style) [✓]. Many writes at one cell; the read composes them. Concurrent editors never need a merge because the operators are commutative / last-writer-wins per key (wanderland-loom-pack-design "commutative and need no operational transform").

addr: ":events:run:R"
# the append log at this cell — each line a signed generation:
- type_addr: ":events:run"   payload: { status: running, retries: 0 }                       # seed  @T0
- type_addr: ":types:poke"   payload: { op: $inc,   path: retries, value: 1 }                # @T1
- type_addr: ":types:poke"   payload: { op: $merge, path: payload,
                                        value: { status: succeeded, duration_ms: 15509 } }   # @T2
# overlay: newest seed as base, pokes in at-order, LWW per key
# output: { status: succeeded, retries: 1, duration_ms: 15509 }

D2 — a moving label leaves a walkable trace [◇]. Promotion moves a label, and a label is a pointer (advaita-projection-data-model). The immutable instances stay put; the moving promoted cell gets a new generation naming the new instance, auto-sealed to the prior one via trace — no seq, no new column.

# promote 001420777 over 001419158 — append a new generation at the label cell (:…:promoted, type :types:pointer):
# gen0  :…:drugbank:promoted   { points_at: :…:001419158 }          trace = <genesis>
# gen1  :…:drugbank:promoted   { points_at: :…:001420777 }          trace = <sig(gen0)>, signed over it
#
# read  :…:promoted            → head generation = gen1 → instance 001420777   (overlay newest-seed)
# walk  promotion history       → down the trace chain: gen1 →(trace)→ gen0
#       cheap view      = at-order over at(:…:promoted)
#       verifiable view = recurse trace→signature within the cell (tamper-evident, gap-detecting)

The instances are pinned by revision (:…:drugbank:001419158 is a cell you cite by signature); the label is the moving head of a :types:pointer cell. Same board, one vertical stack — the history is the stack read downward.

The pointer — the proposed data model [◇]

Cell, revision, projection — the selector

A pointer's target is a selector resolved through the cell's reads (§The board, §Walking vs. overlay). Up to four parts — the first three find the generation, the last projects a field:

# pluck one image's digest out of a build that produced eight
points_at:
  under: ":traces:sc-pipeline-images:<run-id>"   # the run          (idx_to_addr)
  from:  ":boundary:docker_build"                 # the 8 builds     (idx_from_addr)
match:
  result: { image: sc-pipeline-clownfish }        # ShapeMatcher → the clownfish build
# revision: head (newest by at) — or @<sig> to pin a specific rebuild
path: result.image_digest                          # → "sha256:…"

sc-pipeline-images builds eight images, all through docker_build, so they are eight generations in one cell told apart only by their payload. from gets the eight; match plucks clownfish; at/head resolves a rebuilt one to the latest; path pulls the digest. Nothing in the pointer knows it was the fifth docker_build — position never enters it.

The vocabulary is addressable-first: the indexed address legs always run before the shape filter, so a board move is bounded before ShapeMatcher sees a record. A bare match: with no under:/from:/type: pre-scope (a store-wide shape scan) is the one thing rejected (§Open questions).

Record shape

Reuses the label shape verbatim, so promotion tags and pointers are one mechanism.

to_addr:   ":<anything>:<name>"        # you name it whatever you want
from_addr: ":sessions:…"               # signed like every record
type_addr: ":types:pointer"            # the switch Stage 5 keys on
payload:
  points_at: ":events:run:echo-…"      # REQUIRED — any address, any mount
  path:      "metadata.image_digest"   # OPTIONAL — dot-path into the resolved value

The value object

Storage::Pointer.new(store:, addr:)          # store = Registry OR Adapters::Store (exposes resolve/overlay)
  #deref(max_depth: N)  → the resolved value
  #record / #raw        → the undereferenced pointer record (Case B, deref:false)
  #points_at, #path     → the pointer's own fields
  #target               → one hop only (optional)
Registry.pointer(addr)                        # optional convenience factory — NOT required

The registry stays neutral: "request a Pointer and .deref" vs "read a record" is the resolve-vs-manage distinction. A pointer you don't deref is the raw record, so a boundary can still move a label without ever resolving it.

Deref algorithm (spec these)

resolve(addr, depth=0, seen={}):            # the DEFAULT read; walks UNHYDRATED
  raise PointerCycle        if addr ∈ seen  OR  depth > max_depth   # cycle-safe
  rec = store.overlay(addr)                  # antiparticle + operator applied — NOT hydrated
  raise PointerUnresolved   if rec is nil    # dangling hop — see policy
  if rec.type_addr == ":types:pointer":
      seen.add(addr)
      resolved = resolve(rec.payload.points_at, depth+1, seen)   # recurse → re-enters resolve → cross-mount
      return extract(resolved, rec.payload.path)                 # this pointer's path applies to the resolved value
  else:
      return extract(rec.payload, path-from-the-pointer-that-reached-here or whole)

# hydration happens ONCE, at the boundary, on the value about to be returned:
return hydrate(resolve(addr))               # only the final post-path value pulls its IOUs
raw(addr)    = store.overlay(addr)          # the opt-out: the plain pointer record, no walk, no hydrate

Points that need nailing, each mapped to an AC:

Semantic Decision
Default = resolve a read that lands on a :types:pointer walks the chain and returns the target value; raw opts out to the undereferenced record.
Chain pointer→pointer→…→source, each hop store.overlay(next). Path composes: a pointer's path applies to the fully-resolved value of its points_at.
Cross-mount points_at names any address; the recursion re-enters Registry.resolve, so a mount-A pointer resolves a mount-B record with zero special-casing.
Hydrate last the walk reads composed-but-unhydrated records; hydration runs once, on the final post-path value being returned — intermediate hops and unselected fields never pull their IOUs.
Cycle-safe visited-set and max_depth; a loop raises Storage::PointerCycle, never hangs.
Raw round-trips plain raw (a.k.a. #record/#raw) returns the composed pointer record exactly like Case A — no walk, no hydrate. The resolve is default; the raw view is the opt-out and transparent.
Missing target Recommend: resolve raises Storage::PointerUnresolved on any dangling hop (a written pointer names an address that should resolve; silence hides a bug). Existence probes use a non-raising raw/#target, which may return nil. Consistent rule: resolve is all-or-raise.

Where it lives

A pointer is a storage device. The overlay read follows it, in the same layer that composes the rows of an address, so no reader above the store sees a pointer.