It’s a Syn (Part 4)

We are starting to get to the business end. So how can we model a netsplit? The easiest thing seems to be to add a set for each node, listing the nodes that are visible from it:

AllOtherNodes(n) ==
    Nodes \ {n}

Init ==
     ...
    /\ visible_nodes = [n \in Nodes |-> AllOtherNodes(n)]

At the start, all nodes can see all other nodes. Now, when we register a new value:

Register(n) ==
     ...
    /\ inbox' = [o \in Nodes |->
                       IF o \in visible_nodes[n] THEN
                           Append(inbox[o], [action |-> "sync_register", name |-> next_val])
                       ELSE
                           inbox[o]
                   ]

we only send messages to the nodes that are still visible (same for unregister). And we add two new state transitions:

Next ==
    /\ \E n \in Nodes:
        ...
        \/ Disconnect(n)
        \/ Reconnect(n)

To disconnect:

Disconnect(n) ==
    /\ Cardinality(visible_nodes[n]) > 0
    /\ LET other_node == CHOOSE o \in visible_nodes[n]: TRUE
        IN visible_nodes' = [o \in Nodes |-> CASE
            (o = other_node) -> visible_nodes[o] \ {n}
            [] (o = n) -> visible_nodes[o] \ {other_node}
            [] OTHER -> visible_nodes[o]
        ]
    /\ UNCHANGED <<registered, locally_registered, inbox, next_val>>

If other nodes are still visible; we select a node, at random, from the set and remove it (on both sides). And reconnection is the opposite:

Reconnect(n) ==
    /\ Cardinality(AllOtherNodes(n) \ visible_nodes[n]) > 0
    /\ LET other_node == CHOOSE o \in (AllOtherNodes(n) \ visible_nodes[n]): TRUE
        IN visible_nodes' = [o \in Nodes |-> CASE
            (o = other_node) -> visible_nodes[o] \union {n}
            [] (o = n) -> visible_nodes[o] \union {other_node}
            [] OTHER -> visible_nodes[o]
        ]
    /\ UNCHANGED <<registered, locally_registered, inbox, next_val>>

We also need to update our invariant:

AllRegistered ==
    \A n \in Nodes:
        Len(inbox[n]) = 0 /\ visible_nodes[n] = AllOtherNodes(n) => locally_registered[n] = registered

to only compare the registered values, once a netsplit has healed. If we run this model:

Error: Invariant AllRegistered is violated.
Error: The behavior up to this point is:
State 1: <Initial predicate>
/\ next_val = 0
/\ inbox = (n1 :> <<>> @@ n2 :> <<>>)
/\ locally_registered = (n1 :> {} @@ n2 :> {})
/\ registered = {}
/\ visible_nodes = (n1 :> {n2} @@ n2 :> {n1})

State 2: <Next line 78, col 5 to line 85, col 19 of module syn>
/\ next_val = 0
/\ inbox = (n1 :> <<>> @@ n2 :> <<>>)
/\ locally_registered = (n1 :> {} @@ n2 :> {})
/\ registered = {}
/\ visible_nodes = (n1 :> {} @@ n2 :> {})

State 3: <Next line 78, col 5 to line 85, col 19 of module syn>
/\ next_val = 1
/\ inbox = (n1 :> <<>> @@ n2 :> <<>>)
/\ locally_registered = (n1 :> {0} @@ n2 :> {})
/\ registered = {0}
/\ visible_nodes = (n1 :> {} @@ n2 :> {})

State 4: <Next line 78, col 5 to line 85, col 19 of module syn>
/\ next_val = 1
/\ inbox = (n1 :> <<>> @@ n2 :> <<>>)
/\ locally_registered = (n1 :> {0} @@ n2 :> {})
/\ registered = {0}
/\ visible_nodes = (n1 :> {n2} @@ n2 :> {n1})

it fails pretty quickly. But that is what we should expect, as new keys could have been added while the network was partitioned, and we aren’t doing anything to resolve that. And that is what we will be looking into, next time.

It’s a Syn (Part 3)

I said last time that waiting for all messages to be processed before performing any new actions was unrealistic, so let’s remove those two pre-conditions:

diff --git a/syn.tla b/syn.tla
index 5bab535..d75d5d3 100644
--- a/syn.tla
+++ b/syn.tla
@@ -15,7 +15,6 @@ Init ==
     /\ removed = 0
 
 Register(n) ==
