Rules And Schedules
Rules And Schedules
Most of a Grid model is declarative: bindings recompute when their inputs change. Rules let a model act — set a flag, stamp a time, increment a counter — in response to a condition or a schedule.
There are three rule families. WHEN has four explicit event forms:
| Block | Fires when |
|---|---|
WHEN <reference> |
the reference is touched (marked dirty), regardless of value |
WHEN <predicate> |
a dependency is touched and the newly settled predicate is true |
WHEN <expression> CHANGES |
a dependency is touched and the newly settled value differs from the previous observation |
WHEN <expression> BECOMES <value> |
the observed value changes into the target value |
EVERY <interval|cron> |
a recurring schedule elapses |
AT <datetime> |
a one-shot moment arrives |
1. Anatomy
WHEN <reference|predicate> [CHANGES | BECOMES <value>]
[DEBOUNCE <duration> | THROTTLE <duration>]
[(SERIAL | CONCURRENT <limit>)
(LATEST | QUEUE <capacity> [OVERFLOW LATEST] | LEDGER)] THEN
<rule action>
<rule action>
END
EVERY <interval|cron> (SKIP MISSED | BACKFILL) THEN
<rule action>
END
AT <datetime> (SKIP MISSED | BACKFILL) THEN
<rule action>
ENDEvery block:
- Opens with its trigger keyword and ends with
END. - Contains one or more rule actions (assignments).
- May span multiple lines — the body folds until
END.
2. WHEN — React To A Touch, Predicate, Or Transition
The authored form selects the event semantics. There is no implicit
false-to-true rule and no RISING alias.
# Fires whenever `upload` is touched, even if its value is unchanged.
WHEN upload THEN
attempts += 1
END
# Re-evaluates when `load` or `threshold` is touched; fires whenever true.
WHEN load > threshold THEN
alert = "paged"
paged_at = NOW()
END
# Fires only when the settled status differs from its previous value.
WHEN status CHANGES THEN
status_changed_at = NOW()
END
# Fires only on a transition into TRUE.
WHEN deploy_requested BECOMES TRUE THEN
deploy_started_at = NOW()
ENDA bare direct cell, range, or named reference is the touch form. Any compound
expression without CHANGES or BECOMES is a predicate and follows the
coercion-to-boolean rules. Consequently,
WHEN ready and WHEN ready = TRUE are intentionally different: the first
runs on every touch; the second runs on every touch for which ready settles
to true. Use WHEN ready BECOMES TRUE for the false-to-true transition.
CHANGES and BECOMES compare settled values, not invalidation markers. Their
previous observation is persisted with the rule's scheduler state, so restart
does not manufacture a transition. A rule establishes its initial baseline
without firing.
2.1 Rate limiting: DEBOUNCE / THROTTLE
A single optional rate-limit clause may sit between the condition and
THEN. The two modes are mutually exclusive, and both apply to WHEN
only (they are rejected on EVERY, which is already rate-limited by its
schedule, and on AT, which is one-shot).
| Clause | Behavior |
|---|---|
DEBOUNCE <duration> |
Trailing edge. For touch/transition forms, each new event restarts the deadline and the latest event fires after the quiet period. For predicates, the predicate must remain true through the deadline; false cancels it. |
THROTTLE <duration> |
Leading edge. Fires the first eligible touch/transition immediately, then suppresses eligible events for the duration. |
WHEN cpu > 0.9 DEBOUNCE 30s THEN
page = "sustained-high-cpu" # only if CPU stays hot for 30s
END
WHEN deploy_requested BECOMES TRUE THROTTLE 5min THEN
trigger_deploy = NOW() # at most one deploy per 5 minutes
ENDThe duration is a positive numeric-seconds literal, an ISO-8601
duration"PT5S" literal, or a suffix literal (5s, 100ms, 2min).
A zero, negative, or unrecognized duration is rejected at compile time.
Armed DEBOUNCE deadlines fire on the trailing edge even if no other input
changes. THROTTLE only gates fires that an input-driven tick would otherwise
produce.
3. EVERY — Recurring Schedule
EVERY runs its body on a repeating interval or cron expression. A
missed-run policy is required (see §5).
EVERY duration"PT15M" SKIP MISSED THEN
heartbeat += 1
last_beat = NOW()
END
EVERY cron"0 * * * *" BACKFILL THEN
hourly_count += 1
note = "hourly-reconciliation"
ENDThe schedule is a duration"..." / suffix literal (interval) or a
cron"..." literal (calendar schedule).
4. AT — One-Shot Moment
AT fires once when a specific datetime arrives. A missed-run policy is
required.
AT dt"2026-12-31T23:59:00Z" BACKFILL THEN
year_end_close = TRUE
closed_at = NOW()
ENDIf the model is deployed after the moment has passed, the missed-run
policy decides whether it fires (BACKFILL) or is skipped
(SKIP MISSED).
5. Missed-Run Policy
EVERY and AT must declare what happens when scheduled runs were
missed — typically because the process was down, or the model was
deployed after the scheduled time.
| Policy | Behavior |
|---|---|
SKIP MISSED |
Ignore missed occurrences; resume at the next future tick |
BACKFILL |
Run once to catch up on missed occurrences |
Always pick one explicitly. AI agents and the canonical style treat a missing policy as an error.
6. Rule Action Bodies
The statements inside a THEN … END body are rule actions. They are
more restricted than top-level assignments because a rule wave must
commit atomically.
Allowed:
- Plain eager
=assignments. - Conditional
?=and compound+=/-=/*=//=. - Read-modify-write modes:
add,sub,multiply,divide,concat/append,prepend. - Override-layer modes
RESETandCLEAR(see §6.2). - Targets that are named or coordinate bindings, or finite ranges.
- Bare async-worker calls such as
HTTP_JSON(...)as post-commit effects. - Table insert/upsert/update/delete actions (
table T +=|^=|*=|-= rhs).
Rejected:
- Lazy
~=— rule actions need a committed value, not a deferred one. - Nested rule blocks — no
WHENinsideWHEN. - External async functions in an assignment or table-mutation RHS — a
rule wave does not wait on a worker result. Bind value-producing async work
at the top level or in
DO; use a bare call in the rule body when the call itself is the intended post-commit effect.
All action expressions read one pre-commit snapshot. Grid then commits the cell/state actions atomically. File writes, table mutations, and bare external calls run afterward in source order. Consequently, an effect sees arguments from the snapshot, while later model evaluation sees the committed state. An effect failure is reported independently and does not roll back the already committed rule state.
EVERY duration"PT1M" SKIP MISSED THEN
attempts += 1
table Audit += { kind: "tick", prior_attempts: attempts }
HTTP_JSON(webhook_url)
ENDBACKFILL performs one catch-up wave, even when several schedule occurrences
were missed.
6.1 Execution admission and event retention
The default is SERIAL LATEST: one effectful handler run may be in flight,
and a newer eligible event supersedes any older event still waiting. State-only
handlers normally finish in the admitting scheduler tick, so this default keeps
the ordinary reactive path compact and adds no hidden event ledger.
Add an explicit policy after any rate limit and before THEN when the handler
needs a different throughput contract:
# Keep an ordered, bounded backlog. If full, retain the prefix and replace
# its final pending event with the newest event.
WHEN upload_arrived SERIAL QUEUE 100 OVERFLOW LATEST THEN
table UploadAudit += upload_arrived
END
# Run at most eight effectful handlers at once and retain every waiting event.
WHEN webhook CONCURRENT 8 LEDGER THEN
HTTP_JSON(destination, webhook)
END| Policy | Contract |
|---|---|
SERIAL LATEST |
At most one effectful run in flight; retain only the newest pending event |
SERIAL QUEUE n [OVERFLOW LATEST] |
At most one effectful run in flight; retain n pending events in order; on overflow preserve the prefix and replace the tail with the newest event |
SERIAL LEDGER |
At most one effectful run in flight; retain every pending event |
CONCURRENT k ... |
Use the selected retention policy with at most k effectful runs in flight |
k is 1–64 and n is 1–1,000,000; invalid limits are compile errors.
OVERFLOW LATEST is optional because it is the only bounded-queue overflow
policy and therefore the default.
Each retained event carries its event-time input/override snapshot. Predicate,
transition, action-expression, and effect arguments therefore observe the
event that was admitted, not whatever input happens to be newest when a slot
opens. Each handler is still its own deterministic rule wave: state commits
atomically, in order, before its external effects are dispatched. State-only
events can drain synchronously (up to the scheduler's per-tick wave budget);
CONCURRENT governs genuinely in-flight external effects, not parallel writes
to model state.
Pending events, debounce snapshots, monotonic run ids, and in-flight runs are
persisted with the model's rule state. A host restart replays an unfinished
external run with the same execution id. Delivery is consequently
at least once: a crash after an external service accepts a request but
before completion is durably recorded can replay it. Effect receivers should
deduplicate by execution id when exactly-once business behavior is required.
LEDGER is deliberately opt-in because its pending state is unbounded; prefer
a bounded QUEUE for high-volume signals unless every event is mandatory.
Grid exposes bounded-cardinality admission telemetry without model or rule identifiers as Prometheus labels:
grid_rule_events_pendingandgrid_rule_runs_in_flightare resident gauges.grid_rule_backlogged_rulescounts rules with retained or active work.grid_rule_events_dropped_totalcounts bounded-retention supersessions.grid_rule_runs_replayed_totalcounts unfinished runs re-admitted after persisted state is restored.
A set action whose target also has a plain (non-default) formula
would silently shadow that formula: the rule's write wins durably and
the formula's value never surfaces again. That earns the
GRID_MIXED_OWNERSHIP warning. To let a rule override a computed binding on
purpose, mark the formula default;
to declare a value whose owner is the rule system itself, use
STATE. Otherwise move the rule off
the formula.
6.2 Override-layer actions — RESET / CLEAR
A rule write installs a sticky override on its target (the write wins
over any formula until something removes it). set and the RMW modes put
a value on; RESET and CLEAR are the inverse operations that take it
off — neither is expressible with set:
default price = model_estimate # base formula (overridable)
WHEN incident THEN price = manual_number END # override wins, sticky
WHEN resolved THEN RESET price END # drop override → base governs again
WHEN purge THEN CLEAR price END # pin a sticky BLANK| Action | Effect | Value after |
|---|---|---|
target = expr |
install/replace the override | expr |
RESET target |
remove the override | the base formula (or BLANK if none) |
CLEAR target |
pin a BLANK override |
BLANK |
RESET and CLEAR carry no value and take a bare target (RESET B1, not
RESET B1 = …). On a coordinate binding with no base formula the two converge — both
leave it BLANK. Because they never collide with a base op, neither
triggers the mixed-ownership warning.
The same model applies to STATE: its declaration supplies the initial/reset
base, and rule writes supply the current sticky revision.
# WRONG: external call in a rule body
WHEN tick THEN
rate = FX_RATE("EUR", "USD") # rejected
END
# RIGHT: external at top level, rule reads it
rate = FX_RATE("EUR", "USD") DEFAULT 1.08
WHEN tick THEN
snapshot = rate
END7. Inline Schedule Modifiers
A single assignment can carry a schedule modifier instead of a full block. These are sugar; the canonical style prefers explicit blocks for shared models.
G1 = NOW() ONCE # capture the first successful value
G2 = NOW() EVERY cron"0 * * * *" BACKFILL
G3 = TRUE AT dt"2026-12-31T23:59:00Z" SKIP MISSED| Modifier | Equivalent |
|---|---|
<expr> ONCE |
capture the first successful value; retain it across unchanged model reloads and host restarts |
<expr> EVERY … |
an EVERY block with a single action |
<expr> AT … |
an AT block with a single action |
Schedule modifiers attach only to eager = assignments. ~=, ?=, and
compound assignments are rejected. The full ONCE semantics and rejection
rules are documented in assignments.md.
An inline EVERY or AT target reads as BLANK until its first firing. A
rule-only target has the same declared-but-unwritten behavior.
8. Iteration Context
Inside a rule body that writes a range, the per-cell position references are available:
WHEN spread > 5pct THEN
D1:D3 = ROW_INDEX
ENDROW_INDEX, COL_INDEX, IS_FIRST, IS_LAST, and CELL_COUNT resolve
relative to the broadcast target.
9. Evaluation Semantics
- Waves. When a trigger fires, Grid evaluates the body's actions as one wave and commits them together. Downstream eager cells recompute after the wave commits.
- Within a wave. Every action RHS reads the same pre-commit snapshot. The writes commit atomically in source order; a rule action does not observe an earlier action's newly written value.
- Rate limits.
DEBOUNCEandTHROTTLEkeep their state per rule, so the next trigger sees the correct cooldown or pending deadline. - Scheduled time.
EVERY,AT, and armedWHEN ... DEBOUNCErules can fire from time alone; they do not need another binding change to wake them. - No recursive write trigger. Committed rule writes are mirrored to dependent calculations without being reintroduced as fresh input touches. A rule therefore does not recursively trigger itself merely by writing.
10. Common Mistakes
| Mistake | Fix |
|---|---|
EVERY duration"PT5M" THEN … END |
Add a policy: … SKIP MISSED THEN or … BACKFILL THEN |
WHEN … THEN B1 ~= … END |
Use = in rule bodies (no ~=) |
WHEN … THEN rate = FX_RATE(…) END |
Call externals at top level; read the binding from the rule |
WHEN a THEN WHEN b THEN … END END |
No nested rule blocks |
B1 = "x" and WHEN … THEN B1 = "y" END |
One owner per target; fold into a formula or a rule, not both |
WHEN x DEBOUNCE 5s THROTTLE 5s THEN … END |
At most one rate-limit clause per WHEN |
WHEN x CONCURRENT LEDGER THEN … END |
Supply a limit: CONCURRENT 8 LEDGER |
WHEN x SERIAL QUEUE 0 THEN … END |
Queue capacity must be at least 1 |
11. Worked Example
MODEL "Rulebook Operations"
DESCRIPTION "Reactive alerts plus scheduled reconciliation."
VERSION "1.0.0"
AUTHOR "Grid Team"
TAGS "rules", "schedules"
# Inputs
load = 0
incidents = 0
frozen = FALSE
# Derived status (declarative — no rule needed)
status = frozen THEN "frozen" ELSE incidents > 0 THEN "incident" ELSE "normal"
# React the moment load crosses the line and the desk is open
WHEN load > 100 AND frozen = FALSE THEN
review = "risk-review"
flagged_at = NOW()
END
# Page ops, but at most once per minute
WHEN incidents > 0 THROTTLE 1min THEN
paged = "ops-paged"
paged_at = NOW()
END
# Heartbeat every 15 minutes; skip any missed beats after downtime
EVERY duration"PT15M" SKIP MISSED THEN
heartbeat += 1
last_beat = NOW()
END
# Year-end close, catching up if we deploy late
AT dt"2026-12-31T23:59:00Z" BACKFILL THEN
year_end = TRUE
END
END MODEL12. See Also
assignments.md— inlineONCE/EVERY/ATmodifiers and their rules.reference.md— statement forms.external-functions.md— why externals can't run inside rule bodies.ai-agent-guide.md— the hard rules for generating rule blocks.