Rules Your Customers Write
Every B2B product reaches this point: each customer wants its own rules. Invoices over $10,000 need the CFO. Software goes through IT. New vendors need a review. So you design a rules format, usually JSON:
{ "all": [
{ "field": "amount", "op": ">", "value": 10000 },
{ "field": "vendor.months_known", "op": "<", "value": 6 }
],
"then": { "require": "cfo" } }
Then the requests start. Dates. "Any of these." A percentage of last month's spend. Each one means new operators, a new evaluator branch, and a new corner of the UI, and the format still can't say what customers mean.
Instead, let the rules be Stof. Your platform defines the vocabulary, each customer's rules are a small document, and your app runs them in a sandbox. The rules can say anything a short function can, and you never extend a rules language again.
The Vocabulary Your Platform Provides
Your platform owns one part of the document: the types and functions rules can use, and the function that runs them. This is your API for rule writers, and it's ordinary Stof:
#[type]
Invoice: {
float amount: 0
list approvals: []
/// Ask a role to approve this invoice before it's paid.
fn require(role: str, reason: str) {
self.approvals.push_back({'role': role, 'reason': reason});
}
}
/// Runs every #[approval] rule on an invoice, and returns the approvals it needs.
fn evaluate(input: map) -> list {
using invoice = Obj::from_map(input);
invoice.set_prototype('Invoice');
invoice.approvals = [];
for (const rule in funcs(attributes = 'approval')) rule(invoice);
invoice.approvals
}
To give rules more to work with, add to it: an invoice.vendor_total(days) method, a Calendar.is_quarter_end() function from your app, and so on.
A Customer's Rules
Each customer's rules are functions tagged #[approval]. They can be written in an editor in your app, by your support team, or by an AI assistant from a plain-English description (see Run Rules a Model Writes). Press Run to evaluate a few invoices:
/// Large invoices from vendors we haven't worked with long need the CFO.
#[approval]
fn new_vendor_large(invoice: Invoice) {
if (invoice.amount > 10_000 && invoice.vendor.months_known < 6) {
invoice.require('cfo', 'large invoice from a new vendor');
}
}
/// Software purchases need IT, whatever the amount.
#[approval]
fn software(invoice: Invoice) {
if (invoice.category == 'software') invoice.require('it', 'software purchases go through IT');
}
// ---- the platform's part ----
#[type]
Invoice: {
float amount: 0
list approvals: []
fn require(role: str, reason: str) {
self.approvals.push_back({'role': role, 'reason': reason});
}
}
fn evaluate(input: map) -> list {
using invoice = Obj::from_map(input);
invoice.set_prototype('Invoice');
invoice.approvals = [];
for (const rule in funcs(attributes = 'approval')) rule(invoice);
invoice.approvals
}
#[main]
fn main() {
const invoices = [
{'id': 'inv_1', 'amount': 25000, 'category': 'services', 'vendor': {'name': 'Acme', 'months_known': 2}},
{'id': 'inv_2', 'amount': 1200, 'category': 'software', 'vendor': {'name': 'Figma', 'months_known': 30}},
{'id': 'inv_3', 'amount': 300, 'category': 'office', 'vendor': {'name': 'Staples', 'months_known': 40}},
];
for (const invoice in invoices) {
const needed = self.evaluate(invoice);
if (needed.len() == 0) pln(invoice.get('id') + ': approved');
for (const approval in needed) {
pln(invoice.get('id') + ': needs ' + approval.get('role') + ' (' + approval.get('reason') + ')');
}
}
}Each rule is a few readable lines with a doc comment, and it's the customer's own record of how they work.
Running Them
Load the platform's part plus the customer's rules, and evaluate:
import { StofDoc } from '@formata/stof';
type Approval = { role: string; reason: string };
const toApprovals = (list: unknown) =>
(list as Map<string, string>[]).map((approval) => Object.fromEntries(approval) as Approval);
async function load(rules: string) {
const doc = await StofDoc.new(PLATFORM + rules);
doc.setMaxExecutionTime(100);
return doc;
}
export async function approvalsFor(customer: Customer, invoice: Invoice): Promise<Approval[]> {
const doc = await load(customer.rules); // cache this per customer
return toApprovals(await doc.call('evaluate', invoice));
}
One customer's rules can't see another's data, your database, or the network. They see the invoice they're given and the vocabulary you provide, and a rule that loops forever stops after 100ms.
Previewing a Change
Rules are code, so you can try a change before it's live. Run the current rules and the proposed ones over recent invoices, and show the customer exactly what would change:
export async function previewRules(current: string, proposed: string, recent: Invoice[]) {
const before = await load(current);
const after = await load(proposed); // throws with the line number if it doesn't parse
const changes = [];
for (const invoice of recent) {
const was = toApprovals(await before.call('evaluate', invoice)).map((a) => a.role).sort().join(', ') || 'approved';
const now = toApprovals(await after.call('evaluate', invoice)).map((a) => a.role).sort().join(', ') || 'approved';
if (was !== now) changes.push({ invoice: invoice.id, was, now });
}
return changes;
}
Adding "anything over $5,000 needs a manager" might give:
[ { invoice: 'inv_1', was: 'cfo', now: 'cfo, manager' },
{ invoice: 'inv_3', was: 'approved', now: 'manager' } ]
That's a far better review than reading a JSON tree, and it's something a JSON rule format rarely offers.
Why This Works
- No rules language to build. Your vocabulary is a Stof type with a few functions. Growing it means adding a function, not a new operator, parser branch, and UI widget.
- Rules say what customers mean. Conditions, math, units, dates, and helper functions, in a few readable lines.
- Safe for many customers. Each customer's rules run in their own sandbox, with a time limit, and only reach what you give them.
- Easy to keep. Rules are text with comments and tests. Store them per customer, version them, diff them, and preview them before they go live.
The same pattern fits webhooks and automations, pricing and discount rules, alert conditions, lead routing, data validation, and any "if this, then that" a product lets users configure.