Skip to main content

ADR 0027 - Reserved column names + directive arg validation

  • Status: accepted
  • Date: 2026-05-09
  • Spec target: XTL 0.1
  • Affects: evaluation.md, language.md, ADR-0011, ADR-0012

Context

Two distinct silent-fallthrough surfaces, bundled because they share the same shape ("expected validation, got silent garbage"):

#1 — Source column names colliding with internal context keys

The reference impl's row-eval context contains internal keys beyond the source's columns:

  • Rows — the current data block's row array (used by COUNT(), len .Rows)
  • __rownum — the 1-based index inside the current data block
  • __activeSource__ — current @source block target
  • __joinedRow__ — paired row from @join
  • __config__ / __inputs__ / __sources__ / __lists__ — the reserved sheet contexts

When a source column name collides with one of these, the row-data spread either shadows the internal key (breaking Rows, __config__, etc. inside the block) or is shadowed by it (making the column unreachable via [Column]).

Example: a source with a column named Rows. Authors write {{ [Rows] }} expecting the column value; the renderer's spread order makes Rows resolve to the internal dataRows array, which stringifies to "[object Object],[object Object]" in the output. Pure silent garbage, no diagnostic.

#2 — Empty / malformed directive bodies

{{ @filter }}, {{ @sort }}, {{ @top }}, {{ @source }}, {{ @repeat }}, {{ @join }} (and variants with malformed bodies) were silently ignored. parseDirective returned null for any body it couldn't parse; the parser treated the cell as not a directive row, the directive never applied, and the cell's content flowed through unchanged.

Concrete shapes that fell through:

  • {{ @filter }} — author thinks they filtered, no filter applied
  • {{ @source }}@source directive missing the source name, block defaults to default source
  • {{ @sort }} — no field, no sort applied
  • {{ @top }} — no count, no truncation
  • {{ @join Customers on x[k] = y[k] }} — neither x nor y matches the joined source, silently no-op

All of these produced "everything ran fine" output that didn't match author intent.

Considered Options

For #1 (reserved column names):

  • A. Reject at parse. Author renames the column upstream.
  • B. Auto-namespace at row-eval. Internal keys get a prefix the user can't write (__xl3_internal_*). Pro: any column name works. Con: breaks the existing len .Rows / __config__[k] template syntax that authors rely on.
  • C. Document the collision and keep silent shadowing. Worst option; user-hostile.

For #2 (directive validation):

  • D. Throw a single coded error for any failed directive parse.
  • E. Throw fine-grained errors per directive type / failure reason (xl3/filter/missing-arg, xl3/sort/missing-field, etc.). Pro: more helpful diagnostics. Con: error-code surface bloats; most failures share the same root cause (malformed body).
  • F. Keep silent. Worst option; user-hostile.

Decision

#1 — Adopt A (reject reserved column names at parse). #2 — Adopt D (single xl3/directive/invalid-syntax code).

Reserved column names

The following source column names are reserved and MUST be rejected by readHeaders (or equivalent) at parse time with xl3/source/reserved-column-name:

  • Rows
  • __rownum
  • __activeSource__
  • __joinedRow__
  • Any name matching the regex ^__[a-z]+__$ (defensive: future additions to the internal context space won't conflict with user data)

The reserved set is the union of:

  1. xl3-internal context keys the renderer adds to row data (Rows, __rownum, __activeSource__, __joinedRow__).
  2. Reserved sheet ctx names (__config__, __inputs__, __sources__, __lists__) — already covered by the ^__[a-z]+__$ pattern check.

Authors who hit this error rename the column upstream (the most common offender is Rows, which maps cleanly to Items / Records / Entries / etc.).

Directive validation

When a cell expression is recognized as a directive (isDirectiveExpression matches @filter|@sort|@top|@repeat|@source|@join) but parseDirective cannot parse the body, the parser MUST raise xl3/directive/invalid-syntax with the offending expression in the message.

This single coded error covers:

  • @filter (no body)
  • @filter [field] (no operator/value)
  • @sort (no body) and @sort foo (no bracket)
  • @top (no count) and @top abc (non-numeric)
  • @source (no name) and @source __config__ (reserved name)
  • @repeat (no direction)
  • @join (no body or malformed on clause)

The previous behavior — silent ignore — was the same as a parse failure; this ADR just makes it visible.

Why one code, not many

The error message names the directive and quotes the offending expression, which is enough for an author to find and fix the issue. Splitting into 6+ codes would bloat the catalog without adding diagnostic power.

If a future shape needs distinct dispatch (a host wants different UX for "missing arg" vs. "reserved name"), this ADR can be amended to split the code; until then a single code keeps the surface small.

Consequences

  • Templates that previously silently fell through on bad directives now error loudly. Author-fix work is identical (correct the directive); the difference is errors are visible at preview time instead of latent in output.
  • Templates that previously silently lost data on Rows-named columns now error at parse. Author-fix work is one rename upstream.
  • Two new error codes added to the ADR-0015 catalog and snapshot.
  • Conformance fixtures pin both: 109 (reserved column name), 110 (empty @filter), 111 (empty @source).

References

  • ADR-0011 — Reserved sheet naming (^__[a-z]+__$ pattern lineage)
  • ADR-0012 — Multi-source data model (introduces __activeSource__, __joinedRow__)
  • ADR-0015 — Stable error codes
  • ADR-0024 — Function arity (similar "fail loudly" pattern)