System Opero

A client asks to change one number - a story about automations maintenance

Automation in business systems often has a low barrier to entry. Maintaining it is another matter entirely. A few words on three patterns we met on implementations, and the design decisions that came out of them in Opero.

Konrad JarosińskiKonrad Jarosiński · Tech Lead
11 min read
Cost invoice approval rule shown as a graph in the Opero editor

Updated 31 August 2026

The approval threshold on cost invoices sits at five thousand. The client wants ten. He calls on a Thursday, because the new quarter starts Monday and the board raised the limits.

From where he stands, this is like changing a setting on his phone. One number, one field, two minutes.

I know how it actually ends, because I have watched it end that way often enough. The request goes into a queue. Someone has to open the script file, find the right place, understand the code around it, and be willing to touch it. Then test it, which in practice means running it on production and watching what happens. Two weeks, assuming nobody is on holiday.

This is the moment a low-code platform stops being low-code. Everything else could be configured by clicking. Forms, fields, views, reports, permissions. Then you reach automation and suddenly you need a developer again.

This article is about why that happens. And about what we did with it in Opero.

In the past I have configured the automation layer on three different BPM-class systems, and beyond the installation and the configuration itself, the lion's share of the work afterwards is maintenance. None of this comes from demos or vendor material. It all comes from month three, because only then do you find out what you actually bought.

To keep this concrete, I will keep returning to one process. A cost invoice arrives. Below five thousand, from an approved supplier, it goes straight to accounting. Above that, it needs a department head. Above twenty thousand, a second approval. Three working days of silence triggers a reminder, five days an escalation to the manager. An invoice with no matching purchase order goes down a separate clarification path. Nothing exotic. Almost every company has some version of this, with different numbers.


Let me show how this seemingly simple process looks across different BPM systems.

Road one: it all fits in a single formula

The gentlest of the three approaches looks like a spreadsheet. You write the logic as a formula, conditions nested inside conditions, an IF several layers deep, the whole thing in one field.

Credit where it is due: the barrier to entry really is low. Someone who knows spreadsheets writes their first rule the same day, without training and without our help. That is reasonably in line with the low-code philosophy and I am not going to quibble about it. However...

The trouble starts when the process stops being simple. Our invoice has three thresholds, a supplier check, a purchase order check and two time-based paths. All of it lands in one expression that nobody wants to touch six months later. You cannot inspect it stage by stage, because there are no stages. You cannot see which branch a given run took, because the branches are parentheses. When a twelve-thousand invoice goes somewhere it should not have, you are left reading a formula and counting brackets by eye.

There is a second limitation, less obvious. In these systems queries exist to display data, not to reshape it. So any logic that works on a set, even something as ordinary as “find every unapproved invoice older than three days”, comes back into that same formula and makes it longer.

An excellent tool for simple and mid-range automation. At the advanced end it becomes the problem.

Road two: a wall of code

At the other extreme there is no visual layer at all. Automation is a file written in the vendor's own language, usually close to JavaScript, fired by a schedule or an event.

The ceiling here is effectively unlimited. Our approval process is an hour of work for a competent developer, with no fighting the tool. If a company has a technical team and treats the platform as a runtime, this is a reasonable choice.

But the barrier to entry is high, and the barrier to maintenance is higher still. A year on, nobody but the author knows what the code does, and the author is usually on another project or at another company. The client changes nothing on their own. Which brings us back to that Thursday phone call, because changing five thousand to ten takes exactly the same path as redesigning the whole process.

It builds sophisticated logic and it drifts away from the idea of delivering applications quickly. In practice it is programming, just in a worse editor than a normal development environment.

Road three: straight into the database

The third approach is the most direct. You configure actions, group them into agent jobs, attach a schedule, and execution means SQL statements against live tables. Including writes and inserts.

The freedom is enormous. Someone who knows SQL will do anything they need here, and do it fast.

Except that the configuration layer holds full write access to production tables, and the only safeguard is the attention of the person writing the query. One imprecise condition and we have an incident. What comes next is worse, or rather what does not come next: an operation like that bypasses field validation, permissions and change history, because it runs underneath all of them. The change has no author. The record history shows nothing.

This is the one point in this entire article that everybody understands immediately, technical background or not. Whoever configures the automation can delete production data, and the system will never mention it.

