A declarative JOIN: read ONE input's payload, index it into a LEFT and a
RIGHT side (each a %{join_key => item} built from a per-side key fn), then
emit one row per left key joined to its right item (right may be absent). The
common declared-vs-observed reconcile/variance shape — the author writes the
two side keys + the join row, not the read/write/changed plumbing.
LEFT join by default. outer: true makes it a FULL OUTER join: right-only
keys also emit (into.(jk, nil, right_item)), for reconciles where an
unexpected right-side member is itself a finding (a rogue repo the baseline
never declared) rather than something to silently drop.
Two inputs
left_over:/right_over: read TWO DIFFERENT nodes, each scoped by its own
keys. That needs left_owns:/right_owns: — the payload columns each side
writes — because a claim names one side's keys and leaves the other side
unread.
left_over:/right_over: read TWO DIFFERENT nodes. An earlier attempt at
this shape was reverted for writing nils over good data — a budget claim
destroyed the actual and vice versa — and the revert concluded that was "the
shape's natural failure mode, not an oversight". The failure was real; the
diagnosis was one level too shallow.
A claim key is a JOIN key. The reverted version scoped both sides by the one
claim's keys against each side's own payload key — two different columns,
usually different values (an Actuals row keyed "a1" joins on
acct: "5000"). So the scoped read matched nothing, the side came back
empty, and into emitted nil for its columns.
Each side is therefore scoped by the column it is INDEXED BY — its own
join-key attribute. Both sides then read the rows the claim is actually
about, into sees real values on both, and no nil is ever emitted to be
written. Nothing about per-column writes is needed; the read was the bug.
Two consequences worth knowing:
- A
fnside computes its join key in the BEAM, so there is no column to push a filter into and that side reads WHOLE. Correct, and no worse than the one-input form, which also reads whole for a fn side. - The claim rule defaults to
:group, not:identity: an input's changed keys are its own, so they are translated to join keys through the side that propagated — the same edge the read is scoped by.
So left/right/inner/outer stay ordinary options; outer: true is a full
outer join here exactly as it is with one input.