Rules & schedules

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 a setTimeout for the next due EVERY/AT tick — that's the smart wakeup scheduler. Per-model scheduler state is persisted to Redis under model:<id>:rule:scheduler so 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()
END

2. 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()
END

2.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()
END

2.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!
END

If 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
END

2.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
END

ROW_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
END

2.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
END

A 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 ... END

DEBOUNCE 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)
END

DEBOUNCE — 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)
END

Use 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)
END

Use 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()
END

Duration 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 ... END

3.2 Cron Form

EVERY cron"0 * * * *" BACKFILL THEN
  F1 = F1 + 1
  F2 = "hourly-reconciliation"
END

Standard 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
END

If 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"
END

Datetime 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
END

Style 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)
END

These 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
END

Instead, just call the external at the top level:

B1 = FX_RATE("EUR", "USD") DEFAULT 1.08

The 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"
END

7. 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)", ""))
END

Spatial 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 MODEL

9.2 Retry Counter With Range Broadcast

WHEN A1 > 5pct THEN
  D1:D5 = ROW_INDEX     # writes 1..5 into D1..D5
END

9.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:

  1. Each EVERY and AT rule's trigger expression is evaluated (in-process for RUNTIME "ts", via the _eval_rule_trigger Redis Function for the Lua runtimes) to obtain its current interval / due timestamp.
  2. The orchestrator computes the next due ms across all rules and arms a single per-model setTimeout that wakes exactly when the next rule is due.
  3. 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
  ...
END

Add SKIP MISSED or BACKFILL:

EVERY duration"PT5M" SKIP MISSED THEN
  ...
END

11.2 Cycle Between Trigger And Action

WHEN A1 > 0 THEN
  A1 = A1 + 1     # rule writes its own trigger; will loop forever
END

The 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
END

Choose 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
END

Use = 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