A pricing language with two keywords, because the third one would have been a mistake
A stainless top with a channel underneath does not have one price rule. It has the rule for the top, plus the rule for whichever channel section the width calls for — a narrow item gets one section, a wider one gets a heavier section, and past a certain breadth it gets a different one again. In the business’s spreadsheet that was a nested conditional inside a cell, and it worked until someone needed to know which branch had fired.
Of 362 pricing rules in the system, 349 were plain ordered arithmetic. Thirteen were this shape: they needed to call other rules and choose between them. That is a small enough minority that the temptation is to special-case it, and a large enough one that special-casing it breaks.
So it got a language. The language has two keywords.
The two keywords
A composite rule is a single line of text. It sums a chain of calls to other rules, and it may end with a single choice between rules, decided by comparing one dimension against a number.
CALL(a) + CASE(B < 20, CALL(b), B <= 30, CALL(c), CALL(d))
CALL(a) + CALL(b) + CALL(c)
CALL(a) + CALL(b)
That is the whole grammar. Calls can be added or subtracted. The branch conditions are a single comparison — one dimension, one operator from the four inequalities, one number. The trailing call inside the branch list, with no condition, is the fallback and always matches. There is no boolean combination, no nesting of branches, no arithmetic on the results beyond adding and subtracting the calls.
The temptation to add a third keyword was constant, and refusing it is the reason the thing is maintainable. Every construct you add to a language authored by non-programmers is a construct you have to validate, explain, display in an editor, and debug when someone writes it wrong. Two keywords fit on one line of help text.
Why it is evaluated, not flattened
The first instinct was to flatten composites at import time — walk each call, paste the child’s steps into the parent, and end up with 362 plain rules and no second evaluation path to maintain. It is genuinely tempting, and it does not work for two independent reasons.
The branch cannot be resolved at import. Which child runs depends on a dimension the customer has not entered yet. Flattening would mean materialising every branch as a separate rule and then needing a mechanism to choose between them at quote time — which is the mechanism you were trying to avoid building, now with three times the rules.
And the children are shared. The same channel rule is called by several composites. Flattening copies it into all of them, and the moment the business edits the channel rule, the copies are stale. That is precisely the spreadsheet failure the system exists to remove.
So composites are stored as parsed structure and walked at evaluation time. The expression text is kept alongside the parsed form, because the text is what the business wrote and what they will recognise when they come back to it.
The three things that go wrong at runtime
Cycles. A rule that calls a rule that calls the first one is an infinite loop and a stack overflow at quote time. Evaluation carries a set of the rules already visited on this branch of the descent and refuses to enter one twice, with an explicit circular-reference error rather than a crash. This is not hypothetical caution — the rules are authored by hand in an admin screen, and a call chain of three or four is not visible to the person editing the second one.
Missing children. A composite can name a child that does not exist. Thirteen composites in this catalogue plus a handful of package rules referenced twelve codes that were never defined anywhere in the source. The engine’s behaviour is that a missing child contributes zero and evaluation continues.
That is a judgement call and it deserves a defence, because it is arguably wrong. The defence is that the alternative — failing the whole quotation because one sub-rule is undefined — makes a data gap in one feature block a hundred unrelated lines. The cost is that a price can come out quietly low. It is only acceptable because the gap is reported separately, as a structured reason attached to the feature, rather than being swallowed. A tolerant evaluator is only safe if something else is intolerant on its behalf.
Inputs the parent does not know it needs. This is the subtle one. The quoting interface has to ask the user for every dimension and every material the calculation will need, before the calculation runs. For a plain rule that is a property of the rule. For a composite it is a property of the whole tree, including branches that will not be taken.
So the material slots and input requirements are unioned across the root and every child reachable from it, including every branch of the choice. The user is asked once per distinct slot name, not once per rule that mentions it. If you only collected the requirements of the branch that ended up firing, you would have to re-prompt mid-calculation, which is not a thing an interface can do gracefully.
Unioning over untaken branches means occasionally asking for an input that this particular configuration will not use. That is the right trade — an extra dropdown is a mild annoyance, a calculation that stops halfway to ask a question is not.
Choosing the branch dimension
The comparisons are on a single dimension, and where none is written the default is the breadth. That default exists because in this domain the branch is nearly always about how wide the item is — support sections, panel joins and channel weights are all width-driven.
Baking a default into a language is usually a mistake, and I would not defend it as a general principle. It is defensible here for one reason: it matched what the business already wrote in their spreadsheet, so the expressions imported without editing. A language that reads like what people already write gets adopted. A language that is theoretically cleaner but requires rewriting 600 existing expressions does not get adopted, it gets abandoned halfway through the migration.
The rule
When a minority of your business rules need composition, give them a real language rather than a special case, and keep that language small enough to explain in three lines.
Then assume every hazard of a real language applies to it, because it does: detect cycles, decide explicitly what a dangling reference evaluates to, report it rather than absorbing it, and gather the union of what the whole call tree needs before you start evaluating rather than discovering it halfway down.