ADR-0006: Rules as typed, serializable data¶
Status: Accepted, amended by ADR-0041 Date: 2026-09-28 Deciders: Alex Nodeland
Context¶
Reflex had two filter APIs (filter classes, and callables of (event, context)), trigger functions that the loop never called, and registration through decorators with side effects on a global registry. None of it could be serialized, inspected or checked before running.
Rules should be as easy for an agent to write as for a person: the planned third system lets a chat agent propose a rule that a person accepts.
Decision¶
- A
Ruleis a Pydantic model:name,when(a condition),scope,then(an action reference), and policies (retry,ordering,on_dead_letter,start,enabled). - A condition is a pipeline of stages, each a discriminated union of kinds:
- filter (stateless):
on(types),where(field, op, value),all,any,not, andpredicate(name) - dedupe: drop events whose key was seen within a window
- pattern (stateful):
each,count(at_least, within),sequence(steps, within),absence(within) - throttle: at most N firings per period
- filter (stateless):
- A fluent builder produces the models:
on(ServiceError).where(F.severity >= 7).count(at_least=3, within=timedelta(minutes=1)).Fbuilds field references, which are checked against the event types the filter admits when the rule is built. - Escape hatches are named:
predicate("business_hours")refers to a registered, pure Python function. - Actions are referenced by name (
{"action": "triage"}).run(action)names the action and lets the reactor register it; rules loaded from JSON resolve names against registered actions. - A JSON Schema for rules is generated from the models and checked in. A rule's identity is its name; a hash of its definition detects changes, which reset its state.
Amendment (2026-09-29): what the documentation found¶
- A field is checked on the types that can reach it. A
whereis checked against the event types its own conjunction admits: theonfilters beside it in anall, or, with none, what the enclosing filter admits. Ananyadmits what any of its filters admits, and anottakes away the types its filter accepts whatever their fields, rather than adding them. A sequence's steps are checked on what the condition's filter admits, and the scope and dedupe fields on every type it admits. Checking everywhereagainst every type named anywhere rejected valid rules, such as a sequence of a deploy and then a severe error (no field 'severity' on deploy.finished). The builder andRule.checkshare the one definition, andRule.checklists each problem once. enabledis honoured. A disabled rule stays registered and checked, but the reactor does not evaluate it: its cursor holds, it records no firings, and its pending and retrying runs are not claimed.Storage.due_runstakes the disabled rules and leaves their runs out, so they wait without taking up an executor's limit. A new workspace still meets a disabled rule at its first event; one added to workspaces that already have events begins when it is enabled. Enabling a rule resumes it from its cursor, at least once; replaying it quietly from the head (replay_rule(mode="rebuild", from_seq=head)) before enabling it skips the backlog.enabledis not part ofdefinition(), so toggling it never resets state. The evaluation-lag metric leaves disabled rules out, and the workspace's rule status, over REST and MCP, shows whether each rule is enabled. Before,enabledwas never read, and a disabled rule fired.- A missing field is false at run time. A
whereon a field an envelope does not have, including a path through a value that is not an object, compares as false (so itsnotis true), and is never an evaluation error. Conformance cases pin it.
Options considered¶
| Option | Serializable | Checked before running | Expressiveness |
|---|---|---|---|
| Typed models with a builder (chosen) | Yes, with a schema | Yes: field references and action names | High, with named escape hatches |
| Python decorators and predicates | No | Only by running | Highest |
| Declarative YAML or JSON only | Yes | By schema | Limited, and a second language |
Trade-off analysis¶
Decorators are the quickest to write, but a rule that is only code cannot be listed, diffed, validated, stored or proposed by an agent. Typed models keep Python's ergonomics through the builder and add all of that. The named escape hatch covers what the condition kinds cannot express, without making the rule opaque.
Consequences¶
- Easier: rules can be shown in an admin UI, stored, versioned, generated by an agent and validated before they run.
- Harder: every new condition kind needs a model, a reducer, fixtures and a schema update.
- Runtime rule management (storing and installing rules through the API) is additive and left for after v0.1.
Action items¶
- Implement the rule models, builder and schema generation (RFC-0001 phase 1).