Filters — Self-Correcting Cells
Grammar
cell_filter = "filter" expression.
A filter clause is optional and trails a cell, source, or
out declaration’s type/initializer. Its expression is
deduced exactly like a relationship binding’s, plus
one reserved identifier: _ always refers to the candidate value being conformed (of the
filtered cell’s own declared type), never a cell. _ is reserved inside a filter expression
only; outside one it’s an ordinary identifier (or the conditional
default-branch token). A cell can have at most one filter; filters have no names.
cell level: i32 = 50 filter 0..=100; // a fixed range
cell level: i32 = 50 filter 0..=max; // upper bound is another cell
cell level: i32 = 50 filter clamp(_, 0, max); // an arbitrary expression over `_`
A filter expression must reference _ at least once (unless it’s a range expression, see
range filters) and must produce a value of exactly the filtered cell’s own type; violating either is a
parse-time error, not a runtime one.
Filters, including range-bound evaluators, must be purely functional: equal candidate and argument values produce equivalent values or errors, without externally observable side effects or reads of mutable external state. A conforming filter must also be idempotent: filtering its result again with the same arguments leaves that result unchanged. There is no guarantee that a filter executes, or when or how often it executes. The sheet may reuse a result while preserving conformance and diagnostics.
Writing never filters
Writing a cell stores exactly the supplied value without applying its filter. The filter applies when the sheet resolves.
sheet s {
source level: i32 = 50 filter 0..=100;
}
The filter cannot cause a write to fail or report an error. A read returns the written value until the sheet next resolves.
The raw value is never lost
A filtered cell keeps two values: its raw last-written value, the source, and, when something currently claims it, a computed override, the derived value. Reading the cell always returns the derived value if one is present, the source value otherwise. A filter writes only the derived value, preserving the original input across resolutions. If a dynamic bound loosens, the cell returns toward its last-written value rather than an intermediate clamped value:
sheet spring_back {
source max: i32 = 100 filter 0..=200;
source level: i32 = 50 filter 0..=max;
}
Self-referencing and forced methods also preserve source values; see reverting to source.
Range filters
A CEL inclusive range over a built-in numeric type
defines a range filter. Resolution clamps the candidate into [lo, hi].
The sheet can report the current bounds independently of a candidate value:
sheet s {
source level: i32 = 50 filter 0..=100;
}
A range filter need not reference _; its endpoints are independent of the candidate.
The tutorial’s live example displays bounds that track the range.
Derived cells: diagnosed, never corrected
When a relationship claims a filtered cell, the filter diagnoses violations without changing the derived value. A violation does not prevent resolution; reads still return the relationship’s result:
sheet diagnose_only {
cell bound: i32 = 100 filter 0..=100;
source driver: i32 = 500;
relationship {
bound := driver;
}
}
A filter violation on a source or derived cell never causes resolution to fail. Hosts can query violations through the embedding API.
A filter on an output cell
An out declaration accepts a filter after its := initializer.
An output cell is always derived, so its filter only diagnoses violations:
sheet s {
source width: i32 = 4;
out area := width filter 0..=100;
}
Errors
The parser catches filter-declaration mistakes before resolving the sheet: a non-range
filter body that never references _ fails with “filter must reference _”, and a body
with the wrong complete type fails with “filter must produce T”. Filters accept tuples
and arrays as well as concrete leaves; compound results must match every nested field and
element type. At most one filter may be attached per cell.