Skip to content

Determinations

A determination is a rule that computes some fields from others. You declare its inputs (the fields that trigger it), its outputs (the fields it writes), and a compute function — and the engine runs it whenever an input changes. A line’s amount from quantity × price, an order’s total from its lines, the tax from the address: all determinations.

The engine is determine() in @onerp/core: it takes a record, an Update and the business object’s determinations — from TenantMeta.rules(businessObject) — and runs every triggered one to a fixpoint. On save, BusinessObjectMutator then checks the result before anything is persisted: every reference must target a real record, and outside a draft no validator may report an issue and every required field is filled.

Keep three shapes separate. A BusinessObject is the current value, an Update is requested edits, and Changes is the observed before/after diff used by events and audit. A determination returns an Update (or null) just like a caller’s write; its output is requested intent, not an observed diff.

An Update addresses a collection with one CollectionUpdate: create, update and delete buckets, using the same shape at every nesting level. Created rows may omit an id; edited rows must name one. assignCreatedRowIds assigns ids to created rows and rejects addressing a row more than once, including nested collections. It prepares update structure and identity; HTTP scalar or access parsing remains a separate boundary concern. mergeUpdate accumulates intent across edits: scalar values use the later value, collection buckets accumulate, and deletion dominates other edits for the same row.

A determination is a provider. Extend the base Determination.on builds, mark the class with @Determination(), and list it in the plugin’s providers:

domains/inventory/src/goods-movement/line-quantity.determinations.ts
@Determination()
@Injectable()
export class LineUnitDefault extends Determination.on(GoodsMovementSchema, {
name: "inventory/lineUnitDefault",
description: "Defaults a line's unit to the picked product's base unit.",
inputs: (movement) => [movement.lines.product],
outputs: (movement) => [movement.lines.unit],
}) {
constructor(private readonly bom: BusinessObjectManager) {
super();
}
async compute(
movement: BusinessObject<typeof GoodsMovementSchema, false>,
delta: Update<typeof GoodsMovementSchema>
): Promise<Update<typeof GoodsMovementSchema> | null> { … }
}

inputs and outputs are callbacks over the business object’s field paths, so a misspelled field is a compile error. Composition resolves them to dotted paths against the extended business object — see the meta pipeline. Identity is the (business object, name) pair. A validator is the same shape: Validator.on(Schema, { name }), @Validator(), and a validate(state) that returns field issues.

Two arguments: the record and the delta that triggered this pass. Everything else it needs arrives through the constructor, like any NestJS provider.

The record is typed Readonly<BusinessObject<S, false>> — the unvalidated view. Every field reads as nullable there, including required: true ones, because a determination runs before the record has to be valid. That’s why real rules are full of null checks: they are forced by the type, not defensive habit. When a value is genuinely guaranteed by the run order, a rule throws rather than defaults — payment-schedule.determination.ts throws on a null totalAmount, because documentTaxTotals must have run before it.

Return an Update covering some or all of the declared outputs, or null for “nothing to say this pass”. A null return is the normal way a rule declines — not an error, and it does not stop the pass.

Each path inputs returns is a trigger, and there are three shapes:

  • doc.quantity — a scalar field of the business object itself.
  • doc.items — a child collection as a whole. Fires on any change to it: a row arriving, a row leaving, or any field on any row edited — including rows another rule created in the same pass. This is what the line-position rule reads.
  • doc.items.unitPrice — a field on a child collection. Fires when any row’s unitPrice changes, and also when a row is created or deleted. doc.items.taxLines addresses a grandchild collection as a whole and fires on any change to it.

On create, every determination runs — whether or not its inputs appear in the delta. This makes inputs: () => [] a real idiom rather than a dead rule — it means “on create only”, which is how the document-date default stamps today’s date.

Determinations form a graph through their inputs and outputs: an edge runs from A to B when one of A’s outputs matches one of B’s inputs (line tax → line total → order total). Composition sorts the graph topologically, and the engine runs rules in that order, so everything downstream recomputes from one change. Ties break lexicographically, so the order is deterministic across runs.

Cycles are rejected at composition — the boot throws — unless every participant is marked convergent: true. That flag is a promise that the composed loop settles to a fixpoint; the engine cannot verify it, so a fuel limit backs it up at runtime. One run is one step however many rows it touches, so the budget is a constant (200) and a large create costs no more fuel than a small one.