-    /\ \A o \in Nodes: Len(inbox[o]) = 0
     /\ next_val < MaxValues
     /\ registered' = [registered EXCEPT![n] = registered[n] \union {next_val}]
     /\ next_val' = next_val + 1
@@ -34,7 +33,6 @@ ItemToRemove(n) ==
     CHOOSE r \in registered[n]: TRUE
 
 Unregister(n) ==
-    /\ \A o \in Nodes: Len(inbox[o]) = 0
     /\ Cardinality(registered[n]) > 0
     /\ LET item_to_remove == ItemToRemove(n)
         IN registered' = [registered EXCEPT![n] = registered[n] \ {item_to_remove}]

Now if we run the model checker, we have a problem:

Error: Invariant AllRegistered is violated.
Error: The behavior up to this point is:
State 1: <Initial predicate>
/\ next_val = 0
/\ inbox = (n1 :> <<>> @@ n2 :> <<>>)
/\ added = 0
/\ removed = 0
/\ registered = (n1 :> {} @@ n2 :> {})

State 2: <Next line 55, col 5 to line 60, col 19 of module syn>
/\ next_val = 1
/\ inbox = (n1 :> <<>> @@ n2 :> <<[action |-> "sync_register", name |-> 0]>>)
/\ added = 1
/\ removed = 0
/\ registered = (n1 :> {0} @@ n2 :> {})

State 3: <Next line 55, col 5 to line 60, col 19 of module syn>
/\ next_val = 1
/\ inbox = ( n1 :> <<>> @@
  n2 :>
      << [action |-> "sync_register", name |-> 0],
         [action |-> "sync_unregister", name |-> 0] >> )
/\ added = 1
/\ removed = 1
/\ registered = (n1 :> {} @@ n2 :> {})

State 4: <Next line 55, col 5 to line 60, col 19 of module syn>
/\ next_val = 1
/\ inbox = (n1 :> <<>> @@ n2 :> <<[action |-> "sync_unregister", name |-> 0]>>)
/\ added = 1
/\ removed = 1
/\ registered = (n1 :> {} @@ n2 :> {0})

State 5: <Next line 55, col 5 to line 60, col 19 of module syn>
/\ next_val = 1
/\ inbox = ( n1 :> <<[action |-> "sync_unregister", name |-> 0]>> @@
  n2 :> <<[action |-> "sync_unregister", name |-> 0]>> )
/\ added = 1
/\ removed = 2
/\ registered = (n1 :> {} @@ n2 :> {})

State 6: <Next line 55, col 5 to line 60, col 19 of module syn>
/\ next_val = 1
/\ inbox = (n1 :> <<>> @@ n2 :> <<[action |-> "sync_unregister", name |-> 0]>>)
/\ added = 1
/\ removed = 2
/\ registered = (n1 :> {} @@ n2 :> {})

In this scenario, a key (0) was added on node 1 and then immediately removed. But node 2 still had both messages waiting, so it also added the key; and then decided to remove it itself before getting to the unregister message from node 1. This is getting more interesting, but the problem lies with our model, not the protocol.

It’s generally considered bad form to update the registry for another process (and this can actually be enforced); but the real problem here is that even though removing an item that doesn’t exist from a set is a no-op, we are double counting. So we think that we have added 1 item, and removed 2; and therefore the size of the set should be -1, which is never going to happen.

Assuming we want to allow the non-strict behaviour, we could probably solve this with some clever conditional updates of the counters; but I think I would prefer instead to track a “platonic ideal” of what should be registered, and compare the actual state to that instead.

Init ==
    ...
    /\ registered = {}
    /\ locally_registered = [n \in Nodes |-> {}]

Register(n) ==
    ...
    /\ registered' = registered \union {next_val}
    /\ locally_registered' = [locally_registered EXCEPT![n] = locally_registered[n] \union {next_val}]

AllRegistered ==
    \A n \in Nodes:
        Len(inbox[n]) = 0 => locally_registered[n] = registered

This does remove the previous error, but even with just 5 keys, we are generating far more states than before:

Model checking completed. No error has been found.
    ...
1782413 states generated, 357188 distinct states found, 0 states left on queue.
The depth of the complete state graph search is 26.

This means running the model will be slower, but should also mean it’s more likely to find some interesting concurrent executions.

Next stop, netsplits!

