Rules & schedules

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

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

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

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

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

If 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 RESET and CLEAR (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 WHEN inside WHEN.
  • 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)
END

BACKFILL 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_pending and grid_rule_runs_in_flight are resident gauges.
  • grid_rule_backlogged_rules counts rules with retained or active work.
  • grid_rule_events_dropped_total counts bounded-retention supersessions.
  • grid_rule_runs_replayed_total counts 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
END

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

ROW_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. DEBOUNCE and THROTTLE keep their state per rule, so the next trigger sees the correct cooldown or pending deadline.
  • Scheduled time. EVERY, AT, and armed WHEN ... DEBOUNCE rules 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 MODEL

12. See Also