Two smaller irritations come with it. Complex logic gets expensive, because every operation hits the database separately. And the only realistic trigger is the schedule. Our three-day escalation works fine, but routing an invoice for approval “as soon as it arrives” means polling the database every few minutes to see whether anything showed up. Immediate reaction does not exist here. A shorter interval does.

Three roads, one trade-off

Put side by side, the same shape shows up three times. Automation layers usually pick two of three: a low barrier to entry, a high ceiling, and a low cost of ownership. The first road gives up the ceiling. The second gives up the barrier to entry. The third gives up safety and peace of mind.

If you are the one choosing a platform, that trade-off is worth translating into three questions you will answer for yourself within a year anyway. Who here is going to change this. Will it carry our one unusual process. What will it cost us when something changes or breaks.

When we designed rules in Opero, we tried not to give up any of the three.

What came out of it

A rule with us is a graph, not a text. It has an entry point and edges between steps, so a branch is part of the structure rather than a bracket in a formula. Our approval process looks like a diagram: an amount condition, two paths out of it, named steps along each. A non-technical person can read it and say what happens, even if they would not have built it themselves. That is a lower bar than “can author a rule”, and it is the bar that decides whether someone in the company can find the threshold step and change the number in it.

ApexShot-2026-08-27_13-50-18.png
An example rule in Opero

There are thirty step types, and that is not a list for effect. It follows from one decision: things that do different work should be different steps. Working with data means fetching records with filters, creating a record, updating a record, a contractor, a user, or a single custom field. Flow control means a condition with two outputs, a switch with many cases, a loop over an array, plus filter, transform, aggregate, sort and deduplicate. Communication means email, in-app notification, webhook and the AI step. Then files, meaning document generation from a template and attachment handling, and control: calling another rule, saving to context, writing to the log, waiting, and deliberately ending a run.

It sounds like a lot, and it is. The point is that logic which would be one enormous formula on the first road breaks here into a handful of named steps, each with a result the later steps can see and a place on the diagram.

There are twenty-two triggers, and the schedule is only one of them. Record created, updated and deleted, on any custom object. A specific custom field changing on a user, an organisation or a contractor. A workflow transition, narrowable to one named transition or one stage, and workflow completion. Contractor created and updated. Sales and cost invoices created and updated, plus invoice file generated. Attachment added and updated. User created, user updated, user joining the organisation. Organisation data changed. Manual execution from the interface or the API. And cron.

In practice this means the invoice goes for approval the moment it is created, not at the next scheduled sweep. The three-day escalation still runs on cron, because that is the right tool for it. The point is that the schedule is one option rather than the only one.

Several triggers have one more property I would not have thought to ask about until it hurt me. A record-update trigger can be narrowed by who caused the update: interface edits only, API changes only, or both. That is the guard against a rule reacting to its own write and winding itself up.

We do have SQL, but it does not live inside a rule step. It exists only as a named query with declared parameters, it is validated when saved and it is scoped to one specific company. A rule can invoke it and pass parameters. It cannot rewrite it on the fly. The difference from the third road is not that data cannot be changed or deleted here, because it can. The difference is where the risk sits. There, a mistake in a condition is created while configuring the automation and lands on production immediately. Here, the query is a separate object that was written once, validated and reviewed, and its reach is limited to one company regardless of who calls it.

Ordinary writes do not go through SQL at all. Updating a record, creating a record, updating a contractor or a custom field are separate steps that respect the field contract, validation and change history. When a rule changes an invoice status, the record history shows it the same way it shows a manual change. Automation is not an invisible hand operating below the system.

There is a script step too, because without one the ceiling would be lower than we want. The difference is in how it is built. The list of available elements is closed rather than merely restrictive: a script sees the rule context, a set of helper functions and the standard language tools, and anything outside that list simply does not exist. Reaching for the network, the filesystem, the system clock or dynamic code evaluation fails validation before the script ever runs. On top of that there is a five-second execution limit, so no script can hold a run hostage. The data-reading helpers are scoped to the organisation the rule runs in.

The script is therefore a tool for things that cannot be assembled from steps, not the default way to write logic. That distinction matters, because the whole of road two rests on the script being the only way.

The part nobody asks about during a demo