It’s a Syn (Part 2)

To make things more interesting, we need to take the simple model, and run it with multiple concurrent nodes:

CONSTANTS
Nodes = {n1, n2}

Just two, to start with. The model state needs to become more complex:

Init ==
    /\ inbox = [n \in Nodes |-> <<>>]
    /\ registered = [n \in Nodes |-> {}]
    /\ next_val = 0
    /\ added = 0
    /\ removed = 0

Each node now has its own set of registered values; and, this being an Erlang system, it makes sense to model the inbox for messages (a sequence, for each node). In this case, a node is really a process (or gen_server).

Next ==
    /\ \E n \in Nodes:
        \/ Register(n)
        \/ SyncRegister(n)
        \/ Unregister(n)
        \/ SyncUnregister(n)
        \/ Complete

There are more options, for the next state: we now pick a node from the set of all nodes, and apply an action to that. Also, register & unregister have split in two (async).

Register(n) ==
    /\ \A o \in Nodes: Len(inbox[o]) = 0
    /\ next_val < MaxValues
    /\ registered' = [registered EXCEPT![n] = registered[n] \union {next_val}]
    /\ next_val' = next_val + 1
    /\ added' = added + 1
    /\ inbox' = [o \in Nodes |-> IF o = n THEN inbox[o] ELSE Append(inbox[o], [action |-> "sync_register", name |-> next_val])]
    /\ UNCHANGED <<removed>>

First we update the local registry, then broadcast a message to all other nodes, to do the same.

SyncRegister(n) ==
    /\ Len(inbox[n]) > 0
    /\ Head(inbox[n]).action = "sync_register"
    /\ registered' = [registered EXCEPT![n] = registered[n] \union {Head(inbox[n]).name}]
    /\ inbox' = [inbox EXCEPT![n] = Tail(inbox[n])]
    /\ UNCHANGED <<removed, added, next_val>>

To make things simpler, I decided that a pre-condition for register would be that all nodes have empty inboxes (i.e. any previous messages have already been processed). This obviously isn’t realistic, and the constraint will be removed later.

ItemToRemove(n) ==
    CHOOSE r \in registered[n]: TRUE

Unregister(n) ==
    /\ \A o \in Nodes: Len(inbox[o]) = 0
    /\ Cardinality(registered[n]) > 0
    /\ LET item_to_remove == ItemToRemove(n)
        IN registered' = [registered EXCEPT![n] = registered[n] \ {item_to_remove}]
        /\ inbox' = [o \in Nodes |-> IF o = n THEN inbox[o] ELSE Append(inbox[o], [action |-> "sync_unregister", name |-> item_to_remove])]
    /\ removed' = removed + 1
    /\ UNCHANGED <<added, next_val>>

SyncUnregister(n) ==
    /\ Len(inbox[n]) > 0
    /\ Head(inbox[n]).action = "sync_unregister"
    /\ registered' = [registered EXCEPT![n] = registered[n] \ {Head(inbox[n]).name}]
    /\ inbox' = [inbox EXCEPT![n] = Tail(inbox[n])]
    /\ UNCHANGED <<removed, added, next_val>>

Unsurprisingly, unregister is still a mirror image of the same steps. We maintain the same invariant:

AllRegistered ==
    \A n \in Nodes:
        Len(inbox[n]) = 0 => Cardinality(registered[n]) = (added - removed)

but evaluated on each node, individually; and we don’t bother checking until all messages have been processed (as the counters obviously won’t match).

And now we also check that all messages have been consumed, when the system halts:

PROPERTIES
AllMessagesProcessed

AllMessagesProcessed ==
    \A n \in Nodes:
        <>(Len(inbox[n]) = 0)

Again, we can run this with the model checker:

Starting...
Implied-temporal checking--satisfiability problem has 2 branches.
Computing initial states...
Finished computing initial states: 1 distinct state generated at 2024-01-05 13:15:50.
Progress(41) at 2024-01-05 13:15:50: 543 states generated, 286 distinct states found, 0 states left on queue.
Checking 2 branches of temporal properties for the complete state space with 572 total distinct states at (2024-01-05 13:15:50)
Finished checking temporal properties in 00s at 2024-01-05 13:15:50
Model checking completed. No error has been found.
  Estimates of the probability that TLC did not check all reachable states
  because two distinct states had the same fingerprint:
  calculated (optimistic):  val = 4.0E-15
