Wanderland

Wanderland Derive

Wanderland::Derive is wanderland-core's value-shaping module: a record goes in, an augmented record comes out. It is pure — no IO. Anything that needs the outside world (a clock, a "who am I" lookup) arrives as an injected binding, so every spec is unit-testable with plain fixtures. Loading lives behind adapters; Derive only transforms what they return.

Source: wanderland-core/lib/wanderland/derive.rb, with matching gates in lib/wanderland/shape_matcher.rb. The first consumer is looking-glass's jira_agenda boundary, whose content/jira-agenda.yml is a worked example of the whole language.

API

Two class methods cover the module:

Inside the engine, the derive_config boundary (lib/wanderland/boundaries/derive_config.rb) is the IO shell around the pure module: it loads a base record (inline data: or a YAML file:), runs the derive: spec with the given bindings:, and emits { ok:, config: } as its crossing — so derived flags land on the request context and downstream slots gate on them via when: { config: { flag: value } }. A malformed base or an unknown op halts with 422; a missing file halts with 404.

# Apply a derivation spec; returns the record plus one key per `as`.
Wanderland::Derive.run(record, spec, bindings: { "me" => whoami }, now: Time.now)

# First-wins bucketing of records over an ordered category list.
Wanderland::Derive.partition(records, categories, now: Time.now)

bindings and now are the injection points. run stringifies keys throughout, so specs and records mix symbol and string keys freely.

Value expressions

A value expression is one of three things, and it may appear in any value position (from, on, then, default) — that single rule is the whole recursion:

Form Example Meaning
literal 42, "high", true the value itself
path "fields.status.name" dot-dig into the record; numeric segments index arrays
op-node { op: "age_days", from: "fields.updated" } compute inline

Paths beginning with

Wanderland Derive

Wanderland::Derive is wanderland-core's value-shaping module: a record goes in, an augmented record comes out. It is pure — no IO. Anything that needs the outside world (a clock, a "who am I" lookup) arrives as an injected binding, so every spec is unit-testable with plain fixtures. Loading lives behind adapters; Derive only transforms what they return.

Source: wanderland-core/lib/wanderland/derive.rb, with matching gates in lib/wanderland/shape_matcher.rb. The first consumer is looking-glass's jira_agenda boundary, whose content/jira-agenda.yml is a worked example of the whole language.

API

Two class methods cover the module:

Inside the engine, the derive_config boundary (lib/wanderland/boundaries/derive_config.rb) is the IO shell around the pure module: it loads a base record (inline data: or a YAML file:), runs the derive: spec with the given bindings:, and emits { ok:, config: } as its crossing — so derived flags land on the request context and downstream slots gate on them via when: { config: { flag: value } }. A malformed base or an unknown op halts with 422; a missing file halts with 404.

# Apply a derivation spec; returns the record plus one key per `as`.
Wanderland::Derive.run(record, spec, bindings: { "me" => whoami }, now: Time.now)

# First-wins bucketing of records over an ordered category list.
Wanderland::Derive.partition(records, categories, now: Time.now)

bindings and now are the injection points. run stringifies keys throughout, so specs and records mix symbol and string keys freely.

Value expressions

A value expression is one of three things, and it may appear in any value position (from, on, then, default) — that single rule is the whole recursion:

Form Example Meaning
literal 42, "high", true the value itself
path "fields.status.name" dot-dig into the record; numeric segments index arrays
op-node { op: "age_days", from: "fields.updated" } compute inline

