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.
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.
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 Source: Two class methods cover the module: Inside the engine, the A value expression is one of three things, and it may appear in any value position ( Paths beginning with Input positions ( A spec is a list. Entries run top-to-bottom, and each writes its result onto the record under its Every spec entry carries the same attributes; The same fallback as Value form — Record form — with neither A Each bucket in the result is its category's presentation keys plus Shapes go through Matchers commonly used in category shapes: Because matcher names win over field names at the same level, derived scalars must avoid names like Ops and matchers are registered blocks, mirroring each other: An op handler receives the evaluated input, its resolved args, and a context whose Looking-glass's Jira's v3 search API returns 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.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
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
from, on, then, default) — that single rule is the whole recursion:
Form
Example
Meaning
literal
42, "high", truethe 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
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.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
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
pluckdefaultthe input, or
default when nil (the default op)
defaultvaluethe 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 / nevalueequality / inequality against
value
matchesretrue when the input matches the regex
inlisttrue when the input is a member of list
gt gte lt ltennumeric comparison;
false when either side is non-numeric
maptable, defaulttable lookup on the input, string keys first
lower / upper
case-folded string
size
.size of the input; 0 for anything without one (nil included)
joinseparray joined into a string,
", " by default
coalescefrom (a list)first non-nil result among the listed value expressions
caseon/from, cases, defaultfirst-wins branch — see the next section
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
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.on: (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
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
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
count and either items (the matched records, derived keys included) or, with group_by, groups of { key, count, items }.Match shapes
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
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).matches, count, empty, first, last, any, all, not, keys, and the comparison ops — the same reserved-word trap as template Cursor keys.Registration
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
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
/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 } }
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.