Assignments

Assignments

Assignments

Assignments are the primary statement form. Every cell value comes from an assignment.

This doc covers all assignment shapes. For rule blocks, see rules-and-schedules.md. For the language as a whole, see reference.md.


1. Anatomy

<target>[::<type-tag>] <op> <expression>
Part Required Notes
<target> yes A cell reference or a finite range
<type-tag> no Semantic type label, e.g. currency
<op> yes One of =, ~=, ?=, +=, -=, *=, /=
<expression> yes Any expression — see reference.md

Examples:

A1 = 10
A2 as currency = A1 * 100
A3 ~= EXPENSIVE_FETCH()
A4 ?= MAYBE_FAIL()
A5 += 5
A6:A10 = B1

2. Assignment Operators

2.1 = (Eager)

The default. The cell recomputes every time any of its inputs change.

A1 = SUM(B1:B10)

When any of B1 through B10 change, A1 recomputes immediately.

2.2 ~= (Lazy)

The cell does not compute until something reads it. Useful for expensive or external work.

A2 ~= ML_SCORE([0.1, 0.2, 0.3, 0.4])

Reads happen via:

  • Direct reads from the API (GET /api/models/:id/resolve/:symbol).
  • Reads by other cells (a downstream eager cell that references A2 triggers A2 to compute).

Once computed, the value is cached. The cache invalidates when an input changes, returning the cell to the lazy state.

2.3 ?= (Conditional Eager)

Same as =, but if the right-hand side evaluates to an error, the assignment is skipped — the existing value (if any) is preserved.

A3 ?= MAYBE_FAIL()

If MAYBE_FAIL() returns #N/A, A3 keeps its previous value rather than becoming #N/A.

2.4 Compound Assignments

A4 += 5      # A4 = A4 + 5
A4 -= 5
A4 *= 2
A4 /= 2

These work only on single-cell targets. The right-hand side is evaluated against the current value of the target.


3. Targets

3.1 Single Cell

A1 = 10
$A$1 = 10
Sheet1!A1 = 10
'Q4 Revenue'!B2 = 10
Revenue = 10                 # named reference

3.2 Finite Ranges

A range target broadcasts the right-hand side to each cell in the range. The range must be a finite rectangle.

A1:A10 = B1                  # writes B1 to each of A1..A10
A1:C3 = 0                    # writes 0 to all 9 cells
A1:A5 = [10, 20, 30, 40, 50] # writes the array elementwise

Full-column (A:A) and full-row (1:1) targets are not allowed.

3.3 Destructuring

Bind multiple symbols from a single array-returning expression:

[total, avg, min, max] = SUMMARY(B1:B10)

Rest capture binds the tail:

[head, ...tail] = ARRAY_OF_VALUES

The number of named bindings before ... must not exceed the array length.


4. Type Tags

A type tag attaches semantic meaning to the value. The grammar is:

<target>::<type-tag> = <expression>
A1 as currency = 100000
A2 as percentage = 21pct
A3 as date = d"2026-04-10"
A4 as bps = 25bps
A5 as score = ML_SCORE(features)

The runtime validates that the produced value is kind-compatible with the tag. If you tag as currency but the expression returns a string, you'll get #TYPE!.

See reference.md for the full list of tags and which kinds they map to.

4.1 Tags On Range Targets

The tag applies to every cell in the range:

A1:A10 as currency = 0

4.2 Tags Are Sticky

Once a cell carries a type tag, the tag persists across recomputations until a different assignment overwrites it (or removes it by omitting the tag).

A1 as currency = 100              # A1 is currency
A1 = A1 + 1                     # A1 is still currency
A1 as percentage = 0.5            # A1 is now percentage
A1 = "hello"                    # A1 is now plain string (tag removed)

5. Input / Output Decorators

Two optional decorator keywords may appear at the start of an assignment:

input  <target>[as <tag>] = <expression>
output <target>[as <tag>] <op> <expression>

They label cells as part of the model's external write surface (input) and read surface (output). They are advisory in ordinary deployments, but in protected mode the runtime strictly enforces the surface (see § 5.4).

5.1 input Decorator

input marks a cell as an externally-writable input. The runtime API may set its value via setInput / setBatchInput; in protected mode, no other cell may be written.

input price = 0
input qty   as integer = 0
input items = []

Restrictionsinput may only appear on a plain eager = assignment with a default value. The parser rejects:

Form Why
input A1 ~= … Lazy cells have no initial value to act as a default
input A1 ?= … Conditional cells skip on error — undefined initial state
input A1 += 1 (and -=, *=, /=) Compound assigns require an existing value
input A1 = … ONCE (and EVERY, AT) Scheduled cells are runtime-driven, not externally writable
Inside WHEN … THEN … END rule actions Rule actions are internal model machinery; declare input on the top-level cell instead

The default value (the right-hand side of =) is the value the cell holds before any external write arrives. It can be any expression — including a function call — as long as it does not depend on other declared inputs that have no default of their own.

5.2 output Decorator

output marks a cell as an externally-readable output. The runtime API may resolve it via resolve / getRange / getModelSnapshot; in protected mode, no other cell may be read.

output total       = price * qty
output history     ~= EXPENSIVE_AGGREGATE(transactions)
output last_seen   = NOW() ONCE
output running_log += event

output is permitted on every assignment shape — eager, lazy, conditional, compound, scheduled, ranges, destructuring, and rule-action targets. Use it liberally to declare the API you are exposing.

5.3 Combining Decorators

input and output may appear together, in either order:

input output A1 = 0            # writable input that also surfaces as a read
output input A1 = 0            # equivalent

Each keyword may appear at most once per assignment. Decorators are case-insensitive (input, INPUT, Input all parse the same).

5.4 Protected Mode

A deployment opts in to protected mode by passing protectedMode: true to deployModel (the in-memory TS runtime), or by setting protectedMode: true on the deploy request that the backend GridService forwards to the Lua runtimes.

When enabled:

  • WritessetInput / setBatchInput reject any symbol that is not in model.declaredInputs.
  • Readsresolve / getRange / getModelSnapshot reject any symbol that is not in model.declaredOutputs. getRange rejects the entire range if any one cell is undeclared.
  • Deploy-time validationdeployModel runs validateProtectedModel and refuses the deployment when:
    1. Any implicit input (a cell referenced but never assigned in the source) is missing a matching input declaration. There must be no hidden write surface.
    2. The model declares no input and no output. A protected deployment with no surface is almost certainly a mistake.

Outside protected mode, the decorators are still parsed and preserved — they appear on model.declaredInputs, model.declaredOutputs, and on every snapshot — but the runtime does not enforce access control.

5.5 Common Mistakes

input A1 += 1     # rejected: input requires plain `=` with a default
input A1 ~= 0     # rejected: input requires plain `=` with a default
A1 = input        # rejected: `input` is a reserved word
input input A1=0  # rejected: duplicate `input` decorator

To attach input to a counter you intend to increment from rules, declare the input separately:

input  counter = 0                # external surface
WHEN trigger > 0 THEN
  output counter += 1            # rule mutation, exposed as read
END

6. Schedule Modifiers (TS Runtime)

A single-action assignment can carry a schedule modifier. This is equivalent to wrapping the assignment in a single-action rule block.

G1 = NOW() EVERY cron"0 * * * *" BACKFILL
G2 = TRUE AT dt"2026-12-31T23:59:00Z"
G3 = NOW() ONCE

Schedule modifiers are sugar for:

EVERY cron"0 * * * *" BACKFILL THEN
  G1 = NOW()
END
 
AT dt"2026-12-31T23:59:00Z" SKIP MISSED THEN
  G2 = TRUE
END

Three forms are accepted by the parser:

Form v1 behavior
<expr> EVERY <duration|cron> [SKIP MISSED|BACKFILL] Wraps as an EVERY rule block
<expr> AT <datetime> [SKIP MISSED|BACKFILL] Wraps as an AT rule block
<expr> ONCE Compute exactly once at deploy/build time and never recompute. Useful for capturing a snapshot value (e.g. B1 = NOW() ONCE). The value persists across redeploys when the expression is unchanged; if a deploy changes the expression, the cell recomputes.

6.1 Restrictions

Schedule modifiers attach only to eager = assignments. The code path that detects modifiers explicitly rejects:

  • ~= lazy assignments
  • ?= conditional eager assignments
  • += / -= / *= / /= compound assignments

A model with A1 ~= expr EVERY ... builds with the error Scheduled assignment modifiers support eager '=' assignments only. A model with A1 ~= expr ONCE builds with the error The ONCE modifier supports eager \=` assignments only.`

6.2 Runtime parity

Schedule modifiers — including ONCE, EVERY, and AT — run on every runtime. Both Lua-backed runtimes (lua_generated, lua_interpreter) compile the modifier expressions and rely on the TS GridService to drive the schedule wakeup. Behavior matches the in-process ts runtime exactly. The canonical style prefers explicit rule blocks for shared models; modifiers are a convenience for short single-action cases.

6.3 ONCE semantics