543 states generated, 286 distinct states found, 0 states left on queue.
The depth of the complete state graph search is 41.

And the number of possible states has ballooned from the simple model, but still no errors!

We can also try registering a larger number of keys, or using more nodes, with the same result; but the search space, and runtime, can increase dramatically (this is the downside to exhaustive checking, vs example based testing or even generative random testing).

It’s a Syn (Part 1)

I was looking at the Syn process registry recently, and thought it might be interesting to try modelling its netsplit recovery protocol (eventual consistency) in TLA+.

It’s always worth starting with something so simple, that it can’t be wrong. In this case I decided that scopes were out of scope, as was metadata; and I would first try to build a model for a single node.

Init ==
    /\ registered = {}
    /\ next_val = 0
    /\ added = 0
    /\ removed = 0

We have an empty set, for the registered values. A serial for the next item to be added (using integers as “keys”); and two counters, for the number of items added & removed.

Next ==
    \/ Register
    \/ Unregister
    \/ Complete

The next state is a choice of registering a value, unregistering a value; or reaching the end (an arbitrary limit).

Register ==
    /\ next_val < 5
    /\ registered' = registered \union {next_val}
    /\ next_val' = next_val + 1
    /\ added' = added + 1
    /\ UNCHANGED <<removed>>

If we haven’t hit that limit yet (pre-condition), then we add the next value to the set of registered values, and increment the counter.

ItemToRemove ==
    CHOOSE r \in registered: TRUE

Unregister ==
    /\ Cardinality(registered) > 0
    /\ registered' = registered \ {ItemToRemove}
    /\ removed' = removed + 1
    /\ UNCHANGED <<added, next_val>>

Unregistration is pretty similar. The pre-condition this time is that the set is not empty, and obviously we remove the (randomly selected) key instead.

Complete ==
    /\ next_val = 5
    /\ UNCHANGED <<added, next_val, registered, removed>>

Finally, once 5 values have been added (no matter how many were removed), we give up.

INVARIANTS
AllRegistered

AllRegistered == Cardinality(registered) = (added - removed)

And after every step, we ensure that the number of items in the set is equal to those added minus those removed.

If we run the model:

$ docker run --rm -it -v $PWD:/app -w /app openjdk:14-jdk-alpine java -XX:+UseParallelGC -cp tla2tools.jar tlc2.TLC -config syn.cfg -workers auto -cleanup syn.tla
TLC2 Version 2.18 of Day Month 20?? (rev: cab6f13)
...
Computing initial states...
Finished computing initial states: 1 distinct state generated at 2024-01-05 12:23:27.
Model checking completed. No error has been found.
  Estimates of the probability that TLC did not check all reachable states
  because two distinct states had the same fingerprint:
  calculated (optimistic):  val = 1.8E-17
37 states generated, 21 distinct states found, 0 states left on queue.
The depth of the complete state graph search is 11.

No errors are found. Not a huge surprise, but this gives us a good foundation to build something more realistic.

TLA+ with concurrency

Last time, we built a specification for a simple state machine. We can add some concurrency, by providing a list of customers, each with their own account:

CONSTANTS Customers

The value will be provided in the config file:

Customers = {c1, c2}

This is a set, containing two customers. We can add more, but the search space will balloon dramatically, and the model checker will take far longer to run.

We now initialise the variables for each customer:

Init ==
    /\ state = [c \in Customers |-> "open"]
    /\ balance = [c \in Customers |-> 0]

You can think of those as a dictionary initialiser, with the same value for every customer:

State 1: <Initial predicate>
/\ state = (c1 :> "open" @@ c2 :> "open")
/\ balance = (c1 :> 0 @@ c2 :> 0)

And now, every time round the loop:

Next ==
   /\ \E customer \in Customers:
        \/ \E amount \in 1..10:
                Deposit(customer, amount)
        \/ \E amount \in 1..10:
                Withdraw(customer, amount)
        \/ Close(customer)
        \/ Reopen(customer)

We first pick a customer, and then an action to perform on their account. For example:

Init
Next
Deposit(c1, 5)
Next
Close(c2)
Next
Withdraw(c1, 3)
...

Each transition is also updated, to check the variables for that customer:

Deposit(customer, amount) ==
    /\ state[customer] = "open"
    /\ balance[customer] < 100
    /\ balance' = [balance EXCEPT![customer] = (balance[customer] + amount)]
    /\ UNCHANGED <<state>>

