Actions
Up to this point, the coffee machine model describes what the system is – parts, attributes, ports, connections. But a machine that only has structure does nothing. This chapter adds behavior: what the system does when someone presses the brew button.
In SysML v2, behavior is modeled with actions. An action definition describes a reusable unit of behavior, the same way a part definition describes a reusable structural component. Actions compose into workflows through sequencing, parallelism, branching, and signaling.
Defining an Action
An action definition declares a kind of behavior. It follows the same def pattern you have seen with parts and items:
action def GrindBeans;
That says: there is a kind of behavior called GrindBeans. It does not say when or where it happens. It is a type, not an occurrence.
To say that something performs this behavior, you create an action usage inside another action:
action def GrindBeans;
action def HeatWater;
action def BrewCycle {
action grind : GrindBeans;
action heat : HeatWater;
}
action grind : GrindBeans says: inside a BrewCycle, there is a step called grind, and it is of type GrindBeans. This is the same definition/usage pattern from Chapter 2, applied to behavior instead of structure.
Without anything else, the two sub-actions have no defined order. SysML v2 does not assume sequential execution. You have to say it.
Sequential Composition
The first ... then statement declares that one action must complete before another begins:
action def BrewCycle {
action grind : GrindBeans;
action heat : HeatWater;
first grind then heat;
}
first grind then heat creates a succession – an ordering constraint that says grind finishes before heat starts. This is a directed relationship between two action usages, not a property of either action by itself.
You can chain successions to build longer sequences:
action def BrewCycle {
action grind : GrindBeans;
action heat : HeatWater;
action extract : ExtractCoffee;
action dispense : DispenseCoffee;
first grind then heat;
first heat then extract;
first extract then dispense;
}
That reads: grind, then heat, then extract, then dispense. Each first ... then is a separate succession statement. Together they form a chain.
Every action has two implicit endpoints: start and done. You can anchor your sequence to them:
action def BrewCycle {
action grind : GrindBeans;
action heat : HeatWater;
action extract : ExtractCoffee;
action dispense : DispenseCoffee;
first start then grind;
first grind then heat;
first heat then extract;
first extract then dispense;
first dispense then done;
}
first start then grind says that grind is the first thing that happens when a BrewCycle begins. first dispense then done says the cycle finishes after dispensing completes. These endpoints make the boundary of the action explicit – a reviewer can see exactly where behavior begins and ends.
You can also chain successions on a single line using the then keyword after an action usage:
action def BrewCycle {
action grind : GrindBeans;
then action heat : HeatWater;
then action extract : ExtractCoffee;
then action dispense : DispenseCoffee;
}
The then before an action usage is shorthand: it creates a succession from the previously declared action to the current one. This form is more compact but means the same thing as writing separate first ... then statements.
Naming a succession
Everything so far has written the ordering inline, inside the action body. There is a second form that names it:
succession [<name>] [first] <source> then <target>;
action def BrewCycle {
action grind : GrindBeans;
action heat : HeatWater;
action extract : Extract;
succession grindDone first grind then heat;
succession heat then extract;
}
Read the first one as: this ordering constraint is called grindDone, and it
says grind comes before heat.
Both lines declare the same kind of relationship the bare first ... then does.
What the keyword buys you is a name, and a named succession is a model
element like any other – you can document it, refer to it from a requirement, or
allocate it. That is why larger models tend to use this form for the orderings
that matter and the bare form for the rest.
Note that first is optional here. succession heat then extract; and
succession first heat then extract; mean the same thing – the specification says
so directly: “If a succession declaration includes only the related features part,
then the keyword first can be omitted” (KerML §7.4.6.4). The coffee machine’s own
orchestration model uses the shorter spelling throughout.
One more thing that clause tells you, and it is worth knowing because it explains
what a succession actually is: if you give a succession no explicit type, it is
implicitly typed by the library association HappensBefore, which lives in the
standard library’s Occurrences package. An ordering constraint
is not a special syntactic device bolted onto actions – it is a connector, typed
from the standard library, whose two ends happen to be things ordered in time.
Action Parameters
Actions often need inputs and produce outputs. You declare these with in and out parameters:
action def GrindBeans {
in item beans : CoffeeBeans;
out item grounds : GroundCoffee;
}
in item beans : CoffeeBeans says that GrindBeans receives coffee beans as input. out item grounds : GroundCoffee says it produces ground coffee as output. The item keyword here means the parameter carries an item – something that flows through the system.
Parameters make the interface of an action explicit. When you compose actions, the outputs of one step can connect to the inputs of the next:
action def GrindBeans {
in item beans : CoffeeBeans;
out item grounds : GroundCoffee;
}
action def ExtractCoffee {
in item grounds : GroundCoffee;
in item water : HotWater;
out item coffee : BrewedCoffee;
}
A reviewer can see at a glance what each step needs and what it produces, without reading the internals.
Parallel Composition with Fork and Join
Not everything in a workflow happens one step at a time. In the coffee machine, heating water and grinding beans can happen simultaneously – the grinder and the heater are independent subsystems.
SysML v2 models parallelism with fork and join nodes. A fork splits one flow into multiple concurrent paths. A join waits for all concurrent paths to complete before continuing.
action def BrewCycle {
action grind : GrindBeans;
action heat : HeatWater;
action extract : ExtractCoffee;
action dispense : DispenseCoffee;
first start then fork forkNode;
first forkNode then grind;
first forkNode then heat;
first grind then join joinNode;
first heat then joinNode;
first joinNode then extract;
first extract then dispense;
first dispense then done;
}
Here is what happens at runtime:
- The cycle starts.
forkNodesplits execution into two concurrent paths.grindandheatrun in parallel.joinNodewaits until bothgrindandheatfinish.extractruns after the join.dispenseruns after extraction.- The cycle ends.
The fork keyword declares a fork node – a control node with no behavior of its own. Its job is to start multiple paths at the same time. The join keyword declares a join node that synchronizes those paths back together. Think of a fork as a split and a join as a rendezvous.
Fork and join nodes are not actions with duration. They are instantaneous control points. The real work happens in the action usages (grind, heat, extract, dispense). The control nodes just direct traffic.
Decisions and Merges
Sometimes a workflow needs to choose between paths based on a condition. SysML v2 models this with decide and merge nodes.
A decision node picks one outgoing path. A merge node brings multiple alternative paths back together. Where fork/join models “do all of these,” decide/merge models “do one of these.”
Suppose the coffee machine can make either espresso or drip coffee, depending on a setting:
action def BrewCycle {
in attribute espressoMode : Boolean;
action grind : GrindBeans;
action heat : HeatWater;
action espressoExtract : ExtractEspresso;
action dripExtract : ExtractDrip;
action dispense : DispenseCoffee;
first start then grind;
first grind then heat;
first heat then decide decideMethod;
if espressoMode then espressoExtract;
else dripExtract;
first espressoExtract then merge mergeMethod;
first dripExtract then mergeMethod;
first mergeMethod then dispense;
first dispense then done;
}
After heating, the decideMethod node evaluates the condition. If espressoMode is true, execution follows the path to espressoExtract. Otherwise, it follows the path to dripExtract. Only one path runs.
The mergeMethod node reunites the two alternatives. Whichever extraction method was chosen, execution continues to dispense after the merge. The merge does not wait for both paths – only one path is active, and the merge simply accepts whichever one arrives.
This is the key difference between fork/join and decide/merge:
- Fork/join: all paths execute concurrently, join waits for all.
- Decide/merge: one path executes based on a condition, merge accepts whichever arrives.
The guard condition (if espressoMode) is attached to the outgoing succession from the decision node. The else keyword marks the default path taken when no guard is satisfied.
Combining Control Flow
Real workflows combine sequencing, parallelism, and decisions. Here is a more complete brew cycle that uses all three:
package CoffeeMachineActions {
import ScalarValues::*;
import CoffeeMachineDomain::*;
action def GrindBeans {
in item beans : CoffeeBeans;
}
action def HeatWater {
in item water : Water;
}
action def ExtractEspresso;
action def ExtractDrip;
action def DispenseCoffee {
out item coffee : BrewedCoffee;
}
action def BrewCycle {
in attribute espressoMode : Boolean;
in item beans : CoffeeBeans;
in item water : Water;
out item coffee : BrewedCoffee;
action grind : GrindBeans {
in item beans = BrewCycle::beans;
}
action heat : HeatWater {
in item water = BrewCycle::water;
}
action espressoExtract : ExtractEspresso;
action dripExtract : ExtractDrip;
action dispense : DispenseCoffee {
out item coffee = BrewCycle::coffee;
}
// Grind and heat in parallel
first start then fork forkPrep;
first forkPrep then grind;
first forkPrep then heat;
first grind then join joinPrep;
first heat then joinPrep;
// Choose extraction method
first joinPrep then decide decideMethod;
if espressoMode then espressoExtract;
else dripExtract;
first espressoExtract then merge mergeMethod;
first dripExtract then mergeMethod;
// Dispense and finish
first mergeMethod then dispense;
first dispense then done;
}
}
Read the control flow section from top to bottom and you get the full story: the cycle starts by forking into parallel preparation (grind and heat). After both finish, a decision selects the extraction method. After extraction, the results merge and the coffee is dispensed.
The action parameters (in item beans, out item coffee) make the data flow explicit alongside the control flow. The binding in item beans = BrewCycle::beans connects the sub-action’s input to the enclosing action’s parameter. This is how data threads through a composed workflow.
Send and Accept
Actions can communicate with each other through signals. The send action transmits a signal, and the accept action waits for one to arrive. This is how you model event-driven behavior – one part of the system notifying another that something happened.
action def NotifyReady {
send BrewComplete() to display;
}
send BrewComplete() to display says: transmit a BrewComplete signal to the display port (or part). The signal is an item that carries information about an event.
On the receiving side:
action def WaitForBrew {
accept brewDone : BrewComplete;
}
accept brewDone : BrewComplete says: pause and wait until a BrewComplete signal arrives. When it does, execution continues and the signal is available as brewDone.
You can use accept inside an action body to model a step that blocks until an event occurs:
action def ServeCustomer {
accept orderReceived : BrewRequest;
then action brew : BrewCycle;
then action notify send BrewComplete() to customerDisplay;
}
This reads: wait for a brew request, then perform the brew cycle, then notify the customer display. The accept at the beginning means the action does not start its real work until the triggering event arrives.
Routing a message through a specific port
When a part has more than one port, a plain send ... to <part> leaves the channel unspecified. The via keyword routes a signal along a particular port so the model says which connection carries it:
action def RouteSignalThroughPort {
send LowWaterSignal() via communicationPort;
}
send LowWaterSignal() via communicationPort says: route the LowWaterSignal through the communicationPort, not whichever other port the part happens to have. This matters when a part exposes several ports and the routing decision is part of the model, not left to the runtime.
On the receiving side you can select an incoming channel with via as well:
action def RouteAcceptThroughPort {
accept alert : FaultSignal via communicationPort;
}
accept alert : FaultSignal via communicationPort says: wait for a FaultSignal arriving on the communicationPort. The via keyword selects which incoming channel the message comes from, so an accept with a port filter only fires for messages on that port.
Send and accept are the bridge between structure and behavior. The structural model defines ports and connections (Chapter 6). The behavioral model uses send and accept to say what signals travel through those connections and when.
Connecting Actions to Structure
An action definition describes behavior in the abstract. To say which part of the system performs it, you use perform inside a part definition:
part def CoffeeMachine {
part grinder : Grinder;
part brewer : Brewer;
part waterTank : WaterTank;
perform action brewCycle : BrewCycle;
}
perform action brewCycle : BrewCycle says: a CoffeeMachine performs a BrewCycle. This connects the structural model (what the machine is made of) with the behavioral model (what the machine does). You can also allocate individual sub-actions to specific parts, saying that the grinder performs the grinding and the brewer performs the extraction – that is what allocate is for, covered in Chapter 6.
This separation is deliberate. The action decomposition (BrewCycle contains grind, heat, extract, dispense) is independent of the structural decomposition (CoffeeMachine contains grinder, brewer, waterTank). You can change the action decomposition without touching the structure, and vice versa. The perform relationship is the bridge between the two.
Actions That Modify State
So far every action the coffee machine has performed only reads its inputs and produces outputs – the model describes data flowing through the workflow, but nothing changes a value mid-flight. Real behavior is messier: an action increments a brew counter, flips a mode flag, or aborts a cycle when a sensor trips. SysML v2 has constructs for all three – assignments, bidirectional parameters, loops, and early termination – and they all live inside action bodies, the same bodies you have been writing.
Assignment
An assignment mutates a value as the workflow runs. It binds an expression to an attribute or a parameter reference with the := operator:
assign <target> := <expression>;
Here is a brew cycle that records how many brews it has run:
action def BrewCycle {
inout attribute brewCycles : Integer;
assign brewCycles := brewCycles + 1;
}
assign brewCycles := brewCycles + 1 says: take the current value of brewCycles, add one to it, and write the result back. This is an assignment – it runs when the action body executes, not when the model is parsed. It is different from declaring an attribute with an initial value (which is set once when the part is created), and different from a calc (which is a pure function of its inputs). An assignment is a side effect on a binding the caller owns.
That is why the target has to be something the action can reach back out to. Writing to a plain in parameter would be pointless – the caller would never see the change. The next section makes the mechanics explicit.
inout parameters
Parameters come in three directions. in carries a value into the action; out carries a value out of it. inout does both: the caller supplies an initial value, the action can read it and write it back:
action def ToggleMode {
inout attribute espressoMode : Boolean;
}
inout attribute espressoMode : Boolean says: the caller hands the action the current value of espressoMode, and any change the action makes is visible to the caller when the action completes. Inside the action body, espressoMode reads like an in and writes like an out.
Use inout whenever the natural model of the behavior is “update this thing in place” rather than “take an old value, return a new one”. A mode toggle, a running counter, a shared status flag – all of these want inout, because the same binding carries the value in and the updated value out.
While loops
Not every workflow is a fixed sequence of steps. Sometimes you need to keep doing something as long as a condition holds. A while loop runs its body repeatedly until a guard becomes false:
while <condition> { ... }
Stir the water until it reaches the target temperature:
action def HeatAndStir {
in attribute targetTemp : Real;
inout attribute currentTemp : Real;
while currentTemp < targetTemp {
action stir : Stir;
assign currentTemp := currentTemp + 1.0;
}
}
while currentTemp < targetTemp { ... } says: as long as currentTemp is below targetTemp, keep running the actions inside the braces. The condition is evaluated at the start of each iteration; when it becomes false, the loop exits and execution continues after the closing brace.
Reach for while when the number of iterations is not known up front – a polling loop, a feedback controller, a “keep trying until the sensor is happy” pattern. If you already know how many times to run, the next loop is clearer.
For loops
When you have a collection and want to do something once per element, a for loop iterates over it:
for <item> in <collection> { ... }
Process each brew request in a queue:
action def ProcessBrewQueue {
in attribute brewRequests : BrewRequest[0..*];
out attribute successCount : Integer;
assign successCount := 0;
for request in brewRequests {
action brew : BrewCycle {
in item beans = request.beans;
}
assign successCount := successCount + 1;
}
}
for request in brewRequests { ... } says: bind request to each element of brewRequests in turn and run the body once per element. Inside the body, request refers to the current element. The loop runs over the whole sequence; when it is done, execution continues after the loop.
for is the loop to reach for when the iteration is driven by data – one pass per item in a collection – rather than by a runtime condition. It keeps the “does this run the right number of times?” question off the reviewer’s plate: the answer is “once per element”.
A tool caveat. sysml-rs currently reports
AX009 -- for loop has no collection referenceon everyforloop, including this one. The construct is specification syntax and the example is correct; the tool is not yet resolving the collection. If you see AX009, you have not mistyped anything.
Terminate
A workflow should be able to abort early when something goes wrong. The terminate action stops the enclosing action immediately and returns control to the caller:
terminate;
Abort a brew if the temperature is unsafe:
action def BrewCycle {
in attribute temperature : Real;
if temperature > 105.0 {
send FaultSignal() to panelPort;
terminate;
}
then action extract : ExtractCoffee;
}
terminate; says: stop this action right now. No further actions in the current scope run – the extract step below the guard never executes. The fault signal has already been sent; the caller is handed back control with the action in an aborted state.
Use terminate for hard cutoffs: a safety interlock that must not let the rest of the cycle proceed, a validation failure that makes the rest of the work meaningless. It is the action equivalent of an early return – reach for it when nothing later in the body should run.
The Story So Far
The coffee machine now has behavior. Here is what the model captures after this chapter:
- Action definitions for each step:
GrindBeans,HeatWater,ExtractEspresso,ExtractDrip,DispenseCoffee. - A composed workflow (
BrewCycle) that sequences, parallelizes, and branches those steps. - Parameters that thread data (beans, water, coffee) through the workflow.
- Control flow with fork/join for parallelism and decide/merge for branching.
- Send and accept for event-driven communication between parts.
- Message routing with
via <port>to direct signals through a specific channel. - Assignment (
:=) andinoutparameters for state a workflow mutates in place. - Loops –
whilefor condition-driven repetition,forfor collection iteration. - Terminate for an early, clean abort of an action body.
- Perform to connect actions to the parts that execute them.
The next chapter, Chapter 8, builds on this foundation. Where actions describe what the machine does during a single brew, states describe which mode the machine is in over time: idle, brewing, cleaning, error. Actions will appear again as entry, do, and exit behaviors attached to states.
Common Mistakes
Forgetting to sequence sub-actions. If you write two action usages without any first ... then between them, SysML v2 does not assume they run in order. They have no defined ordering at all. Always add explicit successions.
Confusing fork/join with decide/merge. A fork starts all outgoing paths concurrently. A decision picks exactly one. If you use a fork where you meant a decision, your model says the system does both things at the same time – which may not be what you intended. Likewise, a join waits for all incoming paths. A merge just accepts whichever single path arrives. Mixing them up changes the semantics of your workflow.
Writing one massive action body. It is tempting to put all behavior into a single action definition with dozens of sub-actions and complex control flow. Break large actions into smaller named definitions. Each definition should represent a meaningful step that can be reviewed and discussed independently. Compose them in a top-level action the way you compose parts in a top-level structure.