Rules And Schedules
Rules And Schedules
Rule blocks are how Grid models express reactive behavior — actions that fire when conditions become true, on a recurring schedule, or at a specific moment in time.
Runtime support. Rule blocks now run on every Grid runtime (
ts,lua_generated,lua_interpreter). On the Lua runtimes the TS GridService still drives the rule wave (so cascade ordering is identical) and arms asetTimeoutfor the next dueEVERY/ATtick — that's the smart wakeup scheduler. Per-model scheduler state is persisted to Redis undermodel:<id>:rule:schedulerso rule firings survive restarts.
1. The Three Trigger Forms
| Form | Fires when | Header |
|---|---|---|
WHEN |
The condition becomes true (transition from false to true) | WHEN <expression> THEN |
EVERY |
A recurring schedule elapses | EVERY <duration|cron> [SKIP MISSED|BACKFILL] THEN |
AT |
A specific datetime is reached (one-shot) | AT <datetime> [SKIP MISSED|BACKFILL] THEN |
All three close with END:
WHEN A1 > 100 THEN
C1 = "alert"
C2 = NOW()
END2. WHEN: Condition-Triggered Rules
WHEN fires its body when the condition transitions from false to
true. It does not fire on every recompute while the condition
remains true — only on the rising edge.
2.1 Basic Form
WHEN A1 > 100 THEN
C1 = "over-limit"
C2 = NOW()
END2.2 Multiple Symbols In The Condition
The rule's trigger set is the union of all symbols referenced in the condition. The rule re-evaluates when any of those symbols change.
WHEN A1 > 100 AND A3 = FALSE THEN
C1 = "review"
C2 = NOW()
END2.3 Atomic Action Wave
All actions in a rule body see the same pre-trigger snapshot of cell values. They commit together as one atomic wave.
WHEN A1 > 100 THEN
C1 = A1 * 1.1
C2 = C1 + 1 # uses the pre-trigger value of C1, not the value just assigned!
ENDIf you need sequential effects, use a DO block in a single
right-hand side:
WHEN A1 > 100 THEN
C1 = DO
new_val = A1 * 1.1
new_val + 1
END
END2.4 Range Targets In Rule Actions
A rule action may write to a finite rectangular range. The right-hand side broadcasts:
WHEN A4 > 5pct THEN
D1:D3 = ROW_INDEX
ENDROW_INDEX is a special value that resolves to the row number for
each target cell during the broadcast.
2.5 Multiple WHEN Blocks On The Same Target
Allowed. Later rules win in the same wave (source order):
WHEN A1 > 100 THEN
C1 = "first"
END
WHEN A1 > 200 THEN
C1 = "second" # overrides "first" if A1 > 200
END2.6 WHEN Cannot Coexist With A Top-Level Assignment On The Same Target
C1 = "default" # top-level assignment
WHEN A1 > 0 THEN
C1 = "active" # rejected at build time
ENDA target is owned by either top-level formula assignments or by
rule actions, never both. To get a default + override behavior, use a
THEN ... ELSE expression at the top level:
C1 = A1 > 0 THEN "active" ELSE "default"2.7 Rate-Limiting With DEBOUNCE And THROTTLE
WHEN rules accept an optional rate-limit modifier between the
condition and THEN:
WHEN <expression> [DEBOUNCE <duration> | THROTTLE <duration>] THEN ... ENDDEBOUNCE and THROTTLE are mutually exclusive — a WHEN may
carry one or neither, never both. They apply only to WHEN (EVERY
is already rate-limited by its schedule, and AT is one-shot).
The duration is a regular expression that must evaluate to a positive
number of seconds, an ISO-8601 duration"PT5S" literal, or a suffix
literal (5s, 100ms, 2min). Cell references and arithmetic are
allowed, so you can drive the limit from a configurable cell:
WHEN price_drop_pct > 5pct DEBOUNCE settings_debounce_seconds THEN
alerts!notify("price-drop", symbol, price_drop_pct)
ENDDEBOUNCE — Trailing-Edge Over A Continuous-Truth Streak
DEBOUNCE d arms a timer when the condition becomes true. The body
fires once, on the first tick where the timer has expired and
the condition is still true. Subsequent fires are suppressed until the
condition goes false (which ends the streak); the next rising edge
starts a fresh timer.
If the condition goes false before the timer expires, the pending fire is cancelled.
WHEN typing_indicator = TRUE DEBOUNCE 1s THEN
presence!mark_typing(user_id)
ENDUse this when you only care about the settled value of a noisy signal — UI typing indicators, sensor jitter, draft-save batching.
THROTTLE — Leading-Edge Within A Window
THROTTLE d fires the body immediately on the first rising edge,
then suppresses further fires for d. Once d has elapsed, the next
rising edge starts a new throttle cycle.
WHEN error_count > 0 THROTTLE 30s THEN
alerts!page_oncall(error_count)
ENDUse this when the first event matters but you want to avoid spamming a downstream system. The body is guaranteed to be the first notification, with the latency of a real fire (no waiting period like debounce introduces).
Persistence And Smart Wakeups
Rate-limit state is persisted per rule (alongside AT-rule
completion flags) so a process restart does not lose an armed timer
or a pending throttle window.
On the Lua runtime the smart-wakeup scheduler factors armed DEBOUNCE
deadlines into its setTimeout, so the trailing edge fires on time
even if no other input arrives in the interim. THROTTLE does not
schedule wakeups — it only gates fires that would otherwise happen on
a normal input-driven tick.
Misconfigured Durations
If the duration expression evaluates to zero, a negative number, an error, or anything we can't recognize as a duration, the rule is skipped for that tick and any in-flight rate-limit state is cleared. The rest of the model continues to evaluate normally.
3. EVERY: Recurring Schedules
EVERY fires its body on a schedule. The schedule can be either a
duration or a cron expression.
3.1 Duration Form
EVERY duration"PT15M" SKIP MISSED THEN
E1 = E1 + 1
E2 = NOW()
ENDDuration uses ISO-8601 syntax (P[n]Y[n]M[n]DT[n]H[n]M[n]S):
| Duration | Meaning |
|---|---|
duration"PT5M" |
every 5 minutes |
duration"PT1H" |
every hour |
duration"P1D" |
every day |
duration"PT15M" |
every 15 minutes |
Or use suffix literal sugar:
EVERY 5min SKIP MISSED THEN ... END
EVERY 1d BACKFILL THEN ... END3.2 Cron Form
EVERY cron"0 * * * *" BACKFILL THEN
F1 = F1 + 1
F2 = "hourly-reconciliation"
ENDStandard 5-field cron syntax: minute hour day-of-month month day-of-week.
| Cron | Meaning |
|---|---|
cron"0 9 * * 1-5" |
9am Mon-Fri |
cron"*/5 * * * *" |
every 5 minutes |
cron"0 0 1 * *" |
midnight on the 1st of every month |
3.3 Missed-Run Policy
If the runtime is offline or busy when a scheduled time elapses, the missed-run policy decides whether to replay:
| Policy | Behavior |
|---|---|
SKIP MISSED |
Skip the missed occurrences. Run on the next future tick. |
BACKFILL |
Replay each missed occurrence in order, subject to safety caps. |
Missed-run policy is required for EVERY and AT blocks. There
is no default — you must choose.
3.4 Idempotency Caveat
BACKFILL may fire your action many times in quick succession after a
long outage. Make sure the action body is idempotent or rate-limited:
EVERY duration"PT15M" BACKFILL THEN
T1 = T1 + 1 # this counter will reflect every missed tick
ENDIf you don't want catch-up, use SKIP MISSED.
4. AT: One-Shot Time Triggers
AT fires its body once at the specified datetime. After firing, it
does not fire again.
AT dt"2026-12-31T23:59:00Z" BACKFILL THEN
G1 = TRUE
G2 = "year-end-close"
ENDDatetime literals use ISO-8601 with a timezone (Z for UTC, or
+HH:MM).
4.1 Missed-Run Behavior For AT
| Policy | Behavior |
|---|---|
SKIP MISSED |
If the datetime has already passed when the model loads, do not fire. |
BACKFILL |
If the datetime has passed and the action has not yet been recorded as fired, fire once now. |
After firing, the rule is marked complete and never fires again, even across restarts.
5. Schedule Modifiers (Sugar)
A single-action assignment may carry a schedule modifier instead of a full rule block:
G1 = NOW() EVERY cron"0 * * * *" BACKFILL
G2 = TRUE AT dt"2026-12-31T23:59:00Z"These are sugar for:
EVERY cron"0 * * * *" BACKFILL THEN
G1 = NOW()
END
AT dt"2026-12-31T23:59:00Z" SKIP MISSED THEN
G2 = TRUE
ENDStyle note. The canonical style prefers explicit blocks over modifiers in shared models, because modifiers blur the line between formulas and rules. Use modifiers only for tiny one-off cases.
6. Rule Action Body Restrictions
Inside a WHEN / EVERY / AT body, only plain eager assignments
are allowed:
WHEN cond THEN
X1 = expr # OK
X2 as currency = expr # OK
X1:X3 = expr # OK (range broadcast)
ENDThese are rejected:
| Form | Why |
|---|---|
X1 ~= expr |
Lazy not supported in rule bodies |
X1 ?= expr |
Conditional eager not supported in rule bodies |
X1 += 5 |
Compound assignment not supported in rule bodies |
WHEN ... END nested |
No nested rule blocks |
MODEL directives |
Header-only |
X1 = FX_RATE(...) (or any external_async call) |
External functions are not allowed in rule action right-hand sides |
The external-function restriction matters for "refresh on a schedule" patterns. Don't write:
EVERY cron"0 * * * *" SKIP MISSED THEN
B1 = FX_RATE("EUR", "USD") # rejected
ENDInstead, just call the external at the top level:
B1 = FX_RATE("EUR", "USD") DEFAULT 1.08The function's cache policy (30s TTL for FX_RATE) handles
refresh in the background. See external-functions.md.
If you need conditional behavior, put the condition in the rule's trigger or in the action's right-hand side:
WHEN A1 > 0 THEN
C1 = A1 > 100 THEN "high" ELSE "low"
END7. Special References Inside Rule Actions
These are available only inside rule action bodies:
| Reference | Resolves to |
|---|---|
ROW_INDEX |
The row offset of the current target cell during a range broadcast (1-based) |
COL_INDEX |
The column offset (1-based) |
IS_FIRST |
TRUE for the first cell in the broadcast range |
IS_LAST |
TRUE for the last cell in the broadcast range |
CELL_COUNT |
Total number of cells in the broadcast range |
ABOVE / BELOW / LEFT / RIGHT |
The neighbor of the current target |
ABOVE(N) etc. |
The N-th neighbor |
NEIGHBORS |
A 1-cell radius region around the target (3×3) |
NEIGHBORS(N) |
A radius-N region around the target ((2N+1)×(2N+1)) |
Example using all five iteration-context references:
WHEN trigger > 0 THEN
D1:D5 = `cell {ROW_INDEX} of {CELL_COUNT}` & IF(IS_FIRST, " (start)", IF(IS_LAST, " (end)", ""))
ENDSpatial references give you Excel-style relative positioning without hard-coding addresses.
8. Trigger Dependency Tracking
The runtime indexes rule blocks by their trigger symbols:
| Trigger | Indexed by |
|---|---|
WHEN cond THEN ... |
All symbols referenced in cond |
EVERY <schedule> |
The internal scheduler |
AT <datetime> |
The internal scheduler |
When any indexed symbol changes, the runtime checks whether the rule should fire. Symbols not in the trigger set do not cause re-evaluation.
9. Worked Examples
9.1 Operational State Machine
MODEL "Operations"
RUNTIME "lua_generated"
VERSION "1.0.0"
A1 = 0 # incident count
A2 = "clear" # ops state
# State derivation (eager)
B1 = A1 > 0 THEN "incident" ELSE "normal"
# Reactive escalation
WHEN A1 > 0 OR A2 = "degraded" THEN
C1 = "ops-paged"
C2 = NOW()
END
# Periodic heartbeat
EVERY duration"PT15M" SKIP MISSED THEN
D1 = D1 + 1
D2 = NOW()
END
# Year-end one-shot
AT dt"2026-12-31T23:59:00Z" BACKFILL THEN
E1 = TRUE
E2 = "year-end-close"
END
END MODEL9.2 Retry Counter With Range Broadcast
WHEN A1 > 5pct THEN
D1:D5 = ROW_INDEX # writes 1..5 into D1..D5
END9.3 Combined Reactive + Scheduled
A canonical pattern: one WHEN for reactive escalation, one EVERY
for periodic refresh, one AT for a deadline.
See examples/canonical/05-rulebook-operations.grid
and examples/canonical/07-treasury-control-plane.grid
for full real-world examples.
10. Operational Notes
10.1 The Scheduler
GridService maintains an internal scheduler in TypeScript regardless of the model's runtime. On model load and after every committed write:
- Each
EVERYandATrule's trigger expression is evaluated (in-process forRUNTIME "ts", via the_eval_rule_triggerRedis Function for the Lua runtimes) to obtain its current interval / due timestamp. - The orchestrator computes the next due ms across all rules and arms a
single per-model
setTimeoutthat wakes exactly when the next rule is due. - After firing, the scheduler advances and re-arms the next wakeup.
This smart wakeup scheme avoids polling: an idle model with no scheduled rules adds zero work to the event loop.
10.2 Persistence
Rule scheduler state — startedAtMs, lastTickMs, and the per-rule
completedAtMs flags used by AT one-shots — is persisted to a dedicated
Redis hash at model:<id>:rule:scheduler. Restarting the backend
re-hydrates the orchestrator and resumes scheduling from the last
persisted tick, so a one-shot AT rule fires exactly once even across
restarts.
10.3 Safety Caps
BACKFILL is bounded by MAX_BACKFILL_RUNS_PER_TICK (256) to prevent
infinite catch-up loops after long outages. Cascade depth within a
single rule wave is bounded by MAX_RULE_WAVES (32). Both constants
live in src/core/runtime/ruleScheduler.ts and are shared across
the TS and Lua orchestrators.
10.4 Runtime Parity
All three runtimes (ts, lua_generated, lua_interpreter) execute
rule blocks identically. Trigger and action expression evaluation runs
in-process for ts and as read-only Redis Functions
(_eval_rule_trigger, _eval_rule_action) for the Lua runtimes; in
both cases the TS GridService owns wave ordering, conditional ?=
filtering, and atomic _batch_input commits.
11. Common Mistakes
11.1 Forgetting The Missed-Run Policy
EVERY duration"PT5M" THEN # parse error
...
ENDAdd SKIP MISSED or BACKFILL:
EVERY duration"PT5M" SKIP MISSED THEN
...
END11.2 Cycle Between Trigger And Action
WHEN A1 > 0 THEN
A1 = A1 + 1 # rule writes its own trigger; will loop forever
ENDThe runtime detects same-wave cycles and aborts the wave. Don't write your trigger from inside the rule body.
11.3 Mixing Top-Level And Rule On Same Target
B1 = "default"
WHEN A1 > 0 THEN
B1 = "active" # rejected at build
ENDChoose one ownership model for any given cell.
11.4 Using A Lazy Operator In A Rule Action
WHEN A1 > 0 THEN
B1 ~= ML_SCORE(A1) # rejected — only `=` allowed
ENDUse = and rely on the action wave for atomicity.
11.5 Trigger Inputs On Lua
Rule blocks are valid on every runtime. The only caveat unique to
lua_generated / lua_interpreter is that EVERY/AT trigger expressions
must evaluate to a numeric ms value (or a duration/cron literal that
desugars to one). All language features — including spatial references
(ABOVE, BELOW, LEFT, RIGHT, NEIGHBORS) — are supported on every
runtime; the dependency graph and the grid_spatial_ref runtime helper
keep evaluation order and resolution consistent.
12. See Also
reference.md— base language.assignments.md— assignment shapes.external-functions.md— async functions.docs/specs/external_functions.md— formal queue spec (relevant when an external call is inside a rule body's=RHS).