Two independent rules may not write the same field

Section titled “Two independent rules may not write the same field”

Composition checks that determinations with no dependency path between them have disjoint outputs. Two such rules writing one field would give an order-dependent answer, so composition throws instead, naming both rules and the field (amount and items.amount are different fields). A shared output is fine when one rule depends on the other — the edge makes the order deterministic. This fires when the server boots, not on some unlucky record.

A rule whose output is a whole collection (outputs: (doc) => [doc.items], a rule that creates or deletes rows) feeds every rule with an items or items.* input. When one of those writes a field the first one reads back, the two form a cycle and both must say convergent: true.

Every rule attaches to the business object

Section titled “Every rule attaches to the business object”

A determination or validator is declared on the business object and addresses the tree by path: inputs doc.customer and doc.items.product, output doc.items.unitPrice, an issue at lines.<id>.quantity. There is no line-attached rule. A rule that needs a header fact reads it from the record; nothing is ever copied onto rows to be reachable.

The engine hands a rule the merged delta: every row that changed since it last ran, with a row created and edited in the same pass arriving as one row under create.

delta is complete, so a rule may select the rows it works on from it. Do that when a row costs a read or a call — linePriceLookup prices the rows named in delta.items.update, or every row when a header input (customer, currency, documentDate) is in the delta. When the work is arithmetic, map every row and return them all: the engine keeps only the values that actually change (E8), so unchanged rows neither write nor re-trigger anything. A rule’s own output never re-queues it through the graph; if it reads a field it writes (propose-when-empty), the effective-change check stops the loop.

A rule reads the world, and never writes it

Section titled “A rule reads the world, and never writes it”

A rule injects what it reads: BusinessObjectManager for records, and a function class for a computed answer. linePriceLookup injects Pricing and calls this.pricing.run({ … }) directly — an in-process call that skips the function dispatcher and its permission check.

Rules run inside a read-only unit of work: a staged write throws “A rule may not stage writes”. That is why a determination may never invoke an action. Its only effect is the delta it returns.

Within a single determine(), the engine collects every path the incoming delta touched and suppresses those fields from every determination’s output. Type a price and the price lookup’s answer for that row is dropped for the rest of the pass; the value you typed stands.

That suppression is scoped to the one mutation. It is derived from this delta and nothing else. There is no persistent record anywhere — in the schema, the engine or the runtime — that a field was once set by hand.

While a form is open the effect looks durable, because the form runtime folds the whole edit log into every pass: everything you typed since the record loaded is in the delta each time, so it stays suppressed. On save the log is cleared and the saved record becomes the new base, so the next edit’s delta contains only that edit — and a determination whose inputs it touches now runs with nothing suppressing its output. Type a unit price, save, then change that line’s quantity, and the looked-up price replaces yours. That is intended, not a gap: quantity-tier pricing only works if a quantity change re-prices the line.

Making a value stick: propose only when empty

Section titled “Making a value stick: propose only when empty”

The mechanism that survives a save is a guard in the rule body: write only when the field is still empty. Six root rules in sales open exactly this way:

domains/sales/src/document/header-defaults.determinations.ts
async compute(doc) {
if (doc.currency) {
return null;
}
// … resolve the currency from the customer, then the profile
}

The guard reads state, not the delta, so it holds on every pass and every save thereafter. Whether a field is a proposal the user owns once set, or a derivation the engine owns forever, is a decision you make per rule and encode in that first if. The engine does not make it for you.

Field output yields to the user; structural output does not. Inside one pass, a determination’s field value loses to a user edit of that same field, but its row delete wins over the user’s field edits on that row — the row goes — and only a user’s own structural op on that row beats it. The tax determinations rely on this: they delete stale tax-line rows outright rather than proposing their absence.

Rules run on the server only. While a form is open, every edit posts the folded edit log to POST /api/business-object/:namespace/:name/:id/preview (or /api/drafts/…/preview for a draft), which runs the determinations in a read-only unit of work and answers the computed record without persisting it. Validators do not run on a preview; they run on save, after the determinations. The figures a form shows are computed by the same code that computes them on save.

A preview must not consume a document number, so documentNumbering returns a numberSeries(seriesId) marker and the repository swaps it for a real number inside the commit transaction.