At the heart of go-pflow is an interpreted language for defining and executing token models. This language gives us a declarative way to specify Petri nets with rich data semantics—going beyond simple integer counters to structured state, guard predicates, and conservation invariants.
The language distills to four primitives:
| Term | Maps To | Purpose |
|---|---|---|
cell |
State/Place | Containers for tokens or data |
func |
Action/Transition | Operations that transform state |
arrow |
Arc | Flow connections with optional bindings |
guard |
Predicate | Boolean conditions gating actions |
This minimal vocabulary captures the essential structure of executable schemas. Every token model—from ERC-20 tokens to game boards to workflow engines—can be expressed with these four terms.
The DSL uses an S-expression syntax familiar to Lisp programmers:
(schema ERC-20
(version v1.0.0)
(state totalSupply :type uint256)
(state balances :type map[address]uint256 :exported)
(state allowances :type map[address]map[address]uint256)
(action transfer :guard {balances[from] >= amount && to != address(0)})
(action approve)
(action mint :guard {to != address(0)})
(action burn :guard {balances[from] >= amount})
(arc balances -> transfer :keys (from))
(arc transfer -> balances :keys (to))
(arc mint -> totalSupply)
(arc totalSupply -> burn)
(constraint conservation {sum(balances) == totalSupply}))
Each form declares structure: states hold data, actions perform operations, arcs define flow, and constraints enforce invariants.
The language distinguishes two kinds of state:
Integer counters representing discrete token quantities. This is classic Petri net semantics:
type Snapshot struct {
Tokens map[string]int // TokenState
Data map[string]any // DataState
}
Typed containers holding structured data—maps, records, or scalar values. This extends Petri nets to handle real-world state:
(arc balances -> transfer :keys (from)) binds from to the map keymap[address]uint256The combination enables modeling systems where both control flow (tokens) and data transformations (balances, permissions, state) matter.
Guards are boolean expressions that gate action execution. An action fires only when its guard evaluates to true.
Guards support a familiar expression language:
Comparison: balances[from] >= amount
to != address(0)
count > 0
Logical: condition1 && condition2
condition1 || condition2
!condition
Indexing: balances[key]
allowances[owner][spender]
Field: schedule.revocable
data.timestamp
Function: sum(balances)
count(owners)
address(0)
Guards evaluate against a context containing:
from, to, amount)sum, count)The evaluator uses short-circuit evaluation—&& stops on first false, || stops on first true—making guards efficient even with complex conditions.
From source text to running model, the language follows a classic interpreter pipeline:
LPAREN, SYMBOL, GUARD, etc.SchemaNode, StateNode, ActionNode, ArcNodeSchema with validated referencesEach action execution follows:
If the guard fails, the action is blocked. If an invariant fails, the action is rolled back.
Invariants are constraints that must hold across all state transitions. They catch bugs at runtime and document system properties.
The most common invariant type: quantities that must be preserved:
(constraint conservation {sum(balances) == totalSupply})
This declares that the total of all balances must always equal totalSupply. The sum() aggregate function computes across all map entries.
Other aggregate functions:
sum(place) — sum all valuescount(place) — count non-zero entriestokens(place) — token count at place| Aspect | Guard | Invariant |
|---|---|---|
| When checked | Before action fires | After state update |
| Failure behavior | Block action | Rollback transaction |
| Purpose | Precondition | System property |
| Scope | Single action | Entire schema |
Guards are "can we do this?" checks. Invariants are "did we break anything?" checks.
Consider a simple token transfer:
(action transfer :guard {balances[from] >= amount && to != address(0)})
(arc balances -> transfer :keys (from))
(arc transfer -> balances :keys (to))
Execution trace:
balances[0x1a2b] >= 100 and 0x3c4d != address(0)balances[from], bind to actionbalances[to] += amount, balances[from] -= amountsum(balances) == totalSupplyThe guard prevents overdrafts. The invariant catches any implementation bug that would create or destroy tokens.
We could define models in Go structs or JSON. Why a dedicated language?
Declarative clarity: The DSL makes structure visible. (arc balances -> transfer :keys (from)) is clearer than wiring up method calls.
Executable specification: The same text that documents the model also runs it. No translation gap between spec and implementation.
Analysis-friendly: Parseable structure enables static analysis—checking guard completeness, invariant coverage, reachability.
Composable: Schemas can import states and actions from other schemas. The DSL makes composition explicit.
Little language philosophy: A small, focused language does one thing well. The four-term vocabulary keeps models comprehensible.
The language is implemented in go-pflow:
import "github.com/pflow-xyz/go-pflow/tokenmodel/dsl"
schema, err := dsl.ParseSchema(`
(schema Counter
(state count :kind token :initial 10)
(action decrement :guard {tokens(count) > 0})
(arc count -> decrement)
(arc decrement -> done))
`)
For visual exploration, pflow.xyz provides a browser-based editor with ODE analysis.
cell, func, arrow, guard capture token model structure