Everything above is about building. What follows is about the day something does not work, and that is the part nobody asks about while buying and everybody asks about in month three.

You test a rule without touching data. The engine walks the graph for real: conditions are evaluated, scripts execute, templates render, branches are chosen. But everything that would change the state of the world is captured instead of performed. No email goes out, no record is written, no webhook fires, no document is generated. At the end you get three things: the run status, a list of what a real execution would have done, and the order of visited steps together with what each one returned.

Reads, meanwhile, run for real, so the test runs against real data rather than invented numbers. If the rule fetches unapproved invoices, it gets the ones actually waiting. That is the difference between a test that proves the logic works and a test that only proves it compiles. It applies to every rule, including the nightly ones, so you do not have to wait until midnight to find out whether the escalation works.

Every execution can be reviewed afterwards. The history includes a player that animates the path a specific run took: the order of steps visited, the branch chosen at each fork, and the data going into and out of each one. “Why did this invoice go to a second approval when it was only twelve thousand” stops being an investigation and becomes a click. You see the condition that decided it, along with the values it was working on.

ApexShot-2026-08-27_13-52-04.png
The replay of an executed rule

Two separate things live here and they are worth telling apart. Replaying the history is viewing; it executes nothing. Replaying the rule re-runs it against the same event, this time for real and with all its effects, and you use it after fixing a bug to finish what failed the first time. The history records that the run was a replay, so it does not blend into what happened on its own. And we do not delete history after thirty days. A run from six months ago still opens.

Failure is not silent here. Every step except the one that deliberately ends a run has a separate error output, and whoever configures the rule decides what happens: stop, take a different path, notify someone and carry on, record the problem and move to the next step. The error branch can see what went wrong, so the notification can carry the actual reason rather than a bare “something failed”.

With integrations this is the difference between a system that works and a system that is stuck. If the webhook to accounting does not answer, you can decide that the invoice goes for approval anyway while an administrator gets a notification with the error in it.

Separately there is a fuse against runaway loops. A loop has an iteration limit, twenty-five by default and a hundred at most, and every step has a visit limit within a single run. A rule that would otherwise loop forever ends with a visible error in the history instead of spinning on production over a weekend.

The right to create and edit rules belongs to the system administrator, either someone on our side or a designated person at the client. The definition is versioned, so when something started behaving differently on Tuesday, you can check whether anyone touched the rule on Tuesday.

The AI step, or who actually decides about the data

There is a step that calls a language model, but the interesting part is not the part usually written about.

The company connects its own key to the provider it chooses, so the client decides whose model receives their data and on what terms. We do not stand in the middle of that relationship.

The step configuration also declares which parts of the rule context the prompt can see at all. The whole rule environment does not go out, only what was pointed at. If the step is there to classify an invoice description, it gets the invoice description, not the contractor data that happens to be sitting in the context because an earlier step fetched it.

You can require the answer in a defined structure rather than free text, which matters when the result is going into a field on a record instead of a message for a human. And you can decide what happens when the model returns something unexpected: stop the rule, or record the error and continue. The rest, meaning categorisation, classification, extracting data from text and transformations, is exactly what you would expect.

Back to Thursday

I do not think those three approaches are bad. Each one solves a real problem and each has situations where it is the right answer. My claim is only that each has a cost, and that the cost can be named. The formula gives up the ceiling. The script gives up the barrier to entry. SQL against a live database gives up peace of mind.

What mattered to us was not giving up any of the three. A graph instead of a formula, so it can be read. Events instead of only a schedule, so it reacts immediately. Named, validated queries instead of SQL pasted into a step, so data access is accountable. A sandbox instead of open code, so the ceiling stays and the risk does not. And a test before go-live plus a player afterwards, because that is the whole difference between “works in the demo” and “works in month three”.

As for that Thursday call about moving the threshold from five thousand to ten, it ends the way the client assumed it would: someone on their side opens the rule, finds the step with the amount condition, changes the number, and runs a test to see what would happen before saving it. No ticket, and no waiting until Monday.

Related reading

About the author

Konrad Jarosiński

Inżynier i architekt z 15 letnim doświadczeniem.

All articles

Next step

See how this works on your own processes.

We can walk through your operations, show how Opero would model them, and be direct about what fits and what does not.