Skip to main content

Rule Syntax

Rules express temporal constraints using modal mu-calculus.

Submitting Rules​

A rule commit must leave the contract with a witness model that meets every rule, the new one included. Write the rule, write or synthesize the model, and commit both:

modal add-rule --name alice_signs \
'always([-signed_by(/members/alice.id)] false)'
modality model synthesize --rule rules/alice_signs.modality \
--verify -o model/default.modality
modal commit --all --sign alice.modal_passfile

The validator checks the model against every rule and refuses the commit if one fails. So a rule that no model can meet cannot be added.

That does not prove the contract can always move. A rule such as always([] false) is met by a model with no moves, and then no commit is ever accepted again. The public testnet runs predicate theory v3 and refuses an edge whose labels cannot hold together, such as +num_gt(/x.num, "5") +num_lt(/x.num, "3"). A network that sets no version, such as the bundled devnets, runs v0 and reads each predicate label as an opaque name. On v0 a model can meet a rule with that edge, and then no commit takes it, so a rule that needs it can leave the contract stuck. modal c theory lists those dead edges. See Predicate theory.

Basic Structure​

rule <name> {
starting_at $PARENT
formula {
<modal_formula>
}
}

// Or as default export
export default rule {
starting_at $PARENT
formula {
<modal_formula>
}
}

A rule file holds one or more rules. Each rule has one or more formula { ... } blocks, optionally named (formula no_early_release { ... }), and every one is checked and enforced. A file of top-level formula <name> { ... } blocks is also a rule file; each formula is a rule. // comments may appear between items, not inside a formula.

The validator reads the whole file or refuses the commit. A misspelled keyword, an unclosed brace, or trailing text is an error, never skipped.

Anchoring (starting_at)​

starting_at $PARENT           // The commit that adds the rule

A rule is anchored at the commit that adds it, from the states that commit reaches. starting_at is optional, and $PARENT is its only value. Any other anchor is refused.

OperatorMeaning
[ACTION] φAfter ALL ACTION transitions, φ holds
<ACTION> φAfter SOME ACTION transition, φ holds
[-ACTION] φIf ACTION is refused/impossible, φ holds
[<+ACTION>] φCommitted: CAN do ACTION and CANNOT refuse

Commitment Versus Enabledness​

Use the operator that matches the claim you want the contract to make:

<+PAY> true

PAY is enabled from the current witness state.

[<+PAY>] true

PAY is committed: it is enabled and its refusal edge is unavailable.

With several labels, [<+PAY +signed_by(/parties/alice.id)>] true refuses each one: no commit may move without PAY, and none without Alice's signature.

[+PAY] +signed_by(/parties/alice.id)

Every matching PAY transition must carry Alice's signature predicate.

Avoid [+PAY] true as a guard. A box formula with inner true is satisfied even when there is no matching PAY transition, so it does not prove PAY happened or that PAY is committed. Run modality model lint <file> before signing rules; it reports this as modality/vacuous-box-guard.

Temporal Operators (Syntactic Sugar)​

always(φ)           // φ holds forever (invariant)
// = gfp(X, φ & []X)

eventually(φ) // φ holds now, or some path of moves reaches φ
// = lfp(X, φ | <>X)

until(p, q) // some path keeps p true until it reaches q
// = lfp(X, q | (p & <>X))

eventually and until promise that φ can be reached, not that it will be. No rule can make a commit happen: runs may stop, or loop, before reaching φ. To promise that φ stays reachable wherever the contract goes, write always(eventually(φ)).

Fixed Points​

// Greatest fixed point (νX) - invariants, safety
gfp(X, property & []X)

// Least fixed point (μX) - reachability, liveness
lfp(X, target | <>X)

// Unicode alternatives
νX. (property & []X)
μX. (target | <>X)

A name in a posted rule must be a variable bound by lfp or gfp. Model node names are the model author's choice, so a rule such as always(safe) would hold on any model with a node named safe and constrain no commit. The validator refuses a rule that names a node. Rules already in a contract's log keep replaying.

Boolean Connectives​

φ & ψ           // Conjunction (and)
φ | ψ // Disjunction (or)
!φ // Negation (not)
true // Always true
false // Always false

Prefer explicit boolean form for conditional rules:

!+modifies(/members) | +all_signed(/members)

The parser still accepts implication syntax in some contexts, but docs and onboarding examples avoid it so temporal steps and proof implication are not conflated. modality model lint <file> reports signed-rule uses as modality/implication-sugar; rewrite them to explicit Boolean form before signing.

Lint Findings​

modality model lint <file> reads every rule and formula in a file and reports what parses but does not say what it seems to. modal add-rule prints the same findings for the rule it writes, and checks the new rule against the rules already in rules/.

CodeFinding
modality/vacuous-box-guard[+ACTION] true holds everywhere
modality/implication-sugarA -> B; write !A | B
modality/bare-witness-propA bare name, which reads a model node, not contract state
modality/witness-node-leakA bare name that matches a node of the witness model
modality/backward-eventually-orderingeventually under a guard, read as "already happened"
modality/unsatisfiable-label-setBox or diamond labels no commit can carry together
modality/guarded-diamondalways(!<+X> true | <+X +E> true), which forbids nothing; write always([+X -E] false)
modality/leading-next-boxA rule that starts with [], which also leaves the next commit free
modality/redundant-labelA label the other labels of its box or diamond already imply
modality/redundant-conjunctA [L] false box another box of the same rule already covers
modality/subsumed-ruleA rule whose boxes other rules already cover

The last three use the standard predicate declarations and no contract state, so what they report holds in every contract: +num_gt(/x.num, "7") implies +num_gt(/x.num, "5"), and always([-signed_by(/a.id)] false) covers always([+modifies(/x) -signed_by(/a.id)] false).