ADR 0069 — Per-block directive scoping
- Status: accepted
- Date: 2026-05-24
- Spec target: XTL 0.1
- Affects: language.md (Directives); impl (parser); 1 new error code
Context
Through XTL 0.1 up to 0.7.x, transformation directives
(@filter, @sort, @top, @source, @join, @group) attach
to a sheet's single data block by construction — there's only one
block, so attachment is unambiguous. Phase 2 enables multiple
@block directives per sheet (ADRs 0067, 0068), making directive
attachment a real design question:
- When a sheet has two
@blocks and one@filter, which block does the filter modify? - When a sheet has 3 blocks and a
@sourcedirective in a cell that overlaps two of the blocks' column ranges, which block binds the source?
Three credible models for the attachment rule:
- Sheet-level (status quo) — directive applies to all blocks
on the sheet. Simple, but breaks the multi-block use case
(different
@filterper block is a common need). - Explicit per-block syntax — extend
@blockto nest the transformations, e.g.,{{ @block A:D filter=[Status]="VIP" sort=[Rev] desc }}. Reduces number of directive cells but reinvents the existing directive grammar inside@block. - Proximity-based per-block scoping (this ADR) — directives
attach to the closest
@blockthey apply to, by row position and column overlap.
Considered Options
A. Proximity-based per-block scoping (this ADR). A directive
cell D at position (r_D, c_D) attaches to the data block B
on the sheet such that:
r_D < B.startRow(directive is above the block); AND- The column range
[c_D..c_D]of the directive cell overlaps with[B.colStart..B.colEnd]; AND - Among all blocks satisfying (1) and (2),
B.startRow - r_Dis minimized (the closest block below the directive in column- overlap zone wins).
If no block satisfies (1) and (2), the directive is orphan and
parsing raises xl3/directive/orphan with the cell location.
B. Sheet-level (no scoping change). Directives apply to all blocks. Simple but doesn't support per-block filtering — the primary motivation for multi-block.
C. Explicit binding via @block arguments. Fold the
transformation directives into the @block declaration:
{{ @block A:D source=Customers filter=[Status]="VIP" }}.
Reduces cell count but conflicts with ADR-0067's "small
@block grammar" decision and duplicates existing directive
syntax.
Option A chosen. Proximity scoping uses the same positional
intuition Excel authors already apply when reading a template
(a @filter cell sitting above and aligned with a block's
columns is "that block's filter"). It requires zero new syntax,
re-uses existing directive cells, and degrades to the sheet-
level behavior naturally when there's only one block on the
sheet (every directive trivially attaches to that block).
Decision
The following becomes normative spec text in language.md's
"Directives" section (extending existing prose):
Directive scoping (multi-block sheets). Each transformation directive (
@filter,@sort,@top,@source,@join,@group,@repeat) attaches to exactly one data block on the sheet, determined by the following rule:Let
Dbe the directive's cell at position(r_D, c_D). The blockBit attaches to is the one such that:
- The directive's row is strictly above the block's first row:
r_D < B.startRow.- The directive's column lies within the block's column range:
B.colStart <= c_D <= B.colEnd.- Among blocks satisfying (1) and (2),
B.startRow - r_Dis minimized.If no block satisfies conditions (1) and (2), the directive is orphan and the parser raises
xl3/directive/orphanat parse time with the cell location.On single-block sheets (one block detected per ADR-0066/0068), the rule degenerates: every directive cell positioned above the block's first row and within the block's column range attaches to that one block. Directives outside the block's column range on single-block sheets ALSO raise
xl3/directive/orphan— consistent with multi-block — even though there is only one block to attach to.
@subtotalis an exception: it sits inside a block (its row position determines which block contains it, ADR-0058 row binding rules apply within that block).
Worked example
row 1: {{ @sort [Revenue] desc }} {{ @source Vendors }}
row 2: {{ @filter [Status]="VIP" }} {{ @top 5 }}
row 3: {{ @block A:D }} {{ @block E:H }}
row 4: {{ [Account] }} {{ [Name] }} {{ [Rev] }} {{ [Status] }} {{ [Sku] }} {{ [Price] }} {{ [Stock] }} {{ [Vendor] }}
@sort [Revenue] descat A1 → col A is in block 1's col-range[A..D]AND r=1 < block 1 startRow=3 → attaches to block 1.@source Vendorsat E1 → col E in block 2's[E..H]AND r=1 < block 2 startRow=3 → attaches to block 2.@filter [Status]="VIP"at A2 → block 1.@top 5at H2 → block 2.
Each block ends up with its own filter / sort / source / top without any explicit binding markup.
Single-block sheet behavior
A directive cell at (r_D, c_D) on a sheet with only one block
still requires c_D to lie within the block's column range to
attach. If a single-block sheet has a @filter cell positioned
to the right of the block's last column, the directive raises
xl3/directive/orphan — same as multi-block. The benefit:
authors writing single-block templates today get a clear error
if they accidentally place a directive outside the block's
column scope, instead of the previous "directive silently
applies sheet-wide" behavior.
(This is a narrow behavior change: pre-0.8 templates with a directive outside the single block's col range previously had that directive applied; now it errors. In practice no real-world template was observed using this pattern intentionally — the out-of-scope placement was always an authoring mistake.)
Consequences
One new error code — xl3/directive/orphan, raised at parse
time when a directive cannot attach to any block on the sheet.
The error message names the directive name and cell location.
G3 clock reset compounds with ADRs 0067/0068.
Narrow behavior change on single-block sheets. A directive placed outside the block's column range previously applied sheet-wide; now it errors. Migration: move the directive into a column within the block's range (almost always what the author intended). Documented in 0.7.x → 0.8.0 migration notes.
Sheet-level directives effectively don't exist anymore. With proximity-based attachment, every directive belongs to exactly one block (or is orphan). There is no "applies to all blocks" semantics — authors who want the same filter on multiple blocks must declare the filter directive cell once per block (a small but justified cost of explicit scoping).
Directive order on a shared row is irrelevant. When multiple
directives sit on the same row (e.g., {{ @sort ... }} at A1
and {{ @source ... }} at E1), each attaches independently per
the rule. Evaluation order across directive types is unchanged
(ADR-0029).
Conformance fixture coverage:
153-directive-orphan-error— directive cell outside any block's column range raisesxl3/directive/orphan.154-multi-block-per-block-filter— two blocks with different@filterdirectives, each block's output reflects only its own filter.155-multi-block-row-function-scope—ROW()returns the iteration index of the cell's containing block (carry-over from ADR-0066 single-block rule, now exercised in multi-block context).
References
- ADR-0029 — directive composition and evaluation order (unchanged by this ADR; ordering across directive types is the same).
- ADR-0066 — column-scoped data block; provides the
colStart/colEndused by the column-overlap check. - ADR-0067 —
@blockdirective grammar. - ADR-0068 — multi-block detection (strict mode); companion to this ADR.