ONCE cells follow these rules:

  1. The expression evaluates at deploy time (during deployModel), alongside other eager calculations.
  2. After the first successful evaluation, invalidations from upstream inputs or calculations do not trigger recomputation. The cell stays at its captured value.
  3. On deployModel of an updated source, the runtime preserves the prior ONCE value when the cell's expression is unchanged. If the expression changes, the cell recomputes once with the new expression.
  4. If the first attempt errors (e.g. #DIV/0!), the result is not sticky — the cell retries on the next invalidation wave.

7. Owner Symbols (Compiler-Synthesized)

When the compiler lowers nested external calls, it generates synthetic targets like __ext__:A1:9f23c8. These are internal-only and not user-addressable.

You will see them in:

  • API responses for status / job inspection.
  • Generated Lua source.
  • Audit / observability streams.

You should never write a synthetic symbol in your own assignments.


8. Restrictions Summary

Constraint Where it applies
Range targets must be finite rectangles Top-level and rule-action assignments
Compound += / -= / *= / /= requires single-cell target All contexts
Named-argument binding := is for function calls, not assignments All contexts
Inside rule actions, =, ?=, and compound (+=/-=/*=//=) are allowed; ~= and nested rules are rejected Rule action body
Compound RHS may not contain external_async calls (would re-trigger every wave) Rule action body
Sheet/workbook qualifiers use single quotes for spaces; identifiers don't need them All contexts
input decorator requires a plain = assignment with a default value; rejected on ~=, ?=, compound, scheduled, and rule-action targets All contexts
output decorator is permitted on any assignment shape All contexts
Each decorator (input, output) may appear at most once per assignment All contexts

9. Common Mistakes

9.1 Missing Operator

A1 10            # parse error
A1 = 10          # correct

9.2 Single-Quoted String

A1 = 'hello'     # 'hello' is parsed as a sheet qualifier
A1 = "hello"     # correct

9.3 Range Target Too Large

A:A = 0          # rejected — full-column ranges not allowed as targets
A1:A1000 = 0    # works, but explodes the dependency graph
A1:A1000 ~= 0   # ~= not allowed in range target broadcasting

Use a single-cell assignment with an array-valued RHS instead. For constant fills the dedicated helpers are clearest:

A1 = ZEROS(1000, 1)         # column of zeros
A1 = ONES(1000, 1)          # column of ones
A1 = FILL("TBD", 1000, 1)   # column of any constant value
A1 = REPEAT(0, 1000)        # equivalent shorthand for column of zeros

When each cell depends on its row/column index, prefer an array comprehension — it lowers to a single MAKEARRAY cell but reads more naturally:

A1 = [r * 2 FOR r IN 1..1000]
A1 = [r * c FOR r IN 1..100, c IN 1..10]

Reach for MAKEARRAY directly when you need three or more index variables, want to share heavy intermediates via LET, or are computing the dimensions dynamically. For constant fills, prefer FILL / ZEROS / ONES / REPEAT.

9.4 Incompatible Type Tag

A1 as currency = "hello"      # #TYPE! — string can't be currency
A1 as currency = "100"        # OK — string coerces to number
A1 as currency = 100          # canonical

9.5 Cycle

A1 = A2 + 1
A2 = A1 + 1     # both cells become #CIRC!

Cycles are detected at model build time and reported as #CIRC!. Lazy cells (~=) participate in cycle detection.


10. Examples

A complete, well-typed model:

MODEL "Pricing"
RUNTIME "lua_generated"
VERSION "1.0.0"
 
# Declared inputs (writable surface in protected mode)
input  A1 as currency   = 100000    # base price
input  A2 as percentage = 7pct      # discount
input  A3 as percentage = 21pct     # tax rate
 
# Declared outputs (readable surface in protected mode)
output B1 as currency = A1 * (1 - A2)      # discounted price
output B2 as currency = B1 * (1 + A3)      # gross price
 
# Lazy expensive lookup, exposed as output
output C1 ~= FX_RATE("USD", "EUR")
 
# Conditional output (only if FX succeeds)
output D1 as currency ?= B2 * C1
 
# Internal counter (not part of the external surface)
E1 = 0
E1 += 1                            # E1 → 1
 
# Range broadcast (every cell carries the `output` decorator)
output F1:F5 = 0
 
# Destructuring (decorator broadcasts to each binding)
output [min, max, avg] = SUMMARY([100, 200, 300, 400])
 
END MODEL

This model is safe to deploy with protectedMode: true: every implicit input is declared (the model has no implicit inputs), and the model declares both an input and an output.


11. See Also