ADR-0026: The reactor's evaluation: rules on workspaces, the depth of reflexr's facts, and rebuilds¶
Status: Accepted Date: 2026-09-28 Deciders: Alex Nodeland Amends: ADR-0010
Context¶
Building the reactor's evaluation (RFC-0001 phase 2c) settled four questions the design left open:
- Where rules are registered. Replaying a rule, listing rules and a rule's progress are workspace operations that need the rules' definitions. Executing runs needs the actions.
- The depth of reflexr's own facts. ADR-0010 bounds loops by rejecting a run's events beyond a depth limit. Rules can also watch reflexr's facts (
rule_fired,run_succeeded), and if those facts sat at depth 0, two rules could trigger each other through them forever. No event a run publishes would ever be rejected. - What a replay does. Replaying from the start could rebuild a rule's state quietly or act on the past again.
- Where a rule starts. "Now" was decided at a rule's first evaluation. Since the reactor only discovers workspaces with a log, a new workspace's first events came before its first evaluation, and
start="now"rules skipped them.
Decision¶
- Rules and predicates are registered on
Workspaces; actions on the reactor.Workspaces(storage, events=..., rules=[...], predicates={...})checks every rule's event types, fields and predicates at construction, and rejects duplicate names. The reactor checks the actions. - Every fact about a firing or run is one step deeper than what the firing matched, the same depth as the events its run emits:
Firing.causationandRun.causation.evaluate(max_depth=)refuses a firing whose facts would exceed the limit. The refusal is recorded as an evaluation error, dead-lettered for the rule, so every cycle through firings is bounded. An error keeps the depth and chain of the envelope it is about. An error while evaluating another rule'srule_erroredis dead-lettered, but not appended as a new fact, so errors cannot feed on each other. - Replays rebuild by default.
replay_rule(rule, from_seq=, mode=)resets the rule. In"rebuild"mode,RuleProgress.silent_throughis set to the head, so the state is recomputed without recording firings or errors."refire"records them again, as new runs with new ids.RuleResetrecordssilent_through, so the log says what a replay did. - A new workspace meets every rule at its first event. The first append to a workspace starts every registered rule at seq 0. A rule added later starts at the head if
start="now", or at the beginning ifstart="beginning". A rule whose definition changed starts afresh at the head; replaying is how to reprocess history.
Options considered¶
Depth of facts: one step deeper, with refusal in core (chosen)¶
| Dimension | Assessment |
|---|---|
| Safety | Every cycle through a firing adds a step, and core refuses past the limit |
| Complexity | Low: two properties and one check, pinned by conformance cases |
Depth of facts: facts at depth 0¶
Cons: two rules watching each other's rule_fired loop forever at constant depth.
Depth of facts: reject facts beyond the limit on append¶
Cons: facts are reflexr's own record and must never fail to append; the run's outcome would go unrecorded.
Replay: always refire¶
Cons: replaying after a fix would page everyone again for the past.
Consequences¶
- Easier: surfaces list rules and replay them through the same
Workspaces, and the reactor is only a runtime. - Easier: loops through reflexr's own facts are bounded, and the refusal is visible as a dead letter.
- Harder: a refused firing does nothing but record an error. Operators see it in dead letters and the
reflexr.rule.errorsmetric.
Action items¶
- Core:
Firing.causation,Run.causation,evaluate(max_depth=),RuleProgress.silent_through, andsafety.jsonandrebuilds.jsonconformance cases. -
Workspaces(rules=, predicates=),Workspace.replay_rule, andReactor.evaluate. - Execution with leases, and actions (phase 2c, second part).