And the invariant is updated to check every balance:

NoNegativeBalance ==
    /\ \A customer \in Customers:
        balance[customer] >= 0

We can run this version, without any problems:

Computing initial states...
Finished computing initial states: 1 distinct state generated at 2023-03-09 15:53:06.
Model checking completed. No error has been found.
  Estimates of the probability that TLC did not check all reachable states
  because two distinct states had the same fingerprint:
  calculated (optimistic):  val = 2.5E-9
  based on the actual fingerprints:  val = 7.4E-14
996601 states generated, 48400 distinct states found, 0 states left on queue.
The depth of the complete state graph search is 27.
The average outdegree of the complete state graph is 1 (minimum is 0, the maximum 16 and the 95th percentile is 2).
Finished in 02s at (2023-03-09 15:53:08)

But the more observant amongst you will have noted that there is no interaction between the accounts.

We can solve this, by adding a “transfer” transition:

  \/ \E from, to \in Customers, amount \in 1..10:
        Transfer(from, to, amount)

This is slightly more interesting, but because the transfer is done atomically, our model still succeeds. So, as a final flourish, we can instead queue the actual work for later.

Error: Invariant NoNegativeBalance is violated.
Error: The behavior up to this point is:
State 1: <Initial predicate>
/\ state = (c1 :> "open" @@ c2 :> "open")
/\ balance = (c1 :> 0 @@ c2 :> 0)
/\ transfers = {}

State 2: <Next line 50, col 5 to line 57, col 23 of module bank>
/\ state = (c1 :> "open" @@ c2 :> "open")
/\ balance = (c1 :> 0 @@ c2 :> 1)
/\ transfers = {}

State 3: <Next line 50, col 5 to line 57, col 23 of module bank>
/\ state = (c1 :> "open" @@ c2 :> "open")
/\ balance = (c1 :> 0 @@ c2 :> 1)
/\ transfers = {[amount |-> 1, from |-> c2, to |-> c1]}

State 4: <Next line 50, col 5 to line 57, col 23 of module bank>
/\ state = (c1 :> "open" @@ c2 :> "open")
/\ balance = (c1 :> 0 @@ c2 :> 0)
/\ transfers = {[amount |-> 1, from |-> c2, to |-> c1]}

State 5: <Next line 50, col 5 to line 57, col 23 of module bank>
/\ state = (c1 :> "open" @@ c2 :> "open")
/\ balance = (c1 :> 1 @@ c2 :> -1)
/\ transfers = {}

Now customer 2 has £1 in their account, they write a check for c1, but sneakily empty their account before it is cashed.

Specifying a state machine with TLA+

It’s relatively straight forward to specify a (simple) state machine using TLA+ (this video is a good intro). We can revisit a previous example…

There are two required functions (operators): Init which provides the initial variables:

VARIABLES state, balance

Init ==
    /\ state = "open"
    /\ balance = 0

The starting position (of the state machine) is the string “open”, and the balance is 0.

And the Next function defines the possible transitions:

Next ==
    \/ \E amount \in 1..10:
            Deposit(amount)
    \/ \E amount \in 1..10:
            Withdraw(amount)
    \/ Close
    \/ Reopen

With an imperative mindset, you can think of this as “choosing” at random one of four options every time round the loop (in reality, the model checker will exhaustively explore every possible path).

So some possible executions are:

Init
Next
Deposit(5)
Next
Withdraw(1)
Next
Deposit(3)
Next
Close
Next
Reopen
Next
...ad infinitum

The Deposit transition:

Deposit(amount) ==
    /\ state = "open"
    /\ balance < 100
    /\ balance' = balance + amount
    /\ UNCHANGED <<state>>

Has two pre-conditions: that the state machine is currently in the “open” state (see diagram in the post linked above), and that the balance is less than 100 (this is purely to ensure that the model checker does not run forever).

If those are satisfied, the remainder can run, and set the post balance to be the current balance plus the deposited amount.

The Withdraw is a mirror image;

Withdraw(amount) ==
    /\ state = "open"
    /\ (balance - amount) >= 0
    /\ balance' = balance - amount
    /\ UNCHANGED <<state>>

But this time, the guard clause is that the balance is sufficient to allow the withdrawal, and the amount is then subtracted.

And finally, Close and Reopen merely update the state:

Close ==
    /\ state = "open"
    /\ state' = "closed"
    /\ UNCHANGED <<balance>>

Reopen ==
    /\ state = "closed"
    /\ state' = "open"
    /\ UNCHANGED <<balance>>

And guarantee that a closed account can only be re-opened, and vice versa.

We also include an invariant:

NoNegativeBalance ==
    balance >= 0

That the balance cannot drop below 0 (no overdraft facility here!)

We can then run the model, using the downloaded jar file, and a config file:

$ docker run --rm -it -v $PWD:/app -w /app openjdk:14-jdk-alpine java -XX:+UseParallelGC -cp tla2tools.jar tlc2.TLC -config bank.cfg -workers auto -cleanup bank.tla
TLC2 Version 2.18 of Day Month 20?? (rev: cab6f13)
Running breadth-first search Model-Checking with fp 17 and seed -6395706311673644266 with 8 workers on 8 cores with 3488MB heap and 64MB offheap memory [pid: 1] (Linux 6.2.1-arch1-1 amd64, Oracle Corporation 14-ea x86_64, MSBDiskFPSet, DiskStateQueue).
Parsing file /app/bank.tla
Parsing file /tmp/tlc-5979345560125251717/Integers.tla (jar:file:/app/tla2tools.jar!/tla2sany/StandardModules/Integers.tla)
Parsing file /tmp/tlc-5979345560125251717/_TLCTrace.tla (jar:file:/app/tla2tools.jar!/tla2sany/StandardModules/_TLCTrace.tla)
Parsing file /tmp/tlc-5979345560125251717/Naturals.tla (jar:file:/app/tla2tools.jar!/tla2sany/StandardModules/Naturals.tla)
Parsing file /tmp/tlc-5979345560125251717/TLC.tla (jar:file:/app/tla2tools.jar!/tla2sany/StandardModules/TLC.tla)
Parsing file /tmp/tlc-5979345560125251717/TLCExt.tla (jar:file:/app/tla2tools.jar!/tla2sany/StandardModules/TLCExt.tla)
Parsing file /tmp/tlc-5979345560125251717/Sequences.tla (jar:file:/app/tla2tools.jar!/tla2sany/StandardModules/Sequences.tla)
Parsing file /tmp/tlc-5979345560125251717/FiniteSets.tla (jar:file:/app/tla2tools.jar!/tla2sany/StandardModules/FiniteSets.tla)
Semantic processing of module Naturals
Semantic processing of module Integers
Semantic processing of module Sequences
Semantic processing of module FiniteSets
Semantic processing of module TLC
Semantic processing of module TLCExt
Semantic processing of module _TLCTrace
Semantic processing of module bank
Starting... (2023-03-06 14:01:19)
Computing initial states...
Finished computing initial states: 1 distinct state generated at 2023-03-06 14:01:19.
Model checking completed. No error has been found.
  Estimates of the probability that TLC did not check all reachable states
  because two distinct states had the same fingerprint:
  calculated (optimistic):  val = 2.4E-14
2266 states generated, 220 distinct states found, 0 states left on queue.
The depth of the complete state graph search is 15.
The average outdegree of the complete state graph is 1 (minimum is 0, the maximum 8 and the 95th percentile is 3).
Finished in 00s at (2023-03-06 14:01:19)

And we see that the invariant was not breached, after 220 distinct states.

Obviously, this isn’t a very useful example, but next time we will add some concurrency!

Monotonic sequence in TLA+

I wanted to declare an invariant, that a sequence was monotonic, i.e. always increasing. <<1, 2, 3>> would pass, as would <<1, 3, 5>>; but <<1, 2, 1>> would not.

I thought I might be able to use forall:

MonotonicSeq ==
    IF Len(seq) > 1 THEN
        \A e \in Tail(seq):
            e > Head(seq)
    ELSE TRUE

but was quickly disillusioned:

Error: Evaluating invariant MonotonicSeq failed.
TLC encountered a non-enumerable quantifier bound
<<2>>.

Apparently that syntax only works with sets.

Eventually I worked out how to write a recursive operator:

RECURSIVE Monotonic(_)

Monotonic(seq) ==
    IF Len(seq) > 1 THEN
        /\ Len(SelectSeq(seq, LAMBDA X : X < Head(seq))) = 0
        /\ Monotonic(Tail(seq))
    ELSE TRUE