Rules: domain logic
Properties
Property rules
-
The assistant MUST NOT declare a property if it is used only once.
Exception: a property may still be declared if it is added to a form.
-
Every property parameter MUST be used in its expression. Unused parameters are forbidden.
-
The assistant MUST assume standard
NULLpropagation for property expressions: if any parameter isNULL, the result isNULL.Exceptions that do NOT nullify on a single
NULLoperand: the selection operators —OVERRIDE, which returns the first non-NULLoperand, andIF ... THEN ... ELSE, whose CONDITION is theNULL-tolerant part: aNULLcondition takes theELSEbranch, while a non-NULLone returns theTHENvalue as it is, soIF TRUE THEN NULL ELSE 1isNULL—MIN/MAX, theNULL-tolerant arithmetic(+)/(-), theCONCATconcatenation (aNULLoperand is skipped together with its separator), andGROUPaggregates (GROUP SUM,GROUP MAX, etc.) — aNULLoperand or value is skipped instead of propagating.OR,NOTandXORdo not propagate either: they read a non-NULLoperand asTRUE, soNULL OR TRUEisTRUEandNOT NULLisTRUE.ANDis the one that does — it returnsTRUEonly when both operands are non-NULL, soTRUE AND NULLisNULL.GROUP LASTskips aNULLonly while it has noWHERE, where non-NULLness of the aggregated expression is what serves as the condition; given an explicitWHERE, a row satisfying it contributes its value even when that value isNULL.These exceptions still yield
NULLwhen:- every operand or aggregated value is
NULL— exceptNOT, whose whole point is to answerTRUEthere; (+)/(-)orGROUP SUMproduces0(a zero result is returned asNULL).
- every operand or aggregated value is
-
The assistant MUST NOT use
GROUPwith aBYblock (includingGROUP AGGR) inside expressions: in a type cast, in arithmetic (including(+)/(-)), as an argument of another property, or as an implementation of an abstract property via+=.Such an operator defines the parameters of its result itself, so it is allowed only as an entire property definition: the right-hand side of a definition via
=or an inline definition in square brackets; in any other position the platform raises the errorBY clause in GROUP operator cannot be used in expressions. To use the result in an expression, the assistant SHOULD first rewrite the operator withoutBY, replacing each grouping with an equality condition on an outer parameter (GROUP SUM f(x) IF g(x) = y); otherwise, apply the inline form[GROUP ... BY ...](...)to arguments or declare a separate property and refer to it.The restriction is tied specifically to the
BYblock:GROUPwithoutBYtakes its parameters from the outer context and may be used inside expressions.When reasoning about
GROUP AGGR, the assistant MUST treat it asGROUP MAXwith an additional constraint. -
The assistant SHOULD avoid unnecessary conditions when the language semantics already produce the required result.
-
The assistant MUST NOT create a property whose expression is equal to one of its parameters.
-
The assistant MUST NOT create multiple properties with identical expressions.
-
If a property is calculated from another property but has different parameters, the assistant SHOULD try to keep the same property name.
-
To check whether a property is
NULL, the assistant SHOULD useIF NOT property(...).To check that it is not
NULL, the assistant SHOULD useIF property(...). -
The assistant SHOULD specify
CHARWIDTHin the property definition rather than in form design.
For a simple property composition that only forwards
another property, the assistant SHOULD NOT repeat
CHARWIDTH on the derived property unless it must differ.
-
For static objects, the assistant MUST NOT use
staticCaptionorstaticNameproperties.The assistant MUST use
captionandnameinstead.This applies to writing as well:
captionandnameare simple compositions over the stored caption and name, so an assignment to them passes into the stored property. A static object's caption is assigned throughcaption; the name must not be changed — changingnameis forbidden by a system constraint.namereturns the static object's canonical name —<namespace>_<Class>.<object>, not the short identifier. When the part after the dot is needed, the assistant SHOULD usebasicNamefrom theUtilssystem module. -
Property names SHOULD be concise and avoid unnecessary words.
-
The assistant SHOULD NOT use words in a property name that duplicate parameter class names unless required for clarity.
-
The assistant SHOULD NOT specify an explicit namespace for a property unless necessary.
-
When creating a DATA property — or a simple composition over a DATA property (for example, pulling the name of a related object) — for a single object's own attribute, the assistant MUST deliberately decide whether to place it in the system
idorbasegroup viaIN.Attributes that form the object's business identity and appear in its representation SHOULD go in the
idgroup; other primary attributes go in thebasegroup (idis nested underbase).A property SHOULD NOT be placed in
idorbasewhen it is not the object's own primary attribute. -
When dividing values of integer classes, the assistant MUST cast one of the operands to
NUMERIC, not the result.The ratio of two integers is integer division, so an outer cast like
NUMERIC[16,4](a * b / c)silently drops the fractional part; the correct form isNUMERIC[16,4](a) * b / c. -
A parameter's class annotation (
prop(SubClass x)) is a signature, not a runtime filter: it resolves same-named properties and sets the signature, but the computed set is determined by the properties used in the expression. Reading a parent-class property with a subclass-annotated parameter still ranges over ALL objects of the parent class (e.g. in aGROUP SUM— silently wrong totals).To restrict the set to a class, the assistant MUST add an explicit
x IS SubClasscondition (or use a property declared on that subclass). -
In the
GROUP ... BYoperator the assistant MUST NOT list in theBYblock the upper parameters used in the operator's expressions: each such parameter is already implicitly a group — a parameter of the created property — and keeps its place in the signature.With an explicit parameter list on the left, the
BYexpressions are mapped in order only to the parameters not used in the expressions; a mismatch in count or classes is an error. -
MAXandMINare prefix operators over a comma-separated operand list (MAX a, b), not infix ones:a MAX bdoes not parse — the platform reportsno viable alternative at input 'MAX'.The operand list extends as far as the expression allows, so everything after the comma belongs to the operator:
MAX a, b * cisMAX(a, b * c), whilex * MAX a, bis fine as it stands. Where a following operator must apply to the maximum itself, the operator MUST be parenthesized:(MAX a, b) * c.These operators compare the operands of a single row; a maximum across rows is
GROUP MAX.
Abstract property rules (+=)
-
The value class of a
+=implementation MUST fit within the value class declared on the abstract property; there is no implicit cast — an implementation with a wider class is rejected at server startup with a "wrong value class of implementation" error, whosespecifiedandexpectedlines name the implementation's class and the declared one.Arithmetic is what widens the class most often, and it widens further than it looks:
+and-— likeMIN/MAXand the selection operators — take the common ancestor, widening the whole part and the scale independently, so the result can be wider than either operand:NUMERIC[16,2] + NUMERIC[10,4]isNUMERIC[18,4];*adds both the whole parts and the scales:NUMERIC[16,2] * NUMERIC[10,4]isNUMERIC[26,6];/widens catastrophically: with the default settings its scale is always the maximumNUMERICscale (32), soNUMERIC[16,2] / NUMERIC[16,2]isNUMERIC[48,32].
A
GROUPaggregate mostly keeps the class of what it aggregates — aGROUP SUM,GROUP MAXorGROUP LASTover aNUMERIC[16,2]isNUMERIC[16,2]— but it carries outward whatever that expression already widened to.GROUP CONCATis the aggregate that widens by itself: its result is a string of unlimited length (ISTRINGagainst a declaredISTRING[250]). Plain string concatenation widens as well, summing the operands' lengths (ISTRING[326]against a declaredISTRING[250]).Any such expression the assistant MUST wrap in an explicit cast to the declared class:
f(X x) += NUMERIC[16,2](a(x) / b(x));f(X x) += ISTRING[250](a(x) + b(x));For operands of integer classes the cast MUST go on an operand first, so that the division is not integer division (see rule 16 of the property rules); the result still widens to scale
32like any other division, so the outer cast is needed as well:f(X x) += NUMERIC[16,2](NUMERIC[16,2](a(x)) / b(x));
Ordering rules (ORDER)
-
Where two rows can share an order key and the answer depends on which of them wins — which of two same-date rows is the
GROUP LAST, which of two equal-priority rows aTOP 1takes, where aPARTITION PREVsteps back to — the assistant MUST spell the tiebreak out, usually as the object itself:ORDER date(d), d.The platform does fill an incomplete order in on its own for several of these, so the symptom is not randomness between runs; it is that the row chosen is whichever one a service order over the interfaces selects, which is not what the domain asked for. Writing the tiebreak is how the choice becomes the intended one.
-
A cumulative
PARTITION SUM ... ORDERwith noTOPorOFFSETis the case where a tiebreak MUST NOT be added by reflex. Its default frame gives every row sharing an order key the same cumulative value. Adding a tiebreak changes the result — from a total per group of equal keys to a total per row — which is a decision about the domain, not a safety measure. UnderTOPorOFFSETrule 1 applies as usual: those pick rows, and which rows they pick is worth saying. -
PARTITION LASTdoes not read the order to compute its value: it is the value of the current row.GROUP LASTis the one that picks by order.
Actions and assignment
Action rules
-
The assistant MUST avoid
FORwhen the same result can be expressed with a set-based construct.FORiterates row by row and SHOULD be the last resort when no declarative alternative exists. The one measured exception runs the other way: when the assigned value is aGROUPaggregate whose bounds correlate with the row being updated, and both sets are large, the set-based form can compile to a query that materializes the whole correlation, and a row-by-rowFOR ... NOINLINEcan be the faster one — where an index on the aggregated class lets each row's aggregate be answered by an index lookup.Prefer set-based alternatives, for example:
- aggregation or set materialization
->
GROUP SUM,GROUP CONCAT,GROUP MAX,GROUP LAST,GROUP AGGR - assigning a property over a set
-> direct property assignment with parameters
instead of a
FOR ... DOloop - exporting tabular or hierarchical data
->
EXPORT FROM,EXPORT JSON FROM,EXPORT XML FROM,EXPORT CSV FROM - building structured payloads
->
JSON FROM,XML FROM - bulk integration writes
->
NEW,DELETE, or set-based property change instead of a per-rowFOR
FORis acceptable when the body has genuine per-row control flow such as conditionalAPPLY,MESSAGE,throwException, or external calls that cannot be expressed as a set operation. - aggregation or set materialization
->
-
Parameters introduced in
NEW alias = ClassandFOR expr(p) [NEW alias = Class] DO { ... }do NOT follow the usual lexical scoping rules of mainstream programming languages.Such parameters are visible ONLY inside the body of the
NEWblock or theFORloop that introduces them.The assistant MUST NOT reference these parameters outside their introducing block.
When dependent computation must reuse these parameters, the assistant SHOULD nest further
NEWorFORblocks inside the introducing block, where the parameters are still in scope, rather than lifting values out into auxiliary storage.Conversely, a parameter declared inside a
GROUPaggregate belongs to that aggregate and is NOT visible outside of it; in particular it cannot serve as the loop variable of the enclosingFOR. Declare the variable as theFOR's own parameter and use the aggregate only as a boolean condition over it. -
The assistant SHOULD avoid introducing
LOCALproperties without a concrete need.A
LOCALmaterializes a temporary table in PostgreSQL only once it holds more than one row, so the runtime cost well above a stack variable in a conventional language applies toLOCALs with parameters (buffers keyed by row number, per-object values). A parameterlessLOCALholds at most one row and always stays in memory, so parameterless flags and single values are cheap; avoid them to keep the number of entities down, not because of cost. -
A
LOCALis normally justified when BOTH conditions hold:- its value is non-trivial to compute (aggregation, joins, multi-step logic, external calls, or other work worth materializing), AND
- the same value is consumed more than once, so materializing it avoids recomputation.
-
When possible, the assistant SHOULD prefer alternatives to a fresh
LOCAL:- inline the expression at each use site if it is cheap
- nest
NEW/FORblocks so intermediate values stay in parameter scope - use a regular (non-
LOCAL) calculated property when the value is reusable across actions
-
These are recommendations, not hard prohibitions. If the assistant cannot find a working syntax for a
LOCAL-free construction, or some other approach keeps failing and a clean action cannot be built, falling back to aLOCALis acceptable as a last resort.Established
LOCALpatterns mandated by other rules (e.g. import staging, nested-session carry-over) remain valid; the assistant SHOULD still keep suchLOCALs minimal in count and scope. -
The parameters of the top-level statements of an action body share one parameter context: identical names denote the same parameter, and a parameter's class is declared only at its first use.
In generated scripts (
eval, data seeding) the assistant SHOULD give the parameters of top-level statements unique names, so as not to depend on the statement order. -
Many system utility actions return their result through a same-named parameterless
LOCALproperty (for example, inUtils: the actionfileExists[ISTRING[500]]writes into the propertyfileExists[]). Such an element is an ACTION, not a boolean property: the assistant MUST call the action first and then read the parameterless property (fileExists(path); IF fileExists() THEN ...), and MUST NOT use the parameterized form inside an expression (IF fileExists(path)is wrong).
Assignment rules (<-)
-
The arguments of the changed property on the left side of
<-may be expressions over the statement's parameters (sentFolder(account(f)) <- f), but new local parameters can be introduced only as typed parameters, not inside expressions. Writing "into a computed key" by analogy with imperativemap[key] = valueeasily breaks this.So when remapping self-referential links while deep-copying an object graph, the assistant SHOULD keep an inverse map and iterate with the TARGET object as the parameter —
link(Copy n) <- newOf(link(srcOf(n))) WHERE spec(n);— rather than writelink(newOf(x)) <- newOf(link(x)); -
<- expr IF condassigns the whole expression to ALL objects: wherecondfails, the property is overwritten withNULL. It is effectively reset-plus-set.When ADDING an assignment to a property already populated earlier in the same action, the assistant MUST use the
WHEREform (prop(x) <- TRUE WHERE cond(x)), which changes only the rows matching the condition. A second IF-form assignment to the same property MUST be treated as a review red flag. -
Inline in an action or event body,
PREV(<expr>)takes the WHOLE wrapped expression to the session-start state, including its argument sub-expressions: an argument computed in the current session (aLOCAL, a property of an object created in the session) reads asNULLinsidePREV, silently nulling the result.To read previous data with current arguments, the assistant MUST wrap
PREVin a separate property —prevF(x) = PREV(f(x));— and call it instead of writingPREV(f(<session-computed arg>))inline. -
A parameter through which a property whose name is declared on several classes is read or changed MUST be annotated with its class at first use (
date(Interaction i) <- ...): an overloaded name is resolved by the parameter classes, and an untyped parameter yields an "ambiguous name" error. This especially concerns events: their statement is a separate parameter context in which the class is not inferred from anywhere else, and ani IS Interactioncondition does not set the parameter's class.
Loop rules (FOR, WHILE)
-
FORfixes its set before the first iteration: the condition is evaluated once, the matching rows are read, and the body then runs once per row of that set. What the body changes — the data under the condition included — does not add or remove iterations.WHILEis the operator that re-reads, but it does so per STEP, not per row: one step re-evaluates the condition, reads the whole matching set and runs the body for every row of it, and only then is the set read again; iteration stops when it comes back empty. So a row already in the current step still gets its turn even if an earlier row of that same step has made the condition false for it. -
Without
ORDERaFORwalks its set in arbitrary order. The assistant MUST give an explicitORDERwhenever the result depends on the sequence — numbering, running totals, anything reading what an earlier iteration wrote — or wheneverTOPlimits how many rows are taken, and MUST end thatORDERwith a key that separates any two rows.
Thread rules (NEWTHREAD, NEWEXECUTOR)
-
A server-side thread action shares the change session of the calling code, and change sessions are not thread-safe.
The assistant SHOULD therefore wrap the body of a server-side
NEWTHREADinNEWSESSION, and inNEWSESSION NEWSQLwhen it needs a database transaction of its own. It is a trade: a plainNEWSESSIONno longer sees the caller's unsaved changes, so the wrapping is left out only where sharing the session is deliberate AND the two are known not to run at the same time.What the wrapping buys inside an
APPLYtransaction depends on WHEN the body actually starts.NEWSESSION,NEWSQLincluded, creates no session while the transaction is still open — the action is deferred into the current one — and the check happens at the moment the body runs, not at the moment it is scheduled. ASCHEDULE DELAYis a number of milliseconds, not a barrier waiting for the apply, so it guarantees nothing either. The assistant MUST NOT count on a thread started from a global handler being isolated.A client executor is the opposite case: the action is delivered to the user's connection and runs there in its own fresh session, so wrapping it adds nothing.
Events (WHEN)
Event rules (WHEN)
-
A
WHENevent fires whenever its condition becomes true during a session and writes its target property unconditionally. If the same target property is also changed explicitly elsewhere in the session (user input, action assignment, import), the event overwrites that explicit change. -
When the event's purpose is only to derive or default a value from other inputs, the assistant SHOULD guard the condition with
AND NOT CHANGED(<target>)for each target property the event writes.This prevents the event from clobbering an explicit change to the target made elsewhere in the same session.
-
The guard SHOULD be omitted only when the event must forcibly override any explicit change — for example, maintained totals, audit stamps, or invariants the user is not allowed to bypass.
-
Rules 1-3 describe the event-action form
WHEN <condition> DO <target> <- <expr>. The calculated event form<target> <- <expr> WHEN <condition>behaves differently: its change is calculated when the target property is accessed, and an explicit change of that property in the session takes priority over the event's change.So to default a value while yielding to an explicit change, the calculated event form alone is enough — no guard is needed. Testing
CHANGED(<target>)in its condition is not possible in any case: the target would then depend on its own change, forming a cycle<target>->CHANGED(<target>)-><target>.In the absence of an explicit change the event writes the value of the expression even when it is
NULL. -
A
WHENcondition is checked on deleted objects too. Deleting an object resets its data properties toNULL, so a condition that reacts to a value becomingNULLis satisfied for every deleted object whose value had been non-NULL, and the handler runs on the object that is already gone.Which change operators those are is decided by the transition each of them covers:
DROPPED,CHANGED,DROPCHANGEDandSETDROPPEDinclude non-NULLtoNULLand therefore fire on deletion;SETandSETCHANGEDrequire the new value to be non-NULLand do not.Where the condition can fire on the way to
NULL, and the handler must not act on a deletion or on an object leaving the class, it MUST be narrowed with<object> IS <Class>.
Where local events actually run
-
A local event handler does not run at the moment the data changes. It runs at a point in the session's life: a form synchronising, a form opening, an
APPLYstarting, a nested session being created, or an explicitSystem.executeLocalEvents[].Outside an interactive form — an action called from an external system, a scheduler task — the only one of those that normally happens is the apply. So reading a property right after changing the data it depends on returns the value WITHOUT the local handlers applied, unlike a calculated property, which is always current.
The assistant MUST NOT rely on a local handler having run in such a place: either let
APPLYdo it, or callSystem.executeLocalEvents[]before the read.
Constraints
-
When a value choice in one property must be restricted based on values of other properties — sibling fields on the same form, current context, related objects — the assistant SHOULD first consider
CONSTRAINT ... CHECKED BY <property>.CHECKED BYmakes the change dialog for the listed property automatically filter out values that would violate the constraint, so the restriction is enforced declaratively at the point of selection, not after the fact.The filter reaches those change dialogs only. An input mechanism that offers values in some other way does not use it, and there a violating value is rejected only when the constraint itself is checked.
-
Manual form filters or hand-rolled validation actions SHOULD be the fallback when
CHECKED BYcannot express the restriction (e.g. the filter depends on transient UI state not modeled as a property, or the rule is advisory rather than enforced) — and also when the restriction IS expressible that way but the value is picked through some other mechanism, which theCHECKED BYfilter does not reach. -
The assistant SHOULD NOT put heavy aggregates over large tables (especially nested non-materialized ones) into a
CONSTRAINTcondition: the incremental check at apply can expand into an impractically large query, even with computation hints on the properties.For such expensive checks, use a
WHENevent instead: a cheap change-detector condition, reads of the heavy values intoLOCALs in the handler, thenMESSAGE+CANCELon violation.
Change sessions (NEWSESSION, APPLY)
-
Before introducing
NEWSESSION, the assistant MUST decide which session behavior is required. None of the choices below applies during anAPPLYtransaction — inside a global event handler or an applied action — where no session is created at all: the inner action is deferred and runs in the current session, inside the same transaction. The assistant MUST NOT expect an independent commit there.- isolated independent unit ->
NEWSESSION - isolated unit that must also see selected local properties
from the upper session ->
NEWSESSION NESTED (...) - isolated unit that must see all local properties
from the upper session ->
NEWSESSION NESTED LOCAL - child dialog or editor that must work with unsaved upper-session
objects and return its changes to that upper session
->
NESTEDSESSION
- isolated independent unit ->
-
For actions added to forms, there are two main patterns:
- readonly form pattern: the form is effectively browse-only, so actions added to it SHOULD run in a new session by default
- editable form pattern:
the form has editable properties, so any action added to it
that uses
NEWSESSIONMUST either:APPLY;IF canceled() THEN RETURN;beforeNEWSESSION, or be fully independent from unsaved changes in that form
-
Plain
NEWSESSIONis the default for isolated work that must not accidentally apply the caller's pending form changes.Typical patterns in the source:
- readonly list forms with
PROPERTIES(...) NEWSESSION NEW, EDIT, DELETE - status transitions or dependent document creation
after a preceding
APPLY - external or integration actions that isolate HTTP calls and persist their own results
- small immediate UI updates with
NEWSESSION { APPLY { ... } }
- readonly list forms with
-
If inner logic depends on upper-session local state such as selections, marks, or import buffers, the assistant MUST carry that state explicitly through
NESTED (...)orNESTED LOCALon the operator, or declare the property itselfDATA LOCAL NESTED, which carries it over without being listed on the operator. Neither route works underNEWSQL: on a connection of its own it migrates nothing, so the assistant MUST NOT combineNEWSQLwith a dependency on upper-session local state. -
A successful
APPLYclears the session, and with it everyLOCALproperty in it by default: after such anAPPLYreturns, a plainLOCALis empty again. AnAPPLYthat fails or is cancelled leaves the session as it was, locals included — which is why the assistant MUST NOT read aLOCALafterAPPLYto tell success from failure;canceled()is what tells them apart. Inside a nested session there is no clearing at all: the changes are copied to the parent session and the nested one is left standing, locals and all.Outside a nested session, a
LOCALvalue survives a SUCCESSFULAPPLYwhen EITHER:- the
LOCALis declared asNESTEDat declaration time (LOCAL NESTED name = Type ();orname = DATA LOCAL NESTED Type (...);), OR - the
APPLYexplicitly preserves it viaAPPLY NESTED (name1, ..., nameN)orAPPLY NESTED LOCALfor all locals.
The assistant MUST NOT rely on a plain
LOCALvalue computed before a SUCCESSFULAPPLYto still be readable after it. Two cases keep it: a nested session, which clears nothing at all, and an apply that failed or was cancelled, which leaves the session as it was. If a staged value must outliveAPPLY— for example, an import buffer read during post-apply follow-up — the assistant MUST either declare it withNESTED, or list it inAPPLY NESTED (...)(or useAPPLY NESTED LOCAL) at the call site. - the
-
When using
NEWSESSION NESTED (...)orNEWSESSION NESTED LOCAL, the assistant SHOULD preserve the same nested local properties onAPPLYif the result must be copied back to the upper session, for example withAPPLY NESTED (...)orAPPLY NESTED LOCAL. -
The assistant MUST NOT replace
NESTEDSESSIONwith plainNEWSESSIONfor child forms or dialogs attached to a parent object that may still be unsaved in the current form session. -
Before opening a fresh
NEWSESSIONfrom an action started on an edit form, the assistant SHOULD decide whether current form changes must be saved first.The common pattern is:
APPLY;IF canceled() THEN RETURN;NEWSESSION { ... }This pattern is used before status changes, document generation, and other isolated follow-up actions.
-
After
APPLY, the assistant MUST checkcanceled()only when later logic depends on whether the save succeeded — to early-return, skip a follow-up side effect, or roll back staged work.APPLYin an interactive context shows the constraint message to the user on its own. The assistant MUST NOT addIF canceled() THEN MESSAGE applyMessage()afterAPPLYin interactive actions solely to report the failure — it duplicates the message the platform already shows. Explicit surfacing viaapplyMessage()orthrowException(applyMessage())is required only for non-interactive callers (API endpoints, background integrations) where no dialog is shown.If
APPLYfails because of a constraint, the changes remain unsaved in the current session, and any followingAPPLYin the same session will also fail until the offending data is fixed or the changes are discarded (for example withCANCEL). -
The assistant SHOULD keep
NEWSESSIONblocks small and purpose-specific: isolate one unit of work, apply it if needed, and exit.The assistant MUST NOT introduce
NEWSESSIONmerely to hide session-visibility bugs. If upper-session changes must remain visible, nested session semantics are required. -
The body of
APPLYmay run more than once. The apply transaction MAY be retried automatically after an update conflict, a deadlock or a timeout — whether it is depends on the failure and on the attempt limit — and the applied action and the synchronous global handlers are inside what a retry repeats.So they MUST be safe to repeat. An irreversible external side effect — sending mail, calling an HTTP API, printing, writing a file — MUST NOT be done there: it belongs after the apply has succeeded, where
canceled()says whether it did.