# Port: `edflow` → `extensions/workflows`

2026-08-11

The eleventh port. *When this happens, and these things are true, do that.*

A rules engine is the one extension that makes every other extension worth
more, because it is how an operator connects things nobody wrote a feature for:
*when a first post contains a link, hide it and tell the moderators*; *when a
purchase is paid for, add the buyer to a group*; *when somebody's third post
lands, give them a badge*.

Convoro already has every piece it needs — typed events, a queue, webhooks, a
mail outbox, points, badges, groups and private messages. What it has never had
is a way for an operator to join them up without writing PHP.

## The shape

A **rule** is a trigger, a list of conditions, and a list of actions. Rules for
one trigger run in order, and a rule may say *stop after this one matches*, so a
specific rule can take precedence over a general one.

Conditions are evaluated **in the request**; actions are **queued**. That split
is deliberate and it is the whole performance story: a condition is a couple of
array lookups and at worst one indexed query, while an action can send mail,
call a webhook, or write to five tables. Convoro's standing rule about work in a
visitor's request draws the line in exactly that place.

## 🚨 The re-entrancy guard, which is not optional

*When a post is created, post a reply* is an infinite loop that takes the site
down. The engine refuses to dispatch a trigger that is already being dispatched
further up the same stack, and that guard is the difference between a rules
engine and an outage.

It is a guard rather than a rule the operator has to remember, because the
operator writing that rule is not doing anything unreasonable — it is a
perfectly sensible thing to want, and the engine is what has to survive it.

## 🚨 Why the log has three outcomes

`matched`, `skipped` and `error`. The interesting one is **skipped**, because
"why didn't my rule fire?" is the question a rules engine gets asked most, and
the answer is almost always a condition nobody expected to fail.

So a skipped evaluation records *which* condition stopped it, in words. And
because skips are also the overwhelming bulk of the traffic — every rule is
skipped on almost every event — logging them is a setting that defaults to off.
A log that fills a disk is a log somebody turns off entirely.

## What Convoro can and cannot trigger on

The original has twenty-nine triggers. Convoro has typed events for most of the
same ground, and where it does not the answer is written down rather than
quietly dropped:

**Ported directly** — content created, edited, deleted, reported; content
hidden, locked or pinned; a topic deleted; a member registering; a member
logging in; a reaction given.

**Ported through a seam that already exists** — a purchase being paid for, a
poll being voted in, a file being downloaded, a club being joined, an event
RSVP. Each of those modules dispatches nothing today, so this port adds the
event to the module it belongs to. 🚨 That is the right place for it: an event
is part of what a module *is*, and a rules engine that reached into another
module's tables to notice things would be a rules engine that breaks whenever
that module changes.

**Not ported, because Convoro has no such thing** — course completion (no
LMS), invoice status (Convoro has purchases, not invoices), and the
spammer flag. Each is absent rather than stubbed: a trigger that can never fire
is a line in a menu that wastes somebody's afternoon.

## Conditions

Forum is, author is in group, author's post count, account age, content contains
text, content matches a pattern, tagged with, title contains text, is the first
post in a topic. Every one of them takes an operator — *is*, *is not*, *at
least*, *at most* — because a condition that can only be positive is half a
condition.

🚨 **A pattern condition is a regular expression somebody typed into a form**,
which means it can be catastrophic — a backtracking pattern against a long post
is a request that never returns. It is compiled once with a length cap on the
subject, and a pattern that fails to compile is a condition that logs *why* and
does not match, rather than an exception that takes the event down.

## Actions

Lock, unlock, pin, unpin, hide, approve; add tags; add or remove a secondary
group; change the primary group; award points; award a badge; send a private
message; send a webhook.

All queued, all wrapped, and 🚨 **one failing action does not stop the rest**.
A rule that adds a group and sends a message should not silently skip the group
because the message failed — each action records its own outcome.

## Not built

**The Pages-record button.** The original lets a rule be triggered by a button
an administrator places on a Pages record, which needs a front-end button
manager and a per-record configuration surface. It is a coherent feature rather
than a detail, and it belongs in its own version rather than bolted onto the
first — noted here so it is a decision rather than an omission.