Paths beginning with read bindings rather than the record: `$me.accountId digs into the "me" binding; $now` and `$today produce the injected clock as a Time and a Date. $-strings inside args resolve the same way.

Input positions (from, on) and output positions (then, default) treat bare strings differently. In an input position a string is a path; in an output position a string is a literal, and only an op-node computes. then: fresh produces the string "fresh".

Derivation specs

A spec is a list. Entries run top-to-bottom, and each writes its result onto the record under its as name, so later entries read earlier ones:

- { as: updated_days, from: fields.updated, op: age_days }
- { as: stale,        from: updated_days,   op: gte, args: { n: 14 } }

Builtin ops

Op Args Produces
pluck default the input, or default when nil (the default op)
default value the input, or value when nil
age_days whole days from the input timestamp to now; nil in, nil out
days_until whole days from today to the input date; negative when past
present true when the input is non-nil and non-empty
blank true when the input is nil or empty
eq / ne value equality / inequality against value
matches re true when the input matches the regex
in list true when the input is a member of list
gt gte lt lte n numeric comparison; false when either side is non-numeric
map table, default table lookup on the input, string keys first
lower / upper case-folded string
size .size of the input; 0 for anything without one (nil included)
join sep array joined into a string, ", " by default
coalesce from (a list) first non-nil result among the listed value expressions
case on/from, cases, default first-wins branch — see the next section

Every spec entry carries the same attributes; args is the only op-specific part. The per-op examples below expose every argument each op reads.

- as: stale             # required — output key written onto the record; an entry without `as` is skipped
  op: gte               # operator name; omitted → pluck
  from: updated_days    # input expression — record path, "$binding.path", literal, or a nested op-node
  args:                 # op arguments; a top-level string value starting with "$" resolves
    n: 14               #   against bindings ($me.…) or the clock ($now, $today)

pluck

- as: status
  op: pluck             # the default op — `op:` can be omitted entirely
  from: fields.status.name
  args:
    default: "Unknown"  # produced when the input is nil (an absent path included)

default

- as: assignee
  op: default
  from: fields.assignee.displayName
  args:
    value: "unassigned" # produced when the input is nil; a non-nil input passes through

The same fallback as pluck under a different argument name (value here, default there).

age_days

- as: updated_days
  op: age_days          # (now - input) in whole days, floored; input parsed with Time.parse
  from: fields.updated  # nil input → nil output; no args

days_until

- as: due_days
  op: days_until        # (input date - today); negative when the date is past
  from: fields.duedate  # parsed with Date.parse; nil input → nil output; no args

present / blank

- as: described
  op: present           # true when the input is non-nil and non-empty; no args
  from: fields.description

- as: untitled
  op: blank             # the inverse — true when the input is nil or empty; no args
  from: fields.summary

eq / ne

- as: mine
  op: eq                # Ruby == against args.value
  from: fields.assignee.accountId
  args:
    value: $me.accountId # a $-string argument reads the bindings

- as: foreign
  op: ne                # != — same argument
  from: fields.assignee.accountId
  args:
    value: $me.accountId

matches

- as: blocked
  op: matches           # Regexp.new(args.re) tested against input.to_s; nil input → false
  from: fields.status.name
  args:
    re: "Blocked|Impediment"

in

- as: active
  op: in                # true when the input is a member of args.list
  from: fields.status.name
  args:
    list: ["In Progress", "In Review"]

gt / gte / lt / lte

- as: overdue
  op: lt                # numeric comparison against args.n
  from: due_days        # reads an earlier entry's output — entries run top-to-bottom
  args:
    n: 0                # a non-numeric value on either side → false, not an error

map

- as: face
  op: map
  from: band
  args:
    table:              # lookup table — the input's String form is tried first, then the raw value
      fresh: "#9ccfd8"
      aging: "#f6c177"
      stale: "#eb6f92"
    default: "#6e6a86"  # produced when the input is under neither key form

lower / upper

- as: status_key
  op: lower             # input.to_s case-folded; no args; nil → ""
  from: fields.status.name

size

- as: desc_blocks
  op: size              # input.size; anything without .size (nil included) → 0; no args
  from: fields.description.content

join

- as: label_list
  op: join              # Array(input) joined into one string
  from: fields.labels
  args:
    sep: " / "          # separator; omitted → ", "

coalesce

- as: shown_date
  op: coalesce          # takes its candidates from args.from — the entry's own from: is ignored
  args:
    from:               # each candidate is a full input expression, evaluated in order;
      - fields.duedate                          #   a record path
      - { op: age_days, from: fields.created }  #   a nested op-node
      - "$today"                                #   a binding token
                        # the first non-nil result wins

The case op

case is the general branch. An ordered cases list runs first-wins: the first branch whose when passes produces its then; no match produces default. It has two forms, picked by whether the entry names an input.

Value formon: (or from:, they are interchangeable here) names the subject; each when is a ShapeMatcher predicate on that value:

- as: band
  op: case
  on: updated_days          # input expression — path, $binding, literal, or op-node
  cases:                    # ordered; the first passing branch wins
    - when: { lt: 7 }       # a matcher shape applied to the input value
      then: fresh           # `then` is an OUTPUT expression: a bare string is a literal
    - when: { lt: 21 }
      then: aging
  default: stale            # produced when no branch matches; also an output expression

Record form — with neither on: nor from:, each when is a whole-record shape, which is how multi-field conditions are written:

- as: idle_days
  op: case
  cases:
    - when:                                        # field AND field over the derived record
        state: indeterminate
        mine: true
      then: { op: age_days, from: fields.updated } # a branch can compute — an op-node output
  default: 0                                       # omitted → nil when nothing matches

A when of a literal (when: indeterminate in the value form) means normalized equality against the subject. Because then and default are output positions, only an op-node computes there — then: fresh produces the string "fresh", never a path read.

Partition

partition(records, categories) buckets first-wins over an ordered category list. Each record lands in the first category whose match passes; a record matching nothing lands nowhere.

categories:
  - header: "⚑ Overdue"           # presentation keys — header, face, anything except
    face: "#eb6f92"               #   match/group_by — pass through to the bucket untouched
    match:                        # ShapeMatcher shape over the (derived) record; first match wins
      due: true                   #   `true` leaf — present and non-nil
      due_days: { lt: 0 }         #   matcher leaf — numeric predicate
    group_by: fields.status.name  # optional — items grouped by this path; the bucket then
                                  #   carries groups: [{ key, count, items }] instead of items

  - header: "Everything else"     # no match key (or an empty one) matches every record —
    face: "#6e6a86"               #   the catch-all sits last because order decides

Each bucket in the result is its category's presentation keys plus count and either items (the matched records, derived keys included) or, with group_by, groups of { key, count, items }.

Match shapes

Shapes go through Wanderland::ShapeMatcher. At each level of a shape hash, keys that name registered matchers are matchers; every other key is a field lookup whose value is matched recursively. A literal leaf means normalized equality; true as a leaf means present-and-non-nil.

match: { due: true, due_days: { lt: 0 } }             # field AND field-with-matcher
match: { status: { matches: "Blocked|Impediment" } }  # regex on a field

Matchers commonly used in category shapes: gt gte lt lte (numeric, sizes count), matches (regex), one_of (enum), empty, has_key, keys, min_length / max_length, prefix / begins / ends, contains / includes / excludes (arrays), any (some element matches a shape), first / last / nth, not (negate a shape), all (AND over shapes), any_of (OR over shapes), count, occurs, in_order / run (subsequence assertions), is_a (type predicate).

Because matcher names win over field names at the same level, derived scalars must avoid names like matches, count, empty, first, last, any, all, not, keys, and the comparison ops — the same reserved-word trap as template Cursor keys.

Registration

Ops and matchers are registered blocks, mirroring each other:

Wanderland::Derive.register(:age_days) { |value, args, ctx| ... }   # ctx: .now .bindings .record .eval(expr)
Wanderland::ShapeMatcher.register(:count) { |actual, operand| ... } # 3-arity form gets a MatcherContext

An op handler receives the evaluated input, its resolved args, and a context whose eval re-enters the expression language — which is how coalesce walks its candidate list. Registration lives with the code that needs the op; YAML picks it up by name.

Worked example: agenda classification

Looking-glass's /jira/agenda route is the reference consumer: one JQL fetch, a derive spec producing scalars (state, updated_days, due_days, described, faces via map, bands via case), then partition over ordered categories with header/face/group_by presentation. The description-presence classifier shows the shape of a typical addition:

derive:
  - { as: described,   from: fields.description,         op: present }
  - { as: desc_blocks, from: fields.description.content, op: size }

categories:
  - { header: "✎ Needs description", face: "#ebbcba", match: { described: false } }

Jira's v3 search API returns description as an ADF document, so character length is unavailable; presence (present on the field) and block count (size on its content list) stand in for it. A title-only ticket derives described: false, desc_blocks: 0.